From 24699e5deea1ae90699607d26fe3547e3af1a381 Mon Sep 17 00:00:00 2001 From: dhempler <286749748+dhempler@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:18:16 -0500 Subject: [PATCH 1/9] Add guided AWS self-hosting installer --- .github/workflows/deploy-aws-self-hosted.yml | 383 + .gitignore | 17 +- README.md | 4 + infrastructure/README.md | 1184 +++ .../cloudformation/backend-api.yaml | 1850 ++++ infrastructure/cloudformation/bootstrap.yaml | 108 + .../cloudformation/frontend-site.yaml | 206 + infrastructure/cloudformation/full-stack.yaml | 499 + infrastructure/environments/README.md | 60 + .../environments/customer-values.sample.json | 29 + .../environments/deployment-workbook.md | 289 + .../environments/first-rollout-checklist.md | 59 + .../environments/github-actions-setup.md | 233 + .../private-deployment-gitignore.sample | 5 + .../environments/private-deployment-repo.md | 162 + .../private-deployment-workflow.sample.yml | 383 + infrastructure/environments/prod/README.md | 56 + .../prod/app-config-secret.template.json | 21 + .../environments/prod/backend-parameters.json | 81 + .../prod/bootstrap-parameters.json | 7 + .../environments/prod/deploy-split-stack.sh | 279 + .../prod/frontend-parameters.json | 9 + .../setup/api-repository-access.md | 38 + .../environments/setup/aws-account-access.md | 39 + infrastructure/environments/setup/aws-cli.md | 34 + .../environments/setup/aws-iam-roles.md | 33 + .../environments/setup/customer-values.md | 51 + .../setup/deployment-repository.md | 85 + .../environments/setup/github-cli.md | 36 + .../setup/github-deployment-secrets.md | 35 + .../environments/setup/github-environments.md | 34 + .../environments/setup/local-runtime.md | 35 + infrastructure/environments/staging/README.md | 48 + .../staging/app-config-secret.template.json | 21 + .../staging/backend-parameters.json | 81 + .../staging/bootstrap-parameters.json | 7 + .../staging/deploy-split-stack.sh | 295 + .../staging/frontend-parameters.json | 9 + infrastructure/environments/start-here.md | 786 ++ .../examples/app-config-secret.sample.json | 21 + ...dit-environment-starter-output.sample.json | 341 + .../examples/backend-outputs.sample.json | 14 + .../examples/backend-parameters.sample.json | 81 + .../backend-stack-outputs.sample.json | 13 + .../bootstrap-admin-secret.sample.json | 16 + .../examples/bootstrap-parameters.sample.json | 7 + .../examples/database-secret.sample.json | 4 + ...ploy-aws-frontend-infra-output.sample.json | 62 + .../deploy-aws-full-output.sample.json | 108 + ...eploy-aws-publish-build-output.sample.json | 61 + .../deploy-aws-publish-output.sample.json | 48 + .../deploy-backend-output.sample.json | 36 + .../deploy-bootstrap-output.sample.json | 15 + .../deploy-frontend-output.sample.json | 17 + ...deploy-frontend-publish-output.sample.json | 30 + ...ll-stack-frontend-infra-output.sample.json | 64 + .../deploy-full-stack-full-output.sample.json | 64 + ...ull-stack-publish-build-output.sample.json | 47 + ...ploy-full-stack-publish-output.sample.json | 34 + ...patch-github-aws-deploy-output.sample.json | 38 + .../examples/frontend-outputs.sample.json | 5 + .../examples/frontend-parameters.sample.json | 9 + .../full-stack-parameters.sample.json | 88 + .../package-api-backend-output.sample.json | 32 + .../examples/package-manifest.sample.json | 32 + ...plan-environment-deploy-output.sample.json | 190 + ...are-environment-starter-output.sample.json | 61 + .../publish-frontend-output.sample.json | 29 + .../publish-lambda-layer-output.sample.json | 18 + .../run-api-migrations-output.sample.json | 28 + ...ave-split-stack-outputs-output.sample.json | 32 + .../show-rollout-status-output.sample.json | 223 + .../sync-app-config-secret-output.sample.json | 6 + ...ithub-app-config-secret-output.sample.json | 12 + .../sync-legacy-ssm-output.sample.json | 51 + ...upload-backend-artifact-output.sample.json | 9 + ...validate-api-migrations-output.sample.json | 69 + .../validate-backend-output.sample.json | 58 + .../validate-bootstrap-output.sample.json | 55 + .../validate-frontend-output.sample.json | 51 + ...lidate-frontend-publish-output.sample.json | 59 + ...ll-stack-frontend-infra-output.sample.json | 58 + .../validate-full-stack-output.sample.json | 56 + ...date-full-stack-publish-output.sample.json | 62 + ...it-stack-frontend-infra-output.sample.json | 59 + .../validate-split-stack-output.sample.json | 57 + ...ate-split-stack-publish-output.sample.json | 64 + .../verify-split-stack-output.sample.json | 76 + infrastructure/iam/README.md | 72 + ...loudformation-execution-policy.sample.json | 194 + ...formation-execution-role-trust.sample.json | 12 + .../iam/github-oidc-deploy-policy.sample.json | 118 + .../github-oidc-deploy-role-trust.sample.json | 20 + package.json | 54 + scripts/audit-api-repo-contract.mjs | 317 + scripts/audit-environment-starter.mjs | 352 + scripts/bootstrap-initial-admin.mjs | 798 ++ scripts/deploy-aws.mjs | 811 ++ scripts/deploy-backend.mjs | 712 ++ scripts/deploy-bootstrap.mjs | 205 + scripts/deploy-frontend.mjs | 353 + scripts/deploy-full-stack.mjs | 921 ++ scripts/discover-github-aws-role-arns.mjs | 174 + scripts/dispatch-github-aws-deploy.mjs | 312 + scripts/environment-setup-wizard.mjs | 358 + scripts/installer-adopt-frontend-origin.mjs | 88 + scripts/installer-app-config-secret.mjs | 181 + scripts/installer-aws-handoff.mjs | 152 + scripts/installer-aws-preflight.mjs | 276 + scripts/installer-aws-roles.mjs | 237 + scripts/installer-bootstrap-admin.mjs | 104 + scripts/installer-browser-smoke.mjs | 214 + scripts/installer-common.mjs | 218 + scripts/installer-configure.mjs | 161 + scripts/installer-customer-values.mjs | 227 + scripts/installer-deploy.mjs | 207 + scripts/installer-doctor.mjs | 211 + scripts/installer-github-readiness.mjs | 181 + scripts/installer-github-setup.mjs | 300 + scripts/installer-init.mjs | 148 + scripts/installer-observe.mjs | 294 + scripts/installer-preflight.mjs | 198 + scripts/installer-report.mjs | 214 + scripts/installer-run.mjs | 222 + scripts/installer-start.mjs | 254 + scripts/installer-update.mjs | 155 + scripts/installer-verify.mjs | 57 + scripts/launch-staging.mjs | 189 + scripts/lib/api-migration-data-api-shim.mjs | 407 + scripts/lib/arg-utils.mjs | 22 + scripts/lib/environment-setup-metadata.mjs | 347 + scripts/lib/github-cli-readiness.mjs | 63 + scripts/lib/progress-utils.mjs | 43 + scripts/package-api-backend.mjs | 251 + scripts/plan-environment-deploy.mjs | 1122 +++ scripts/prepare-environment-starter.mjs | 418 + scripts/publish-frontend-assets.mjs | 302 + scripts/publish-lambda-layer.mjs | 117 + scripts/reset-prod.mjs | 36 + scripts/reset-staging.mjs | 341 + scripts/run-api-migrations-data-api.mjs | 868 ++ scripts/run-api-migrations.mjs | 378 + scripts/save-split-stack-outputs.mjs | 233 + scripts/setup-private-deployment-repo.mjs | 253 + scripts/show-deployment-summary.mjs | 146 + scripts/show-environment-setup-guide.mjs | 314 + scripts/show-rollout-status.mjs | 454 + scripts/smoke-aws-tooling.mjs | 8777 +++++++++++++++++ scripts/sync-app-config-secret.mjs | 217 + scripts/sync-github-app-config-secret.mjs | 176 + scripts/sync-legacy-ssm-parameters.mjs | 281 + scripts/upload-backend-artifact.mjs | 160 + scripts/validate-aws-deploy.mjs | 1470 +++ scripts/verify-split-stack.mjs | 313 + 154 files changed, 38036 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/deploy-aws-self-hosted.yml create mode 100644 infrastructure/README.md create mode 100644 infrastructure/cloudformation/backend-api.yaml create mode 100644 infrastructure/cloudformation/bootstrap.yaml create mode 100644 infrastructure/cloudformation/frontend-site.yaml create mode 100644 infrastructure/cloudformation/full-stack.yaml create mode 100644 infrastructure/environments/README.md create mode 100644 infrastructure/environments/customer-values.sample.json create mode 100644 infrastructure/environments/deployment-workbook.md create mode 100644 infrastructure/environments/first-rollout-checklist.md create mode 100644 infrastructure/environments/github-actions-setup.md create mode 100644 infrastructure/environments/private-deployment-gitignore.sample create mode 100644 infrastructure/environments/private-deployment-repo.md create mode 100644 infrastructure/environments/private-deployment-workflow.sample.yml create mode 100644 infrastructure/environments/prod/README.md create mode 100644 infrastructure/environments/prod/app-config-secret.template.json create mode 100644 infrastructure/environments/prod/backend-parameters.json create mode 100644 infrastructure/environments/prod/bootstrap-parameters.json create mode 100644 infrastructure/environments/prod/deploy-split-stack.sh create mode 100644 infrastructure/environments/prod/frontend-parameters.json create mode 100644 infrastructure/environments/setup/api-repository-access.md create mode 100644 infrastructure/environments/setup/aws-account-access.md create mode 100644 infrastructure/environments/setup/aws-cli.md create mode 100644 infrastructure/environments/setup/aws-iam-roles.md create mode 100644 infrastructure/environments/setup/customer-values.md create mode 100644 infrastructure/environments/setup/deployment-repository.md create mode 100644 infrastructure/environments/setup/github-cli.md create mode 100644 infrastructure/environments/setup/github-deployment-secrets.md create mode 100644 infrastructure/environments/setup/github-environments.md create mode 100644 infrastructure/environments/setup/local-runtime.md create mode 100644 infrastructure/environments/staging/README.md create mode 100644 infrastructure/environments/staging/app-config-secret.template.json create mode 100644 infrastructure/environments/staging/backend-parameters.json create mode 100644 infrastructure/environments/staging/bootstrap-parameters.json create mode 100755 infrastructure/environments/staging/deploy-split-stack.sh create mode 100644 infrastructure/environments/staging/frontend-parameters.json create mode 100644 infrastructure/environments/start-here.md create mode 100644 infrastructure/examples/app-config-secret.sample.json create mode 100644 infrastructure/examples/audit-environment-starter-output.sample.json create mode 100644 infrastructure/examples/backend-outputs.sample.json create mode 100644 infrastructure/examples/backend-parameters.sample.json create mode 100644 infrastructure/examples/backend-stack-outputs.sample.json create mode 100644 infrastructure/examples/bootstrap-admin-secret.sample.json create mode 100644 infrastructure/examples/bootstrap-parameters.sample.json create mode 100644 infrastructure/examples/database-secret.sample.json create mode 100644 infrastructure/examples/deploy-aws-frontend-infra-output.sample.json create mode 100644 infrastructure/examples/deploy-aws-full-output.sample.json create mode 100644 infrastructure/examples/deploy-aws-publish-build-output.sample.json create mode 100644 infrastructure/examples/deploy-aws-publish-output.sample.json create mode 100644 infrastructure/examples/deploy-backend-output.sample.json create mode 100644 infrastructure/examples/deploy-bootstrap-output.sample.json create mode 100644 infrastructure/examples/deploy-frontend-output.sample.json create mode 100644 infrastructure/examples/deploy-frontend-publish-output.sample.json create mode 100644 infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json create mode 100644 infrastructure/examples/deploy-full-stack-full-output.sample.json create mode 100644 infrastructure/examples/deploy-full-stack-publish-build-output.sample.json create mode 100644 infrastructure/examples/deploy-full-stack-publish-output.sample.json create mode 100644 infrastructure/examples/dispatch-github-aws-deploy-output.sample.json create mode 100644 infrastructure/examples/frontend-outputs.sample.json create mode 100644 infrastructure/examples/frontend-parameters.sample.json create mode 100644 infrastructure/examples/full-stack-parameters.sample.json create mode 100644 infrastructure/examples/package-api-backend-output.sample.json create mode 100644 infrastructure/examples/package-manifest.sample.json create mode 100644 infrastructure/examples/plan-environment-deploy-output.sample.json create mode 100644 infrastructure/examples/prepare-environment-starter-output.sample.json create mode 100644 infrastructure/examples/publish-frontend-output.sample.json create mode 100644 infrastructure/examples/publish-lambda-layer-output.sample.json create mode 100644 infrastructure/examples/run-api-migrations-output.sample.json create mode 100644 infrastructure/examples/save-split-stack-outputs-output.sample.json create mode 100644 infrastructure/examples/show-rollout-status-output.sample.json create mode 100644 infrastructure/examples/sync-app-config-secret-output.sample.json create mode 100644 infrastructure/examples/sync-github-app-config-secret-output.sample.json create mode 100644 infrastructure/examples/sync-legacy-ssm-output.sample.json create mode 100644 infrastructure/examples/upload-backend-artifact-output.sample.json create mode 100644 infrastructure/examples/validate-api-migrations-output.sample.json create mode 100644 infrastructure/examples/validate-backend-output.sample.json create mode 100644 infrastructure/examples/validate-bootstrap-output.sample.json create mode 100644 infrastructure/examples/validate-frontend-output.sample.json create mode 100644 infrastructure/examples/validate-frontend-publish-output.sample.json create mode 100644 infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json create mode 100644 infrastructure/examples/validate-full-stack-output.sample.json create mode 100644 infrastructure/examples/validate-full-stack-publish-output.sample.json create mode 100644 infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json create mode 100644 infrastructure/examples/validate-split-stack-output.sample.json create mode 100644 infrastructure/examples/validate-split-stack-publish-output.sample.json create mode 100644 infrastructure/examples/verify-split-stack-output.sample.json create mode 100644 infrastructure/iam/README.md create mode 100644 infrastructure/iam/cloudformation-execution-policy.sample.json create mode 100644 infrastructure/iam/cloudformation-execution-role-trust.sample.json create mode 100644 infrastructure/iam/github-oidc-deploy-policy.sample.json create mode 100644 infrastructure/iam/github-oidc-deploy-role-trust.sample.json create mode 100644 scripts/audit-api-repo-contract.mjs create mode 100644 scripts/audit-environment-starter.mjs create mode 100644 scripts/bootstrap-initial-admin.mjs create mode 100644 scripts/deploy-aws.mjs create mode 100644 scripts/deploy-backend.mjs create mode 100644 scripts/deploy-bootstrap.mjs create mode 100644 scripts/deploy-frontend.mjs create mode 100644 scripts/deploy-full-stack.mjs create mode 100644 scripts/discover-github-aws-role-arns.mjs create mode 100644 scripts/dispatch-github-aws-deploy.mjs create mode 100644 scripts/environment-setup-wizard.mjs create mode 100644 scripts/installer-adopt-frontend-origin.mjs create mode 100644 scripts/installer-app-config-secret.mjs create mode 100644 scripts/installer-aws-handoff.mjs create mode 100644 scripts/installer-aws-preflight.mjs create mode 100644 scripts/installer-aws-roles.mjs create mode 100644 scripts/installer-bootstrap-admin.mjs create mode 100644 scripts/installer-browser-smoke.mjs create mode 100644 scripts/installer-common.mjs create mode 100644 scripts/installer-configure.mjs create mode 100644 scripts/installer-customer-values.mjs create mode 100644 scripts/installer-deploy.mjs create mode 100644 scripts/installer-doctor.mjs create mode 100644 scripts/installer-github-readiness.mjs create mode 100644 scripts/installer-github-setup.mjs create mode 100644 scripts/installer-init.mjs create mode 100644 scripts/installer-observe.mjs create mode 100644 scripts/installer-preflight.mjs create mode 100644 scripts/installer-report.mjs create mode 100644 scripts/installer-run.mjs create mode 100644 scripts/installer-start.mjs create mode 100644 scripts/installer-update.mjs create mode 100644 scripts/installer-verify.mjs create mode 100644 scripts/launch-staging.mjs create mode 100644 scripts/lib/api-migration-data-api-shim.mjs create mode 100644 scripts/lib/arg-utils.mjs create mode 100644 scripts/lib/environment-setup-metadata.mjs create mode 100644 scripts/lib/github-cli-readiness.mjs create mode 100644 scripts/lib/progress-utils.mjs create mode 100644 scripts/package-api-backend.mjs create mode 100644 scripts/plan-environment-deploy.mjs create mode 100644 scripts/prepare-environment-starter.mjs create mode 100644 scripts/publish-frontend-assets.mjs create mode 100644 scripts/publish-lambda-layer.mjs create mode 100644 scripts/reset-prod.mjs create mode 100644 scripts/reset-staging.mjs create mode 100644 scripts/run-api-migrations-data-api.mjs create mode 100644 scripts/run-api-migrations.mjs create mode 100644 scripts/save-split-stack-outputs.mjs create mode 100644 scripts/setup-private-deployment-repo.mjs create mode 100644 scripts/show-deployment-summary.mjs create mode 100644 scripts/show-environment-setup-guide.mjs create mode 100644 scripts/show-rollout-status.mjs create mode 100644 scripts/smoke-aws-tooling.mjs create mode 100644 scripts/sync-app-config-secret.mjs create mode 100644 scripts/sync-github-app-config-secret.mjs create mode 100644 scripts/sync-legacy-ssm-parameters.mjs create mode 100644 scripts/upload-backend-artifact.mjs create mode 100644 scripts/validate-aws-deploy.mjs create mode 100644 scripts/verify-split-stack.mjs diff --git a/.github/workflows/deploy-aws-self-hosted.yml b/.github/workflows/deploy-aws-self-hosted.yml new file mode 100644 index 000000000..8c7c51481 --- /dev/null +++ b/.github/workflows/deploy-aws-self-hosted.yml @@ -0,0 +1,383 @@ +name: Deploy AWS Self-Hosted + +on: + workflow_dispatch: + inputs: + environment: + description: "Environment starter to deploy" + required: true + type: choice + options: + - staging + - prod + aws_region: + description: "AWS region" + required: true + default: us-east-1 + type: string + deployment_source: + description: "How to provide the backend artifact" + required: true + default: api-repo + type: choice + options: + - api-repo + - package-manifest + - backend-artifact + api_repo: + description: "Repository to check out when deployment_source=api-repo" + required: false + default: ChurchApps/Api + type: string + api_ref: + description: "Git ref for the API repo checkout" + required: false + default: main + type: string + package_manifest_file: + description: "Manifest path when deployment_source=package-manifest" + required: false + default: "" + type: string + backend_artifact_source_file: + description: "Backend zip path when deployment_source=backend-artifact" + required: false + default: "" + type: string + migration_artifact_source_file: + description: "Optional migration zip path for backend-artifact mode" + required: false + default: "" + type: string + dependencies_layer_source_file: + description: "Optional dependencies layer zip path for backend-artifact mode" + required: false + default: "" + type: string + sync_app_config_secret: + description: "Write app-config-secret.json from AWS_APP_CONFIG_SECRET_JSON" + required: true + default: false + type: boolean + sync_bootstrap_admin_secret: + description: "Write bootstrap-admin-secret.json from AWS_BOOTSTRAP_ADMIN_SECRET_JSON" + required: true + default: false + type: boolean + run_api_migrations: + description: "Run Api CLI migrations after deploy" + required: true + default: false + type: boolean + run_bootstrap_admin: + description: "Seed the first admin login after deploy" + required: true + default: false + type: boolean + api_migration_action: + description: "Migration action when enabled" + required: true + default: up + type: choice + options: + - up + - down + - status + api_migration_module: + description: "Migration module when enabled" + required: true + default: all + type: choice + options: + - all + - membership + - attendance + - content + - giving + - messaging + - doing + - reporting + verify_http_after_deploy: + description: "Probe the frontend URL after deploy" + required: true + default: false + type: boolean + preview_only: + description: "Run starter audit plus deploy-plan preflight only" + required: true + default: false + type: boolean + +jobs: + deploy: + name: Deploy ${{ inputs.environment }} + runs-on: ubuntu-latest + environment: aws-${{ inputs.environment }} + permissions: + contents: read + id-token: write + + steps: + - name: Checkout B1Admin + uses: actions/checkout@v4 + + - name: Checkout Api repo + if: ${{ inputs.deployment_source == 'api-repo' }} + uses: actions/checkout@v4 + with: + repository: ${{ inputs.api_repo }} + ref: ${{ inputs.api_ref }} + path: Api + token: ${{ secrets.API_REPO_CHECKOUT_TOKEN || github.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Validate workflow inputs + run: | + case "${{ inputs.deployment_source }}" in + api-repo) + ;; + package-manifest) + if [[ -z "${{ inputs.package_manifest_file }}" ]]; then + echo "package_manifest_file is required when deployment_source=package-manifest." >&2 + exit 1 + fi + ;; + backend-artifact) + if [[ -z "${{ inputs.backend_artifact_source_file }}" ]]; then + echo "backend_artifact_source_file is required when deployment_source=backend-artifact." >&2 + exit 1 + fi + ;; + *) + echo "Unsupported deployment_source: ${{ inputs.deployment_source }}" >&2 + exit 1 + ;; + esac + + - name: Select AWS auth mode + id: auth_mode + env: + AWS_ROLE_TO_ASSUME_SECRET: ${{ secrets.AWS_ROLE_TO_ASSUME }} + run: | + if [[ -n "${AWS_ROLE_TO_ASSUME_SECRET}" ]]; then + echo "mode=oidc" >> "${GITHUB_OUTPUT}" + else + echo "mode=keys" >> "${GITHUB_OUTPUT}" + fi + + - name: Configure AWS Credentials via OIDC + if: ${{ steps.auth_mode.outputs.mode == 'oidc' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + role-session-name: b1admin-${{ inputs.environment }}-deploy + role-duration-seconds: 3600 + aws-region: ${{ inputs.aws_region }} + + - name: Configure AWS Credentials via access keys + if: ${{ steps.auth_mode.outputs.mode == 'keys' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region }} + + - name: Install Dependencies + run: npm ci + + - name: Install Api Dependencies + if: ${{ inputs.deployment_source == 'api-repo' }} + working-directory: Api + run: | + corepack enable + yarn install + + - name: Materialize app config secret + if: ${{ inputs.sync_app_config_secret }} + env: + AWS_APP_CONFIG_SECRET_JSON: ${{ secrets.AWS_APP_CONFIG_SECRET_JSON }} + run: | + if [[ -z "${AWS_APP_CONFIG_SECRET_JSON}" ]]; then + echo "Missing AWS_APP_CONFIG_SECRET_JSON secret for app config sync." >&2 + exit 1 + fi + printf '%s' "${AWS_APP_CONFIG_SECRET_JSON}" > "infrastructure/environments/${{ inputs.environment }}/app-config-secret.json" + + - name: Materialize bootstrap admin secret + if: ${{ inputs.sync_bootstrap_admin_secret }} + env: + AWS_BOOTSTRAP_ADMIN_SECRET_JSON: ${{ secrets.AWS_BOOTSTRAP_ADMIN_SECRET_JSON }} + run: | + if [[ -z "${AWS_BOOTSTRAP_ADMIN_SECRET_JSON}" ]]; then + echo "Missing AWS_BOOTSTRAP_ADMIN_SECRET_JSON secret for bootstrap admin sync." >&2 + exit 1 + fi + printf '%s' "${AWS_BOOTSTRAP_ADMIN_SECRET_JSON}" > "infrastructure/environments/${{ inputs.environment }}/bootstrap-admin-secret.json" + + - name: Write preflight plan summary + env: + AWS_REGION: ${{ inputs.aws_region }} + API_REPO_PATH: ./Api + PACKAGE_MANIFEST_FILE: ${{ inputs.deployment_source == 'package-manifest' && inputs.package_manifest_file || '' }} + BACKEND_ARTIFACT_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.backend_artifact_source_file || '' }} + MIGRATION_ARTIFACT_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.migration_artifact_source_file || '' }} + DEPENDENCIES_LAYER_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.dependencies_layer_source_file || '' }} + SYNC_APP_CONFIG_SECRET: ${{ inputs.sync_app_config_secret && 'true' || 'false' }} + SYNC_BOOTSTRAP_ADMIN_SECRET: ${{ inputs.sync_bootstrap_admin_secret && 'true' || 'false' }} + RUN_API_MIGRATIONS: ${{ inputs.run_api_migrations && 'true' || 'false' }} + RUN_BOOTSTRAP_ADMIN: ${{ inputs.run_bootstrap_admin && 'true' || 'false' }} + API_MIGRATION_ACTION: ${{ inputs.api_migration_action }} + API_MIGRATION_MODULE: ${{ inputs.api_migration_module }} + API_MIGRATION_RUNNER: data-api + VERIFY_HTTP_AFTER_DEPLOY: ${{ inputs.verify_http_after_deploy && 'true' || 'false' }} + run: | + PLAN_DIR="deployment/${{ inputs.environment }}" + PLAN_FILE="${PLAN_DIR}/preflight-plan.md" + mkdir -p "${PLAN_DIR}" + { + echo "## Preflight Plan" + echo "" + } >> "${GITHUB_STEP_SUMMARY}" + if yarn plan:environment-deploy -- \ + --environment="${{ inputs.environment }}" \ + --region="${{ inputs.aws_region }}" \ + --deployment-source="${{ inputs.deployment_source }}" \ + --api-repo-path="./Api" \ + --api-repo="${{ inputs.api_repo }}" \ + --api-ref="${{ inputs.api_ref }}" \ + --package-manifest-file="${PACKAGE_MANIFEST_FILE}" \ + --backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}" \ + --migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}" \ + --dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}" \ + --sync-app-config-secret="${SYNC_APP_CONFIG_SECRET}" \ + --sync-bootstrap-admin-secret="${SYNC_BOOTSTRAP_ADMIN_SECRET}" \ + --run-api-migrations="${RUN_API_MIGRATIONS}" \ + --run-bootstrap-admin="${RUN_BOOTSTRAP_ADMIN}" \ + --api-migration-action="${API_MIGRATION_ACTION}" \ + --api-migration-module="${API_MIGRATION_MODULE}" \ + --api-migration-runner="${API_MIGRATION_RUNNER}" \ + --verify-http-after-deploy="${VERIFY_HTTP_AFTER_DEPLOY}" \ + --output=markdown > "${PLAN_FILE}"; then + cat "${PLAN_FILE}" >> "${GITHUB_STEP_SUMMARY}" + : + else + cat "${PLAN_FILE}" >> "${GITHUB_STEP_SUMMARY}" || true + { + echo "" + echo "_The preflight plan reported blockers. The deploy step will re-check them and may stop early._" + } >> "${GITHUB_STEP_SUMMARY}" + fi + + - name: Deploy environment + env: + AWS_REGION: ${{ inputs.aws_region }} + CLOUDFORMATION_EXECUTION_ROLE_ARN: ${{ secrets.AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN }} + API_REPO_PATH: ./Api + PACKAGE_MODE: layered + PACKAGE_MANIFEST_FILE: ${{ inputs.deployment_source == 'package-manifest' && inputs.package_manifest_file || '' }} + BACKEND_ARTIFACT_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.backend_artifact_source_file || '' }} + MIGRATION_ARTIFACT_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.migration_artifact_source_file || '' }} + DEPENDENCIES_LAYER_SOURCE_FILE: ${{ inputs.deployment_source == 'backend-artifact' && inputs.dependencies_layer_source_file || '' }} + SYNC_APP_CONFIG_SECRET: ${{ inputs.sync_app_config_secret && 'true' || 'false' }} + SYNC_BOOTSTRAP_ADMIN_SECRET: ${{ inputs.sync_bootstrap_admin_secret && 'true' || 'false' }} + RUN_API_MIGRATIONS: ${{ inputs.run_api_migrations && 'true' || 'false' }} + RUN_BOOTSTRAP_ADMIN: ${{ inputs.run_bootstrap_admin && 'true' || 'false' }} + API_MIGRATION_ACTION: ${{ inputs.api_migration_action }} + API_MIGRATION_MODULE: ${{ inputs.api_migration_module }} + API_MIGRATION_RUNNER: data-api + VERIFY_HTTP_AFTER_DEPLOY: ${{ inputs.verify_http_after_deploy && 'true' || 'false' }} + PREVIEW_ONLY: ${{ inputs.preview_only && 'true' || 'false' }} + run: ./infrastructure/environments/${{ inputs.environment }}/deploy-split-stack.sh + + - name: Save source metadata + if: ${{ success() && !inputs.preview_only }} + env: + B1ADMIN_REPO: ${{ github.repository }} + B1ADMIN_REF: ${{ github.ref_name }} + API_REPO: ${{ inputs.api_repo }} + API_REF: ${{ inputs.api_ref }} + DEPLOYMENT_SOURCE: ${{ inputs.deployment_source }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + PRIVATE_REPO_SHA: ${{ github.sha }} + run: | + mkdir -p "deployment/${{ inputs.environment }}" + node -e ' + const fs = require("fs"); + const child = require("child_process"); + const git = (cwd) => child.execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(); + const metadata = { + ok: true, + environment: "${{ inputs.environment }}", + writtenAt: new Date().toISOString(), + githubActions: { + runId: process.env.WORKFLOW_RUN_ID, + privateRepoSha: process.env.PRIVATE_REPO_SHA + }, + b1admin: { + repo: process.env.B1ADMIN_REPO, + ref: process.env.B1ADMIN_REF, + sha: git(".") + }, + api: { + repo: process.env.API_REPO, + ref: process.env.API_REF, + sha: process.env.DEPLOYMENT_SOURCE === "api-repo" ? git("Api") : "" + } + }; + fs.writeFileSync("deployment/${{ inputs.environment }}/source-metadata.json", `${JSON.stringify(metadata, null, 2)}\n`); + ' + + - name: Upload deployment evidence + if: ${{ success() && !inputs.preview_only }} + uses: actions/upload-artifact@v4 + with: + name: aws-${{ inputs.environment }}-deployment-evidence + path: deployment/${{ inputs.environment }}/ + if-no-files-found: error + + - name: Upload preflight plan for preview-only run + if: ${{ success() && inputs.preview_only }} + uses: actions/upload-artifact@v4 + with: + name: aws-${{ inputs.environment }}-preflight-plan + path: deployment/${{ inputs.environment }}/preflight-plan.md + if-no-files-found: error + + - name: Upload preflight plan on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: aws-${{ inputs.environment }}-preflight-plan + path: deployment/${{ inputs.environment }}/preflight-plan.md + if-no-files-found: ignore + + - name: Write deployment summary + if: ${{ success() && !inputs.preview_only }} + run: | + SUMMARY_FILE="deployment/${{ inputs.environment }}/deployment-summary.json" + if [[ ! -f "${SUMMARY_FILE}" ]]; then + echo "Missing deployment summary file: ${SUMMARY_FILE}" >&2 + exit 1 + fi + yarn show:deployment-summary -- --summary-file="${SUMMARY_FILE}" --output=markdown >> "${GITHUB_STEP_SUMMARY}" + { + echo "" + echo "- Artifact: \`aws-${{ inputs.environment }}-deployment-evidence\`" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Write preview-only summary + if: ${{ success() && inputs.preview_only }} + run: | + { + echo "" + echo "## Preview-Only Result" + echo "" + echo "- Preview-only mode: \`true\`" + echo "- AWS mutation skipped after starter audit and deploy-plan preflight." + echo "- Artifact: \`aws-${{ inputs.environment }}-preflight-plan\`" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.gitignore b/.gitignore index 35c1e6d90..779ccb1f5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ /test-results.json .serena .claude -Notes.md -#yarn.lock +Notes.md +.DS_Store +#yarn.lock # Logs logs @@ -98,9 +99,15 @@ typings/ # dotenv environment variables file .env -.env.* -!.env.sample -!.env.example +.env.* +!.env.sample +!.env.example +infrastructure/environments/*/app-config-secret.json +infrastructure/environments/*/bootstrap-admin-secret.json +deployment/ +infrastructure/debug/ +infrastructure/iam/generated/ +infrastructure/artifacts/ # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index aadd4f94c..50b1eb3d4 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,7 @@ To accept online donations you must first register for developer credentials wit After obtaining your tokens, open **Settings → Giving Settings** in B1Admin, select the provider, paste in your Public and Private keys, and toggle "Pay Fees" as desired. Finally, configure your fee parameters in **Fee Options**. [![B1Admin Dev Setup](https://img.youtube.com/vi/5zsEJEp6yMw/0.jpg)](https://www.youtube.com/watch?v=5zsEJEp6yMw) + +### AWS Self-Hosting + +To deploy B1Admin into your own AWS account, or update an existing AWS install, start with the [AWS self-hosting guide](./infrastructure/environments/start-here.md). diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 000000000..be5a2ca85 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,1184 @@ +# AWS Deployment + +This repo now includes AWS deployment building blocks for both the B1Admin frontend and a backend foundation: + +- Bootstrap stack: [`cloudformation/bootstrap.yaml`](./cloudformation/bootstrap.yaml) +- Frontend stack: [`cloudformation/frontend-site.yaml`](./cloudformation/frontend-site.yaml) +- Backend stack: [`cloudformation/backend-api.yaml`](./cloudformation/backend-api.yaml) +- Full-stack nested template: [`cloudformation/full-stack.yaml`](./cloudformation/full-stack.yaml) +- Full deployment wrapper: [`../scripts/deploy-aws.mjs`](../scripts/deploy-aws.mjs) +- Nested-stack deploy helper: [`../scripts/deploy-full-stack.mjs`](../scripts/deploy-full-stack.mjs) + +## What This Repo Owns + +- S3 bucket for frontend assets +- CloudFront distribution with SPA routing fallback +- Optional Route53 alias record +- Asset upload + cache invalidation workflow +- Backend VPC with public/private subnets +- Optional NAT gateway for private Lambda egress +- Private AWS service endpoints for S3 and Secrets Manager when NAT is disabled +- Aurora Serverless v2 cluster +- Lambda execution role and VPC networking +- HTTP API Gateway in front of the backend Lambda +- Default server-side encryption on deployment and frontend S3 buckets +- Aurora cluster snapshot preservation on stack delete or replacement +- Retained S3 buckets so stack teardown does not fail on non-empty deployment buckets +- Common runtime IAM for WebSocket management, S3 asset storage, SES mail sending, and Polly speech synthesis + +## What This Repo Does Not Own + +This repo still does not contain the backend API application source or database migrations. The infrastructure here can provision the AWS foundation, but you still need: + +- A packaged backend Lambda artifact uploaded to S3 +- API application code that can run inside Lambda +- Database schema migrations and optional initial-admin bootstrap data +- Any additional supporting services your backend uses + +The frontend should still treat backend/public values as inputs, not assumptions. + +If you also have the real Api repo checked out locally, this repo now includes a backend packaging helper: + +- `yarn package:api-backend -- --api-repo-path=` +- `yarn audit:api-repo-contract -- --api-repo-path= --output=markdown` + +By default it builds the Api repo and creates a self-contained Lambda zip that works with the current CloudFormation backend path. It can also package a layered artifact set with `--package-mode=layered` when you want to stay closer to the Api repo's current Serverless packaging model. +The first successful live private-repo staging rollout on June 24, 2026 ultimately required the layered path because the self-contained Api artifact exceeded Lambda's 250 MB unzipped limit. +The helper now works with the Api repo's Yarn Berry setup through Corepack, so it does not require a globally installed `yarn` binary as long as `corepack` is available. +If the referenced Api repo path exists but key files are unreadable in the current environment, the helper now fails early with a direct readability error instead of falling through into a later Corepack/Yarn child-process failure. +There is also a layer publication helper when you want to promote a packaged dependency layer into AWS directly: + +- `yarn publish:lambda-layer -- --layer-name= --source-file=` + +And there is now a Secrets Manager sync helper for the backend's non-database runtime config: + +- `yarn sync:app-config-secret -- --secret-name= --secret-file=infrastructure/examples/app-config-secret.sample.json` + +If you want the same JSON pushed into a GitHub Actions deployment environment secret for the self-hosted workflow, there is also: + +- `yarn sync:github-app-config-secret -- --environment=staging --secret-file=infrastructure/environments/staging/app-config-secret.json` + +And if you want one wrapper to sync that GitHub environment secret first and then dispatch the workflow with the checked inputs, there is also: + +- `yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --repo=ChurchApps/B1Admin` + +That wrapper now checks `gh auth status` even in `--dry-run=true` mode so a local validation run only reports success when this machine can really use GitHub CLI for the next step. It now distinguishes missing `gh`, invalid GitHub auth, and basic connectivity failures to `github.com`. If you need an offline/test-only dry run, pass `--skip-gh-auth-check=true`. + +If you still rely on the real Api repo's legacy Parameter Store layout, there is also an SSM compatibility helper: + +- `yarn sync:legacy-ssm -- --stack-name= --environment=prod --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` + +## Real Api Repo Contract + +The real backend repo, typically checked out beside this repo at a path such as `../Api`, uses a more specific Lambda contract than a generic single-handler API: + +- primary HTTP Lambda handler: `lambda.web` +- runtime: `nodejs22.x` +- additional Lambda entrypoints in the same package for WebSocket and timer workloads +- deploy-time config in Serverless today is sourced heavily from SSM Parameter Store +- application boot expects `ENVIRONMENT` or `STAGE`, not just `APP_ENV` +- application boot expects MySQL-style per-module connection strings such as `MEMBERSHIP_CONNECTION_STRING` + +The templates in this repo now default the main backend Lambda to `lambda.web` on `nodejs22.x`, and they set `ENVIRONMENT` plus `STAGE` alongside `APP_ENV`. +They also now default the database layer toward Aurora MySQL and generate module-specific MySQL connection strings for membership, attendance, content, giving, messaging, doing, and reporting, plus `DOING_MEMBERSHIP_CONNECTION_STRING`. +They can also provision the Api repo's `lambda.socket` WebSocket handler and the scheduled timer handlers from the same packaged artifact. +They now also expose the Api repo's core runtime config knobs for file storage, mail system, delivery provider, admin URL, store API URL, socket URL, and CORS without needing a separate Serverless-only config layer for those basics. +They also support an optional `AppConfigSecretArn` that can supply the Api repo's non-database secrets and provider keys from a single Secrets Manager JSON document. +They now also support optional Lambda layer ARNs plus `NODE_OPTIONS`, so you can deploy either a fully self-contained backend zip or a packaging flow closer to the Api repo's current Serverless layer setup. +The deployment helpers now also capture the S3 `VersionId` returned by the artifact bucket and pass it into CloudFormation, so backend redeploys pick up changed zip contents even when the artifact key stays the same. + +There are still important gaps between the current CloudFormation backend stack and the full Api repo deployment model: + +- it still reads many deploy-time secrets/config values from SSM Parameter Store today +- the existing Serverless deployment uses a broader IAM/service integration footprint than this stack currently models +- the exact backend packaging/build pipeline is still driven from the Api repo, not from this repo + +That means the current backend template is closer to an AWS hosting foundation for the Api repo than a one-to-one replacement for its existing `serverless.yml`. + +## Stack Layout + +### `bootstrap` + +The bootstrap template provisions: + +- S3 bucket for nested CloudFormation templates +- S3 bucket for Lambda/build artifacts +- Server-side encryption on both buckets by default +- CloudFormation retain policies on both buckets to avoid deleting shared deployment assets automatically + +Use it once per environment or account before the other deployment flows if you do not already have suitable buckets. + +### `backend-api` + +The backend template provisions: + +- VPC +- 2 public subnets +- 2 private subnets +- Optional NAT gateway +- Private S3 gateway endpoint and Secrets Manager interface endpoint when NAT is disabled +- Lambda security group +- Aurora Serverless v2 cluster and writer instance +- HTTP API Gateway +- Optional WebSocket API Gateway wired to `lambda.socket` +- Optional scheduled worker Lambdas for the Api repo timer handlers +- Lambda function sourced from a packaged zip in S3 +- Optional managed S3 asset/content bucket for uploaded media when `FileStore=S3` +- IAM permissions for WebSocket connection management, optional asset-bucket S3 access, SES sending when `MailSystem=SES`, and Polly speech synthesis + +The Aurora cluster now uses CloudFormation `DeletionPolicy: Snapshot` and `UpdateReplacePolicy: Snapshot`, so accidental stack deletion or cluster replacement preserves a final DB snapshot instead of dropping the data immediately. + +The backend template now also generates its own Secrets Manager database password with a URL-safe character set instead of relying on Aurora's opaque managed password generation. That matters because the real Api repo currently consumes MySQL connection URLs from environment variables, and unescaped `@` or `/` characters in a generated password can break that parser. + +### `frontend-site` + +The frontend template provisions: + +- S3 asset bucket +- CloudFront distribution +- Optional Route53 alias records +- Server-side encryption on the asset bucket by default +- CloudFormation retain policy on the asset bucket so uploaded site files are not auto-deleted during stack teardown + +Because these buckets are retained, deleting the related CloudFormation stack will leave the S3 buckets behind. If you intentionally want to remove them, empty and delete the buckets manually after the stack is gone. + +### `deploy:aws` + +The wrapper script deploys the backend stack first, then deploys the frontend stack while automatically importing backend outputs into the frontend build. If you already have a saved backend outputs JSON, you can now pass `--backend-outputs-file=...` to the split-stack wrapper so the frontend half reuses that file instead of reading the backend stack directly. In the later staged publish-only phase (`--skip-backend --skip-frontend --publish-frontend-assets`), it no longer needs the bootstrap stack because that step only reuses existing frontend/backend stack outputs. + +### `full-stack` + +The full-stack CloudFormation template composes the backend and frontend templates as nested stacks. It is best when you want a single infrastructure stack entrypoint, and the `deploy:full-stack` helper now also builds and publishes the frontend bundle unless you pass `--infrastructure-only` or `--frontend-infrastructure-only`. For a later frontend-only publish pass against an existing full-stack deployment, the helper also supports `--skip-infrastructure --publish-frontend-assets`, and that publish-only phase no longer depends on the bootstrap stack because it reuses the already-deployed full-stack outputs. It can also now publish from saved frontend/backend outputs files, or direct bucket/distribution values, when you do not want that later phase to read the full-stack CloudFormation outputs again. + +The full-stack outputs now surface not just frontend publishing values, but also the main backend operational values from the nested backend stack, including: + +- API function name and ARN +- migration function name +- socket function name, WebSocket API ID, and WebSocket endpoint +- scheduled worker function names +- database endpoint, reader endpoint, cluster ARN, port, name, module DB names, and secret ARN +- VPC ID, private subnet IDs, and Lambda security group ID + +Across the helper scripts, CLI flags can also be supplied via environment variables using uppercase underscore names. For example, `--backend-outputs-file` can come from `BACKEND_OUTPUTS_FILE`, and `--stack-name` can come from `STACK_NAME`. + +## Packaging The Real Api Repo + +If the backend source lives beside this repo, the simplest portable path is: + +1. Build and package the backend: + `yarn package:api-backend -- --api-repo-path= --environment=prod` +2. Upload the produced backend zip: + `yarn upload:backend-artifact -- --bootstrap-stack-name= --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/backend/api.zip` +3. Deploy the backend or full stack with the matching `LambdaCodeS3Key`. + +If your CI flow also builds a separate migration zip, you can attach it to the same manifest contract up front: + +- `yarn package:api-backend -- --api-repo-path= --environment=prod --migration-artifact-path=infrastructure/artifacts/api/api-prod-migrations.zip` + +All of the helpers that accept `--api-repo-path` also honor `API_REPO_PATH=` if that is easier for your local shell or CI environment. +If your CI pipeline already ran `package:api-backend`, the higher-level deploy helpers now also accept `--package-manifest-file=` so they can reuse the generated backend artifact and optional layer artifact without re-inspecting the Api repo checkout. That same manifest path also works with `validate:aws-deploy`, and its suggested `upload:backend-artifact` follow-up will reuse the manifest's saved artifact path directly. +The `package:api-backend -- --output=json` result and the written manifest now also include: + +- `recommendedBackendArtifactKey` +- `recommendedMigrationArtifactKey` +- manifest-driven `deploy:backend`, `deploy:aws`, and `deploy:full-stack` next-step hints +- an optional `migrationArtifactPath` when you want the same manifest contract to carry a separate migration zip too + +Artifact paths inside that manifest can now be relative to the manifest file itself, which makes the manifest portable across different local checkout paths and CI workspaces. A machine-readable sample of the direct `package:api-backend -- --output=json` result is included at [`examples/package-api-backend-output.sample.json`](./examples/package-api-backend-output.sample.json). The written manifest shape is also documented at [`examples/package-manifest.sample.json`](./examples/package-manifest.sample.json). The smoke suite contract-checks both samples against representative `package:api-backend -- --output=json` runs so they do not silently drift. + +The packaging helper supports two modes: + +- `self-contained`: + Builds a Lambda zip that includes `dist`, `config`, `lambda.js`, `package.json`, and `node_modules`. This is still useful for smaller backend builds, but the live June 24, 2026 staging rollout showed that the current Api package can exceed Lambda's 250 MB unzipped limit in this mode. +- `layered`: + Builds the Serverless-style main zip plus a separate `layer` zip. Use this when you want to stay closer to the Api repo's current packaging model. It is now the safer default for the AWS environment wrappers because it keeps the function zips below Lambda's unzipped size limit. The deploy wrappers publish the layer for you and pass its ARN through `DependenciesLayerArn`. + +If you want fewer manual steps, the higher-level deploy wrappers can now call that packaging helper for you when you point them at the Api repo: + +- `yarn deploy:backend -- --bootstrap-stack-name= --api-repo-path= --package-mode=self-contained` +- `yarn deploy:aws -- --api-repo-path= --package-mode=layered ...` +- `yarn deploy:full-stack -- --api-repo-path= --package-mode=layered ...` + +Or, if packaging already happened elsewhere: + +- `yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` +- `yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` +- `yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` + +For the layered variant: + +- `yarn deploy:backend -- --bootstrap-stack-name= --api-repo-path= --package-mode=layered` +- `yarn deploy:aws -- --api-repo-path= --package-mode=layered ...` +- `yarn deploy:full-stack -- --api-repo-path= --package-mode=layered ...` + +When those wrappers upload a backend artifact for you and you do not pass an explicit key, they now default to: + +- backend artifact: `//backend/api.zip` +- migration artifact: `//backend/migrations.zip` + +The `--api-repo-path` wrapper flows still require the Api repo to have already run `corepack yarn install`, because the packaging helper builds from the real local checkout. The `--package-manifest-file` path does not have that requirement because it only reuses already-packaged artifacts. +If your machine can see the sibling Api checkout but local commands still fail with a permissions/readability error such as `Operation not permitted`, treat that the same as an unreadable local repo path: switch the local run to `--package-manifest-file=...` or `--backend-artifact-source-file=...`, or use the GitHub Actions `api-repo` path if that runner can read the backend repo. +When you are preparing the environment starter files, `prepare:environment-starter --write=true --write-secret-file=false` now lets you write the non-secret bootstrap/backend/frontend JSON first without materializing `app-config-secret.json` yet. The same helper also accepts `--mobile-app-url`, `--domain-cname-target`, `--domain-a-target`, `--default-stock-photo`, and `--google-analytics-tag` for the optional public runtime fields surfaced by the backend stack. +If you want a lower-risk first pass before packaging, `audit:api-repo-contract` checks the sibling Api repo for the expected `lambda.web`, `lambda.socket`, timer handlers, migration-module hints, build scripts, and package-layout readiness. + +### Local Api Repo Access Troubleshooting + +If a sibling checkout such as `../Api` is visible on disk but local commands still fail with a permissions/readability error, the quickest local fallbacks are: + +- reuse a manifest that was already produced elsewhere: + `yarn deploy:aws -- --package-manifest-file= ...` +- reuse a prepared backend zip directly: + `yarn deploy:aws -- --backend-artifact-source-file= ...` +- generate a full local fallback plan first: + `yarn plan:environment-deploy -- --environment=staging --deployment-source=package-manifest --package-manifest-file= --output=markdown` +- or: + `yarn plan:environment-deploy -- --environment=staging --deployment-source=backend-artifact --backend-artifact-source-file= --output=markdown` + +The same unreadable-repo guidance now appears in `plan:environment-deploy`, `validate:aws-deploy`, and the split-stack environment scripts, so any of those entrypoints should now point you back to the same manifest/artifact alternatives. + +For that layered path you can now either publish the layer manually: + +- `yarn publish:lambda-layer -- --region=us-east-1 --layer-name=b1admin-prod-dependencies --source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip` + +Or let the higher-level deploy wrappers publish it for you: + +- `yarn deploy:backend -- --api-repo-path= --package-mode=layered ...` +- `yarn deploy:aws -- --dependencies-layer-source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip ...` +- `yarn deploy:full-stack -- --dependencies-layer-source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip ...` + +## Frontend Stack Inputs + +The CloudFormation template accepts these parameters: + +- `ProjectName`: naming/tagging prefix +- `EnvironmentName`: `dev`, `staging`, `prod`, or similar +- `BucketName`: optional explicit S3 bucket name +- `AlternateDomainName`: optional custom domain such as `admin.example.com` +- `AcmCertificateArn`: optional ACM cert in `us-east-1`, required with a custom domain +- `HostedZoneId`: optional Route53 zone ID for automatic alias record creation +- `PriceClass`: CloudFront price class + +## Bootstrap Stack Inputs + +The bootstrap template accepts: + +- `ProjectName` +- `EnvironmentName` +- `TemplateBucketName` +- `ArtifactBucketName` +- `EnableBucketVersioning` + +## Backend Stack Inputs + +The backend template accepts: + +- `ProjectName` +- `EnvironmentName` +- `LambdaCodeS3Bucket` +- `LambdaCodeS3Key` +- `LambdaHandler` +- `LambdaRuntime` +- `LambdaArchitecture` +- `LambdaMemorySize` +- `LambdaTimeout` +- `LambdaReservedConcurrency` +- `DependenciesLayerArn` +- `ObservabilityLayerArn` +- `LambdaNodeOptions` +- `EnableWebSocketApi` +- `SocketLambdaHandler` +- `SocketLambdaMemorySize` +- `SocketLambdaTimeout` +- `EnableScheduledWorkers` +- `Timer15MinLambdaHandler` +- `TimerMidnightLambdaHandler` +- `TimerScheduledTasksLambdaHandler` +- `TimerWebhooksLambdaHandler` +- `TimerLambdaMemorySize` +- `TimerLambdaTimeout` +- `RunMigrations` +- `MigrationCodeS3Bucket` +- `MigrationCodeS3Key` +- `MigrationHandler` +- `MigrationRuntime` +- `MigrationMemorySize` +- `MigrationTimeout` +- `MigrationTrigger` +- `DatabaseName` +- `MembershipDatabaseName` +- `AttendanceDatabaseName` +- `ContentDatabaseName` +- `GivingDatabaseName` +- `MessagingDatabaseName` +- `DoingDatabaseName` +- `ReportingDatabaseName` +- `DatabaseEngine` +- `DatabasePort` +- `DatabaseMasterUsername` +- `DatabaseMinCapacity` +- `DatabaseMaxCapacity` +- `ApiCustomDomainName` +- `ApiCertificateArn` +- `ApiHostedZoneId` +- `CreateNatGateway` +- `B1AdminRootUrl` +- `CorsOrigin` +- `FileStore` +- `ManageAssetBucket` +- `AssetBucketName` +- `AppConfigSecretArn` +- `MailSystem` +- `DeliveryProvider` +- `StoreApiUrl` +- `AiProvider` +- `EmailOnRegistration` +- `CaddyHost` +- `CaddyPort` +- VPC and subnet CIDRs +- Optional public/frontend-facing values such as `WebsiteBaseUrl`, `ContentRootUrl`, `TransferUrl`, `SupportEmail`, and related settings + +When `CreateNatGateway=false`, the stack now creates the minimum private AWS endpoints needed for this deployment path itself: + +- S3 gateway endpoint so the migration custom resource can return its CloudFormation response without public internet access +- Secrets Manager interface endpoint so Lambda can read the Aurora master secret without public internet access + +If your backend needs any other outbound internet access or private AWS APIs, you should either leave NAT enabled or add the extra VPC endpoints your application requires. + +For the real Api repo, the current backend stack now injects MySQL-style connection strings for all module databases. The membership database name defaults from `DatabaseName`, while the other module DB names default to `attendance`, `content`, `giving`, `messaging`, `doing`, and `reporting` unless you override them with the explicit `*DatabaseName` parameters. +It also injects the core non-database runtime settings the Api repo reads from `Environment.ts`, including `CONTENT_ROOT`, `B1ADMIN_ROOT`, `FILE_STORE`, `AWS_S3_BUCKET`, `MAIL_SYSTEM`, `DELIVERY_PROVIDER`, `STORE_API_URL`, `CORS_ORIGIN`, `SOCKET_URL`, and `WEBSOCKET_API_ID`. +If `FileStore=S3` and you leave both `AssetBucketName` and `ContentRootUrl` blank, the backend stack can now create a managed content bucket for you when `ManageAssetBucket=true` and infer `CONTENT_ROOT` from that bucket's regional S3 URL. +If you want to stay closer to the Api repo's current Serverless packaging model, you can also provide `DependenciesLayerArn`, `ObservabilityLayerArn`, and `LambdaNodeOptions` instead of forcing everything into one zip. Only set `LambdaNodeOptions` for imports such as `@sentry/aws-serverless/awslambda-auto` when the referenced package is actually present in the deployed zip or layer artifact. +The Lambda role now also includes the common AWS permissions this repo most clearly needs at runtime: WebSocket connection management, S3 access for the configured asset bucket, SES send actions when `MailSystem=SES`, and Polly speech synthesis. + +If you set `AppConfigSecretArn`, the backend Lambdas will also read additional non-database secrets/config from that Secrets Manager JSON document using dynamic references. The expected JSON keys currently include: + +- `jwtSecret` +- `encryptionKey` +- `hubspotKey` +- `mauticUrl` +- `mauticUser` +- `mauticPassword` +- `youTubeApiKey` +- `pexelsKey` +- `vimeoToken` +- `apiBibleKey` +- `youVersionApiKey` +- `praiseChartsConsumerKey` +- `praiseChartsConsumerSecret` +- `googleRecaptchaSecretKey` +- `openRouterApiKey` +- `openAiApiKey` +- `webPushPublicKey` +- `webPushPrivateKey` +- `webPushSubject` + +A sample JSON shape for that secret now lives at [`examples/app-config-secret.sample.json`](./examples/app-config-secret.sample.json). +At minimum, treat `jwtSecret` and `encryptionKey` as required non-empty values for a viable self-hosted backend boot path. +For a real environment, also replace the starter `webPushSubject` mailbox instead of leaving it at `mailto:support@example.com`. +If you want fewer manual steps, the deploy helpers can now sync that secret for you and resolve `AppConfigSecretArn` automatically: + +- `yarn deploy:backend -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` +- `yarn deploy:aws -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` +- `yarn deploy:full-stack -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` + +## Migration Reality Check + +The CloudFormation templates support an optional migration Lambda/custom-resource flow, but the real Api repo currently ships CLI-oriented migration tooling in `tools/migrate.ts`, not a proven Lambda handler such as `dist/migrate.handler`. + +That means: + +- the migration fields in these templates are still useful as an integration point +- but for the current real Api repo, `RunMigrations=true` should currently be treated as a custom extension you still need to implement and verify +- the sample backend/full-stack parameter files now default `RunMigrations` to `false` to avoid implying that a ready-made Lambda migration handler already exists + +For the real Api repo, this repo now includes a helper to run those CLI migrations against a deployed AWS database by reading stack outputs plus the Aurora secret and exporting the expected `*_CONNECTION_STRING` env vars: + +```bash +yarn run:api-migrations -- \ + --api-repo-path= \ + --stack-name=b1admin-prod-backend \ + --action=up \ + --module=all \ + --region=us-east-1 +``` + +If you want to test the resolved migration command/env wiring locally without touching AWS, use the sample files: + +```bash +yarn run:api-migrations -- \ + --api-repo-path= \ + --outputs-file=infrastructure/examples/backend-stack-outputs.sample.json \ + --db-secret-file=infrastructure/examples/database-secret.sample.json \ + --action=status \ + --module=all \ + --dry-run=true \ + --output=json +``` + +You can preflight that standalone migration helper before running it: + +```bash +yarn validate:aws-deploy -- \ + --mode=api-migrations \ + --api-repo-path= \ + --outputs-file=infrastructure/examples/backend-stack-outputs.sample.json \ + --db-secret-file=infrastructure/examples/database-secret.sample.json \ + --action=status \ + --module=all \ + --dry-run=true \ + --output=json +``` + +## Bootstrapping The First Admin Login + +Fresh AWS environments do not get a default sign-in automatically from migrations alone. To make that repeatable without loading demo data, this repo now includes an Aurora Data API helper that seeds: + +- one admin user +- one church record +- the standard `Domain Admins` and `All Members` roles +- the linking `person`, `userChurch`, and `roleMembers` rows + +Start from [`examples/bootstrap-admin-secret.sample.json`](./examples/bootstrap-admin-secret.sample.json), copy it to a private file, and replace every placeholder value. + +Then run: + +```bash +yarn run:bootstrap-admin -- \ + --stack-name=b1admin-prod-backend \ + --region=us-east-1 \ + --bootstrap-admin-secret-file=/absolute/path/to/bootstrap-admin-secret.json +``` + +If you only want to verify the resolved target before touching AWS, add `--dry-run=true --output=json`. + +The helper is idempotent. On rerun it will: + +- reuse the existing church by subdomain +- reuse the existing user by email +- repair any missing role, permission, `person`, `userChurch`, or `roleMembers` rows +- reset the bootstrap user's password again by default + +If you do not want reruns to overwrite that password, add: + +```bash +--bootstrap-admin-reset-password=false +``` + +You can also have the main deploy helpers run this step immediately after a successful backend deploy and optional Api migrations: + +```bash +yarn deploy:aws -- \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --api-repo-path= \ + --run-api-migrations=true \ + --run-bootstrap-admin=true \ + --bootstrap-admin-secret-file=/absolute/path/to/bootstrap-admin-secret.json +``` + +The same flags are also supported by: + +- `yarn deploy:backend` +- `yarn deploy:full-stack` + +For GitHub Actions or other workflow-driven deploys, the workflow-side deploy role needs Aurora Data API transaction permissions plus `secretsmanager:GetSecretValue` if the bootstrap helper runs from the workflow host. + +That helper is a post-deploy operational step, not a CloudFormation custom resource, but it closes much of the gap between the current AWS infrastructure path and the real Api repo's migration model. + +The helper URL-encodes database usernames, passwords, and schema names when it builds `mysql://...` connection strings, so it is safer with real Secrets Manager values that contain reserved URI characters. +It also supports targeted module runs with only the outputs for that module, so `--module=attendance` does not require the full set of module database names. +For the current real Api repo specifically, `--module=all` follows the backend repo's own migration module list, which currently excludes reporting. +The helper and validator will also warn if you target a module that currently has no `tools/migrations/` directory in the Api repo, so unsupported direct runs are easier to spot before deployment. +When that happens, `validate:aws-deploy` now avoids suggesting a follow-up `run:api-migrations` command for that unsupported module. +Outside `--dry-run=true`, the helper now refuses direct runs for modules with no migration directory instead of succeeding with a silent skip. +The backend, split-stack, and full-stack deploy wrappers now fail before deployment for the same unsupported direct-migration targets, and they also fail early when the target Api repo is missing installed dependencies for a real non-dry-run migration. +The standalone `--mode=api-migrations` validator path follows the same rule and no longer suggests a follow-up command for unsupported non-dry-run module targets either. +The split-stack and full-stack wrappers also now reject impossible rollout combinations such as `--run-api-migrations=true` together with `--skip-backend` or `--skip-infrastructure`, instead of failing later for a less relevant reason. +They also now reject invalid `api-migration-action` and `api-migration-module` values before broader deploy preconditions like bucket or stack checks get involved. +The frontend publish helpers now do the same kind of early local validation for `--skip-build`: they fail immediately if `dist/` or `dist/sw.js` is missing, instead of reaching AWS stack or hosting operations first. + +If you want fewer manual steps, `deploy:backend` and `deploy:full-stack` can now invoke that helper for you as an optional post-deploy phase, and `deploy:aws` passes those flags through to the backend deploy: + +- `yarn deploy:backend -- --stack-name=b1admin-prod-backend --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` +- `yarn deploy:aws -- --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` +- `yarn deploy:full-stack -- --stack-name=b1admin-prod --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` + +You can add `--api-migration-dry-run=true` when you want the wrapper to resolve the connection-string wiring without actually executing migrations. + +## Frontend Runtime Inputs + +Build-time environment variables control where the deployed frontend points: + +- `REACT_APP_STAGE` +- `REACT_APP_API_BASE` +- `REACT_APP_CONTENT_ROOT` +- `REACT_APP_B1_WEBSITE_URL` +- `REACT_APP_LESSONS_API` +- `REACT_APP_GOOGLE_ANALYTICS` +- `REACT_APP_SENTRY_DSN` +- `REACT_APP_TRANSFER_URL` +- `REACT_APP_SUPPORT_EMAIL` +- `REACT_APP_SUPPORT_PHONE` +- `REACT_APP_SUPPORT_SITE_URL` +- `REACT_APP_MOBILE_APP_URL` +- `REACT_APP_DOMAIN_CNAME_TARGET` +- `REACT_APP_DOMAIN_A_TARGET` +- `REACT_APP_DEFAULT_STOCK_PHOTO` +- `REACT_APP_CHAT_MODE` + +For a portable deployment, the backend stack should output at least `REACT_APP_API_BASE` and `REACT_APP_CONTENT_ROOT`, and your deployment pipeline should inject them during the frontend build. + +## Deploying The Frontend + +Example: + +```bash +REACT_APP_API_BASE=https://api.example.com \ +REACT_APP_CONTENT_ROOT=https://content.example.com \ +REACT_APP_B1_WEBSITE_URL=https://{subdomain}.example.com \ +yarn deploy:frontend -- \ + --stack-name=b1admin-prod-frontend \ + --region=us-east-1 \ + --environment=prod \ + --project-name=b1admin \ + --alternate-domain-name=admin.example.com \ + --acm-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + --hosted-zone-id=Z1234567890ABC +``` + +If your backend stack already exposes public outputs, you can let the deploy script import them directly: + +```bash +yarn deploy:frontend -- \ + --stack-name=b1admin-prod-frontend \ + --backend-stack-name=b1admin-prod-backend +``` + +You can also point at a saved JSON outputs file or raw `describe-stacks` output file: + +```bash +yarn deploy:frontend -- \ + --stack-name=b1admin-prod-frontend \ + --backend-outputs-file=deployment/backend-outputs.json +``` + +A sample backend outputs file shape is included at [`examples/backend-outputs.sample.json`](./examples/backend-outputs.sample.json). + +When the backend stack is deployed with `ApiCustomDomainName`, its `ApiBaseUrl`, `PublicApiBaseUrl`, and `LessonsApiUrl` outputs now all resolve to that custom domain so the frontend build does not accidentally mix custom-domain traffic with raw `execute-api` endpoints. + +You can also use a frontend parameter file: + +```bash +yarn deploy:frontend -- \ + --stack-name=b1admin-prod-frontend \ + --region=us-east-1 \ + --parameters-file=infrastructure/examples/frontend-parameters.sample.json +``` + +A sample parameter file is included at [`examples/frontend-parameters.sample.json`](./examples/frontend-parameters.sample.json). + +The deploy script will: + +1. Deploy or update the CloudFormation stack. +2. Resolve frontend build-time env vars from your shell and optional backend outputs. +3. Build the Vite app. +4. Sync `dist/` to the provisioned S3 bucket. +5. Upload `sw.js` with `no-cache`. +6. Invalidate the CloudFront distribution. + +After the stack deploy succeeds, the helper now reuses the freshly resolved bucket/distribution outputs directly for the publish step instead of re-reading the frontend stack through CloudFormation a second time. + +If you only want to provision the frontend hosting infrastructure and publish assets later, add `--infrastructure-only`. +Do not combine that with `--skip-build`, because no frontend publish happens in that phase. +If you already have a ready `dist/` bundle and use `--skip-build`, the direct frontend deploy helper now skips backend output resolution too, because no build-time `REACT_APP_*` injection happens in that phase. + +When you are ready to publish frontend assets into an existing frontend stack, use: + +```bash +yarn publish:frontend-assets -- \ + --stack-name=b1admin-prod-frontend \ + --region=us-east-1 \ + --backend-stack-name=b1admin-prod-backend +``` + +That helper will: + +1. Read the frontend stack outputs to find the S3 bucket and CloudFront distribution. +2. Resolve frontend build-time env vars from your shell and optional backend outputs. +3. Build the Vite app unless you pass `--skip-build`. +4. Sync `dist/` to the provisioned S3 bucket. +5. Upload `sw.js` with `no-cache`. +6. Invalidate the CloudFront distribution. + +If your publish environment does not have CloudFormation read access, you can use `--frontend-outputs-file=...` instead of `--stack-name`, or pass `--bucket=...` and `--distribution-id=...` directly. There is also a sample frontend outputs file at [`examples/frontend-outputs.sample.json`](./examples/frontend-outputs.sample.json) that shows the expected shape. +If you are reusing an existing `dist/` with `--skip-build`, the helper no longer needs backend outputs at all, because no build-time `REACT_APP_*` injection happens in that phase. +If you want a machine-readable example of the publish helper result itself, see [`examples/publish-frontend-output.sample.json`](./examples/publish-frontend-output.sample.json). + +This helper now understands both the standalone frontend stack outputs (`SiteBucketName`, `CloudFrontDistributionId`) and the nested full-stack outputs (`FrontendBucketName`, `FrontendDistributionId`), so `--stack-name` can point at either stack shape. + +## Bootstrap A Fresh AWS Account + +If the target AWS account does not already have S3 buckets for templates and artifacts, start here: + +```bash +yarn deploy:bootstrap -- \ + --stack-name=b1admin-prod-bootstrap \ + --region=us-east-1 \ + --project-name=b1admin \ + --environment=prod \ + --template-bucket-name=b1admin-prod-templates-123456789012 \ + --artifact-bucket-name=b1admin-prod-artifacts-123456789012 +``` + +A sample parameter file is included at [`examples/bootstrap-parameters.sample.json`](./examples/bootstrap-parameters.sample.json). + +You can also deploy bootstrap from that file directly: + +```bash +yarn deploy:bootstrap -- \ + --stack-name=b1admin-prod-bootstrap \ + --region=us-east-1 \ + --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json +``` + +Those outputs feed directly into the later deployment flows: + +- The template bucket is used by `deploy:full-stack` +- The artifact bucket is where your packaged backend Lambda zip should live + +After bootstrap, you can let later helpers consume those outputs automatically by passing `--bootstrap-stack-name`. + +If you want to capture the resolved bucket outputs programmatically, add `--output=json`. + +The main deploy helpers also support `--output=json` for machine-readable results: + +- `deploy:bootstrap` +- `deploy:frontend` +- `deploy:backend` +- `deploy:full-stack` +- `deploy:aws` +- `smoke:aws-tooling` + +Representative JSON output for `deploy:bootstrap -- --output=json` is included at [`examples/deploy-bootstrap-output.sample.json`](./examples/deploy-bootstrap-output.sample.json). +Representative JSON output for `deploy:frontend -- --infrastructure-only --output=json` is included at [`examples/deploy-frontend-output.sample.json`](./examples/deploy-frontend-output.sample.json). +Representative JSON output for the normal build-and-publish `deploy:frontend -- --output=json` path is included at [`examples/deploy-frontend-publish-output.sample.json`](./examples/deploy-frontend-publish-output.sample.json). +The backend artifact upload helper also supports `--output=json`. Representative JSON output for `upload:backend-artifact -- --output=json` is included at [`examples/upload-backend-artifact-output.sample.json`](./examples/upload-backend-artifact-output.sample.json). +Representative JSON output for `publish:lambda-layer -- --output=json` is included at [`examples/publish-lambda-layer-output.sample.json`](./examples/publish-lambda-layer-output.sample.json). +Representative JSON output for `publish:frontend-assets -- --output=json` is included at [`examples/publish-frontend-output.sample.json`](./examples/publish-frontend-output.sample.json). +Representative JSON output for `verify:split-stack -- --backend-outputs-file=... --frontend-outputs-file=... --check-aws=false --output=json` is included at [`examples/verify-split-stack-output.sample.json`](./examples/verify-split-stack-output.sample.json). +Representative JSON output for `run:api-migrations -- --dry-run=true --output=json` is included at [`examples/run-api-migrations-output.sample.json`](./examples/run-api-migrations-output.sample.json). +Representative JSON output for `sync:app-config-secret -- --output=json` is included at [`examples/sync-app-config-secret-output.sample.json`](./examples/sync-app-config-secret-output.sample.json). +Representative JSON output for `sync:legacy-ssm -- --output=json` is included at [`examples/sync-legacy-ssm-output.sample.json`](./examples/sync-legacy-ssm-output.sample.json). + +For manifest-driven backend flows, those deploy-helper JSON results now also surface the resolved local provenance fields that CI wrappers usually care about most: + +- `resolvedPackageManifestFile` +- `resolvedBackendArtifactSourceFile` +- `resolvedMigrationArtifactSourceFile` +- `resolvedDependenciesLayerSourceFile` + +On the higher-level wrappers, those values describe what the wrapper itself resolved before it handed work off to nested helpers. +Representative JSON output for `deploy:backend -- --output=json` is included at [`examples/deploy-backend-output.sample.json`](./examples/deploy-backend-output.sample.json). + +The top-level deploy helpers now also return a normal CLI error if a referenced parameters file is missing or unreadable, or if a referenced bootstrap/backend/frontend stack lookup fails, instead of crashing with a raw Node stack trace. That cleanup also applies when one top-level wrapper calls another helper under the hood, and to direct helper command failures such as packaging, S3 uploads, or frontend asset publication. The follow-up helper commands now do the same for referenced outputs JSON files, stack lookups, and AWS-side failures such as `deploy:frontend`, `publish:frontend-assets`, `publish:lambda-layer`, `upload:backend-artifact`, `sync:app-config-secret`, and `sync:legacy-ssm`. + +The AWS helper scripts accept both `--name=value` and `--name value` argument styles. + +All CloudFormation deploy helpers in this repo now pass `--no-fail-on-empty-changeset`, so a no-op re-run is treated as success. + +## Validate Before Deploy + +You can run a preflight check before backend or full-stack deployment: + +```bash +yarn validate:aws-deploy -- \ + --mode=full-stack \ + --region=us-east-1 \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ + --backend-artifact-source-file=../Api/dist/api.zip +``` + +Use `--mode=backend` with `--backend-parameters-file` if you want to validate just the backend path. + +Use `--mode=frontend` with `--frontend-parameters-file` if you want to validate only the frontend hosting path. + +Use `--mode=bootstrap` with `--parameters-file` if you want to validate the initial bucket/bootstrap stack inputs before running `deploy:bootstrap`. If you already know the target stack name, pass `--stack-name` there too so the validator's suggested follow-up command stays copy/pasteable. + +Use `--mode=split-stack` with both `--backend-parameters-file` and `--frontend-parameters-file` if you want to validate the same paired configuration that `deploy:aws` consumes. That validator path now also accepts `--backend-outputs-file=...` when you want the frontend half to build or publish from a saved backend outputs file instead of a live stack lookup. + +You can also pass `--frontend-infrastructure-only` to the validator when you want preflight feedback for the staged frontend-hosting-first flows used by `deploy:aws` and `deploy:full-stack`. + +Use `--mode=frontend-publish` when you want to validate the second phase of that staged flow before running `publish:frontend-assets`. + +For a quick local regression pass over the AWS tooling itself, you can also run: + +```bash +yarn smoke:aws-tooling +``` + +That smoke script checks the main AWS helper files with `node --check`, parses the CloudFormation YAML templates for syntax validity, validates the sample JSON files, runs representative `validate:aws-deploy` scenarios against the sample parameter files, and exercises a few deploy-wrapper guardrails that should fail locally before any AWS call is made, including missing-parameter-file handling for the top-level deploy entrypoints plus missing outputs-file and unreadable stack handling for the publish/upload/sync helper commands. It also contract-checks the checked-in machine-readable example files for `package:api-backend`, `deploy:bootstrap`, `deploy:frontend`, `deploy:backend`, multiple `validate:aws-deploy` modes, `publish:lambda-layer`, `publish:frontend-assets`, `run:api-migrations`, `sync:app-config-secret`, `sync:legacy-ssm`, the split-stack wrapper's hosting-only, publish-only, and normal end-to-end flows, and the full-stack wrapper's hosting-only, publish-only, and normal end-to-end flows against representative fake-AWS runs so those example outputs do not silently drift away from the real helper result shapes. The smoke suite also now fails if a parsed example JSON file is neither contract-checked nor explicitly classified as an input-only sample. If you also have the Api repo checked out locally and readable in the current environment, it will additionally compare the env var keys from that repo's `serverless.yml` against `backend-api.yaml` so the CloudFormation runtime contract does not drift silently. In more restricted environments, those Api-repo checks are skipped rather than failing the whole smoke run. Add `--output=json` if you want a machine-readable summary for CI or other automation. + +This repo also includes a GitHub Actions workflow at [`.github/workflows/aws-tooling-smoke.yml`](../.github/workflows/aws-tooling-smoke.yml) that runs the same smoke suite on pushes, pull requests, and manual dispatches. It now uses the repo's Yarn-first install path (`yarn install --immutable` plus `yarn smoke:aws-tooling`) instead of a separate Yarn-only flow, and it captures the smoke result as `aws-tooling-smoke.json` for upload as a build artifact. + +## Verify After Deploy + +If you are using the split-stack rollout path, you can verify the deployed outputs after `deploy:aws` completes: + +```bash +yarn verify:split-stack -- \ + --region=us-east-1 \ + --backend-stack-name=b1admin-prod-backend \ + --frontend-stack-name=b1admin-prod-frontend +``` + +That helper can: + +- read backend/frontend stack outputs directly from CloudFormation +- verify the frontend S3 bucket is reachable +- verify the CloudFront distribution is reachable +- print the resolved API base URL and frontend app URL + +If you prefer not to hit AWS again, you can also run it from saved outputs files: + +```bash +yarn verify:split-stack -- \ + --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json \ + --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json \ + --check-aws=false \ + --output=json +``` + +`--check-http=true` will also perform a frontend URL check when an app URL is available. If you want an API HTTP probe too, pass `--api-probe-url=...` explicitly so the helper knows which endpoint should answer cleanly. + +Add `--check-aws=true` if you want the validator to also verify live AWS prerequisites such as: + +- current AWS credentials/account access +- template bucket accessibility +- artifact bucket accessibility +- existence/accessibility of a referenced S3 artifact object +- accessibility of ACM certificates for frontend or API custom domains + +Add `--output=json` if you want the validator result in a machine-readable format for CI or wrapper scripts. Invalid or unreadable parameter files, and unreadable `--bootstrap-stack-name` lookups, now return normal validator errors in that output instead of crashing the script outright. For manifest-driven backend validation, the `resolved` object now also includes: + +- `packageManifestFile` +- `backendArtifactSource` +- `migrationArtifactSource` +- `dependenciesLayerSource` + +A representative validator result for that backend + manifest path is included at [`examples/validate-backend-output.sample.json`](./examples/validate-backend-output.sample.json). Single-helper validator examples are also checked in: [`examples/validate-bootstrap-output.sample.json`](./examples/validate-bootstrap-output.sample.json) covers bootstrap preflight, [`examples/validate-frontend-output.sample.json`](./examples/validate-frontend-output.sample.json) covers frontend deploy preflight, and [`examples/validate-api-migrations-output.sample.json`](./examples/validate-api-migrations-output.sample.json) covers standalone Api CLI migration preflight. Normal infrastructure-phase validator examples are also checked in for the main wrapper paths: [`examples/validate-split-stack-output.sample.json`](./examples/validate-split-stack-output.sample.json) covers the standard `deploy:aws` preflight, and [`examples/validate-full-stack-output.sample.json`](./examples/validate-full-stack-output.sample.json) covers the standard `deploy:full-stack` preflight with an explicit template bucket. The staged hosting-first validator path now has checked examples too: [`examples/validate-split-stack-frontend-infra-output.sample.json`](./examples/validate-split-stack-frontend-infra-output.sample.json) covers `deploy:aws --frontend-infrastructure-only`, and [`examples/validate-full-stack-frontend-infra-output.sample.json`](./examples/validate-full-stack-frontend-infra-output.sample.json) covers `deploy:full-stack --frontend-infrastructure-only`. Publish-phase validator examples are also checked in for the staged follow-up flows: [`examples/validate-frontend-publish-output.sample.json`](./examples/validate-frontend-publish-output.sample.json) covers the standalone `publish:frontend-assets` validation path, [`examples/validate-split-stack-publish-output.sample.json`](./examples/validate-split-stack-publish-output.sample.json) covers the split-stack `deploy:aws` follow-up that reuses saved frontend outputs, and [`examples/validate-full-stack-publish-output.sample.json`](./examples/validate-full-stack-publish-output.sample.json) covers the full-stack `deploy:full-stack` follow-up that reuses saved frontend/backend outputs. + +The validator checks things like: + +- bootstrap outputs +- bootstrap parameter file readability plus explicit bootstrap bucket-name sanity checks +- presence of the local backend artifact file +- presence of the local migration artifact file when you provide one +- required Lambda artifact bucket/key inputs +- frontend custom-domain certificate requirements +- backend API custom-domain requirements +- migration handler requirements when `RunMigrations=true` +- optional post-deploy Api CLI migration wiring, including repo availability, migration action/module validation, and DB secret file sanity checks when `--run-api-migrations=true` +- whether artifact keys will be derived automatically from the current project/environment when you use a local artifact or auto-packaging path +- whether the current backend stack is rich enough to support the real Api repo, including a note about the optional legacy SSM sync helper when an app config secret is present + +## Sync Legacy SSM Parameters + +If your AWS account still needs the Api repo's older `/${stage}/...` Parameter Store layout for `serverless.yml`, CLI tasks, or ad hoc ops scripts, you can mirror the current backend stack into that layout: + +```bash +yarn sync:legacy-ssm -- \ + --stack-name=b1admin-prod-backend \ + --environment=prod \ + --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json \ + --region=us-east-1 +``` + +The helper reads: + +- backend or full-stack CloudFormation outputs such as `DatabaseEndpoint`, `DatabaseSecretArn`, and the resolved module database names +- the app-config secret file, `--app-config-secret-arn`, or `AppConfigSecretArn` from the deployed stack outputs when available + +And writes SecureString parameters like: + +- `/prod/jwtSecret` +- `/prod/encryptionKey` +- `/prod/membershipApi/connectionString` +- `/prod/attendanceApi/connectionString` +- `/prod/contentApi/connectionString` +- `/prod/givingApi/connectionString` +- `/prod/messagingApi/connectionString` +- `/prod/doingApi/connectionString` +- `/prod/reportingApi/connectionString` +- provider key paths such as `/prod/openAiApiKey`, `/prod/openRouterApiKey`, `/prod/pexelsKey`, and `/prod/webPushSubject` + +Useful flags: + +- `--app-config-secret-arn=...` to read the non-database values from Secrets Manager instead of a local file +- `--prefix=/staging` to override the default `/${environment}` prefix +- `--dry-run=true` to print the planned parameter names without writing them +- `--include-empty=true` to write blank values instead of skipping them + +You can also fold that into the deployment wrappers: + +- `yarn deploy:backend -- --stack-name=b1admin-prod-backend --parameters-file=infrastructure/examples/backend-parameters.sample.json --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` +- `yarn deploy:aws -- --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` +- `yarn deploy:full-stack -- --stack-name=b1admin-prod --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` + +Those wrappers run the SSM sync after the backend or full stack finishes deploying. + +## Upload The Backend Artifact + +Once your API repo or CI pipeline has produced a Lambda zip, upload it to the artifact bucket: + +```bash +yarn upload:backend-artifact -- \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --source-file=../Api/dist/api.zip \ + --artifact-key=b1admin/backend/api.zip \ + --region=us-east-1 +``` + +You can also provide `--artifact-bucket` directly instead of `--bootstrap-stack-name`. + +If you want a machine-readable example of the upload helper result, see [`examples/upload-backend-artifact-output.sample.json`](./examples/upload-backend-artifact-output.sample.json). + +The resulting S3 key should match the `LambdaCodeS3Key` you pass into `deploy:backend`, `deploy:aws`, or `deploy:full-stack`. + +If you use `deploy:backend`, `deploy:aws`, or `deploy:full-stack` with `--backend-artifact-source-file=...`, `--api-repo-path=...`, or `--package-manifest-file=...`, you can omit the key and let the wrapper default it to `//backend/api.zip`. + +If you package migrations separately, upload that zip the same way with a different key: + +```bash +yarn upload:backend-artifact -- \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --source-file=../Api/dist/migrations.zip \ + --artifact-key=b1admin/backend/migrations.zip \ + --artifact-label="Migration artifact" \ + --region=us-east-1 +``` + +Then pass that key as `MigrationCodeS3Key`. If you omit `MigrationCodeS3Bucket`, the backend stack will reuse the main artifact bucket automatically. + +If you use `--migration-artifact-source-file=...` through the deploy wrappers and omit the key, they default it to `//backend/migrations.zip`. + +## Deploying The Backend + +Example using a parameter file: + +```bash +yarn deploy:backend -- \ + --stack-name=b1admin-prod-backend \ + --region=us-east-1 \ + --parameters-file=infrastructure/examples/backend-parameters.sample.json +``` + +You can also pass values directly: + +```bash +yarn deploy:backend -- \ + --stack-name=b1admin-prod-backend \ + --region=us-east-1 \ + --project-name=b1admin \ + --environment=prod \ + --lambda-code-s3-bucket=my-artifacts-bucket \ + --lambda-code-s3-key=b1admin/backend/api.zip \ + --website-base-url=https://{subdomain}.example.com \ + --content-root-url=https://content.example.com +``` + +The backend deploy script expects a Lambda zip to already be uploaded to S3. + +If you provide `--backend-artifact-source-file=...`, `--api-repo-path=...`, or `--package-manifest-file=...`, the backend deploy script will upload the artifact for you and can derive `LambdaCodeS3Key` automatically from `ProjectName` and `EnvironmentName`. + +The split-stack and full-stack wrappers now also honor `ProjectName` and `EnvironmentName` from their parameter files when deriving default stack-adjacent names like artifact keys, template prefixes, secret names, and layer names. + +If you want the backend API on a first-class domain like `api.example.com`, pass: + +- `ApiCustomDomainName` +- `ApiCertificateArn` +- `ApiHostedZoneId` + +When those values are set, the backend stack will create the API Gateway custom domain, map it to the HTTP API, and create Route53 alias records. `ApiBaseUrl` and `PublicApiBaseUrl` will then resolve to your custom domain instead of the raw `execute-api` hostname. + +If you want the stack to run schema/bootstrap work against Aurora, enable: + +- `RunMigrations=true` +- `MigrationHandler` + +Optional overrides are also available for: + +- `MigrationCodeS3Bucket` +- `MigrationCodeS3Key` +- `MigrationRuntime` +- `MigrationMemorySize` +- `MigrationTimeout` +- `MigrationTrigger` + +By default, the migration Lambda falls back to the main backend artifact bucket/key/runtime. The migration handler is expected to be idempotent and to implement the CloudFormation custom-resource response contract, since the stack invokes it as a custom resource during create/update. + +If you want to upload a separate migration zip as part of the wrapper flow, add: + +- `--migration-artifact-source-file=../Api/dist/migrations.zip` +- `--migration-code-s3-key=b1admin/backend/migrations.zip` + +If you omit those flags, the migration Lambda will continue to reuse the main backend artifact by default. + +If you do provide `--migration-artifact-source-file=...` and omit `--migration-code-s3-key`, the backend, split-stack, and full-stack wrappers will default the key from `ProjectName` and `EnvironmentName`. + +The backend and full-stack templates now also fail fast on a few invalid combinations: + +- `ApiCustomDomainName` without `ApiCertificateArn` +- `RunMigrations=true` without `MigrationHandler` +- `FrontendAlternateDomainName` without `FrontendAcmCertificateArn` in the full-stack template + +## Deploying The Full AWS Footprint + +Once you have a backend artifact in S3, you can deploy both stacks in sequence: + +```bash +yarn deploy:aws -- \ + --region=us-east-1 \ + --environment=prod \ + --project-name=b1admin \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json \ + --frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json \ + --backend-artifact-source-file=../Api/dist/api.zip \ + --migration-artifact-source-file=../Api/dist/migrations.zip \ + --run-migrations=true \ + --migration-handler=index.migrate \ + --api-custom-domain-name=api.example.com \ + --api-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy \ + --api-hosted-zone-id=Z1234567890ABC \ + --frontend-alternate-domain-name=admin.example.com \ + --frontend-acm-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + --frontend-hosted-zone-id=Z1234567890ABC +``` + +The wrapper will: + +1. Optionally resolve the artifact bucket from the bootstrap stack. +2. Optionally upload a backend Lambda zip if `--backend-artifact-source-file` is provided, or reuse the packaged artifact referenced by `--package-manifest-file`. +3. Optionally upload a separate migration zip if `--migration-artifact-source-file` is provided. +4. Deploy the backend foundation, including an API custom domain if configured. +5. Read backend stack outputs. +6. Deploy the frontend stack. +7. Build the frontend with the backend outputs injected as `REACT_APP_*` values. +8. Upload frontend assets and invalidate CloudFront. + +The split-stack wrapper now supports `--frontend-parameters-file` too, so both halves of the deployment can be driven from parameter files instead of mixing file-based backend config with frontend-only CLI flags. +If you already have backend outputs saved from an earlier deploy or CI step, you can also pass `--backend-outputs-file=deployment/backend-outputs.json` so the frontend deploy/publish phases reuse that file instead of querying the backend stack. + +If you only want the split-stack wrapper to provision frontend hosting and publish assets later, add `--frontend-infrastructure-only`. +Do not combine that with `--skip-frontend`, because skipping the frontend step prevents the hosting stack from being created. +Do not add `--skip-build` there either, because no frontend publish is happening in that phase. +Do not combine it with `--publish-frontend-assets` either, because the publish flag is only for the later follow-up phase. + +When you are ready for that second phase, you can either use `publish:frontend-assets` directly or ask the split-stack wrapper to drive it for you: + +```bash +yarn deploy:aws -- \ + --region=us-east-1 \ + --project-name=b1admin \ + --environment=prod \ + --skip-backend \ + --skip-frontend \ + --publish-frontend-assets +``` + +In that publish-only follow-up path, `deploy:aws` now skips backend packaging, artifact upload, layer publication, and secret sync work instead of repeating it unnecessarily. +If you already have a ready `dist/` directory, you can add `--skip-build` there too. +`--publish-frontend-assets` is meant for that later staged follow-up shape, so use it with `--skip-frontend` after an earlier `--frontend-infrastructure-only` run. +When you do use `--skip-build` in that staged follow-up, the wrapper now also stops forwarding backend-stack lookup inputs into the publish helper, because no build-time `REACT_APP_*` injection happens in that phase. +If you do need a build in that later phase, `--backend-outputs-file=...` is also supported there, so the publish helper can inject `REACT_APP_*` values without re-reading the backend stack. +That same split-stack publish-only follow-up now also accepts `--frontend-outputs-file=...`, or direct `--bucket=... --distribution-id=...`, so it can publish without re-reading the frontend stack too. +If you want a machine-readable example of the normal end-to-end split-stack wrapper JSON result, see [`examples/deploy-aws-full-output.sample.json`](./examples/deploy-aws-full-output.sample.json). +If you want a machine-readable example of the earlier `--frontend-infrastructure-only` wrapper JSON result, see [`examples/deploy-aws-frontend-infra-output.sample.json`](./examples/deploy-aws-frontend-infra-output.sample.json). +If you want a machine-readable example of the split-stack wrapper's publish-only JSON result, see [`examples/deploy-aws-publish-output.sample.json`](./examples/deploy-aws-publish-output.sample.json). +If you want the build-driven variant that also carries resolved `frontendPublish.backendBuildEnv` values from a saved backend outputs file, see [`examples/deploy-aws-publish-build-output.sample.json`](./examples/deploy-aws-publish-build-output.sample.json). + +## Deploying With A Single CloudFormation Entry Point + +If you prefer a single CloudFormation stack that creates both nested stacks, first upload the child templates somewhere CloudFormation can reach, such as S3. Then deploy [`cloudformation/full-stack.yaml`](./cloudformation/full-stack.yaml) with a parameter file like [`examples/full-stack-parameters.sample.json`](./examples/full-stack-parameters.sample.json). + +The easiest path in this repo is the helper script: + +```bash +yarn deploy:full-stack -- \ + --stack-name=b1admin-prod \ + --region=us-east-1 \ + --project-name=b1admin \ + --environment=prod \ + --bootstrap-stack-name=b1admin-prod-bootstrap \ + --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ + --backend-artifact-source-file=../Api/dist/api.zip \ + --migration-artifact-source-file=../Api/dist/migrations.zip +``` + +That helper will: + +1. Resolve the template and artifact buckets from the bootstrap stack, if provided. +2. Upload `backend-api.yaml` to your template bucket. +3. Upload `frontend-site.yaml` to your template bucket. +4. Optionally upload a backend Lambda zip if `--backend-artifact-source-file` is provided, or reuse the packaged artifact referenced by `--package-manifest-file`. +5. Optionally upload a separate migration zip if `--migration-artifact-source-file` is provided. +6. Inject those template URLs into the full-stack deployment. +7. Deploy the nested-stack entrypoint. +8. Build the frontend with stack outputs injected as `REACT_APP_*` values. +9. Upload the frontend assets to the created S3 bucket. +10. Invalidate the created CloudFront distribution. + +During that publish step, the helper now passes the resolved frontend bucket/distribution values directly into `publish:frontend-assets` instead of making that helper rediscover them from a temporary frontend outputs file. A temporary backend-outputs manifest is only written when a real frontend build needs stack-driven `REACT_APP_*` injection. + +If you only want the infrastructure and plan to publish everything later, add `--infrastructure-only`. + +If you want the backend plus frontend hosting infrastructure now but plan to publish frontend assets later, add `--frontend-infrastructure-only`. +Do not combine that with `--skip-infrastructure`, because `--skip-infrastructure` is only for the later publish-only follow-up pass. +Do not combine that with `--publish-frontend-assets` either, because the publish flag is only for the later follow-up phase. + +When you are ready for that second phase, you can either use `publish:frontend-assets` directly or ask the full-stack wrapper to drive it for you. The full-stack wrapper now supports a real publish-only follow-up mode when you pass `--skip-infrastructure`: + +```bash +yarn deploy:full-stack -- \ + --stack-name=b1admin-prod \ + --region=us-east-1 \ + --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ + --skip-infrastructure \ + --publish-frontend-assets +``` + +If you prefer, `publish:frontend-assets` still works directly too. +As with the direct publish helper, you can add `--skip-build` when you want to reuse an existing `dist/` bundle. +If your later publish environment does not have CloudFormation read access, `deploy:full-stack -- --skip-infrastructure --publish-frontend-assets` now also accepts `--frontend-outputs-file=...`, or direct `--bucket=... --distribution-id=...`, and it can take `--backend-outputs-file=...` when a fresh build still needs stack-driven `REACT_APP_*` values. +If you want a machine-readable example of the full-stack wrapper's normal end-to-end JSON result, see [`examples/deploy-full-stack-full-output.sample.json`](./examples/deploy-full-stack-full-output.sample.json). +If you want a machine-readable example of the full-stack wrapper's hosting-only non-publish JSON result, see [`examples/deploy-full-stack-frontend-infra-output.sample.json`](./examples/deploy-full-stack-frontend-infra-output.sample.json). +If you want a machine-readable example of the full-stack wrapper's publish-only JSON result, see [`examples/deploy-full-stack-publish-output.sample.json`](./examples/deploy-full-stack-publish-output.sample.json). +If you want the build-driven variant that also carries resolved `frontendEnv` values from a saved backend outputs file, see [`examples/deploy-full-stack-publish-build-output.sample.json`](./examples/deploy-full-stack-publish-build-output.sample.json). +Do not combine `--skip-infrastructure` with `--infrastructure-only` or `--frontend-infrastructure-only`, because those modes describe different phases of the rollout. +Likewise, do not add `--publish-frontend-assets` to a normal full-stack deploy, because the regular full-stack path already publishes frontend assets. + +## Staging Starter Kit + +If you want a concrete environment to start from instead of editing the generic examples in place, use [`environments/staging`](./environments/staging). It includes: + +- bootstrap, backend, and frontend parameter files +- an app-config secret template +- a split-stack deployment script that validates inputs, deploys bootstrap, and then runs `deploy:aws` + +That starter kit defaults the first rollout to no custom domains so you can get a staging stack up before wiring ACM and Route53. See [`environments/staging/README.md`](./environments/staging/README.md) for the exact command sequence and the fields you still need to replace. +That staging path completed successfully on June 24, 2026 with backend stack `b1admin-staging-backend`, frontend stack `b1admin-staging-frontend`, API base URL `https://5wmx09abp3.execute-api.us-east-1.amazonaws.com`, and frontend app URL `https://d1niz7249zvl23.cloudfront.net`. + +## Prod Starter Kit + +There is now a matching production-oriented starter at [`environments/prod`](./environments/prod). It follows the same split-stack pattern as staging: + +- bootstrap, backend, and frontend parameter files +- an app-config secret template +- a split-stack deployment script that validates inputs, deploys bootstrap, and then runs `deploy:aws` + +Like the staging starter, it keeps custom domains blank on the first pass so you can stand up the base production stack before layering in ACM and Route53. See [`environments/prod/README.md`](./environments/prod/README.md) for the exact command sequence and the fields you still need to replace. + +If you want a quick index of both concrete environment starters in one place, see [`environments/README.md`](./environments/README.md). +There is also a shared first-rollout operator checklist at [`environments/first-rollout-checklist.md`](./environments/first-rollout-checklist.md). +For GitHub-driven rollouts, there is also a setup guide for the required repository environments, AWS auth secrets, and OIDC trust shape at [`environments/github-actions-setup.md`](./environments/github-actions-setup.md). +If this repository is public and you do not want the live AWS workflow running here, use the private-repo pattern in [`environments/private-deployment-repo.md`](./environments/private-deployment-repo.md) as the primary rollout model instead. +If you want reusable IAM role templates for the recommended GitHub-OIDC-role plus CloudFormation-execution-role model, use [`iam/README.md`](./iam/README.md). +For a field-by-field preparation pass against the checked-in parameter files, use [`environments/deployment-workbook.md`](./environments/deployment-workbook.md). +For a mechanical starter-file readiness check before a live deploy, run `yarn audit:environment-starter -- --environment=staging --output=json`. +For a tighter “what do I fix next?” view, run `yarn audit:environment-starter -- --environment=staging --only-blockers=true`. +For a copy-paste markdown checklist of those blockers, run `yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown`. +For a safe dry-run that proposes bucket replacements and secret generation before editing starter files, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=json`. +For the exact follow-up commands after that dry-run, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands`. +For a copy-paste markdown prep runbook, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=markdown`. +For a concrete first-run plan that maps the same starter files into both the local script path and the GitHub Actions workflow path, run `yarn plan:environment-deploy -- --environment=staging --output=markdown`. +If you want one compact “what is left?” snapshot across both checked-in environments, run `yarn show:rollout-status -- --output=markdown`. +If you want that same snapshot as copy-paste remediation commands, run `yarn show:rollout-status -- --output=commands`. +If you want the same rollout snapshot in a machine-readable shape for CI or wrapper scripts, run `yarn show:rollout-status -- --output=json`. A representative JSON result is checked in at [`examples/show-rollout-status-output.sample.json`](./examples/show-rollout-status-output.sample.json). +If you want that rollout snapshot to focus on the GitHub-to-AWS path only, run `yarn show:rollout-status -- --deployment-intent=github-actions --output=markdown` so local-only blockers like an unreadable `../Api` checkout do not dominate the summary. +That JSON output now also includes top-level `readyEnvironments`, `blockedEnvironments`, `commandSummary`, `blockerCategories`, `overallHighlightedBlockers`, and `recommendedNextSteps` fields so automation can read the cross-environment state and exact command order without scanning each environment block manually. +In GitHub-focused mode it also reports `deploymentIntent`, `ignoredBlockerCategories`, and `intentBlockerCategories`, and it trims local-only deploy commands out of the recommended command list. +That deploy-plan helper now separates starter/input blockers from local-only execution blockers, which makes it easier to tell whether the local shell path or the GitHub Actions path is actually runnable right now. +It now also recommends the safer execution path directly when the two paths differ in readiness. +It now also reports local GitHub CLI dispatch readiness separately, so the plan can tell you when the GitHub runner path is fine but the machine you are holding cannot actually call `gh workflow run` yet because `gh` is missing, `gh auth login -h github.com` still needs attention, or GitHub is not reachable from this shell. +When no execution path is runnable yet, it now also promotes the most concrete remediation command first, such as `gh auth login -h github.com` or `sync:github-app-config-secret`, instead of dropping back to a generic audit command. +When GitHub is the recommended execution path, the plan now prefers the checked-in `dispatch:github-aws-deploy` wrapper over a raw `gh workflow run` command, while still showing the low-level `gh` form for manual fallback or debugging. +That dispatch helper now also prints the exact `gh run list`, `gh run watch`, and `gh run view` follow-up commands for the latest `deploy-aws-self-hosted.yml` run so the operator can move straight from dispatch into live monitoring. +If the local `api-repo` path is unreadable, it now also emits concrete `package-manifest` and `backend-artifact` fallback commands so the operator can switch local deploy modes without reconstructing those commands manually. +When starter-file blockers still exist, it now recommends `prepare:environment-starter` first and includes the dry-run, markdown, and `--write=true` prep commands directly in the plan output. +The checked-in `deploy-split-stack.sh` wrappers now also support `PREVIEW_ONLY=true`, which runs the starter audit plus deploy-plan preflight and then stops before any AWS mutation. +The deploy planner now surfaces those local preview-only commands directly, alongside matching GitHub `preview_only=true` dispatch commands, so the safer dry-run path is visible in the same plan output as the live deploy path. +Its `--output=commands` mode now prints the recommended next command first and keeps alternate commands after it. +It now also includes the post-deploy `verify:split-stack` follow-up commands and a reminder to work through the shared rollout checklist. +It now also includes exact output-capture commands so the first rollout leaves behind reusable backend and frontend outputs JSON files. +Those output-capture commands now create the destination `deployment//` folder first so they are runnable on a fresh checkout. +It now also includes copy-paste follow-up commands that reuse those saved outputs for later verification and publish-only frontend asset runs, so a later shell or CI step does not need live CloudFormation reads. +If you want that evidence saved with one helper instead of two manual `describe-stacks` commands, run `yarn save:split-stack-outputs -- --environment=staging --region=`. +If you want to re-render the saved `deployment-summary.json` later as a readable checklist, run `yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown`. +If you want only the copy-paste follow-up commands from that saved summary, run `yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=commands`. +On that successful June 24, 2026 staging rollout, `yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend --check-http=true` passed, including HTTP reachability for the CloudFront app URL. +It now also spells out the GitHub post-deploy handoff directly in the plan output, including the expected deployment-evidence artifact on success, the fallback preflight-plan artifact on failure, and what the GitHub job summary should contain. +That audit helper now includes `nextSteps` and `suggestions` in its output so the unresolved staging work is easier to convert into concrete edits. +For manual CI-driven rollouts, there is now a GitHub Actions entrypoint at [`.github/workflows/deploy-aws-self-hosted.yml`](../.github/workflows/deploy-aws-self-hosted.yml). It targets the checked-in `staging` and `prod` environment starter kits and can deploy from a checked-out Api repo, a prebuilt package manifest, or direct backend artifact zip paths. The workflow supports either GitHub OIDC role assumption through `AWS_ROLE_TO_ASSUME` or the older static-key secret pair. +If B1Admin is public, treat that workflow as a template or bootstrap reference and move the live workflow, GitHub Environments, and live parameter files into a separate private deployment repository as described in [`environments/private-deployment-repo.md`](./environments/private-deployment-repo.md). +That workflow now also supports `preview_only=true`, which runs the same runner-side starter audit plus deploy-plan preflight and then stops before any AWS mutation. +If you intend to launch that workflow from this checkout instead of the GitHub UI, `yarn plan:environment-deploy -- --environment=staging --output=markdown` is now the fastest preflight because it shows both the runner-side GitHub Actions blockers and whether local `gh` auth is good enough to dispatch from this machine. +After a successful run, it now also uploads an `aws--deployment-evidence` artifact containing the saved backend outputs, frontend outputs, deployment summary, and any saved preflight plan from `deployment//`. +If the deploy step fails before that full bundle is created, the workflow still uploads an `aws--preflight-plan` artifact so the computed blocker list remains downloadable. +The workflow now also writes a GitHub job summary with the preflight plan, resolved stack names, key URLs, and saved-output follow-up commands so the operator does not need to open the artifact just to see the important results. +One remaining maintenance follow-up from the successful hosted-run path is updating the workflow action runtime mix away from Node 20-targeted actions, because GitHub currently emits a deprecation warning and shims those actions onto Node 24 on hosted runners. + +Example: + +```bash +aws cloudformation deploy \ + --stack-name b1admin-prod \ + --template-file infrastructure/cloudformation/full-stack.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides \ + $(jq -r 'to_entries[] | "\(.key)=\(.value)"' infrastructure/examples/full-stack-parameters.sample.json) +``` + +That stack will: + +1. Create the backend nested stack. +2. Create the frontend nested stack. +3. Surface the important outputs, including API base URL, frontend bucket name, and CloudFront distribution ID. + +If you use the helper script above, the frontend assets are published automatically. If you deploy `full-stack.yaml` manually with raw CloudFormation, you still need a separate asset publish step against the created frontend bucket. + +## Recommended Full-Stack Layout + +For a complete AWS self-hosting setup, use at least two stacks: + +1. `backend-core` +2. `frontend-site` + +`backend-core` should own: + +- API Gateway or Lambda Function URL entrypoints +- Lambda functions +- Aurora cluster +- VPC and subnets +- Secrets Manager parameters +- Optional custom domains for API/content services +- Packaging and deployment of the real API code artifact + +`frontend-site` should consume backend outputs through CI/CD variables, SSM parameters, or a deployment manifest. + +## Next Backend Step + +When you wire this to the real API repo, make sure the backend deployment produces: + +- `ApiBaseUrl` +- `ContentRootUrl` +- `WebsiteBaseUrl` +- `TransferUrl` +- `SupportEmail` +- `SupportPhone` +- `SupportSiteUrl` +- `MobileAppUrl` +- `DomainCnameTarget` +- `DomainATarget` +- Any other public, non-secret frontend endpoints + +That gives you an end-to-end account-agnostic deployment path even if the application source continues to live across multiple repositories. diff --git a/infrastructure/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml new file mode 100644 index 000000000..432a501e2 --- /dev/null +++ b/infrastructure/cloudformation/backend-api.yaml @@ -0,0 +1,1850 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: B1Admin backend foundation with VPC, Lambda, HTTP API, and Aurora Serverless v2. + +Parameters: + ProjectName: + Type: String + Default: b1admin + AllowedPattern: "^[a-z0-9-]+$" + EnvironmentName: + Type: String + Default: prod + AllowedPattern: "^[a-z0-9-]+$" + VpcCidr: + Type: String + Default: 10.30.0.0/16 + PublicSubnet1Cidr: + Type: String + Default: 10.30.0.0/24 + PublicSubnet2Cidr: + Type: String + Default: 10.30.1.0/24 + PrivateSubnet1Cidr: + Type: String + Default: 10.30.10.0/24 + PrivateSubnet2Cidr: + Type: String + Default: 10.30.11.0/24 + CreateNatGateway: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + LambdaCodeS3Bucket: + Type: String + Description: S3 bucket containing the packaged backend Lambda zip. + LambdaCodeS3Key: + Type: String + Description: S3 key for the packaged backend Lambda zip. + LambdaHandler: + Type: String + Default: lambda.web + LambdaRuntime: + Type: String + Default: nodejs22.x + AllowedValues: + - nodejs20.x + - nodejs22.x + - python3.12 + - python3.13 + - provided.al2023 + LambdaArchitecture: + Type: String + Default: arm64 + AllowedValues: [arm64, x86_64] + LambdaMemorySize: + Type: Number + Default: 1024 + MinValue: 128 + LambdaTimeout: + Type: Number + Default: 30 + MinValue: 1 + MaxValue: 900 + LambdaReservedConcurrency: + Type: Number + Default: 0 + MinValue: 0 + DependenciesLayerArn: + Type: String + Default: "" + Description: Optional Lambda layer ARN for packaged backend dependencies. + ObservabilityLayerArn: + Type: String + Default: "" + Description: Optional Lambda layer ARN for Sentry or other observability tooling. + LambdaNodeOptions: + Type: String + Default: "" + Description: Optional NODE_OPTIONS value applied to all backend Lambdas. + EnableWebSocketApi: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + SocketLambdaHandler: + Type: String + Default: lambda.socket + SocketLambdaMemorySize: + Type: Number + Default: 1024 + MinValue: 128 + SocketLambdaTimeout: + Type: Number + Default: 30 + MinValue: 1 + MaxValue: 900 + EnableScheduledWorkers: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + Timer15MinLambdaHandler: + Type: String + Default: lambda.timer15Min + TimerMidnightLambdaHandler: + Type: String + Default: lambda.timerMidnight + TimerScheduledTasksLambdaHandler: + Type: String + Default: lambda.timerScheduledTasks + TimerWebhooksLambdaHandler: + Type: String + Default: lambda.timerWebhooks + TimerLambdaMemorySize: + Type: Number + Default: 256 + MinValue: 128 + TimerLambdaTimeout: + Type: Number + Default: 300 + MinValue: 1 + MaxValue: 900 + RunMigrations: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Whether to create and invoke a migration Lambda as a CloudFormation custom resource. + MigrationCodeS3Bucket: + Type: String + Default: "" + Description: Optional S3 bucket for the migration Lambda package. Falls back to LambdaCodeS3Bucket when omitted. + MigrationCodeS3Key: + Type: String + Default: "" + Description: Optional S3 key for the migration Lambda package. Falls back to LambdaCodeS3Key when omitted. + MigrationHandler: + Type: String + Default: "" + Description: Optional handler for the migration Lambda. Required when RunMigrations=true. + MigrationRuntime: + Type: String + Default: "" + Description: Optional runtime for the migration Lambda. Falls back to LambdaRuntime when omitted. + MigrationMemorySize: + Type: Number + Default: 1024 + MinValue: 128 + MigrationTimeout: + Type: Number + Default: 900 + MinValue: 1 + MaxValue: 900 + MigrationTrigger: + Type: String + Default: "" + Description: Optional value to force the migration custom resource to re-run on stack update. + DatabaseName: + Type: String + Default: membership + AllowedPattern: "^[a-zA-Z][a-zA-Z0-9_]*$" + Description: Legacy/default database name. Used as the membership database when MembershipDatabaseName is not set. + MembershipDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + AttendanceDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + ContentDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + GivingDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + MessagingDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + DoingDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + ReportingDatabaseName: + Type: String + Default: "" + AllowedPattern: "^$|^[a-zA-Z][a-zA-Z0-9_]*$" + DatabaseEngine: + Type: String + Default: aurora-mysql + AllowedValues: + - aurora-mysql + - aurora-postgresql + DatabasePort: + Type: Number + Default: 3306 + DatabaseMasterUsername: + Type: String + Default: app_admin + DatabaseMinCapacity: + Type: Number + Default: 0.5 + DatabaseMaxCapacity: + Type: Number + Default: 2 + ApiCustomDomainName: + Type: String + Default: "" + Description: Optional custom domain for the HTTP API, such as api.example.com + ApiCertificateArn: + Type: String + Default: "" + Description: Optional ACM certificate ARN for the API custom domain + ApiHostedZoneId: + Type: String + Default: "" + Description: Optional Route53 hosted zone ID for the API custom domain + WebsiteBaseUrl: + Type: String + Default: "" + Description: Public member-facing website pattern, such as https://{subdomain}.example.com + ContentRootUrl: + Type: String + Default: "" + B1AdminRootUrl: + Type: String + Default: "" + Description: Public URL for the B1Admin frontend, used by backend-generated links. + CorsOrigin: + Type: String + Default: "*" + FileStore: + Type: String + Default: "S3" + ManageAssetBucket: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + AssetBucketName: + Type: String + Default: "" + Description: Optional S3 bucket name used by the Api repo for uploaded content when FILE_STORE=S3. + AppConfigSecretArn: + Type: String + Default: "" + Description: Optional Secrets Manager ARN containing JSON fields for Api repo runtime secrets and config values. + MailSystem: + Type: String + Default: "SES" + DeliveryProvider: + Type: String + Default: "aws" + StoreApiUrl: + Type: String + Default: "" + AiProvider: + Type: String + Default: "" + EmailOnRegistration: + Type: String + Default: "" + AllowedValues: ["", "true", "false"] + CaddyHost: + Type: String + Default: "" + CaddyPort: + Type: String + Default: "" + TransferUrl: + Type: String + Default: "" + SupportEmail: + Type: String + Default: "" + SupportPhone: + Type: String + Default: "" + SupportSiteUrl: + Type: String + Default: "" + MobileAppUrl: + Type: String + Default: "" + DomainCnameTarget: + Type: String + Default: "" + DomainATarget: + Type: String + Default: "" + DefaultStockPhoto: + Type: String + Default: "" + GoogleAnalyticsTag: + Type: String + Default: "" + SentryDsn: + Type: String + Default: "" + +Rules: + ApiDomainRequiresCertificate: + Assertions: + - Assert: !Or + - !Equals [!Ref ApiCustomDomainName, ""] + - !Not [!Equals [!Ref ApiCertificateArn, ""]] + AssertDescription: ApiCertificateArn is required when ApiCustomDomainName is set. + MigrationRequiresHandler: + Assertions: + - Assert: !Or + - !Equals [!Ref RunMigrations, "false"] + - !Not [!Equals [!Ref MigrationHandler, ""]] + AssertDescription: MigrationHandler is required when RunMigrations is true. + +Conditions: + UseNatGateway: !Equals [!Ref CreateNatGateway, "true"] + CreatePrivateAwsEndpoints: !Equals [!Ref CreateNatGateway, "false"] + CreateWebSocketApi: !Equals [!Ref EnableWebSocketApi, "true"] + CreateScheduledWorkers: !Equals [!Ref EnableScheduledWorkers, "true"] + RunMigrationResources: !Equals [!Ref RunMigrations, "true"] + HasApiCustomDomainName: !Not [!Equals [!Ref ApiCustomDomainName, ""]] + HasApiCertificateArn: !Not [!Equals [!Ref ApiCertificateArn, ""]] + HasApiHostedZoneId: !Not [!Equals [!Ref ApiHostedZoneId, ""]] + CreateApiCustomDomain: !And + - !Condition HasApiCustomDomainName + - !Condition HasApiCertificateArn + CreateApiDnsRecord: !And + - !Condition CreateApiCustomDomain + - !Condition HasApiHostedZoneId + HasWebsiteBaseUrl: !Not [!Equals [!Ref WebsiteBaseUrl, ""]] + HasContentRootUrl: !Not [!Equals [!Ref ContentRootUrl, ""]] + HasB1AdminRootUrl: !Not [!Equals [!Ref B1AdminRootUrl, ""]] + HasTransferUrl: !Not [!Equals [!Ref TransferUrl, ""]] + HasSupportEmail: !Not [!Equals [!Ref SupportEmail, ""]] + HasSupportPhone: !Not [!Equals [!Ref SupportPhone, ""]] + HasSupportSiteUrl: !Not [!Equals [!Ref SupportSiteUrl, ""]] + HasMobileAppUrl: !Not [!Equals [!Ref MobileAppUrl, ""]] + HasDomainCnameTarget: !Not [!Equals [!Ref DomainCnameTarget, ""]] + HasDomainATarget: !Not [!Equals [!Ref DomainATarget, ""]] + HasDefaultStockPhoto: !Not [!Equals [!Ref DefaultStockPhoto, ""]] + HasGoogleAnalyticsTag: !Not [!Equals [!Ref GoogleAnalyticsTag, ""]] + HasSentryDsn: !Not [!Equals [!Ref SentryDsn, ""]] + HasReservedConcurrency: !Not [!Equals [!Ref LambdaReservedConcurrency, 0]] + HasDependenciesLayerArn: !Not [!Equals [!Ref DependenciesLayerArn, ""]] + HasObservabilityLayerArn: !Not [!Equals [!Ref ObservabilityLayerArn, ""]] + HasMigrationCodeS3Bucket: !Not [!Equals [!Ref MigrationCodeS3Bucket, ""]] + HasMigrationCodeS3Key: !Not [!Equals [!Ref MigrationCodeS3Key, ""]] + HasMigrationRuntime: !Not [!Equals [!Ref MigrationRuntime, ""]] + HasLambdaNodeOptions: !Not [!Equals [!Ref LambdaNodeOptions, ""]] + UseS3FileStore: !Equals [!Ref FileStore, "S3"] + ManageAssetBucketEnabled: !Equals [!Ref ManageAssetBucket, "true"] + HasAssetBucketName: !Not [!Equals [!Ref AssetBucketName, ""]] + CreateManagedAssetBucket: !And + - !Condition UseS3FileStore + - !Condition ManageAssetBucketEnabled + - !Not [!Condition HasAssetBucketName] + HasResolvedAssetBucket: !Or + - !Condition HasAssetBucketName + - !Condition CreateManagedAssetBucket + HasAppConfigSecretArn: !Not [!Equals [!Ref AppConfigSecretArn, ""]] + UseSesMailSystem: !Equals [!Ref MailSystem, "SES"] + HasAiProvider: !Not [!Equals [!Ref AiProvider, ""]] + HasEmailOnRegistration: !Not [!Equals [!Ref EmailOnRegistration, ""]] + HasCaddyHost: !Not [!Equals [!Ref CaddyHost, ""]] + HasCaddyPort: !Not [!Equals [!Ref CaddyPort, ""]] + HasMembershipDatabaseName: !Not [!Equals [!Ref MembershipDatabaseName, ""]] + HasAttendanceDatabaseName: !Not [!Equals [!Ref AttendanceDatabaseName, ""]] + HasContentDatabaseName: !Not [!Equals [!Ref ContentDatabaseName, ""]] + HasGivingDatabaseName: !Not [!Equals [!Ref GivingDatabaseName, ""]] + HasMessagingDatabaseName: !Not [!Equals [!Ref MessagingDatabaseName, ""]] + HasDoingDatabaseName: !Not [!Equals [!Ref DoingDatabaseName, ""]] + HasReportingDatabaseName: !Not [!Equals [!Ref ReportingDatabaseName, ""]] + +Resources: + BackendVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref VpcCidr + EnableDnsHostnames: true + EnableDnsSupport: true + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-vpc" + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + InternetGateway: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-igw" + + VpcGatewayAttachment: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref BackendVpc + InternetGatewayId: !Ref InternetGateway + + PublicSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref BackendVpc + AvailabilityZone: !Select [0, !GetAZs ""] + CidrBlock: !Ref PublicSubnet1Cidr + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-public-a" + + PublicSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref BackendVpc + AvailabilityZone: !Select [1, !GetAZs ""] + CidrBlock: !Ref PublicSubnet2Cidr + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-public-b" + + PrivateSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref BackendVpc + AvailabilityZone: !Select [0, !GetAZs ""] + CidrBlock: !Ref PrivateSubnet1Cidr + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-private-a" + + PrivateSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref BackendVpc + AvailabilityZone: !Select [1, !GetAZs ""] + CidrBlock: !Ref PrivateSubnet2Cidr + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-private-b" + + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref BackendVpc + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-public-rt" + + PublicRoute: + Type: AWS::EC2::Route + DependsOn: VpcGatewayAttachment + Properties: + RouteTableId: !Ref PublicRouteTable + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: !Ref InternetGateway + + PublicSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet1 + RouteTableId: !Ref PublicRouteTable + + PublicSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet2 + RouteTableId: !Ref PublicRouteTable + + NatEip: + Type: AWS::EC2::EIP + Condition: UseNatGateway + Properties: + Domain: vpc + + NatGateway: + Type: AWS::EC2::NatGateway + Condition: UseNatGateway + Properties: + AllocationId: !GetAtt NatEip.AllocationId + SubnetId: !Ref PublicSubnet1 + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-nat" + + PrivateRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref BackendVpc + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-private-rt" + + PrivateDefaultRoute: + Type: AWS::EC2::Route + Condition: UseNatGateway + Properties: + RouteTableId: !Ref PrivateRouteTable + DestinationCidrBlock: 0.0.0.0/0 + NatGatewayId: !Ref NatGateway + + PrivateSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet1 + RouteTableId: !Ref PrivateRouteTable + + PrivateSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet2 + RouteTableId: !Ref PrivateRouteTable + + LambdaSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Lambda security group for backend API + VpcId: !Ref BackendVpc + SecurityGroupEgress: + - IpProtocol: -1 + CidrIp: 0.0.0.0/0 + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-lambda-sg" + + DatabaseSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Aurora security group for backend API + VpcId: !Ref BackendVpc + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: !Ref DatabasePort + ToPort: !Ref DatabasePort + SourceSecurityGroupId: !Ref LambdaSecurityGroup + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-db-sg" + + VpcEndpointSecurityGroup: + Type: AWS::EC2::SecurityGroup + Condition: CreatePrivateAwsEndpoints + Properties: + GroupDescription: Interface endpoint security group for backend private AWS service access + VpcId: !Ref BackendVpc + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + SourceSecurityGroupId: !Ref LambdaSecurityGroup + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-vpce-sg" + + SecretsManagerVpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Condition: CreatePrivateAwsEndpoints + Properties: + VpcId: !Ref BackendVpc + VpcEndpointType: Interface + PrivateDnsEnabled: true + ServiceName: !Sub "com.amazonaws.${AWS::Region}.secretsmanager" + SecurityGroupIds: + - !Ref VpcEndpointSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + + S3VpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Condition: CreatePrivateAwsEndpoints + Properties: + VpcId: !Ref BackendVpc + VpcEndpointType: Gateway + ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3" + RouteTableIds: + - !Ref PrivateRouteTable + + DatabaseSubnetGroup: + Type: AWS::RDS::DBSubnetGroup + Properties: + DBSubnetGroupDescription: Private subnets for the Aurora cluster + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + DBSubnetGroupName: !Sub "${ProjectName}-${EnvironmentName}-db-subnets" + + DatabaseMasterSecret: + Type: AWS::SecretsManager::Secret + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + Description: !Sub "Master database credentials for ${ProjectName} ${EnvironmentName}" + GenerateSecretString: + SecretStringTemplate: !Sub '{"username":"${DatabaseMasterUsername}"}' + GenerateStringKey: password + ExcludeCharacters: "\"'@/\\:?%#[]{}()<>&+=,;" + + DatabaseCluster: + Type: AWS::RDS::DBCluster + DeletionPolicy: Snapshot + UpdateReplacePolicy: Snapshot + Properties: + Engine: !Ref DatabaseEngine + DBClusterIdentifier: !Sub "${ProjectName}-${EnvironmentName}-cluster" + DatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + MasterUsername: !Ref DatabaseMasterUsername + MasterUserPassword: !Sub "{{resolve:secretsmanager:${DatabaseMasterSecret}:SecretString:password}}" + Port: !Ref DatabasePort + ServerlessV2ScalingConfiguration: + MinCapacity: !Ref DatabaseMinCapacity + MaxCapacity: !Ref DatabaseMaxCapacity + StorageEncrypted: true + DBSubnetGroupName: !Ref DatabaseSubnetGroup + VpcSecurityGroupIds: + - !Ref DatabaseSecurityGroup + EnableHttpEndpoint: true + DeletionProtection: false + BackupRetentionPeriod: 7 + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + DatabaseInstanceWriter: + Type: AWS::RDS::DBInstance + Properties: + DBClusterIdentifier: !Ref DatabaseCluster + DBInstanceClass: db.serverless + Engine: !Ref DatabaseEngine + PubliclyAccessible: false + AutoMinorVersionUpgrade: true + Tags: + - Key: Name + Value: !Sub "${ProjectName}-${EnvironmentName}-writer" + + ApiFunctionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${ProjectName}-${EnvironmentName}-api-role" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + Policies: + - PolicyName: database-and-secrets + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Ref DatabaseMasterSecret + - Effect: Allow + Action: + - rds-data:BatchExecuteStatement + - rds-data:BeginTransaction + - rds-data:CommitTransaction + - rds-data:ExecuteStatement + - rds-data:RollbackTransaction + Resource: !GetAtt DatabaseCluster.DBClusterArn + + AppConfigSecretAccessPolicy: + Type: AWS::IAM::Policy + Condition: HasAppConfigSecretArn + Properties: + PolicyName: !Sub "${ProjectName}-${EnvironmentName}-app-config-secret" + Roles: + - !Ref ApiFunctionRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Ref AppConfigSecretArn + + WebSocketManageConnectionsPolicy: + Type: AWS::IAM::Policy + Condition: CreateWebSocketApi + Properties: + PolicyName: !Sub "${ProjectName}-${EnvironmentName}-ws-manage-connections" + Roles: + - !Ref ApiFunctionRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${BackendWebSocketApi}/*" + + AssetBucketAccessPolicy: + Type: AWS::IAM::Policy + Condition: HasResolvedAssetBucket + Properties: + PolicyName: !Sub "${ProjectName}-${EnvironmentName}-asset-bucket-access" + Roles: + - !Ref ApiFunctionRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:AbortMultipartUpload + Resource: !Sub + - "arn:${AWS::Partition}:s3:::${ResolvedBucketName}/*" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - Effect: Allow + Action: + - s3:ListBucket + Resource: !Sub + - "arn:${AWS::Partition}:s3:::${ResolvedBucketName}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + + SesSendPolicy: + Type: AWS::IAM::Policy + Condition: UseSesMailSystem + Properties: + PolicyName: !Sub "${ProjectName}-${EnvironmentName}-ses-send" + Roles: + - !Ref ApiFunctionRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ses:SendEmail + - ses:SendRawEmail + - ses:SendTemplatedEmail + - ses:SendBulkTemplatedEmail + Resource: "*" + + PollyAccessPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub "${ProjectName}-${EnvironmentName}-polly-access" + Roles: + - !Ref ApiFunctionRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + + ManagedAssetBucket: + Type: AWS::S3::Bucket + Condition: CreateManagedAssetBucket + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + CorsConfiguration: + CorsRules: + - AllowedHeaders: ["*"] + AllowedMethods: [GET, HEAD, PUT] + AllowedOrigins: ["*"] + ExposedHeaders: [ETag] + MaxAge: 3000 + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + VersioningConfiguration: + Status: Enabled + + ManagedAssetBucketPublicReadPolicy: + Type: AWS::S3::BucketPolicy + Condition: CreateManagedAssetBucket + Properties: + Bucket: !Ref ManagedAssetBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: "*" + Action: + - s3:GetObject + Resource: !Sub "arn:${AWS::Partition}:s3:::${ManagedAssetBucket}/*" + + ApiLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-api" + RetentionInDays: 30 + + ApiFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-api" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref LambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref LambdaMemorySize + Timeout: !Ref LambdaTimeout + Role: !GetAtt ApiFunctionRole.Arn + ReservedConcurrentExecutions: !If [HasReservedConcurrency, !Ref LambdaReservedConcurrency, !Ref "AWS::NoValue"] + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + SOCKET_URL: !If + - CreateWebSocketApi + - !Sub "wss://${BackendWebSocketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${EnvironmentName}" + - "" + SOCKET_PORT: "8087" + CORS_ORIGIN: !Ref CorsOrigin + FILE_STORE: !Ref FileStore + AWS_S3_BUCKET: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + JWT_SECRET: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:jwtSecret}}" + - "" + ENCRYPTION_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:encryptionKey}}" + - "" + MAIL_SYSTEM: !Ref MailSystem + DELIVERY_PROVIDER: !Ref DeliveryProvider + STORE_API_URL: !Ref StoreApiUrl + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + HUBSPOT_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:hubspotKey}}" + - "" + MAUTIC_URL: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticUrl}}" + - "" + MAUTIC_USER: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticUser}}" + - "" + MAUTIC_PASSWORD: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticPassword}}" + - "" + YOUTUBE_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:youTubeApiKey}}" + - "" + PEXELS_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:pexelsKey}}" + - "" + VIMEO_TOKEN: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:vimeoToken}}" + - "" + API_BIBLE_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:apiBibleKey}}" + - "" + YOUVERSION_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:youVersionApiKey}}" + - "" + PRAISECHARTS_CONSUMER_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:praiseChartsConsumerKey}}" + - "" + PRAISECHARTS_CONSUMER_SECRET: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:praiseChartsConsumerSecret}}" + - "" + GOOGLE_RECAPTCHA_SECRET_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:googleRecaptchaSecretKey}}" + - "" + OPENROUTER_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:openRouterApiKey}}" + - "" + OPENAI_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:openAiApiKey}}" + - "" + WEB_PUSH_PUBLIC_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushPublicKey}}" + - "" + WEB_PUSH_PRIVATE_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushPrivateKey}}" + - "" + WEB_PUSH_SUBJECT: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushSubject}}" + - "" + DB_ENGINE: !Ref DatabaseEngine + DB_HOST: !GetAtt DatabaseCluster.Endpoint.Address + DB_PORT: !Ref DatabasePort + DB_NAME: !Ref DatabaseName + DB_SECRET_ARN: !Ref DatabaseMasterSecret + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + ATTENDANCE_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedAttendanceDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedAttendanceDatabaseName: !If [HasAttendanceDatabaseName, !Ref AttendanceDatabaseName, "attendance"] + CONTENT_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedContentDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedContentDatabaseName: !If [HasContentDatabaseName, !Ref ContentDatabaseName, "content"] + GIVING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedGivingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedGivingDatabaseName: !If [HasGivingDatabaseName, !Ref GivingDatabaseName, "giving"] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + DOING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedDoingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedDoingDatabaseName: !If [HasDoingDatabaseName, !Ref DoingDatabaseName, "doing"] + REPORTING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedReportingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedReportingDatabaseName: !If [HasReportingDatabaseName, !Ref ReportingDatabaseName, "reporting"] + DOING_MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + WEBSITE_BASE_URL: !Ref WebsiteBaseUrl + CONTENT_ROOT_URL: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !Sub + - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - "" + CONTENT_ROOT: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !Sub + - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - "" + B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] + TRANSFER_URL: !Ref TransferUrl + SUPPORT_EMAIL: !Ref SupportEmail + SUPPORT_PHONE: !Ref SupportPhone + SUPPORT_SITE_URL: !Ref SupportSiteUrl + MOBILE_APP_URL: !Ref MobileAppUrl + DOMAIN_CNAME_TARGET: !Ref DomainCnameTarget + DOMAIN_A_TARGET: !Ref DomainATarget + DEFAULT_STOCK_PHOTO: !Ref DefaultStockPhoto + GOOGLE_ANALYTICS_TAG: !Ref GoogleAnalyticsTag + SENTRY_DSN: !Ref SentryDsn + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + MigrationLogGroup: + Type: AWS::Logs::LogGroup + Condition: RunMigrationResources + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-migration" + RetentionInDays: 30 + + MigrationFunction: + Type: AWS::Lambda::Function + Condition: RunMigrationResources + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-migration" + Code: + S3Bucket: !If [HasMigrationCodeS3Bucket, !Ref MigrationCodeS3Bucket, !Ref LambdaCodeS3Bucket] + S3Key: !If [HasMigrationCodeS3Key, !Ref MigrationCodeS3Key, !Ref LambdaCodeS3Key] + Handler: !Ref MigrationHandler + Runtime: !If [HasMigrationRuntime, !Ref MigrationRuntime, !Ref LambdaRuntime] + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref MigrationMemorySize + Timeout: !Ref MigrationTimeout + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + SOCKET_URL: !If + - CreateWebSocketApi + - !Sub "wss://${BackendWebSocketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${EnvironmentName}" + - "" + SOCKET_PORT: "8087" + CORS_ORIGIN: !Ref CorsOrigin + FILE_STORE: !Ref FileStore + AWS_S3_BUCKET: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + JWT_SECRET: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:jwtSecret}}" + - "" + ENCRYPTION_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:encryptionKey}}" + - "" + MAIL_SYSTEM: !Ref MailSystem + DELIVERY_PROVIDER: !Ref DeliveryProvider + STORE_API_URL: !Ref StoreApiUrl + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + HUBSPOT_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:hubspotKey}}" + - "" + MAUTIC_URL: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticUrl}}" + - "" + MAUTIC_USER: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticUser}}" + - "" + MAUTIC_PASSWORD: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:mauticPassword}}" + - "" + YOUTUBE_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:youTubeApiKey}}" + - "" + PEXELS_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:pexelsKey}}" + - "" + VIMEO_TOKEN: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:vimeoToken}}" + - "" + API_BIBLE_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:apiBibleKey}}" + - "" + YOUVERSION_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:youVersionApiKey}}" + - "" + PRAISECHARTS_CONSUMER_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:praiseChartsConsumerKey}}" + - "" + PRAISECHARTS_CONSUMER_SECRET: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:praiseChartsConsumerSecret}}" + - "" + GOOGLE_RECAPTCHA_SECRET_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:googleRecaptchaSecretKey}}" + - "" + OPENROUTER_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:openRouterApiKey}}" + - "" + OPENAI_API_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:openAiApiKey}}" + - "" + WEB_PUSH_PUBLIC_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushPublicKey}}" + - "" + WEB_PUSH_PRIVATE_KEY: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushPrivateKey}}" + - "" + WEB_PUSH_SUBJECT: !If + - HasAppConfigSecretArn + - !Sub "{{resolve:secretsmanager:${AppConfigSecretArn}:SecretString:webPushSubject}}" + - "" + DB_ENGINE: !Ref DatabaseEngine + DB_HOST: !GetAtt DatabaseCluster.Endpoint.Address + DB_PORT: !Ref DatabasePort + DB_NAME: !Ref DatabaseName + DB_SECRET_ARN: !Ref DatabaseMasterSecret + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + ATTENDANCE_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedAttendanceDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedAttendanceDatabaseName: !If [HasAttendanceDatabaseName, !Ref AttendanceDatabaseName, "attendance"] + CONTENT_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedContentDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedContentDatabaseName: !If [HasContentDatabaseName, !Ref ContentDatabaseName, "content"] + GIVING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedGivingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedGivingDatabaseName: !If [HasGivingDatabaseName, !Ref GivingDatabaseName, "giving"] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + DOING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedDoingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedDoingDatabaseName: !If [HasDoingDatabaseName, !Ref DoingDatabaseName, "doing"] + REPORTING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedReportingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedReportingDatabaseName: !If [HasReportingDatabaseName, !Ref ReportingDatabaseName, "reporting"] + DOING_MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + CONTENT_ROOT: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !Sub + - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - "" + B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + MigrationRunner: + Type: Custom::MigrationRunner + Condition: RunMigrationResources + Properties: + ServiceToken: !GetAtt MigrationFunction.Arn + MigrationTrigger: !Ref MigrationTrigger + DatabaseSecretArn: !Ref DatabaseMasterSecret + DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseName: !Ref DatabaseName + EnvironmentName: !Ref EnvironmentName + + SocketLogGroup: + Type: AWS::Logs::LogGroup + Condition: CreateWebSocketApi + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-socket" + RetentionInDays: 30 + + SocketFunction: + Type: AWS::Lambda::Function + Condition: CreateWebSocketApi + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-socket" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref SocketLambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref SocketLambdaMemorySize + Timeout: !Ref SocketLambdaTimeout + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !Ref BackendWebSocketApi + SOCKET_URL: !Sub "wss://${BackendWebSocketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${EnvironmentName}" + SOCKET_PORT: "8087" + CORS_ORIGIN: !Ref CorsOrigin + FILE_STORE: !Ref FileStore + AWS_S3_BUCKET: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + MAIL_SYSTEM: !Ref MailSystem + DELIVERY_PROVIDER: !Ref DeliveryProvider + STORE_API_URL: !Ref StoreApiUrl + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + DB_ENGINE: !Ref DatabaseEngine + DB_HOST: !GetAtt DatabaseCluster.Endpoint.Address + DB_PORT: !Ref DatabasePort + DB_NAME: !Ref DatabaseName + DB_SECRET_ARN: !Ref DatabaseMasterSecret + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + CONTENT_ROOT: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !Sub + - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - "" + B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + BackendWebSocketApi: + Type: AWS::ApiGatewayV2::Api + Condition: CreateWebSocketApi + Properties: + Name: !Sub "${ProjectName}-${EnvironmentName}-websocket-api" + ProtocolType: WEBSOCKET + RouteSelectionExpression: "$request.body.action" + + BackendWebSocketIntegration: + Type: AWS::ApiGatewayV2::Integration + Condition: CreateWebSocketApi + Properties: + ApiId: !Ref BackendWebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt SocketFunction.Arn + + BackendWebSocketRouteConnect: + Type: AWS::ApiGatewayV2::Route + Condition: CreateWebSocketApi + Properties: + ApiId: !Ref BackendWebSocketApi + RouteKey: $connect + Target: !Sub "integrations/${BackendWebSocketIntegration}" + + BackendWebSocketRouteDisconnect: + Type: AWS::ApiGatewayV2::Route + Condition: CreateWebSocketApi + Properties: + ApiId: !Ref BackendWebSocketApi + RouteKey: $disconnect + Target: !Sub "integrations/${BackendWebSocketIntegration}" + + BackendWebSocketRouteDefault: + Type: AWS::ApiGatewayV2::Route + Condition: CreateWebSocketApi + Properties: + ApiId: !Ref BackendWebSocketApi + RouteKey: $default + Target: !Sub "integrations/${BackendWebSocketIntegration}" + + BackendWebSocketStage: + Type: AWS::ApiGatewayV2::Stage + Condition: CreateWebSocketApi + Properties: + ApiId: !Ref BackendWebSocketApi + StageName: !Ref EnvironmentName + AutoDeploy: true + + SocketInvokePermission: + Type: AWS::Lambda::Permission + Condition: CreateWebSocketApi + Properties: + FunctionName: !Ref SocketFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${BackendWebSocketApi}/*" + + Timer15MinLogGroup: + Type: AWS::Logs::LogGroup + Condition: CreateScheduledWorkers + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-timer15min" + RetentionInDays: 30 + + Timer15MinFunction: + Type: AWS::Lambda::Function + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-timer15min" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref Timer15MinLambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref TimerLambdaMemorySize + Timeout: !Ref TimerLambdaTimeout + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + + Timer15MinRule: + Type: AWS::Events::Rule + Condition: CreateScheduledWorkers + Properties: + ScheduleExpression: rate(30 minutes) + State: ENABLED + Targets: + - Arn: !GetAtt Timer15MinFunction.Arn + Id: timer15MinTarget + + Timer15MinInvokePermission: + Type: AWS::Lambda::Permission + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Ref Timer15MinFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt Timer15MinRule.Arn + + TimerMidnightLogGroup: + Type: AWS::Logs::LogGroup + Condition: CreateScheduledWorkers + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-timer-midnight" + RetentionInDays: 30 + + TimerMidnightFunction: + Type: AWS::Lambda::Function + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-timer-midnight" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref TimerMidnightLambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref TimerLambdaMemorySize + Timeout: !Ref TimerLambdaTimeout + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + CONTENT_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedContentDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedContentDatabaseName: !If [HasContentDatabaseName, !Ref ContentDatabaseName, "content"] + + TimerMidnightRule: + Type: AWS::Events::Rule + Condition: CreateScheduledWorkers + Properties: + ScheduleExpression: cron(0 5 * * ? *) + State: ENABLED + Targets: + - Arn: !GetAtt TimerMidnightFunction.Arn + Id: timerMidnightTarget + + TimerMidnightInvokePermission: + Type: AWS::Lambda::Permission + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Ref TimerMidnightFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt TimerMidnightRule.Arn + + TimerScheduledTasksLogGroup: + Type: AWS::Logs::LogGroup + Condition: CreateScheduledWorkers + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-timer-scheduled-tasks" + RetentionInDays: 30 + + TimerScheduledTasksFunction: + Type: AWS::Lambda::Function + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-timer-scheduled-tasks" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref TimerScheduledTasksLambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: !Ref TimerLambdaMemorySize + Timeout: !Ref TimerLambdaTimeout + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + MESSAGING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMessagingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMessagingDatabaseName: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + DOING_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedDoingDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedDoingDatabaseName: !If [HasDoingDatabaseName, !Ref DoingDatabaseName, "doing"] + + TimerScheduledTasksRule: + Type: AWS::Events::Rule + Condition: CreateScheduledWorkers + Properties: + ScheduleExpression: cron(0 5 * * ? *) + State: ENABLED + Targets: + - Arn: !GetAtt TimerScheduledTasksFunction.Arn + Id: timerScheduledTasksTarget + + TimerScheduledTasksInvokePermission: + Type: AWS::Lambda::Permission + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Ref TimerScheduledTasksFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt TimerScheduledTasksRule.Arn + + TimerWebhooksLogGroup: + Type: AWS::Logs::LogGroup + Condition: CreateScheduledWorkers + Properties: + LogGroupName: !Sub "/aws/lambda/${ProjectName}-${EnvironmentName}-timer-webhooks" + RetentionInDays: 30 + + TimerWebhooksFunction: + Type: AWS::Lambda::Function + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Sub "${ProjectName}-${EnvironmentName}-timer-webhooks" + Code: + S3Bucket: !Ref LambdaCodeS3Bucket + S3Key: !Ref LambdaCodeS3Key + Handler: !Ref TimerWebhooksLambdaHandler + Runtime: !Ref LambdaRuntime + Architectures: + - !Ref LambdaArchitecture + Layers: + - !If [HasDependenciesLayerArn, !Ref DependenciesLayerArn, !Ref "AWS::NoValue"] + - !If [HasObservabilityLayerArn, !Ref ObservabilityLayerArn, !Ref "AWS::NoValue"] + MemorySize: 256 + Timeout: 120 + Role: !GetAtt ApiFunctionRole.Arn + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + APP_ENV: !Ref EnvironmentName + ENVIRONMENT: !Ref EnvironmentName + STAGE: !Ref EnvironmentName + NODE_OPTIONS: !Ref LambdaNodeOptions + WEBSOCKET_API_ID: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + AI_PROVIDER: !Ref AiProvider + EMAIL_ON_REGISTRATION: !Ref EmailOnRegistration + CADDY_HOST: !Ref CaddyHost + CADDY_PORT: !Ref CaddyPort + MEMBERSHIP_CONNECTION_STRING: !Sub + - "mysql://${DatabaseMasterUsername}:{{resolve:secretsmanager:${DatabaseSecretArn}:SecretString:password}}@${DatabaseHost}:${DatabasePort}/${ResolvedMembershipDatabaseName}" + - DatabaseHost: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseSecretArn: !Ref DatabaseMasterSecret + ResolvedMembershipDatabaseName: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + + TimerWebhooksRule: + Type: AWS::Events::Rule + Condition: CreateScheduledWorkers + Properties: + ScheduleExpression: rate(1 minute) + State: ENABLED + Targets: + - Arn: !GetAtt TimerWebhooksFunction.Arn + Id: timerWebhooksTarget + + TimerWebhooksInvokePermission: + Type: AWS::Lambda::Permission + Condition: CreateScheduledWorkers + Properties: + FunctionName: !Ref TimerWebhooksFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt TimerWebhooksRule.Arn + + BackendHttpApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: !Sub "${ProjectName}-${EnvironmentName}-http-api" + ProtocolType: HTTP + CorsConfiguration: + AllowMethods: + - GET + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + AllowOrigins: + - "*" + AllowHeaders: + - authorization + - content-type + - x-requested-with + ExposeHeaders: + - content-type + MaxAge: 86400 + + BackendHttpApiIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref BackendHttpApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt ApiFunction.Arn + PayloadFormatVersion: "2.0" + + BackendHttpApiRouteDefault: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref BackendHttpApi + RouteKey: "$default" + Target: !Sub "integrations/${BackendHttpApiIntegration}" + + BackendHttpApiStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref BackendHttpApi + StageName: "$default" + AutoDeploy: true + + BackendHttpApiDomainName: + Type: AWS::ApiGatewayV2::DomainName + Condition: CreateApiCustomDomain + Properties: + DomainName: !Ref ApiCustomDomainName + DomainNameConfigurations: + - CertificateArn: !Ref ApiCertificateArn + EndpointType: REGIONAL + SecurityPolicy: TLS_1_2 + + BackendHttpApiMapping: + Type: AWS::ApiGatewayV2::ApiMapping + Condition: CreateApiCustomDomain + Properties: + ApiId: !Ref BackendHttpApi + DomainName: !Ref BackendHttpApiDomainName + Stage: !Ref BackendHttpApiStage + + BackendHttpApiDnsRecord: + Type: AWS::Route53::RecordSet + Condition: CreateApiDnsRecord + Properties: + HostedZoneId: !Ref ApiHostedZoneId + Name: !Ref ApiCustomDomainName + Type: A + AliasTarget: + DNSName: !GetAtt BackendHttpApiDomainName.RegionalDomainName + HostedZoneId: !GetAtt BackendHttpApiDomainName.RegionalHostedZoneId + EvaluateTargetHealth: false + + BackendHttpApiDnsRecordIpv6: + Type: AWS::Route53::RecordSet + Condition: CreateApiDnsRecord + Properties: + HostedZoneId: !Ref ApiHostedZoneId + Name: !Ref ApiCustomDomainName + Type: AAAA + AliasTarget: + DNSName: !GetAtt BackendHttpApiDomainName.RegionalDomainName + HostedZoneId: !GetAtt BackendHttpApiDomainName.RegionalHostedZoneId + EvaluateTargetHealth: false + + ApiInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref ApiFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${BackendHttpApi}/*/*" + +Outputs: + ApiBaseUrl: + Description: Public base URL for the backend API. + Value: !If + - CreateApiCustomDomain + - !Sub "https://${ApiCustomDomainName}" + - !Sub "https://${BackendHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + PublicApiBaseUrl: + Description: Alias output for frontend deployment tooling. + Value: !If + - CreateApiCustomDomain + - !Sub "https://${ApiCustomDomainName}" + - !Sub "https://${BackendHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + ApiFunctionName: + Value: !Ref ApiFunction + ApiFunctionArn: + Value: !GetAtt ApiFunction.Arn + MigrationFunctionName: + Value: !If [RunMigrationResources, !Ref MigrationFunction, ""] + SocketFunctionName: + Value: !If [CreateWebSocketApi, !Ref SocketFunction, ""] + WebSocketApiId: + Value: !If [CreateWebSocketApi, !Ref BackendWebSocketApi, ""] + WebSocketApiEndpoint: + Value: !If + - CreateWebSocketApi + - !Sub "wss://${BackendWebSocketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${EnvironmentName}" + - "" + Timer15MinFunctionName: + Value: !If [CreateScheduledWorkers, !Ref Timer15MinFunction, ""] + TimerMidnightFunctionName: + Value: !If [CreateScheduledWorkers, !Ref TimerMidnightFunction, ""] + TimerScheduledTasksFunctionName: + Value: !If [CreateScheduledWorkers, !Ref TimerScheduledTasksFunction, ""] + TimerWebhooksFunctionName: + Value: !If [CreateScheduledWorkers, !Ref TimerWebhooksFunction, ""] + DatabaseEndpoint: + Value: !GetAtt DatabaseCluster.Endpoint.Address + DatabaseClusterArn: + Value: !GetAtt DatabaseCluster.DBClusterArn + DatabaseReaderEndpoint: + Value: !GetAtt DatabaseCluster.ReadEndpoint.Address + DatabasePort: + Value: !Ref DatabasePort + DatabaseName: + Value: !Ref DatabaseName + MembershipDatabaseName: + Value: !If [HasMembershipDatabaseName, !Ref MembershipDatabaseName, !Ref DatabaseName] + AttendanceDatabaseName: + Value: !If [HasAttendanceDatabaseName, !Ref AttendanceDatabaseName, "attendance"] + ContentDatabaseName: + Value: !If [HasContentDatabaseName, !Ref ContentDatabaseName, "content"] + GivingDatabaseName: + Value: !If [HasGivingDatabaseName, !Ref GivingDatabaseName, "giving"] + MessagingDatabaseName: + Value: !If [HasMessagingDatabaseName, !Ref MessagingDatabaseName, "messaging"] + DoingDatabaseName: + Value: !If [HasDoingDatabaseName, !Ref DoingDatabaseName, "doing"] + ReportingDatabaseName: + Value: !If [HasReportingDatabaseName, !Ref ReportingDatabaseName, "reporting"] + DatabaseSecretArn: + Value: !Ref DatabaseMasterSecret + AppConfigSecretArn: + Value: !Ref AppConfigSecretArn + VpcId: + Value: !Ref BackendVpc + PrivateSubnet1Id: + Value: !Ref PrivateSubnet1 + PrivateSubnet2Id: + Value: !Ref PrivateSubnet2 + LambdaSecurityGroupId: + Value: !Ref LambdaSecurityGroup + AssetBucketName: + Value: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + ContentRootUrl: + Value: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !Sub + - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - "" + WebsiteBaseUrl: + Value: !Ref WebsiteBaseUrl + LessonsApiUrl: + Value: !If + - CreateApiCustomDomain + - !Sub "https://${ApiCustomDomainName}" + - !Sub "https://${BackendHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + TransferUrl: + Value: !Ref TransferUrl + SupportEmail: + Value: !Ref SupportEmail + SupportPhone: + Value: !Ref SupportPhone + SupportSiteUrl: + Value: !Ref SupportSiteUrl + MobileAppUrl: + Value: !Ref MobileAppUrl + DomainCnameTarget: + Value: !Ref DomainCnameTarget + DomainATarget: + Value: !Ref DomainATarget + DefaultStockPhoto: + Value: !Ref DefaultStockPhoto + GoogleAnalyticsTag: + Value: !Ref GoogleAnalyticsTag + SentryDsn: + Value: !Ref SentryDsn + ApiCustomDomainName: + Value: !Ref ApiCustomDomainName diff --git a/infrastructure/cloudformation/bootstrap.yaml b/infrastructure/cloudformation/bootstrap.yaml new file mode 100644 index 000000000..84cac7dfe --- /dev/null +++ b/infrastructure/cloudformation/bootstrap.yaml @@ -0,0 +1,108 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Bootstrap resources for B1Admin AWS deployments. + +Parameters: + ProjectName: + Type: String + Default: b1admin + AllowedPattern: "^[a-z0-9-]+$" + EnvironmentName: + Type: String + Default: prod + AllowedPattern: "^[a-z0-9-]+$" + TemplateBucketName: + Type: String + Default: "" + Description: Optional explicit S3 bucket name for uploaded CloudFormation templates. + ArtifactBucketName: + Type: String + Default: "" + Description: Optional explicit S3 bucket name for packaged Lambda artifacts. + EnableBucketVersioning: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + +Conditions: + HasTemplateBucketName: !Not [!Equals [!Ref TemplateBucketName, ""]] + HasArtifactBucketName: !Not [!Equals [!Ref ArtifactBucketName, ""]] + UseBucketVersioning: !Equals [!Ref EnableBucketVersioning, "true"] + +Resources: + TemplateBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + BucketName: !If [HasTemplateBucketName, !Ref TemplateBucketName, !Ref "AWS::NoValue"] + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + VersioningConfiguration: !If + - UseBucketVersioning + - Status: Enabled + - !Ref "AWS::NoValue" + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + - Key: Purpose + Value: cloudformation-templates + + ArtifactBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + BucketName: !If [HasArtifactBucketName, !Ref ArtifactBucketName, !Ref "AWS::NoValue"] + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + VersioningConfiguration: !If + - UseBucketVersioning + - Status: Enabled + - !Ref "AWS::NoValue" + LifecycleConfiguration: + Rules: + - Id: AbortIncompleteMultipartUploads + Status: Enabled + AbortIncompleteMultipartUpload: + DaysAfterInitiation: 7 + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + - Key: Purpose + Value: deployment-artifacts + +Outputs: + TemplateBucketName: + Description: S3 bucket for nested CloudFormation templates. + Value: !Ref TemplateBucket + TemplateBucketArn: + Value: !GetAtt TemplateBucket.Arn + ArtifactBucketName: + Description: S3 bucket for Lambda artifacts and other packaged assets. + Value: !Ref ArtifactBucket + ArtifactBucketArn: + Value: !GetAtt ArtifactBucket.Arn diff --git a/infrastructure/cloudformation/frontend-site.yaml b/infrastructure/cloudformation/frontend-site.yaml new file mode 100644 index 000000000..2a35497f1 --- /dev/null +++ b/infrastructure/cloudformation/frontend-site.yaml @@ -0,0 +1,206 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: B1Admin static frontend hosting with S3, CloudFront, and optional custom domain. + +Parameters: + ProjectName: + Type: String + Default: b1admin + AllowedPattern: "^[a-z0-9-]+$" + Description: Lowercase project slug used for resource names and tagging. + EnvironmentName: + Type: String + Default: prod + AllowedPattern: "^[a-z0-9-]+$" + Description: Environment slug such as dev, staging, or prod. + BucketName: + Type: String + Default: "" + Description: Optional globally unique S3 bucket name. Leave blank to let CloudFormation generate one. + AlternateDomainName: + Type: String + Default: "" + Description: Optional custom domain for CloudFront, such as admin.example.com. + AcmCertificateArn: + Type: String + Default: "" + Description: Optional ACM certificate ARN in us-east-1. Required when AlternateDomainName is set. + HostedZoneId: + Type: String + Default: "" + Description: Optional Route53 hosted zone ID for creating the DNS alias record. + PriceClass: + Type: String + Default: PriceClass_100 + AllowedValues: + - PriceClass_100 + - PriceClass_200 + - PriceClass_All + Description: CloudFront price class. + +Rules: + DomainRequiresCertificate: + Assertions: + - Assert: !Or + - !Equals [!Ref AlternateDomainName, ""] + - !Not [!Equals [!Ref AcmCertificateArn, ""]] + AssertDescription: AcmCertificateArn is required when AlternateDomainName is set. + HostedZoneRequiresDomain: + Assertions: + - Assert: !Or + - !Equals [!Ref HostedZoneId, ""] + - !Not [!Equals [!Ref AlternateDomainName, ""]] + AssertDescription: AlternateDomainName is required when HostedZoneId is set. + +Conditions: + HasBucketName: !Not [!Equals [!Ref BucketName, ""]] + HasAlternateDomain: !Not [!Equals [!Ref AlternateDomainName, ""]] + HasHostedZone: !Not [!Equals [!Ref HostedZoneId, ""]] + CreateDnsRecord: !And + - !Condition HasAlternateDomain + - !Condition HasHostedZone + +Resources: + SiteBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + BucketName: !If [HasBucketName, !Ref BucketName, !Ref "AWS::NoValue"] + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + VersioningConfiguration: + Status: Enabled + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + SiteBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref SiteBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AllowCloudFrontServicePrincipalReadOnly + Effect: Allow + Principal: + Service: cloudfront.amazonaws.com + Action: s3:GetObject + Resource: !Sub "${SiteBucket.Arn}/*" + Condition: + StringEquals: + AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}" + + CloudFrontOriginAccessControl: + Type: AWS::CloudFront::OriginAccessControl + Properties: + OriginAccessControlConfig: + Name: !Sub "${ProjectName}-${EnvironmentName}-oac" + Description: Access control for the B1Admin frontend bucket. + OriginAccessControlOriginType: s3 + SigningBehavior: always + SigningProtocol: sigv4 + + SiteDistribution: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Aliases: !If + - HasAlternateDomain + - [!Ref AlternateDomainName] + - !Ref "AWS::NoValue" + Comment: !Sub "${ProjectName}-${EnvironmentName} frontend" + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + CachedMethods: + - GET + - HEAD + Compress: true + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 + OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf + TargetOriginId: site-bucket-origin + ViewerProtocolPolicy: redirect-to-https + CustomErrorResponses: + - ErrorCode: 403 + ResponseCode: 200 + ResponsePagePath: /index.html + ErrorCachingMinTTL: 0 + - ErrorCode: 404 + ResponseCode: 200 + ResponsePagePath: /index.html + ErrorCachingMinTTL: 0 + DefaultRootObject: index.html + Enabled: true + HttpVersion: http2and3 + IPV6Enabled: true + Origins: + - Id: site-bucket-origin + DomainName: !GetAtt SiteBucket.RegionalDomainName + OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id + S3OriginConfig: {} + PriceClass: !Ref PriceClass + ViewerCertificate: !If + - HasAlternateDomain + - AcmCertificateArn: !Ref AcmCertificateArn + MinimumProtocolVersion: TLSv1.2_2021 + SslSupportMethod: sni-only + - CloudFrontDefaultCertificate: true + + SiteDnsRecord: + Type: AWS::Route53::RecordSet + Condition: CreateDnsRecord + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref AlternateDomainName + Type: A + AliasTarget: + DNSName: !GetAtt SiteDistribution.DomainName + HostedZoneId: Z2FDTNDATAQYW2 + EvaluateTargetHealth: false + + SiteDnsRecordIpv6: + Type: AWS::Route53::RecordSet + Condition: CreateDnsRecord + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref AlternateDomainName + Type: AAAA + AliasTarget: + DNSName: !GetAtt SiteDistribution.DomainName + HostedZoneId: Z2FDTNDATAQYW2 + EvaluateTargetHealth: false + +Outputs: + SiteBucketName: + Description: S3 bucket that stores the frontend assets. + Value: !Ref SiteBucket + SiteBucketArn: + Description: ARN of the frontend asset bucket. + Value: !GetAtt SiteBucket.Arn + CloudFrontDistributionId: + Description: CloudFront distribution ID. + Value: !Ref SiteDistribution + CloudFrontDistributionDomainName: + Description: CloudFront distribution domain name. + Value: !GetAtt SiteDistribution.DomainName + AppUrl: + Description: URL for the deployed site. + Value: !If + - HasAlternateDomain + - !Sub "https://${AlternateDomainName}" + - !Sub "https://${SiteDistribution.DomainName}" diff --git a/infrastructure/cloudformation/full-stack.yaml b/infrastructure/cloudformation/full-stack.yaml new file mode 100644 index 000000000..6b715d62d --- /dev/null +++ b/infrastructure/cloudformation/full-stack.yaml @@ -0,0 +1,499 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: B1Admin full AWS deployment entrypoint using nested backend and frontend stacks. + +Parameters: + ProjectName: + Type: String + Default: b1admin + AllowedPattern: "^[a-z0-9-]+$" + EnvironmentName: + Type: String + Default: prod + AllowedPattern: "^[a-z0-9-]+$" + BackendTemplateUrl: + Type: String + Description: S3 or HTTPS URL for backend-api.yaml uploaded template. + FrontendTemplateUrl: + Type: String + Description: S3 or HTTPS URL for frontend-site.yaml uploaded template. + LambdaCodeS3Bucket: + Type: String + LambdaCodeS3Key: + Type: String + LambdaHandler: + Type: String + Default: lambda.web + LambdaRuntime: + Type: String + Default: nodejs22.x + LambdaArchitecture: + Type: String + Default: arm64 + AllowedValues: [arm64, x86_64] + LambdaMemorySize: + Type: Number + Default: 1024 + LambdaTimeout: + Type: Number + Default: 30 + LambdaReservedConcurrency: + Type: Number + Default: 0 + DependenciesLayerArn: + Type: String + Default: "" + ObservabilityLayerArn: + Type: String + Default: "" + LambdaNodeOptions: + Type: String + Default: "" + EnableWebSocketApi: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + SocketLambdaHandler: + Type: String + Default: lambda.socket + SocketLambdaMemorySize: + Type: Number + Default: 1024 + SocketLambdaTimeout: + Type: Number + Default: 30 + EnableScheduledWorkers: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + Timer15MinLambdaHandler: + Type: String + Default: lambda.timer15Min + TimerMidnightLambdaHandler: + Type: String + Default: lambda.timerMidnight + TimerScheduledTasksLambdaHandler: + Type: String + Default: lambda.timerScheduledTasks + TimerWebhooksLambdaHandler: + Type: String + Default: lambda.timerWebhooks + TimerLambdaMemorySize: + Type: Number + Default: 256 + TimerLambdaTimeout: + Type: Number + Default: 300 + RunMigrations: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + MigrationCodeS3Bucket: + Type: String + Default: "" + MigrationCodeS3Key: + Type: String + Default: "" + MigrationHandler: + Type: String + Default: "" + MigrationRuntime: + Type: String + Default: "" + MigrationMemorySize: + Type: Number + Default: 1024 + MigrationTimeout: + Type: Number + Default: 900 + MigrationTrigger: + Type: String + Default: "" + DatabaseName: + Type: String + Default: membership + MembershipDatabaseName: + Type: String + Default: "" + AttendanceDatabaseName: + Type: String + Default: "" + ContentDatabaseName: + Type: String + Default: "" + GivingDatabaseName: + Type: String + Default: "" + MessagingDatabaseName: + Type: String + Default: "" + DoingDatabaseName: + Type: String + Default: "" + ReportingDatabaseName: + Type: String + Default: "" + DatabaseEngine: + Type: String + Default: aurora-mysql + AllowedValues: + - aurora-mysql + - aurora-postgresql + DatabasePort: + Type: Number + Default: 3306 + DatabaseMasterUsername: + Type: String + Default: app_admin + DatabaseMinCapacity: + Type: Number + Default: 0.5 + DatabaseMaxCapacity: + Type: Number + Default: 2 + ApiCustomDomainName: + Type: String + Default: "" + ApiCertificateArn: + Type: String + Default: "" + ApiHostedZoneId: + Type: String + Default: "" + CreateNatGateway: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + VpcCidr: + Type: String + Default: 10.30.0.0/16 + PublicSubnet1Cidr: + Type: String + Default: 10.30.0.0/24 + PublicSubnet2Cidr: + Type: String + Default: 10.30.1.0/24 + PrivateSubnet1Cidr: + Type: String + Default: 10.30.10.0/24 + PrivateSubnet2Cidr: + Type: String + Default: 10.30.11.0/24 + FrontendBucketName: + Type: String + Default: "" + FrontendAlternateDomainName: + Type: String + Default: "" + FrontendAcmCertificateArn: + Type: String + Default: "" + FrontendHostedZoneId: + Type: String + Default: "" + FrontendPriceClass: + Type: String + Default: PriceClass_100 + AllowedValues: + - PriceClass_100 + - PriceClass_200 + - PriceClass_All + WebsiteBaseUrl: + Type: String + Default: "" + ContentRootUrl: + Type: String + Default: "" + B1AdminRootUrl: + Type: String + Default: "" + CorsOrigin: + Type: String + Default: "*" + FileStore: + Type: String + Default: "S3" + ManageAssetBucket: + Type: String + Default: "true" + AllowedValues: ["true", "false"] + AssetBucketName: + Type: String + Default: "" + AppConfigSecretArn: + Type: String + Default: "" + MailSystem: + Type: String + Default: "SES" + DeliveryProvider: + Type: String + Default: "aws" + StoreApiUrl: + Type: String + Default: "" + AiProvider: + Type: String + Default: "" + EmailOnRegistration: + Type: String + Default: "" + AllowedValues: ["", "true", "false"] + CaddyHost: + Type: String + Default: "" + CaddyPort: + Type: String + Default: "" + TransferUrl: + Type: String + Default: "" + SupportEmail: + Type: String + Default: "" + SupportPhone: + Type: String + Default: "" + SupportSiteUrl: + Type: String + Default: "" + MobileAppUrl: + Type: String + Default: "" + DomainCnameTarget: + Type: String + Default: "" + DomainATarget: + Type: String + Default: "" + DefaultStockPhoto: + Type: String + Default: "" + GoogleAnalyticsTag: + Type: String + Default: "" + SentryDsn: + Type: String + Default: "" + +Rules: + ApiDomainRequiresCertificate: + Assertions: + - Assert: !Or + - !Equals [!Ref ApiCustomDomainName, ""] + - !Not [!Equals [!Ref ApiCertificateArn, ""]] + AssertDescription: ApiCertificateArn is required when ApiCustomDomainName is set. + MigrationRequiresHandler: + Assertions: + - Assert: !Or + - !Equals [!Ref RunMigrations, "false"] + - !Not [!Equals [!Ref MigrationHandler, ""]] + AssertDescription: MigrationHandler is required when RunMigrations is true. + FrontendDomainRequiresCertificate: + Assertions: + - Assert: !Or + - !Equals [!Ref FrontendAlternateDomainName, ""] + - !Not [!Equals [!Ref FrontendAcmCertificateArn, ""]] + AssertDescription: FrontendAcmCertificateArn is required when FrontendAlternateDomainName is set. + +Resources: + BackendStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref BackendTemplateUrl + Parameters: + ProjectName: !Ref ProjectName + EnvironmentName: !Ref EnvironmentName + LambdaCodeS3Bucket: !Ref LambdaCodeS3Bucket + LambdaCodeS3Key: !Ref LambdaCodeS3Key + LambdaHandler: !Ref LambdaHandler + LambdaRuntime: !Ref LambdaRuntime + LambdaArchitecture: !Ref LambdaArchitecture + LambdaMemorySize: !Ref LambdaMemorySize + LambdaTimeout: !Ref LambdaTimeout + LambdaReservedConcurrency: !Ref LambdaReservedConcurrency + DependenciesLayerArn: !Ref DependenciesLayerArn + ObservabilityLayerArn: !Ref ObservabilityLayerArn + LambdaNodeOptions: !Ref LambdaNodeOptions + EnableWebSocketApi: !Ref EnableWebSocketApi + SocketLambdaHandler: !Ref SocketLambdaHandler + SocketLambdaMemorySize: !Ref SocketLambdaMemorySize + SocketLambdaTimeout: !Ref SocketLambdaTimeout + EnableScheduledWorkers: !Ref EnableScheduledWorkers + Timer15MinLambdaHandler: !Ref Timer15MinLambdaHandler + TimerMidnightLambdaHandler: !Ref TimerMidnightLambdaHandler + TimerScheduledTasksLambdaHandler: !Ref TimerScheduledTasksLambdaHandler + TimerWebhooksLambdaHandler: !Ref TimerWebhooksLambdaHandler + TimerLambdaMemorySize: !Ref TimerLambdaMemorySize + TimerLambdaTimeout: !Ref TimerLambdaTimeout + RunMigrations: !Ref RunMigrations + MigrationCodeS3Bucket: !Ref MigrationCodeS3Bucket + MigrationCodeS3Key: !Ref MigrationCodeS3Key + MigrationHandler: !Ref MigrationHandler + MigrationRuntime: !Ref MigrationRuntime + MigrationMemorySize: !Ref MigrationMemorySize + MigrationTimeout: !Ref MigrationTimeout + MigrationTrigger: !Ref MigrationTrigger + DatabaseName: !Ref DatabaseName + MembershipDatabaseName: !Ref MembershipDatabaseName + AttendanceDatabaseName: !Ref AttendanceDatabaseName + ContentDatabaseName: !Ref ContentDatabaseName + GivingDatabaseName: !Ref GivingDatabaseName + MessagingDatabaseName: !Ref MessagingDatabaseName + DoingDatabaseName: !Ref DoingDatabaseName + ReportingDatabaseName: !Ref ReportingDatabaseName + DatabaseEngine: !Ref DatabaseEngine + DatabasePort: !Ref DatabasePort + DatabaseMasterUsername: !Ref DatabaseMasterUsername + DatabaseMinCapacity: !Ref DatabaseMinCapacity + DatabaseMaxCapacity: !Ref DatabaseMaxCapacity + ApiCustomDomainName: !Ref ApiCustomDomainName + ApiCertificateArn: !Ref ApiCertificateArn + ApiHostedZoneId: !Ref ApiHostedZoneId + CreateNatGateway: !Ref CreateNatGateway + VpcCidr: !Ref VpcCidr + PublicSubnet1Cidr: !Ref PublicSubnet1Cidr + PublicSubnet2Cidr: !Ref PublicSubnet2Cidr + PrivateSubnet1Cidr: !Ref PrivateSubnet1Cidr + PrivateSubnet2Cidr: !Ref PrivateSubnet2Cidr + WebsiteBaseUrl: !Ref WebsiteBaseUrl + ContentRootUrl: !Ref ContentRootUrl + B1AdminRootUrl: !Ref B1AdminRootUrl + CorsOrigin: !Ref CorsOrigin + FileStore: !Ref FileStore + ManageAssetBucket: !Ref ManageAssetBucket + AssetBucketName: !Ref AssetBucketName + AppConfigSecretArn: !Ref AppConfigSecretArn + MailSystem: !Ref MailSystem + DeliveryProvider: !Ref DeliveryProvider + StoreApiUrl: !Ref StoreApiUrl + AiProvider: !Ref AiProvider + EmailOnRegistration: !Ref EmailOnRegistration + CaddyHost: !Ref CaddyHost + CaddyPort: !Ref CaddyPort + TransferUrl: !Ref TransferUrl + SupportEmail: !Ref SupportEmail + SupportPhone: !Ref SupportPhone + SupportSiteUrl: !Ref SupportSiteUrl + MobileAppUrl: !Ref MobileAppUrl + DomainCnameTarget: !Ref DomainCnameTarget + DomainATarget: !Ref DomainATarget + DefaultStockPhoto: !Ref DefaultStockPhoto + GoogleAnalyticsTag: !Ref GoogleAnalyticsTag + SentryDsn: !Ref SentryDsn + + FrontendStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref FrontendTemplateUrl + Parameters: + ProjectName: !Ref ProjectName + EnvironmentName: !Ref EnvironmentName + BucketName: !Ref FrontendBucketName + AlternateDomainName: !Ref FrontendAlternateDomainName + AcmCertificateArn: !Ref FrontendAcmCertificateArn + HostedZoneId: !Ref FrontendHostedZoneId + PriceClass: !Ref FrontendPriceClass + +Outputs: + BackendStackId: + Value: !Ref BackendStack + FrontendStackId: + Value: !Ref FrontendStack + ApiBaseUrl: + Value: !GetAtt BackendStack.Outputs.ApiBaseUrl + PublicApiBaseUrl: + Value: !GetAtt BackendStack.Outputs.PublicApiBaseUrl + ApiFunctionName: + Value: !GetAtt BackendStack.Outputs.ApiFunctionName + ApiFunctionArn: + Value: !GetAtt BackendStack.Outputs.ApiFunctionArn + MigrationFunctionName: + Value: !GetAtt BackendStack.Outputs.MigrationFunctionName + SocketFunctionName: + Value: !GetAtt BackendStack.Outputs.SocketFunctionName + WebSocketApiId: + Value: !GetAtt BackendStack.Outputs.WebSocketApiId + WebSocketApiEndpoint: + Value: !GetAtt BackendStack.Outputs.WebSocketApiEndpoint + Timer15MinFunctionName: + Value: !GetAtt BackendStack.Outputs.Timer15MinFunctionName + TimerMidnightFunctionName: + Value: !GetAtt BackendStack.Outputs.TimerMidnightFunctionName + TimerScheduledTasksFunctionName: + Value: !GetAtt BackendStack.Outputs.TimerScheduledTasksFunctionName + TimerWebhooksFunctionName: + Value: !GetAtt BackendStack.Outputs.TimerWebhooksFunctionName + ApiCustomDomainName: + Value: !GetAtt BackendStack.Outputs.ApiCustomDomainName + DatabaseEndpoint: + Value: !GetAtt BackendStack.Outputs.DatabaseEndpoint + DatabaseReaderEndpoint: + Value: !GetAtt BackendStack.Outputs.DatabaseReaderEndpoint + DatabaseClusterArn: + Value: !GetAtt BackendStack.Outputs.DatabaseClusterArn + DatabasePort: + Value: !GetAtt BackendStack.Outputs.DatabasePort + DatabaseName: + Value: !GetAtt BackendStack.Outputs.DatabaseName + MembershipDatabaseName: + Value: !GetAtt BackendStack.Outputs.MembershipDatabaseName + AttendanceDatabaseName: + Value: !GetAtt BackendStack.Outputs.AttendanceDatabaseName + ContentDatabaseName: + Value: !GetAtt BackendStack.Outputs.ContentDatabaseName + GivingDatabaseName: + Value: !GetAtt BackendStack.Outputs.GivingDatabaseName + MessagingDatabaseName: + Value: !GetAtt BackendStack.Outputs.MessagingDatabaseName + DoingDatabaseName: + Value: !GetAtt BackendStack.Outputs.DoingDatabaseName + ReportingDatabaseName: + Value: !GetAtt BackendStack.Outputs.ReportingDatabaseName + DatabaseSecretArn: + Value: !GetAtt BackendStack.Outputs.DatabaseSecretArn + AppConfigSecretArn: + Value: !GetAtt BackendStack.Outputs.AppConfigSecretArn + VpcId: + Value: !GetAtt BackendStack.Outputs.VpcId + PrivateSubnet1Id: + Value: !GetAtt BackendStack.Outputs.PrivateSubnet1Id + PrivateSubnet2Id: + Value: !GetAtt BackendStack.Outputs.PrivateSubnet2Id + LambdaSecurityGroupId: + Value: !GetAtt BackendStack.Outputs.LambdaSecurityGroupId + ContentRootUrl: + Value: !GetAtt BackendStack.Outputs.ContentRootUrl + AssetBucketName: + Value: !GetAtt BackendStack.Outputs.AssetBucketName + WebsiteBaseUrl: + Value: !GetAtt BackendStack.Outputs.WebsiteBaseUrl + LessonsApiUrl: + Value: !GetAtt BackendStack.Outputs.LessonsApiUrl + TransferUrl: + Value: !GetAtt BackendStack.Outputs.TransferUrl + SupportEmail: + Value: !GetAtt BackendStack.Outputs.SupportEmail + SupportPhone: + Value: !GetAtt BackendStack.Outputs.SupportPhone + SupportSiteUrl: + Value: !GetAtt BackendStack.Outputs.SupportSiteUrl + MobileAppUrl: + Value: !GetAtt BackendStack.Outputs.MobileAppUrl + DomainCnameTarget: + Value: !GetAtt BackendStack.Outputs.DomainCnameTarget + DomainATarget: + Value: !GetAtt BackendStack.Outputs.DomainATarget + DefaultStockPhoto: + Value: !GetAtt BackendStack.Outputs.DefaultStockPhoto + GoogleAnalyticsTag: + Value: !GetAtt BackendStack.Outputs.GoogleAnalyticsTag + SentryDsn: + Value: !GetAtt BackendStack.Outputs.SentryDsn + FrontendBucketName: + Value: !GetAtt FrontendStack.Outputs.SiteBucketName + FrontendDistributionId: + Value: !GetAtt FrontendStack.Outputs.CloudFrontDistributionId + FrontendDistributionDomainName: + Value: !GetAtt FrontendStack.Outputs.CloudFrontDistributionDomainName + FrontendAppUrl: + Value: !GetAtt FrontendStack.Outputs.AppUrl diff --git a/infrastructure/environments/README.md b/infrastructure/environments/README.md new file mode 100644 index 000000000..c9d40151f --- /dev/null +++ b/infrastructure/environments/README.md @@ -0,0 +1,60 @@ +# Environment Starters + +If you are trying to install the AWS self-hosted stack, start with [`start-here.md`](./start-here.md). + +This folder is reference material for the installer. New installers should not start by editing files in this folder. + +## What Is Here + +- [`start-here.md`](./start-here.md): the main step-by-step guide for a new installer. +- [`setup/`](./setup): short prerequisite explanations linked from the main guide. +- [`prod/`](./prod): checked-in production starter files copied into the user's private repository. +- [`staging/`](./staging): optional practice-environment starter files copied into the user's private repository. +- [`private-deployment-repo.md`](./private-deployment-repo.md): reference guide for the user's private repository. +- [`github-actions-setup.md`](./github-actions-setup.md): lower-level GitHub Actions reference. +- [`deployment-workbook.md`](./deployment-workbook.md): optional planning worksheet. +- [`first-rollout-checklist.md`](./first-rollout-checklist.md): detailed verification checklist after a deploy. + +## Normal Installer Path + +Use `installer:init` once to create the private deployment workspace, then use `installer:run` as the main guided entrypoint. + +```bash +yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown +yarn installer:customer-values -- --customer-file=../b1admin-deploy/customer-values.json --write=true --output=markdown +yarn installer:run -- --deploy-repo-dir=../b1admin-deploy --deploy-env-dir=../b1admin-deploy/environments --deployment-root=../b1admin-deploy/deployment --customer-file=../b1admin-deploy/customer-values.json --environment=prod --output=markdown +``` + +Use `--environment=staging` only when you intentionally want the optional practice deployment before prod. + +## Files Copied To The User's Private Repository + +The installer copies the starter files into the user's private repository. That private copy becomes the live customer workspace. + +Each environment starter includes: + +- `bootstrap-parameters.json` +- `backend-parameters.json` +- `frontend-parameters.json` +- `app-config-secret.template.json` +- `deploy-split-stack.sh` +- `README.md` + +Do not put live customer settings, generated secrets, or deployment evidence back into this source repository. + +## Lower-Level Tools + +Most users should not need these directly. They exist for troubleshooting, automation, and advanced operators. + +- `yarn installer:doctor`: check local tools and repository readiness. +- `yarn installer:update`: update an existing install after B1Admin source code changes. +- `yarn installer:next`: show the next recommended command without running it. +- `yarn installer:configure`: update environment parameter files from customer values. +- `yarn installer:preflight`: check readiness before dispatching a workflow. +- `yarn installer:deploy`: dispatch preview or real deployment workflows. +- `yarn installer:observe`: watch workflow runs and download evidence. +- `yarn installer:report`: write the final deployment report. +- `yarn reset:prod`: remove prod AWS resources when intentionally resetting. +- `yarn reset:staging`: remove staging AWS resources when staging was actually deployed. + +The normal installer path leaves API custom-domain fields blank and uses the generated API Gateway URL. diff --git a/infrastructure/environments/customer-values.sample.json b/infrastructure/environments/customer-values.sample.json new file mode 100644 index 000000000..48562d53c --- /dev/null +++ b/infrastructure/environments/customer-values.sample.json @@ -0,0 +1,29 @@ +{ + "awsRegion": "us-east-1", + "accountId": "", + "repo": "", + "deployRepoDir": "../b1admin-deploy", + "deployEnvDir": "../b1admin-deploy/environments", + "rootDomain": "", + "supportEmail": "", + "supportPhone": "", + "firstAdminEmail": "", + "firstAdminPassword": "", + "firstChurchName": "", + "b1adminRepo": "ChurchApps/B1Admin", + "b1adminRef": "main", + "apiRepo": "ChurchApps/Api", + "apiRef": "main", + "environments": { + "staging": { + "frontendDomain": "", + "frontendCertificateArn": "", + "frontendHostedZoneId": "" + }, + "prod": { + "frontendDomain": "", + "frontendCertificateArn": "", + "frontendHostedZoneId": "" + } + } +} diff --git a/infrastructure/environments/deployment-workbook.md b/infrastructure/environments/deployment-workbook.md new file mode 100644 index 000000000..01a0047ea --- /dev/null +++ b/infrastructure/environments/deployment-workbook.md @@ -0,0 +1,289 @@ +# Deployment Workbook + +Use this workbook only when you want a detailed planning worksheet. For a normal guided install, start with [`start-here.md`](./start-here.md) and answer the installer questions instead of filling this workbook by hand. + +This workbook can help prepare the real values for a first AWS rollout before you run either the local deploy scripts or the GitHub Actions workflow. + +The checked-in starter files already define the parameter shape: + +- [`staging/bootstrap-parameters.json`](./staging/bootstrap-parameters.json) +- [`staging/backend-parameters.json`](./staging/backend-parameters.json) +- [`staging/frontend-parameters.json`](./staging/frontend-parameters.json) +- [`staging/app-config-secret.template.json`](./staging/app-config-secret.template.json) +- [`prod/bootstrap-parameters.json`](./prod/bootstrap-parameters.json) +- [`prod/backend-parameters.json`](./prod/backend-parameters.json) +- [`prod/frontend-parameters.json`](./prod/frontend-parameters.json) +- [`prod/app-config-secret.template.json`](./prod/app-config-secret.template.json) + +The normal install can start with `prod`. Use `staging` only when you intentionally want an optional practice deployment before prod. + +## Rollout Choices + +Decide these first: + +1. Deployment path: + local script or GitHub Actions +2. Backend source: + `api-repo`, `package-manifest`, or `backend-artifact` +3. AWS auth mode for GitHub: + `AWS_ROLE_TO_ASSUME` or static access keys +4. First-pass domain strategy: + blank custom domains or fully wired ACM/Route53 +5. Migration strategy: + no migrations on first deploy or run Api migrations after deploy + +After you decide those, generate a concrete runbook for the chosen environment before the live deploy: + +- `yarn plan:environment-deploy -- --environment=prod --output=markdown` + +That plan now tells you: + +- whether the checked-in starter files are still blocking both local and GitHub paths +- whether only the local machine is blocked +- which deploy path is currently recommended +- which artifact name to expect from GitHub on success and on early failure + +## Bootstrap Values + +Fill these in for the target environment file: + +- `ProjectName` +- `EnvironmentName` +- `TemplateBucketName` +- `ArtifactBucketName` +- `EnableBucketVersioning` + +Recommended notes to capture beside those values: + +- AWS account ID +- AWS region +- whether the bucket names are globally unique already +- whether these buckets are dedicated to this environment or shared + +## Backend Values + +These fields usually need the most attention before a live deploy. + +### Packaging And Runtime + +Confirm or replace: + +- `LambdaCodeS3Bucket` +- `LambdaCodeS3Key` +- `DependenciesLayerArn` +- `ObservabilityLayerArn` +- `LambdaNodeOptions` +- `EnableWebSocketApi` +- `EnableScheduledWorkers` + +If you are using a manifest or direct backend artifact path, confirm that the S3 bucket/key values still match the artifact strategy you intend to run. + +### Database And Network + +Confirm these values are deliberate: + +- `DatabaseName` +- `MembershipDatabaseName` +- `AttendanceDatabaseName` +- `ContentDatabaseName` +- `GivingDatabaseName` +- `MessagingDatabaseName` +- `DoingDatabaseName` +- `ReportingDatabaseName` +- `DatabaseEngine` +- `DatabasePort` +- `DatabaseMasterUsername` +- `DatabaseMinCapacity` +- `DatabaseMaxCapacity` +- `CreateNatGateway` +- `VpcCidr` +- `PublicSubnet1Cidr` +- `PublicSubnet2Cidr` +- `PrivateSubnet1Cidr` +- `PrivateSubnet2Cidr` + +Capture one extra decision here: + +- whether the CIDR ranges overlap anything else in the target AWS account + +### URLs And Public App Settings + +These are the values most likely to need replacement on day one: + +- `WebsiteBaseUrl` +- `ContentRootUrl` +- `B1AdminRootUrl` +- `CorsOrigin` +- `StoreApiUrl` +- `TransferUrl` +- `SupportEmail` +- `SupportPhone` +- `SupportSiteUrl` +- `MobileAppUrl` + +If you are doing a domain-light first pass, decide which of these should point at temporary AWS-generated hostnames and which should stay on your real domains. + +### Domain And DNS + +Fill these only if you are enabling custom API domains on the first rollout: + +- `ApiCustomDomainName` +- `ApiCertificateArn` +- `ApiHostedZoneId` +- `DomainCnameTarget` +- `DomainATarget` + +### Optional Integrations + +Review whether these should stay blank or be configured now: + +- `AppConfigSecretArn` +- `MailSystem` +- `DeliveryProvider` +- `AiProvider` +- `EmailOnRegistration` +- `CaddyHost` +- `CaddyPort` +- `DefaultStockPhoto` +- `GoogleAnalyticsTag` +- `SentryDsn` + +## Frontend Values + +For the first pass, these are usually enough to review: + +- `BucketName` +- `AlternateDomainName` +- `AcmCertificateArn` +- `HostedZoneId` +- `PriceClass` + +If you are intentionally delaying custom-domain cutover, leave the domain and certificate values blank and keep a note that the first verification should use the CloudFront URL. + +## App Config Secret Values + +At minimum, replace these with real secrets: + +- `jwtSecret` +- `encryptionKey` + +Then decide which of the optional keys must be present before the first live run: + +- `hubspotKey` +- `mauticUrl` +- `mauticUser` +- `mauticPassword` +- `youTubeApiKey` +- `pexelsKey` +- `vimeoToken` +- `apiBibleKey` +- `youVersionApiKey` +- `praiseChartsConsumerKey` +- `praiseChartsConsumerSecret` +- `googleRecaptchaSecretKey` +- `openRouterApiKey` +- `openAiApiKey` +- `webPushPublicKey` +- `webPushPrivateKey` +- `webPushSubject` + +If GitHub Actions will manage the secret sync, mirror the same JSON into the `AWS_APP_CONFIG_SECRET_JSON` environment secret. + +## GitHub Actions Inputs + +If you are using [`.github/workflows/deploy-aws-self-hosted.yml`](../../.github/workflows/deploy-aws-self-hosted.yml), pre-decide these values before your first run: + +- `environment` +- `aws_region` +- `deployment_source` +- `api_repo` +- `api_ref` +- `package_manifest_file` +- `backend_artifact_source_file` +- `migration_artifact_source_file` +- `dependencies_layer_source_file` +- `sync_app_config_secret` +- `sync_bootstrap_admin_secret` +- `run_api_migrations` +- `run_bootstrap_admin` +- `api_migration_action` +- `api_migration_module` +- `verify_http_after_deploy` + +Recommended first-run defaults: + +- `environment=prod` +- `deployment_source=api-repo` if the workflow can check out the Api repo cleanly +- `sync_app_config_secret=false` until the secret JSON is final +- `run_api_migrations=false` until the base stack is healthy +- `verify_http_after_deploy=false` unless the public hostname is already expected to answer + +Recommended deployment-source choices: + +- `api-repo` when the runner can check out the Api repo and package it directly +- `package-manifest` when CI already produced a checked-in or attached manifest plus artifact set +- `backend-artifact` when you only want to push a prepared backend zip and optional layer/migration zips + +## Local Script Inputs + +If you are using the local environment script instead, pre-decide these env vars: + +- `AWS_REGION` +- `API_REPO_PATH` +- `PACKAGE_MANIFEST_FILE` +- `BACKEND_ARTIFACT_SOURCE_FILE` +- `MIGRATION_ARTIFACT_SOURCE_FILE` +- `DEPENDENCIES_LAYER_SOURCE_FILE` +- `BOOTSTRAP_STACK_NAME` +- `SYNC_APP_CONFIG_SECRET` +- `RUN_API_MIGRATIONS` +- `API_MIGRATION_ACTION` +- `API_MIGRATION_MODULE` +- `VERIFY_AFTER_DEPLOY` +- `VERIFY_HTTP_AFTER_DEPLOY` + +Recommended local-source choices: + +- leave `PACKAGE_MANIFEST_FILE` and `BACKEND_ARTIFACT_SOURCE_FILE` unset when `API_REPO_PATH` should drive packaging +- set `PACKAGE_MANIFEST_FILE` when you want to reuse an earlier `package:api-backend` result +- set `BACKEND_ARTIFACT_SOURCE_FILE` when the backend zip already exists outside the Api repo + +If the target machine can see `API_REPO_PATH` but cannot actually read that checkout or its `package.json`, prefer `PACKAGE_MANIFEST_FILE` or `BACKEND_ARTIFACT_SOURCE_FILE` for the local run instead of trying to force the script through the unreadable repo. + +## Evidence To Save After Deploy + +After the first rollout, save these somewhere durable: + +- bootstrap stack name +- backend stack name +- frontend stack name +- AWS region +- Secrets Manager secret names +- backend outputs JSON +- frontend outputs JSON +- final workflow inputs or local env vars used +- whether migrations ran +- whether the deploy used `api-repo`, `package-manifest`, or `backend-artifact` + +That record will make later updates or optional staging/prod comparisons much less error-prone. + +The quickest way to save that evidence into the repo workspace is: + +- `yarn save:split-stack-outputs -- --environment=prod --region=` + +That helper writes: + +- `deployment/prod/backend-outputs.json` +- `deployment/prod/frontend-outputs.json` +- `deployment/prod/deployment-summary.json` + +If `deployment/prod/preflight-plan.md` exists too, the saved summary will reference it so the preflight context stays attached to the final deployment evidence. + +For GitHub Actions runs, expect: + +- `aws-prod-deployment-evidence` after a successful deploy +- `aws-prod-preflight-plan` if the deploy fails before the full evidence bundle is created + +After you choose the deployment source and auth path, you can generate a concrete local and GitHub Actions run plan with: + +- `yarn plan:environment-deploy -- --environment=prod --output=markdown` diff --git a/infrastructure/environments/first-rollout-checklist.md b/infrastructure/environments/first-rollout-checklist.md new file mode 100644 index 000000000..8fe9c16ee --- /dev/null +++ b/infrastructure/environments/first-rollout-checklist.md @@ -0,0 +1,59 @@ +# First Rollout Checklist + +Use this after the first real `staging` or `prod` AWS rollout. + +## Before Deploy + +1. Confirm all `replace-me` placeholders and starter-only default values such as `example.com`, `support@example.com`, `mailto:support@example.com`, and `555-555-5555` are gone from the target environment folder. +2. Run `yarn installer:configure -- --environment= --environment-dir="$DEPLOY_ENV_DIR/" --account-id= --output=markdown` if you want one last reviewable replacement pass before touching AWS. +3. Run `yarn installer:preflight -- --environment= --environment-dir="$DEPLOY_ENV_DIR/" --repo="$DEPLOY_REPO" --output=markdown` and clear every blocker before dispatching. +4. If the plan recommends `api-repo`, confirm the Api repo at `../Api` has already run `corepack yarn install`. +5. If the plan recommends `package-manifest` or `backend-artifact`, confirm those referenced files already exist and are readable from the machine or runner you will use. +6. If you are still using the local script with `API_REPO_PATH`, confirm that the checkout and its `package.json` are readable from the current shell, not just present on disk. +7. Confirm the target AWS account, region, ACM certificates, and Route53 zones are the ones you intend to change. +8. Run the environment’s bootstrap and split-stack validator commands before the deploy script. + +## Immediately After Deploy + +1. Run the environment deploy script with verification enabled, or run `yarn verify:split-stack` manually. +2. Confirm the backend stack exists and the frontend stack exists in CloudFormation. +3. Confirm the frontend bucket and CloudFront distribution were resolved by the verification helper. +4. If you enabled `--check-http=true`, confirm the frontend URL responds successfully. +5. Save or download the deployment evidence: + `deployment//deployment-summary.json`, `deployment//backend-outputs.json`, and `deployment//frontend-outputs.json` for local runs, or `aws--deployment-evidence` from GitHub Actions on success. +6. If the GitHub deploy failed before the full evidence bundle was written, download `aws--preflight-plan` and use that blocker list before retrying. +7. If the environment was deployed without `run_bootstrap_admin=true`, seed the first admin with `yarn installer:bootstrap-admin` from an operator-controlled machine after infrastructure verification instead of retrofitting live credentials into GitHub. +8. Record the exact successful GitHub Actions run id and date so the next environment can promote from a known-good reference rather than a loose recollection. + +## Backend Checks + +1. Confirm the resolved `ApiBaseUrl` is the expected hostname. +2. If you synced the app config secret, confirm the expected Secrets Manager secret exists. +3. If you ran migrations, confirm the migration command completed successfully and the target schema is reachable. +4. Check Lambda logs for the main API function if the app boots but responses look wrong. + +## Frontend Checks + +1. Load the frontend app URL and confirm the app shell renders. +2. Verify the app is pointed at the intended API environment. +3. Hard-refresh once and confirm the service worker path is healthy after the no-cache upload. +4. Confirm at least one route refreshes correctly through the CloudFront SPA fallback. +5. If a route fails with `Failed to fetch dynamically imported module`, assume stale hashed chunks first: + close old tabs, open a fresh tab, hard-refresh, and if needed clear site data or unregister the service worker before treating it as a broken deploy. +6. Confirm at least one authenticated page with nested data loads a valid empty state instead of hanging forever when related records are missing. +7. After first login, complete any expected `Select a Church` step before treating the authentication flow as failed. + +## DNS / Domain Checks + +1. If custom domains are enabled, confirm the ACM certificate ARNs and hosted zone IDs match the target account. +2. Confirm the frontend hostname resolves to CloudFront. +3. Confirm the API hostname resolves to API Gateway when `ApiCustomDomainName` is in use. + +## Operational Follow-Up + +1. Save backend and frontend outputs with `yarn save:split-stack-outputs -- --environment= --region=` if you want later publish-only or verification runs without live stack lookups. +2. Re-render the saved summary with `yarn show:deployment-summary -- --summary-file=deployment//deployment-summary.json --output=markdown` when you want a human-readable record later. +3. Record the final stack names, region, secret names, and whether the deploy used `api-repo`, `package-manifest`, or `backend-artifact`. +4. Audit AWS for leftovers from failed retries before calling the environment clean: + old Secrets Manager secrets, old managed asset buckets, and other resources tagged to earlier stack IDs are common after repeated first-rollout attempts. +5. If this was the first staging rollout, promote the verified values into the prod starter only after the staging checks are clean. diff --git a/infrastructure/environments/github-actions-setup.md b/infrastructure/environments/github-actions-setup.md new file mode 100644 index 000000000..87fca98bf --- /dev/null +++ b/infrastructure/environments/github-actions-setup.md @@ -0,0 +1,233 @@ +# GitHub Actions AWS Setup + +Use this guide when you want to understand the lower-level GitHub Actions wiring behind the guided installer. +For a normal install, start with [`start-here.md`](./start-here.md) and let the installer create the private workflow and secrets for you. + +This file is reference material for operators who need to inspect or customize [`.github/workflows/deploy-aws-self-hosted.yml`](../../.github/workflows/deploy-aws-self-hosted.yml). +If the B1Admin repository is public and you do not want the live AWS deploy workflow running here, stop and use [`private-deployment-repo.md`](./private-deployment-repo.md) instead. That private-repo pattern should be the default live path for public-source setups. + +## Environments + +Create the GitHub Environments you plan to use in the private deployment repository: + +- `aws-prod` +- `aws-staging`, only if you run the optional staging deployment + +The workflow targets them automatically through: + +- `aws-prod` when `environment=prod` +- `aws-staging` when `environment=staging` + +If you want manual approvals before deploy, add required reviewers on those GitHub Environments. + +The guided private-repository workflow is the normal install flow for teams deploying B1Admin into their own AWS accounts. +Use the local shell scripts mainly for debugging, recovery, or situations where GitHub Actions is intentionally unavailable. + +## Choose An Auth Mode + +The workflow supports two AWS auth modes: + +1. OIDC role assumption through `AWS_ROLE_TO_ASSUME` +2. Static access keys through `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` + +Prefer OIDC for new setups. Use static keys only if you cannot configure GitHub OIDC in the target AWS account yet. +For the smallest practical permission boundary, prefer a two-role setup: + +1. a narrow GitHub OIDC deploy role stored in `AWS_ROLE_TO_ASSUME` +2. a separate CloudFormation execution role stored in `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` + +Template trust and permission documents for that model live under [`../iam/`](../iam/README.md). + +## Required Secrets + +Set these secrets on each GitHub Environment: + +### OIDC Path + +- `AWS_ROLE_TO_ASSUME` +- `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` + +### Static Key Path + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` + +### Optional Secrets + +- `AWS_APP_CONFIG_SECRET_JSON` +- `AWS_BOOTSTRAP_ADMIN_SECRET_JSON` +- `API_REPO_CHECKOUT_TOKEN` + +Use `AWS_APP_CONFIG_SECRET_JSON` only if you plan to run the workflow with `sync_app_config_secret=true`. +Use `AWS_BOOTSTRAP_ADMIN_SECRET_JSON` only if you plan to run the workflow with `sync_bootstrap_admin_secret=true` and `run_bootstrap_admin=true`. +Use `API_REPO_CHECKOUT_TOKEN` only if the workflow’s `api-repo` checkout needs access beyond the default `github.token`. + +For the recommended installer flow, do not store the bootstrap admin secret in GitHub. +Let the workflow create the environment, then run the first-admin bootstrap locally or through another operator-controlled path after deploy verification. + +## OIDC Role Setup + +Create or reuse an IAM role that trusts GitHub’s OIDC provider and grant it the AWS permissions needed for your rollout. + +### 1. Create The GitHub OIDC Provider + +If your AWS account does not already trust GitHub Actions, create an IAM OIDC provider for: + +- issuer: `https://token.actions.githubusercontent.com` +- audience: `sts.amazonaws.com` + +### 2. Create A Deploy Role + +Use a trust policy shaped like this and replace the placeholders: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": [ + "repo:/:environment:aws-staging", + "repo:/:environment:aws-prod" + ] + } + } + } + ] +} +``` + +That policy limits role assumption to workflow runs that target the `aws-staging` or `aws-prod` GitHub Environments in this repository. + +### 3. Grant Deploy Permissions + +At minimum, the role needs permissions for the rollout surfaces this repo uses: + +- CloudFormation +- S3 +- CloudFront +- Lambda +- IAM pass-role where required +- API Gateway +- Route53 if you use managed DNS records +- Secrets Manager +- EC2 and VPC-related APIs used by the backend stack +- RDS / Aurora +- CloudWatch Logs + +Start with the target environment role, normally prod for the prod-only path. Expand permissions only after preflight or deployment proves the exact access pattern you need. +From the first live staging backend attempts on June 23-24, 2026, these permissions were immediately required on the CloudFormation execution role and are easy to miss: + +- `secretsmanager:GetRandomPassword` +- `apigateway:POST`, `apigateway:GET`, `apigateway:PATCH`, `apigateway:PUT`, `apigateway:DELETE`, `apigateway:TagResource`, `apigateway:UntagResource` +- `s3:PutBucketOwnershipControls` +- `s3:PutBucketCORS` +- `iam:CreateServiceLinkedRole` scoped to `rds.amazonaws.com` +- `rds:CreateDBClusterSnapshot` +- `iam:GetRolePolicy` +- `lambda:GetLayerVersion` + +The checked-in sample execution policy now includes them. + +### 4. Save The Role ARN + +Store the role ARN as the `AWS_ROLE_TO_ASSUME` secret on each GitHub Environment you use: + +- `aws-prod` +- `aws-staging`, only if you run staging + +If you are using the split-role model, also store the CloudFormation execution role ARN as: + +- `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` + +If you created the roles with the recommended names, you do not need to guess the full ARN. Run: + +`yarn discover:github-aws-roles -- --environment=prod --output=markdown` + +That helper prints the resolved AWS role ARNs plus copy-paste `gh secret set ... --body ''` commands for the matching GitHub Environment. + +## Static Key Fallback + +If you cannot use OIDC yet, create an IAM user or automation-specific access path with the same rollout permissions and store: + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` + +Keep that principal limited to the minimum AWS account and resources needed for the environment. + +## App Config Secret JSON + +The guided installer normally generates this for you with `yarn installer:app-config-secret` and stores it with `yarn installer:github-setup -- --write-secrets=true`. + +If you are configuring the workflow manually and want it to materialize `app-config-secret.json`, save a JSON object like this into `AWS_APP_CONFIG_SECRET_JSON`: + +```json +{ + "jwtSecret": "replace-with-real-secret", + "encryptionKey": "replace-with-real-secret" +} +``` + +Add the rest of your real backend app config keys before using it for a live deployment. + +If you already have the finished JSON in this repo, you can sync it into the GitHub environment secret: + +`yarn sync:github-app-config-secret -- --environment=prod --secret-file=infrastructure/environments/prod/app-config-secret.json` + +If you want the repo to sync that secret first and then dispatch the workflow in one step, use: + +`yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --repo=ChurchApps/B1Admin` + +That wrapper validates `gh auth status` by default, even in dry-run mode, so its output is a real readiness signal. It now distinguishes missing `gh`, invalid GitHub auth, and plain connectivity failures to `github.com`. Only use `--skip-gh-auth-check=true` when you are doing an offline/test-only run and do not want GitHub connectivity checked. +It now also prints copy-paste `gh run list`, `gh run watch`, and `gh run view` follow-up commands for the latest `deploy-aws-self-hosted.yml` run so you can monitor the job immediately after dispatching it. +If you want the GitHub runner to stop after the starter audit plus deploy-plan preflight without mutating AWS, add `--preview-only=true` to that helper or set the workflow input `preview_only=true` in the GitHub UI. +If you want a wider preflight before dispatching, run `yarn plan:environment-deploy -- --environment=prod --output=markdown`. The planner now shows both GitHub runner blockers and whether local GitHub CLI is ready enough for this machine to dispatch the workflow directly. + +## First Run + +After the environment secrets are in place: + +1. Finish replacing placeholders in the target private environment folder, normally `../b1admin-deploy/environments/prod/`. +2. Run `yarn plan:environment-deploy -- --environment=prod --output=markdown` and clear anything it still reports for local `gh` auth or GitHub runner readiness. +3. Decide which backend source the workflow should use: + `api-repo`, `package-manifest`, or `backend-artifact` +4. Run the `Deploy AWS Self-Hosted` workflow with: + `environment=prod` +5. Leave `verify_http_after_deploy=false` on the first pass unless the frontend hostname is already expected to respond publicly. +6. Review the GitHub job summary for the preflight deploy plan, resolved stack names, URLs, artifact name, and saved-output follow-up commands. + If you download the artifact later, you can render the same summary locally with `yarn show:deployment-summary -- --summary-file=deployment//deployment-summary.json --output=markdown`. +7. Download the `aws--deployment-evidence` workflow artifact after a successful run if you want the saved backend/frontend outputs, deployment summary, and preflight plan outside the runner. + If the deploy step fails before that full evidence bundle is created, the workflow now uploads `aws--preflight-plan` so you can still download the computed rollout plan and blocker list. +8. Work through [`first-rollout-checklist.md`](./first-rollout-checklist.md) after the workflow finishes. + +## Recommended Clean Restart Loop + +When you need to prove that a brand-new environment can be installed from scratch: + +1. Reset the target environment: + `yarn reset:staging` or `yarn reset:prod` +2. Re-run the preflight: + `yarn plan:environment-deploy -- --environment= --output=markdown` +3. Dispatch the workflow from GitHub Actions. +4. Watch the run through to completion and download `aws--deployment-evidence`. +5. Run `yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin--backend --frontend-stack-name=b1admin--frontend --check-http=true` or the equivalent environment-specific verification command. +6. Seed the first admin outside GitHub only if the environment was intentionally deployed without bootstrap-admin automation. +7. Verify login from the intended hostname and complete any expected `Select a Church` step before judging authentication as broken. + +## Optional Staging Order + +Use this only when you intentionally want a practice deployment before prod: + +1. Configure `aws-staging` +2. Run and verify the staging deployment +3. Configure `aws-prod` +4. Run the production deployment after staging is clean diff --git a/infrastructure/environments/private-deployment-gitignore.sample b/infrastructure/environments/private-deployment-gitignore.sample new file mode 100644 index 000000000..7e175943d --- /dev/null +++ b/infrastructure/environments/private-deployment-gitignore.sample @@ -0,0 +1,5 @@ +environments/*/app-config-secret.json +environments/*/bootstrap-admin-secret.json +customer-values.json +deployment/ +*.log diff --git a/infrastructure/environments/private-deployment-repo.md b/infrastructure/environments/private-deployment-repo.md new file mode 100644 index 000000000..0b4d00f4e --- /dev/null +++ b/infrastructure/environments/private-deployment-repo.md @@ -0,0 +1,162 @@ +# User's Private Repository + +Use a private deployment repository for real customer installs. + +That repo owns: + +- the live GitHub Actions workflow +- `environments/staging/` +- `environments/prod/` +- GitHub Environment named `aws-prod`, and `aws-staging` only if you run the optional staging deployment +- deployment secrets +- workflow run history and deployment evidence + +It does not own application source changes. Customer-specific settings do not get pushed back to B1Admin or Api. + +## Create The Repo + +Create a private GitHub repository such as: + +```text +your-org/b1admin-deploy +``` + +From a sibling B1Admin checkout, create the private repo scaffold and local customer worksheet: + +```bash +yarn installer:init -- \ + --deploy-repo-dir=../b1admin-deploy \ + --output=markdown +``` + +That creates: + +```text +b1admin-deploy/ + .github/workflows/deploy-aws-self-hosted.yml + .gitignore + README.md + customer-values.json + customer-values.sample.json + environments/ + staging/ + prod/ +``` + +The generated `.gitignore` protects local runtime secret files such as: + +- `environments/*/app-config-secret.json` +- `environments/*/bootstrap-admin-secret.json` + +Runtime secret values belong in GitHub Environment secrets or in an operator-controlled local step, not in Git. +`customer-values.json` is local operator input for the installer. The generated `.gitignore` keeps it out of commits. + +## Source Repositories + +The private workflow checks out: + +- the private deployment repo +- B1Admin source, normally `ChurchApps/B1Admin` +- Api source, normally `ChurchApps/Api` + +Do not copy B1Admin source code or Api source code into the private deployment repo. The private deployment repo stores deployment settings and the workflow. The workflow reads the source repositories during deployment. + +You normally do not need a private Api repository. Use a private Api fork or mirror only when your organization intentionally maintains customized backend code or cannot let the workflow read the upstream Api source repository directly. + +The workflow inputs expose these values: + +- `b1admin_repo` +- `b1admin_ref` +- `api_repo` +- `api_ref` + +Use `B1ADMIN_REPO_CHECKOUT_TOKEN` or `API_REPO_CHECKOUT_TOKEN` only when the default GitHub token cannot read those source repositories. + +## GitHub Environments + +Create the GitHub Environments you plan to use in the private deployment repo. + +- `aws-prod` +- `aws-staging`, only if you run the optional staging deployment + +Each environment needs its own deployment secrets. For OIDC, the usual required secrets are: + +- `AWS_ROLE_TO_ASSUME` +- `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` +- `AWS_APP_CONFIG_SECRET_JSON` + +Use `AWS_BOOTSTRAP_ADMIN_SECRET_JSON` only if you intentionally want the workflow to seed the first admin. The recommended installer path seeds the first admin later from an operator-controlled machine. + +To render environment-specific IAM policies and copy-paste AWS/GitHub commands, use `prod` for the normal install: + +```bash +yarn installer:aws-roles -- --environment=prod --account-id= --repo=/ --output-dir=../b1admin-deploy/iam/prod --write=true --output=markdown +``` + +To preview the environment and secret commands: + +```bash +yarn installer:github-setup -- --repo=/ --account-id= --deploy-env-dir="$DEPLOY_ENV_DIR" --output=markdown +``` + +Generate app-config JSON separately for each environment, then let the GitHub setup helper store the required environment secrets: + +```bash +yarn installer:app-config-secret -- --environment=prod --environment-dir="$DEPLOY_ENV_DIR/prod" --support-email=support@ --write=true --output=markdown +yarn installer:github-setup -- --environment=prod --repo=/ --account-id= --deploy-env-dir="$DEPLOY_ENV_DIR" --write=true --write-secrets=true --output=markdown +``` + +## Normal Commands + +Run these from the B1Admin checkout: + +```bash +export DEPLOY_REPO=/ +export DEPLOY_ENV_DIR=../b1admin-deploy/environments + +yarn installer:configure -- --environment=prod --environment-dir="$DEPLOY_ENV_DIR/prod" --account-id= --root-domain= --support-phone= --write=true +yarn installer:preflight -- --environment=prod --environment-dir="$DEPLOY_ENV_DIR/prod" --repo="$DEPLOY_REPO" --output=markdown +yarn installer:deploy -- --environment=prod --environment-dir="$DEPLOY_ENV_DIR/prod" --repo="$DEPLOY_REPO" --preview-only=true +yarn installer:deploy -- --environment=prod --environment-dir="$DEPLOY_ENV_DIR/prod" --repo="$DEPLOY_REPO" --confirm=true +``` + +Use `--environment=staging` only if you intentionally chose the optional practice deployment. + +## Updating Later + +After the first install is complete, update the AWS deployment from the sibling B1Admin source repository with: + +```bash +yarn installer:update -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +That command pulls source code when approved, refreshes this private repository's safe scaffold files, offers to commit and push safe private repository changes, then starts the guided deployment runner. + +Important: `installer:update` is a guided update command, not a zero-downtime guarantee. For a production environment with active users, update optional staging first when available, verify login and key workflows, confirm backups or snapshots exist, review the prod preflight and preview output, and run prod during an approved low-traffic or maintenance window. + +Do not commit `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. + +## OIDC Trust + +The AWS OIDC trust policy should reference the user's private repository, not the B1Admin source repository: + +```json +{ + "StringLike": { + "token.actions.githubusercontent.com:sub": [ + "repo:/:environment:aws-staging", + "repo:/:environment:aws-prod" + ] + } +} +``` + +For policy templates and role details, see [`../iam/README.md`](../iam/README.md). + +[Back to Start Here](./start-here.md) diff --git a/infrastructure/environments/private-deployment-workflow.sample.yml b/infrastructure/environments/private-deployment-workflow.sample.yml new file mode 100644 index 000000000..25b2a1582 --- /dev/null +++ b/infrastructure/environments/private-deployment-workflow.sample.yml @@ -0,0 +1,383 @@ +name: Deploy AWS From Private Repo + +on: + workflow_dispatch: + inputs: + environment: + description: "Environment starter to deploy" + required: true + type: choice + options: + - staging + - prod + aws_region: + description: "AWS region" + required: true + default: us-east-1 + type: string + deployment_source: + description: "How to provide the backend artifact" + required: true + default: api-repo + type: choice + options: + - api-repo + - package-manifest + - backend-artifact + b1admin_repo: + description: "B1Admin source repository to check out" + required: true + default: ChurchApps/B1Admin + type: string + b1admin_ref: + description: "Git ref for the B1Admin source checkout" + required: true + default: main + type: string + api_repo: + description: "Repository to check out when deployment_source=api-repo" + required: false + default: ChurchApps/Api + type: string + api_ref: + description: "Git ref for the API repo checkout" + required: false + default: main + type: string + package_manifest_file: + description: "Manifest path in the private repo when deployment_source=package-manifest" + required: false + default: "" + type: string + backend_artifact_source_file: + description: "Backend zip path in the private repo when deployment_source=backend-artifact" + required: false + default: "" + type: string + migration_artifact_source_file: + description: "Optional migration zip path in the private repo" + required: false + default: "" + type: string + dependencies_layer_source_file: + description: "Optional layer zip path in the private repo" + required: false + default: "" + type: string + package_mode: + description: "Backend packaging mode for api-repo deploys" + required: true + default: layered + type: choice + options: + - layered + - self-contained + sync_app_config_secret: + description: "Write app-config-secret.json from AWS_APP_CONFIG_SECRET_JSON" + required: true + default: false + type: boolean + sync_bootstrap_admin_secret: + description: "Write bootstrap-admin-secret.json from AWS_BOOTSTRAP_ADMIN_SECRET_JSON" + required: true + default: false + type: boolean + run_api_migrations: + description: "Run Api CLI migrations after deploy" + required: true + default: false + type: boolean + run_bootstrap_admin: + description: "Seed the first admin login after deploy" + required: true + default: false + type: boolean + api_migration_action: + description: "Migration action when enabled" + required: true + default: up + type: choice + options: + - up + - down + - status + api_migration_module: + description: "Migration module when enabled" + required: true + default: all + type: choice + options: + - all + - membership + - attendance + - content + - giving + - messaging + - doing + - reporting + verify_http_after_deploy: + description: "Probe the frontend URL after deploy" + required: true + default: false + type: boolean + preview_only: + description: "Run preflight only" + required: true + default: false + type: boolean + +jobs: + deploy: + name: Deploy ${{ inputs.environment }} + runs-on: ubuntu-latest + environment: aws-${{ inputs.environment }} + env: + AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + permissions: + contents: read + id-token: write + + steps: + - name: Checkout private deploy repo + uses: actions/checkout@v4 + + - name: Checkout B1Admin + uses: actions/checkout@v4 + with: + repository: ${{ inputs.b1admin_repo }} + ref: ${{ inputs.b1admin_ref }} + path: B1Admin + token: ${{ secrets.B1ADMIN_REPO_CHECKOUT_TOKEN || secrets.API_REPO_CHECKOUT_TOKEN || github.token }} + + - name: Checkout Api repo + if: ${{ inputs.deployment_source == 'api-repo' }} + uses: actions/checkout@v4 + with: + repository: ${{ inputs.api_repo }} + ref: ${{ inputs.api_ref }} + path: Api + token: ${{ secrets.API_REPO_CHECKOUT_TOKEN || github.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Configure AWS Credentials via OIDC + if: ${{ env.AWS_ROLE_TO_ASSUME != '' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_TO_ASSUME }} + role-session-name: b1admin-${{ inputs.environment }}-deploy + role-duration-seconds: 3600 + aws-region: ${{ inputs.aws_region }} + + - name: Configure AWS Credentials via access keys + if: ${{ env.AWS_ROLE_TO_ASSUME == '' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region }} + + - name: Install B1Admin dependencies + working-directory: B1Admin + run: | + corepack enable + yarn install + + - name: Install Api dependencies + if: ${{ inputs.deployment_source == 'api-repo' }} + working-directory: Api + run: | + corepack enable + yarn install + + - name: Materialize app config secret into private repo + if: ${{ inputs.sync_app_config_secret }} + env: + AWS_APP_CONFIG_SECRET_JSON: ${{ secrets.AWS_APP_CONFIG_SECRET_JSON }} + run: | + if [[ -z "${AWS_APP_CONFIG_SECRET_JSON}" ]]; then + echo "Missing AWS_APP_CONFIG_SECRET_JSON secret for app config sync." >&2 + exit 1 + fi + printf '%s' "${AWS_APP_CONFIG_SECRET_JSON}" > "environments/${{ inputs.environment }}/app-config-secret.json" + + - name: Materialize bootstrap admin secret into private repo + if: ${{ inputs.sync_bootstrap_admin_secret }} + env: + AWS_BOOTSTRAP_ADMIN_SECRET_JSON: ${{ secrets.AWS_BOOTSTRAP_ADMIN_SECRET_JSON }} + run: | + if [[ -z "${AWS_BOOTSTRAP_ADMIN_SECRET_JSON}" ]]; then + echo "Missing AWS_BOOTSTRAP_ADMIN_SECRET_JSON secret for bootstrap admin sync." >&2 + exit 1 + fi + printf '%s' "${AWS_BOOTSTRAP_ADMIN_SECRET_JSON}" > "environments/${{ inputs.environment }}/bootstrap-admin-secret.json" + + - name: Preflight plan + working-directory: B1Admin + run: | + mkdir -p "deployment/${{ inputs.environment }}" + yarn plan:environment-deploy -- \ + --environment="${{ inputs.environment }}" \ + --environment-dir="../environments/${{ inputs.environment }}" \ + --region="${{ inputs.aws_region }}" \ + --deployment-source="${{ inputs.deployment_source }}" \ + --api-repo-path="../Api" \ + --api-repo="${{ inputs.api_repo }}" \ + --api-ref="${{ inputs.api_ref }}" \ + --package-manifest-file="${{ inputs.package_manifest_file && format('../{0}', inputs.package_manifest_file) || '' }}" \ + --backend-artifact-source-file="${{ inputs.backend_artifact_source_file && format('../{0}', inputs.backend_artifact_source_file) || '' }}" \ + --migration-artifact-source-file="${{ inputs.migration_artifact_source_file && format('../{0}', inputs.migration_artifact_source_file) || '' }}" \ + --dependencies-layer-source-file="${{ inputs.dependencies_layer_source_file && format('../{0}', inputs.dependencies_layer_source_file) || '' }}" \ + --sync-app-config-secret="${{ inputs.sync_app_config_secret && 'true' || 'false' }}" \ + --sync-bootstrap-admin-secret="${{ inputs.sync_bootstrap_admin_secret && 'true' || 'false' }}" \ + --run-api-migrations="${{ inputs.run_api_migrations && 'true' || 'false' }}" \ + --run-bootstrap-admin="${{ inputs.run_bootstrap_admin && 'true' || 'false' }}" \ + --api-migration-action="${{ inputs.api_migration_action }}" \ + --api-migration-module="${{ inputs.api_migration_module }}" \ + --api-migration-runner="data-api" \ + --verify-http-after-deploy="${{ inputs.verify_http_after_deploy && 'true' || 'false' }}" \ + --output=markdown | tee "deployment/${{ inputs.environment }}/preflight-plan.md" + + - name: Stop after preview + if: ${{ inputs.preview_only }} + run: echo "Preview-only mode complete." + + - name: Deploy bootstrap + if: ${{ !inputs.preview_only }} + working-directory: B1Admin + env: + CLOUDFORMATION_EXECUTION_ROLE_ARN: ${{ secrets.AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN }} + run: | + yarn deploy:bootstrap -- \ + --region="${{ inputs.aws_region }}" \ + --stack-name="b1admin-${{ inputs.environment }}-bootstrap" \ + --parameters-file="../environments/${{ inputs.environment }}/bootstrap-parameters.json" + + - name: Deploy split stack + if: ${{ !inputs.preview_only }} + working-directory: B1Admin + env: + CLOUDFORMATION_EXECUTION_ROLE_ARN: ${{ secrets.AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN }} + run: | + ARGS=( + --region="${{ inputs.aws_region }}" + --project-name=b1admin + --environment="${{ inputs.environment }}" + --bootstrap-stack-name="b1admin-${{ inputs.environment }}-bootstrap" + --backend-parameters-file="../environments/${{ inputs.environment }}/backend-parameters.json" + --frontend-parameters-file="../environments/${{ inputs.environment }}/frontend-parameters.json" + --verify-http-after-deploy="${{ inputs.verify_http_after_deploy && 'true' || 'false' }}" + --package-mode="${{ inputs.package_mode }}" + ) + + if [[ "${{ inputs.deployment_source }}" == "api-repo" ]]; then + ARGS+=(--api-repo-path="../Api") + elif [[ "${{ inputs.deployment_source }}" == "package-manifest" ]]; then + ARGS+=(--package-manifest-file="../${{ inputs.package_manifest_file }}") + else + ARGS+=(--backend-artifact-source-file="../${{ inputs.backend_artifact_source_file }}") + if [[ -n "${{ inputs.migration_artifact_source_file }}" ]]; then + ARGS+=(--migration-artifact-source-file="../${{ inputs.migration_artifact_source_file }}") + fi + if [[ -n "${{ inputs.dependencies_layer_source_file }}" ]]; then + ARGS+=(--dependencies-layer-source-file="../${{ inputs.dependencies_layer_source_file }}") + fi + fi + + if [[ "${{ inputs.sync_app_config_secret }}" == "true" ]]; then + ARGS+=(--app-config-secret-file="../environments/${{ inputs.environment }}/app-config-secret.json") + fi + + if [[ "${{ inputs.sync_bootstrap_admin_secret }}" == "true" ]]; then + ARGS+=(--bootstrap-admin-secret-file="../environments/${{ inputs.environment }}/bootstrap-admin-secret.json") + fi + + if [[ "${{ inputs.run_api_migrations }}" == "true" ]]; then + ARGS+=(--run-api-migrations=true) + ARGS+=(--api-migration-action="${{ inputs.api_migration_action }}") + ARGS+=(--api-migration-module="${{ inputs.api_migration_module }}") + ARGS+=(--api-migration-runner=data-api) + fi + + if [[ "${{ inputs.run_bootstrap_admin }}" == "true" ]]; then + ARGS+=(--run-bootstrap-admin=true) + fi + + yarn deploy:aws -- "${ARGS[@]}" + + - name: Save deployment evidence + if: ${{ !inputs.preview_only }} + working-directory: B1Admin + run: | + yarn save:split-stack-outputs -- \ + --environment="${{ inputs.environment }}" \ + --region="${{ inputs.aws_region }}" \ + --output=markdown + + - name: Save source metadata + if: ${{ !inputs.preview_only }} + working-directory: B1Admin + env: + B1ADMIN_REPO: ${{ inputs.b1admin_repo }} + B1ADMIN_REF: ${{ inputs.b1admin_ref }} + API_REPO: ${{ inputs.api_repo }} + API_REF: ${{ inputs.api_ref }} + DEPLOYMENT_SOURCE: ${{ inputs.deployment_source }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + PRIVATE_REPO_SHA: ${{ github.sha }} + run: | + mkdir -p "deployment/${{ inputs.environment }}" + node -e ' + const fs = require("fs"); + const child = require("child_process"); + const git = (cwd) => child.execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(); + const metadata = { + ok: true, + environment: "${{ inputs.environment }}", + writtenAt: new Date().toISOString(), + githubActions: { + runId: process.env.WORKFLOW_RUN_ID, + privateRepoSha: process.env.PRIVATE_REPO_SHA + }, + b1admin: { + repo: process.env.B1ADMIN_REPO, + ref: process.env.B1ADMIN_REF, + sha: git(".") + }, + api: { + repo: process.env.API_REPO, + ref: process.env.API_REF, + sha: process.env.DEPLOYMENT_SOURCE === "api-repo" ? git("../Api") : "" + } + }; + fs.writeFileSync("deployment/${{ inputs.environment }}/source-metadata.json", `${JSON.stringify(metadata, null, 2)}\n`); + ' + + - name: Write deployment summary + if: ${{ !inputs.preview_only }} + working-directory: B1Admin + run: | + SUMMARY_FILE="deployment/${{ inputs.environment }}/deployment-summary.json" + if [[ ! -f "${SUMMARY_FILE}" ]]; then + echo "Missing deployment summary file: ${SUMMARY_FILE}" >&2 + exit 1 + fi + yarn show:deployment-summary -- --summary-file="${SUMMARY_FILE}" --output=markdown >> "${GITHUB_STEP_SUMMARY}" + { + echo "" + echo "- Artifact: \`aws-${{ inputs.environment }}-deployment-evidence\`" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload deployment evidence + if: ${{ success() }} + uses: actions/upload-artifact@v4 + with: + name: aws-${{ inputs.environment }}-${{ inputs.preview_only && 'preflight-plan' || 'deployment-evidence' }} + path: | + B1Admin/deployment/${{ inputs.environment }}/ + if-no-files-found: error diff --git a/infrastructure/environments/prod/README.md b/infrastructure/environments/prod/README.md new file mode 100644 index 000000000..991678256 --- /dev/null +++ b/infrastructure/environments/prod/README.md @@ -0,0 +1,56 @@ +# Prod Environment Starter + +This folder contains the checked-in production starter files used by the AWS installer. + +For a real install, start with [`../start-here.md`](../start-here.md). Do not commit live customer production settings to the B1Admin source repository. + +The normal customer path copies these files into a private deployment repository with: + +```bash +yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown +``` + +Configure prod when you are ready for the real install. Staging is optional; you do not need to deploy staging first. + +```bash +yarn installer:configure -- \ + --environment=prod \ + --environment-dir=../b1admin-deploy/environments/prod \ + --account-id= \ + --root-domain= \ + --support-phone= \ + --write=true +``` + +Required starter files: + +- `bootstrap-parameters.json` +- `backend-parameters.json` +- `frontend-parameters.json` +- `app-config-secret.template.json` +- `deploy-split-stack.sh` + +The default production starter leaves API custom-domain fields blank. That is intentional; the normal install uses the generated API Gateway URL. + +For a custom frontend domain, pass these values to `installer:configure`: + +```bash +--frontend-domain=admin.example.com +--frontend-certificate-arn= +--frontend-hosted-zone-id= +``` + +Useful reference commands: + +```bash +yarn installer:preflight -- --environment=prod --environment-dir=../b1admin-deploy/environments/prod --repo=/ --output=markdown +yarn installer:deploy -- --environment=prod --environment-dir=../b1admin-deploy/environments/prod --repo=/ --preview-only=true +yarn installer:deploy -- --environment=prod --environment-dir=../b1admin-deploy/environments/prod --repo=/ --confirm=true +yarn installer:verify -- --environment=prod --region=us-east-1 --output=markdown +``` + +For reset help, use: + +```bash +yarn reset:prod -- --region=us-east-1 --dry-run=true +``` diff --git a/infrastructure/environments/prod/app-config-secret.template.json b/infrastructure/environments/prod/app-config-secret.template.json new file mode 100644 index 000000000..b00b0ed0d --- /dev/null +++ b/infrastructure/environments/prod/app-config-secret.template.json @@ -0,0 +1,21 @@ +{ + "jwtSecret": "replace-me-long-random-jwt-secret", + "encryptionKey": "replace-me-long-random-encryption-key", + "hubspotKey": "", + "mauticUrl": "", + "mauticUser": "", + "mauticPassword": "", + "youTubeApiKey": "", + "pexelsKey": "", + "vimeoToken": "", + "apiBibleKey": "", + "youVersionApiKey": "", + "praiseChartsConsumerKey": "", + "praiseChartsConsumerSecret": "", + "googleRecaptchaSecretKey": "", + "openRouterApiKey": "", + "openAiApiKey": "", + "webPushPublicKey": "", + "webPushPrivateKey": "", + "webPushSubject": "mailto:support@example.com" +} diff --git a/infrastructure/environments/prod/backend-parameters.json b/infrastructure/environments/prod/backend-parameters.json new file mode 100644 index 000000000..9341779e3 --- /dev/null +++ b/infrastructure/environments/prod/backend-parameters.json @@ -0,0 +1,81 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "LambdaCodeS3Bucket": "replace-me-prod-artifact-bucket", + "LambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "LambdaHandler": "lambda.web", + "LambdaRuntime": "nodejs22.x", + "LambdaArchitecture": "arm64", + "LambdaMemorySize": "1024", + "LambdaTimeout": "30", + "LambdaReservedConcurrency": "0", + "DependenciesLayerArn": "", + "ObservabilityLayerArn": "", + "LambdaNodeOptions": "", + "EnableWebSocketApi": "true", + "SocketLambdaHandler": "lambda.socket", + "SocketLambdaMemorySize": "1024", + "SocketLambdaTimeout": "30", + "EnableScheduledWorkers": "true", + "Timer15MinLambdaHandler": "lambda.timer15Min", + "TimerMidnightLambdaHandler": "lambda.timerMidnight", + "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", + "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", + "TimerLambdaMemorySize": "256", + "TimerLambdaTimeout": "300", + "RunMigrations": "false", + "MigrationCodeS3Bucket": "", + "MigrationCodeS3Key": "", + "MigrationHandler": "", + "MigrationRuntime": "", + "MigrationMemorySize": "1024", + "MigrationTimeout": "900", + "MigrationTrigger": "", + "DatabaseName": "membership", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting", + "DatabaseEngine": "aurora-mysql", + "DatabasePort": "3306", + "DatabaseMasterUsername": "app_admin", + "DatabaseMinCapacity": "0.5", + "DatabaseMaxCapacity": "2", + "ApiCustomDomainName": "", + "ApiCertificateArn": "", + "ApiHostedZoneId": "", + "CreateNatGateway": "true", + "VpcCidr": "10.32.0.0/16", + "PublicSubnet1Cidr": "10.32.0.0/24", + "PublicSubnet2Cidr": "10.32.1.0/24", + "PrivateSubnet1Cidr": "10.32.10.0/24", + "PrivateSubnet2Cidr": "10.32.11.0/24", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "ContentRootUrl": "https://content.example.com", + "B1AdminRootUrl": "https://admin.example.com", + "CorsOrigin": "https://admin.example.com", + "FileStore": "S3", + "ManageAssetBucket": "true", + "AssetBucketName": "", + "AppConfigSecretArn": "", + "MailSystem": "SES", + "DeliveryProvider": "aws", + "StoreApiUrl": "https://store.example.com", + "AiProvider": "openrouter", + "EmailOnRegistration": "false", + "CaddyHost": "", + "CaddyPort": "", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "", + "DomainCnameTarget": "", + "DomainATarget": "", + "DefaultStockPhoto": "", + "GoogleAnalyticsTag": "", + "SentryDsn": "" +} diff --git a/infrastructure/environments/prod/bootstrap-parameters.json b/infrastructure/environments/prod/bootstrap-parameters.json new file mode 100644 index 000000000..8e910a896 --- /dev/null +++ b/infrastructure/environments/prod/bootstrap-parameters.json @@ -0,0 +1,7 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "TemplateBucketName": "replace-me-prod-template-bucket", + "ArtifactBucketName": "replace-me-prod-artifact-bucket", + "EnableBucketVersioning": "true" +} diff --git a/infrastructure/environments/prod/deploy-split-stack.sh b/infrastructure/environments/prod/deploy-split-stack.sh new file mode 100644 index 000000000..986fc5b45 --- /dev/null +++ b/infrastructure/environments/prod/deploy-split-stack.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ENV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +AWS_REGION="${AWS_REGION:-us-east-1}" +API_REPO_PATH="${API_REPO_PATH:-../Api}" +PACKAGE_MANIFEST_FILE="${PACKAGE_MANIFEST_FILE:-}" +BACKEND_ARTIFACT_SOURCE_FILE="${BACKEND_ARTIFACT_SOURCE_FILE:-}" +MIGRATION_ARTIFACT_SOURCE_FILE="${MIGRATION_ARTIFACT_SOURCE_FILE:-}" +DEPENDENCIES_LAYER_SOURCE_FILE="${DEPENDENCIES_LAYER_SOURCE_FILE:-}" +PROJECT_NAME="${PROJECT_NAME:-b1admin}" +ENVIRONMENT_NAME="${ENVIRONMENT_NAME:-prod}" +STACK_PREFIX="${STACK_PREFIX:-${PROJECT_NAME}-${ENVIRONMENT_NAME}}" +BOOTSTRAP_STACK_NAME="${BOOTSTRAP_STACK_NAME:-${STACK_PREFIX}-bootstrap}" +SYNC_APP_CONFIG_SECRET="${SYNC_APP_CONFIG_SECRET:-false}" +SYNC_BOOTSTRAP_ADMIN_SECRET="${SYNC_BOOTSTRAP_ADMIN_SECRET:-false}" +RUN_API_MIGRATIONS="${RUN_API_MIGRATIONS:-false}" +RUN_BOOTSTRAP_ADMIN="${RUN_BOOTSTRAP_ADMIN:-false}" +API_MIGRATION_ACTION="${API_MIGRATION_ACTION:-up}" +API_MIGRATION_MODULE="${API_MIGRATION_MODULE:-all}" +VERIFY_AFTER_DEPLOY="${VERIFY_AFTER_DEPLOY:-true}" +VERIFY_HTTP_AFTER_DEPLOY="${VERIFY_HTTP_AFTER_DEPLOY:-false}" +SAVE_OUTPUTS_AFTER_DEPLOY="${SAVE_OUTPUTS_AFTER_DEPLOY:-true}" +OUTPUTS_CAPTURE_DIR="${OUTPUTS_CAPTURE_DIR:-deployment/${ENVIRONMENT_NAME}}" +PREVIEW_ONLY="${PREVIEW_ONLY:-false}" +PACKAGE_MODE="${PACKAGE_MODE:-layered}" +PACKAGE_BUILD_LAYER="${PACKAGE_BUILD_LAYER:-}" + +BOOTSTRAP_PARAMS="${ENV_DIR}/bootstrap-parameters.json" +BACKEND_PARAMS="${ENV_DIR}/backend-parameters.json" +FRONTEND_PARAMS="${ENV_DIR}/frontend-parameters.json" +APP_CONFIG_SECRET_FILE="${ENV_DIR}/app-config-secret.json" +APP_CONFIG_SECRET_TEMPLATE="${ENV_DIR}/app-config-secret.template.json" +BOOTSTRAP_ADMIN_SECRET_FILE="${ENV_DIR}/bootstrap-admin-secret.json" + +require_file() { + local file_path="$1" + if [[ ! -f "${file_path}" ]]; then + echo "Missing required file: ${file_path}" >&2 + exit 1 + fi +} + +ensure_local_api_repo_readable() { + local repo_path="$1" + local package_json_path="${repo_path}/package.json" + + if [[ ! -e "${repo_path}" ]]; then + echo "Local Api repo path does not exist: ${repo_path}" >&2 + echo "Set API_REPO_PATH to a readable checkout, or switch to PACKAGE_MANIFEST_FILE / BACKEND_ARTIFACT_SOURCE_FILE for this local run." >&2 + exit 1 + fi + + if [[ ! -r "${repo_path}" || ! -x "${repo_path}" ]]; then + echo "Local Api repo path is not readable from this shell: ${repo_path}" >&2 + echo "Use PACKAGE_MANIFEST_FILE or BACKEND_ARTIFACT_SOURCE_FILE for local deploys, or use the GitHub Actions api-repo path if the runner can read the backend repo." >&2 + exit 1 + fi + + if [[ ! -f "${package_json_path}" ]]; then + echo "Local Api repo package.json does not exist: ${package_json_path}" >&2 + echo "Set API_REPO_PATH to a valid Api checkout, or switch to PACKAGE_MANIFEST_FILE / BACKEND_ARTIFACT_SOURCE_FILE for this local run." >&2 + exit 1 + fi + + if [[ ! -r "${package_json_path}" ]]; then + echo "Local Api repo package.json is not readable from this shell: ${package_json_path}" >&2 + echo "Use PACKAGE_MANIFEST_FILE or BACKEND_ARTIFACT_SOURCE_FILE for local deploys, or use the GitHub Actions api-repo path if the runner can read the backend repo." >&2 + exit 1 + fi +} + +run_npm() { + ( + cd "${ROOT_DIR}" + npm run "$@" + ) +} + +run_starter_audit() { + if run_npm audit:environment-starter -- \ + --environment="${ENVIRONMENT_NAME}" \ + --only-blockers=true \ + --output=markdown; then + return 0 + fi + + echo "" >&2 + echo "Starter audit failed for ${ENVIRONMENT_NAME}." >&2 + echo "Helpful next commands:" >&2 + echo "- yarn prepare:environment-starter -- --environment=${ENVIRONMENT_NAME} --account-id= --output=json" >&2 + echo "- yarn prepare:environment-starter -- --environment=${ENVIRONMENT_NAME} --account-id= --output=markdown" >&2 + echo "- yarn plan:environment-deploy -- --environment=${ENVIRONMENT_NAME} --output=markdown" >&2 + exit 1 +} + +run_deploy_plan() { + local deployment_source="api-repo" + local plan_args=( + --environment="${ENVIRONMENT_NAME}" + --region="${AWS_REGION}" + --api-repo-path="${API_REPO_PATH}" + --sync-app-config-secret="${SYNC_APP_CONFIG_SECRET}" + --sync-bootstrap-admin-secret="${SYNC_BOOTSTRAP_ADMIN_SECRET}" + --run-api-migrations="${RUN_API_MIGRATIONS}" + --run-bootstrap-admin="${RUN_BOOTSTRAP_ADMIN}" + --api-migration-action="${API_MIGRATION_ACTION}" + --api-migration-module="${API_MIGRATION_MODULE}" + --verify-http-after-deploy="${VERIFY_HTTP_AFTER_DEPLOY}" + --output=markdown + ) + + if [[ -n "${PACKAGE_MANIFEST_FILE}" ]]; then + deployment_source="package-manifest" + plan_args+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") + elif [[ -n "${BACKEND_ARTIFACT_SOURCE_FILE}" ]]; then + deployment_source="backend-artifact" + plan_args+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + if [[ -n "${MIGRATION_ARTIFACT_SOURCE_FILE}" ]]; then + plan_args+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + fi + if [[ -n "${DEPENDENCIES_LAYER_SOURCE_FILE}" ]]; then + plan_args+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + fi + fi + + plan_args+=(--deployment-source="${deployment_source}") + + if run_npm plan:environment-deploy -- "${plan_args[@]}"; then + return 0 + fi + + echo "" >&2 + echo "Deploy plan failed for ${ENVIRONMENT_NAME}. Resolve the reported blockers before continuing." >&2 + exit 1 +} + +require_file "${BOOTSTRAP_PARAMS}" +require_file "${BACKEND_PARAMS}" +require_file "${FRONTEND_PARAMS}" +require_file "${APP_CONFIG_SECRET_TEMPLATE}" + +echo "Auditing prod starter files..." +run_starter_audit + +echo "Planning prod deployment readiness..." +run_deploy_plan + +if [[ "${PREVIEW_ONLY}" == "true" ]]; then + echo "Preview-only mode enabled; stopping after starter audit and deploy plan." + echo "Next live command: ./infrastructure/environments/${ENVIRONMENT_NAME}/deploy-split-stack.sh" + exit 0 +fi + +echo "Validating bootstrap parameters..." +run_npm validate:aws-deploy -- \ + --mode=bootstrap \ + --region="${AWS_REGION}" \ + --stack-name="${BOOTSTRAP_STACK_NAME}" \ + --parameters-file="${BOOTSTRAP_PARAMS}" + +echo "Deploying bootstrap stack..." +run_npm deploy:bootstrap -- \ + --region="${AWS_REGION}" \ + --stack-name="${BOOTSTRAP_STACK_NAME}" \ + --parameters-file="${BOOTSTRAP_PARAMS}" + +echo "Validating split-stack parameters..." +VALIDATE_ARGS=( + --mode=split-stack + --region="${AWS_REGION}" + --backend-parameters-file="${BACKEND_PARAMS}" + --frontend-parameters-file="${FRONTEND_PARAMS}" +) + +DEPLOY_ARGS=( + --region="${AWS_REGION}" + --project-name="${PROJECT_NAME}" + --environment="${ENVIRONMENT_NAME}" + --bootstrap-stack-name="${BOOTSTRAP_STACK_NAME}" + --backend-parameters-file="${BACKEND_PARAMS}" + --frontend-parameters-file="${FRONTEND_PARAMS}" + --package-mode="${PACKAGE_MODE}" +) + +if [[ -n "${PACKAGE_BUILD_LAYER}" ]]; then + DEPLOY_ARGS+=(--package-build-layer="${PACKAGE_BUILD_LAYER}") +fi + +if [[ -n "${PACKAGE_MANIFEST_FILE}" ]]; then + require_file "${PACKAGE_MANIFEST_FILE}" + VALIDATE_ARGS+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") + DEPLOY_ARGS+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") +elif [[ -n "${BACKEND_ARTIFACT_SOURCE_FILE}" ]]; then + require_file "${BACKEND_ARTIFACT_SOURCE_FILE}" + VALIDATE_ARGS+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + DEPLOY_ARGS+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + + if [[ -n "${MIGRATION_ARTIFACT_SOURCE_FILE}" ]]; then + require_file "${MIGRATION_ARTIFACT_SOURCE_FILE}" + VALIDATE_ARGS+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + DEPLOY_ARGS+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + fi + + if [[ -n "${DEPENDENCIES_LAYER_SOURCE_FILE}" ]]; then + require_file "${DEPENDENCIES_LAYER_SOURCE_FILE}" + VALIDATE_ARGS+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + DEPLOY_ARGS+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + fi +else + ensure_local_api_repo_readable "${API_REPO_PATH}" + VALIDATE_ARGS+=(--api-repo-path="${API_REPO_PATH}") + DEPLOY_ARGS+=(--api-repo-path="${API_REPO_PATH}") +fi + +run_npm validate:aws-deploy -- "${VALIDATE_ARGS[@]}" + +if [[ "${SYNC_APP_CONFIG_SECRET}" == "true" ]]; then + require_file "${APP_CONFIG_SECRET_FILE}" + if rg -n "replace-me" "${APP_CONFIG_SECRET_FILE}" >/dev/null 2>&1; then + echo "Found replace-me placeholders in ${APP_CONFIG_SECRET_FILE}. Update them before syncing the app config secret." >&2 + rg -n "replace-me" "${APP_CONFIG_SECRET_FILE}" >&2 + exit 1 + fi +fi + +if [[ "${SYNC_APP_CONFIG_SECRET}" == "true" ]]; then + DEPLOY_ARGS+=(--app-config-secret-file="${APP_CONFIG_SECRET_FILE}") +fi + +if [[ "${SYNC_BOOTSTRAP_ADMIN_SECRET}" == "true" ]]; then + require_file "${BOOTSTRAP_ADMIN_SECRET_FILE}" + if rg -n '"(ChangeMe123!|admin@example.com|examplechurch)"' "${BOOTSTRAP_ADMIN_SECRET_FILE}" >/dev/null 2>&1; then + echo "Found sample bootstrap-admin values in ${BOOTSTRAP_ADMIN_SECRET_FILE}. Update them before enabling bootstrap admin sync." >&2 + rg -n '"(ChangeMe123!|admin@example.com|examplechurch)"' "${BOOTSTRAP_ADMIN_SECRET_FILE}" >&2 + exit 1 + fi + DEPLOY_ARGS+=(--bootstrap-admin-secret-file="${BOOTSTRAP_ADMIN_SECRET_FILE}") +fi + +if [[ "${RUN_API_MIGRATIONS}" == "true" ]]; then + DEPLOY_ARGS+=( + --run-api-migrations=true + --api-migration-action="${API_MIGRATION_ACTION}" + --api-migration-module="${API_MIGRATION_MODULE}" + ) +fi + +if [[ "${RUN_BOOTSTRAP_ADMIN}" == "true" ]]; then + DEPLOY_ARGS+=(--run-bootstrap-admin=true) +fi + +echo "Deploying split-stack prod environment..." +run_npm deploy:aws -- "${DEPLOY_ARGS[@]}" + +if [[ "${SAVE_OUTPUTS_AFTER_DEPLOY}" == "true" ]]; then + echo "Saving split-stack prod outputs..." + run_npm save:split-stack-outputs -- \ + --environment="${ENVIRONMENT_NAME}" \ + --region="${AWS_REGION}" \ + --output-dir="${OUTPUTS_CAPTURE_DIR}" +fi + +if [[ "${VERIFY_AFTER_DEPLOY}" == "true" ]]; then + echo "Verifying split-stack prod environment..." + VERIFY_ARGS=( + --region="${AWS_REGION}" + --backend-stack-name="${STACK_PREFIX}-backend" + --frontend-stack-name="${STACK_PREFIX}-frontend" + ) + + if [[ "${VERIFY_HTTP_AFTER_DEPLOY}" == "true" ]]; then + VERIFY_ARGS+=(--check-http=true) + fi + + run_npm verify:split-stack -- "${VERIFY_ARGS[@]}" +fi diff --git a/infrastructure/environments/prod/frontend-parameters.json b/infrastructure/environments/prod/frontend-parameters.json new file mode 100644 index 000000000..af3fe2f9f --- /dev/null +++ b/infrastructure/environments/prod/frontend-parameters.json @@ -0,0 +1,9 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "BucketName": "", + "AlternateDomainName": "", + "AcmCertificateArn": "", + "HostedZoneId": "", + "PriceClass": "PriceClass_100" +} diff --git a/infrastructure/environments/setup/api-repository-access.md b/infrastructure/environments/setup/api-repository-access.md new file mode 100644 index 000000000..479efdd23 --- /dev/null +++ b/infrastructure/environments/setup/api-repository-access.md @@ -0,0 +1,38 @@ +# API Repository Access + +B1Admin uses the backend code from the `ChurchApps/Api` repository. The deployment workflow checks out that repository, installs its dependencies, packages the Lambda code, and can run its database migrations. + +For the normal guided GitHub Actions deployment, the Api repository does not need to be cloned onto the operator's computer. GitHub Actions checks it out during the workflow run. + +You normally do not need to create your own private Api repository. Use the source Api repository that contains the backend code you intend to deploy. Create a private fork or mirror only if your organization must deploy customized backend code or cannot allow the workflow to read the upstream source repository directly. + +The private deployment workflow also checks out the B1Admin source repository so it can use the deployment scripts and CloudFormation templates. The workflow inputs default to: + +- `b1admin_repo=ChurchApps/B1Admin` +- `b1admin_ref=main` +- `api_repo=ChurchApps/Api` +- `api_ref=main` + +Confirm that the person setting up the install can open the Api repository in GitHub. Then give the private deployment repository's workflow read access: + +- If the Api repository is public, no extra secret is normally needed. +- If the Api repository is private, create a fine-grained personal access token or GitHub App token with read-only contents access to it. A workflow's default token is normally limited to its own repository. +- Save that token as the `API_REPO_CHECKOUT_TOKEN` secret on each deployment environment you use. +- Add `B1ADMIN_REPO_CHECKOUT_TOKEN` only if the workflow cannot read the B1Admin source repository with the default workflow token or the API checkout token. + +Do not give this token write access. The installer deploys a selected Api commit but does not push changes back to the Api repository. + +Optional local Api checkout: + +```text +parent-folder/ + B1Admin/ + b1admin-deploy/ + Api/ +``` + +Use a local `../Api` checkout only for advanced local packaging, local migration work, or troubleshooting. If you do clone it locally, keep it beside `B1Admin`, not inside the user's private repository. + +You are ready when the workflow identity can read the Api source repository and the deployment will use the intended branch, tag, or commit, normally `main`. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/aws-account-access.md b/infrastructure/environments/setup/aws-account-access.md new file mode 100644 index 000000000..ad2446ccb --- /dev/null +++ b/infrastructure/environments/setup/aws-account-access.md @@ -0,0 +1,39 @@ +# AWS Account Access + +You need access to the AWS account where B1Admin will run. Staging is optional. If you use staging, keep staging and prod separated by their environment names and IAM roles. + +For the recommended GitHub Actions deployment, an AWS administrator must create these for each environment you plan to deploy: + +- a GitHub OIDC provider for `token.actions.githubusercontent.com`, if the account does not already have one +- a GitHub deploy role for each environment +- a CloudFormation execution role for each environment + +The deploy role lets GitHub start and manage the deployment. The CloudFormation role creates the B1Admin resources. Use the installer to render the trust and permission policies instead of editing JSON by hand: + +For the normal prod-only path, generate the prod role files: + +```bash +yarn installer:aws-roles -- --environment=prod --account-id= --repo=/ --output-dir=../b1admin-deploy/iam/prod --write=true --output=markdown +``` + +If you choose the optional staging deployment, generate staging role files too: + +```bash +yarn installer:aws-roles -- --environment=staging --account-id= --repo=/ --output-dir=../b1admin-deploy/iam/staging --write=true --output=markdown +``` + +Have an AWS administrator run the printed `aws iam ...` commands. If the account already has the GitHub OIDC provider for `token.actions.githubusercontent.com`, skip the provider creation command. The rendered trust policies scope access to the private deployment repository and the matching GitHub Environment. + +The short operator handoff is in [AWS IAM deployment roles](./aws-iam-roles.md). The full sample trust and permission policies are also documented in the [IAM setup guide](../../iam/README.md). + +Record the AWS account ID, region, deploy-role ARN, and CloudFormation-role ARN. You will use them while preparing the environment and configuring GitHub secrets. + +You are ready when an AWS administrator has created the roles for the environments you plan to deploy and confirmed that their trust policies name your private deployment repository and the matching GitHub Environment. + +After the environment files are configured, the installer preflight checks the active AWS CLI identity and any configured frontend certificate/hosted-zone values: + +```bash +yarn installer:aws-preflight -- --environment=prod --environment-dir="../b1admin-deploy/environments/prod" --account-id= --output=markdown +``` + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/aws-cli.md b/infrastructure/environments/setup/aws-cli.md new file mode 100644 index 000000000..cfc7ff0e6 --- /dev/null +++ b/infrastructure/environments/setup/aws-cli.md @@ -0,0 +1,34 @@ +# AWS CLI + +Install AWS CLI for the guided installer path. The installer uses it for local readiness checks, AWS identity checks, verification, and clean reset commands. + +AWS CLI is optional only if an advanced operator does every AWS inspection and reset step from the AWS console or another approved tool. + +Configure a local AWS profile using your organization's normal sign-in method. Prefer IAM Identity Center or another short-lived credential method over permanent access keys. + +Verify the active identity and region before running any command that changes AWS: + +```bash +aws sts get-caller-identity +aws configure get region +yarn installer:doctor -- --output=markdown +``` + +If you use a named profile, add `--profile ` to AWS CLI commands or set it through your normal shell configuration. + +What good output looks like: + +- `aws sts get-caller-identity` prints an `Account` value that matches the AWS account where B1Admin will run. +- `aws configure get region` prints the deployment region, normally `us-east-1`. +- `installer:doctor` can read the AWS identity without an authentication error. + +Common fixes: + +- If AWS CLI is not found, install AWS CLI and open a new terminal. +- If the account is wrong, sign out or switch profiles before continuing. +- If the region is blank, configure your default region or set the region using your organization's normal AWS CLI setup. +- If your organization uses AWS IAM Identity Center, make sure your session has not expired. + +You are ready when the identity command shows the intended AWS account and the configured region is the region where B1Admin will be deployed, normally `us-east-1`. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/aws-iam-roles.md b/infrastructure/environments/setup/aws-iam-roles.md new file mode 100644 index 000000000..37cb1b66d --- /dev/null +++ b/infrastructure/environments/setup/aws-iam-roles.md @@ -0,0 +1,33 @@ +# AWS IAM Deployment Roles + +GitHub needs permission to deploy B1Admin into AWS. The recommended setup uses GitHub OIDC, which lets GitHub assume short-lived AWS roles without storing long-lived AWS keys in GitHub. + +There are two roles per environment you deploy: + +- GitHub deploy role: trusted by the private deployment repo's GitHub Environment. +- CloudFormation execution role: used by CloudFormation to create and update the B1Admin AWS resources. + +Generate one AWS admin handoff document from the installer: + +```bash +yarn installer:aws-handoff -- --customer-file=../b1admin-deploy/customer-values.json --deploy-repo-dir=../b1admin-deploy --write=true --output=markdown +``` + +Send `../b1admin-deploy/aws-admin-handoff.md` to an AWS administrator. Have them run the printed `aws iam ...` commands. If the AWS account already has the GitHub OIDC provider for `token.actions.githubusercontent.com`, they should skip the provider creation command. + +If you only need one environment, use the lower-level role generator for prod: + +```bash +yarn installer:aws-roles -- --environment=prod --customer-file=../b1admin-deploy/customer-values.json --output-dir=../b1admin-deploy/iam/prod --write=true --output=markdown +``` + +After the roles exist, save these values for each deployed environment: + +- GitHub deploy-role ARN +- CloudFormation execution-role ARN + +The installer can derive the default ARNs from your AWS account ID. If your administrator chooses different role names, pass the actual ARNs when setting GitHub secrets. + +You are ready when the roles exist in AWS and the trust policies name your private deployment repository and the matching GitHub Environment, normally `aws-prod` and, only if you use staging, `aws-staging`. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/customer-values.md b/infrastructure/environments/setup/customer-values.md new file mode 100644 index 000000000..2ec6170bb --- /dev/null +++ b/infrastructure/environments/setup/customer-values.md @@ -0,0 +1,51 @@ +# Customer Values Worksheet + +Collect these values before configuring prod or optional staging. + +The guided installer stores these values in `../b1admin-deploy/customer-values.json`. +You do not need to edit that JSON file by hand. + +The normal setup command creates the file when it does not already exist: + +```bash +yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown +``` + +Then answer the customer setup questions: + +```bash +yarn installer:customer-values -- --customer-file=../b1admin-deploy/customer-values.json --write=true --output=markdown +``` + +Keep `customer-values.json` local. Do not commit it. + +Required: + +- AWS account ID +- AWS region, normally `us-east-1` +- private deployment repository name, for example `your-org/b1admin-deploy` +- root domain, for example `example.com` +- support email address, for example `support@example.com` +- support phone number +- first admin email address +- temporary first admin password +- first church name + +Recommended: + +- B1Admin source repo and branch, normally `ChurchApps/B1Admin` and `main` +- Api source repo and branch, normally `ChurchApps/Api` and `main` + +Optional frontend custom domain: + +- frontend hostname, for example `admin.example.com` +- ACM certificate ARN for that hostname +- Route53 hosted zone ID + +The frontend ACM certificate must be in `us-east-1` because CloudFront requires it there. + +You may leave frontend custom-domain values blank. In that case the first deploy uses a generated CloudFront URL. After that first deploy, the guided runner will ask you to adopt that generated frontend URL so the backend accepts it for browser login. + +Leave API custom-domain values blank for the normal install. The stack will use the generated API Gateway URL. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/deployment-repository.md b/infrastructure/environments/setup/deployment-repository.md new file mode 100644 index 000000000..23e842621 --- /dev/null +++ b/infrastructure/environments/setup/deployment-repository.md @@ -0,0 +1,85 @@ +# Private Deployment Repository + +Create a private GitHub repository owned by the organization or team operating B1Admin. A name such as `b1admin-deploy` works well. + +This repository is the operator's deployment workspace. It should be created in GitHub first, then cloned beside the B1Admin source repository on the operator machine. + +You can create it in the GitHub website, or with GitHub CLI. + +Run this from the parent folder that will contain both repositories: + +```bash +gh repo create /b1admin-deploy --private --clone +``` + +If you use the GitHub website, clone it from that same parent folder before continuing: + +```bash +git clone git@github.com:/b1admin-deploy.git b1admin-deploy +``` + +Check the folder layout before running installer commands: + +```text +parent-folder/ + B1Admin/ + b1admin-deploy/ +``` + +Run installer commands from `B1Admin`, not from `b1admin-deploy`. + +It holds: + +- the deployment workflow +- prod parameter files and optional staging parameter files +- GitHub Environments and their secrets +- an ignored local `deployment/` folder for downloaded workflow evidence, browser-smoke results, and final rollout reports + +It does not need to contain application source changes, and the installer does not push environment settings back to the B1Admin or Api repositories. + +From a sibling B1Admin checkout, use the installer helper to scaffold the private repository and create the local customer worksheet: + +```bash +yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown +``` + +That places [`private-deployment-workflow.sample.yml`](../private-deployment-workflow.sample.yml) at `.github/workflows/deploy-aws-self-hosted.yml`, copies [`private-deployment-gitignore.sample`](../private-deployment-gitignore.sample) to `.gitignore`, creates a short private-repo README, copies the tracked `staging` and `prod` starter files into an `environments` folder, and creates `customer-values.json` if it does not already exist. + +Do not commit `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. Keep runtime secrets in GitHub Environment secrets. + +The workflow has inputs for the source repositories it checks out: + +- `b1admin_repo`, normally `ChurchApps/B1Admin` +- `b1admin_ref`, normally `main` +- `api_repo`, normally `ChurchApps/Api` +- `api_ref`, normally `main` + +The full expected layout and workflow path adjustments are shown in the [user's private repository guide](../private-deployment-repo.md). + +Commit and push the safe scaffold files before any workflow dispatch: + +```bash +git -C ../b1admin-deploy add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments +git -C ../b1admin-deploy commit -m "Add B1Admin deployment scaffold" +git -C ../b1admin-deploy push +``` + +You are ready when the private repository contains the workflow and environment folders, and the repository's Actions tab shows the `Deploy AWS From Private Repo` workflow. + +If the Actions tab does not show the workflow after pushing, confirm that the workflow file exists at `.github/workflows/deploy-aws-self-hosted.yml` in the user's private repository and that GitHub Actions are enabled for that repository. + +After the first install, keep using this private repository for updates. The normal update command is: + +```bash +yarn installer:update -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +This is a guided update command, not a zero-downtime guarantee. For production with active users, verify staging first when available and run prod updates during an approved low-traffic or maintenance window. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/github-cli.md b/infrastructure/environments/setup/github-cli.md new file mode 100644 index 000000000..84db3bf63 --- /dev/null +++ b/infrastructure/environments/setup/github-cli.md @@ -0,0 +1,36 @@ +# GitHub CLI + +Install GitHub CLI for the guided installer path. The installer uses `gh` to check repository access, prepare GitHub Environments and secrets, dispatch deployments, watch workflow runs, and download deployment evidence. + +GitHub CLI is optional only if an advanced operator chooses to do the GitHub steps manually in the GitHub website instead of using the guided runner. + +After installing `gh`, authenticate and select the GitHub account that can access the private deployment repository: + +```bash +gh auth login +gh auth status +``` + +Check repository access: + +```bash +gh repo view / +yarn installer:doctor -- --repo=/ --output=markdown +``` + +What good output looks like: + +- `gh auth status` shows you are logged in to the GitHub account that can access the user's private repository. +- `gh repo view /` prints repository information instead of `not found` or `HTTP 404`. +- `installer:doctor` can read the repository and does not report a GitHub authentication blocker. + +Common fixes: + +- If `gh` is not found, install GitHub CLI and open a new terminal. +- If the wrong GitHub account is signed in, run `gh auth logout`, then `gh auth login`. +- If the repository is not found, confirm the repository name and ask the GitHub administrator to grant your account access. +- If the repository exists but Actions are disabled, enable Actions in the user's private repository settings. + +You are ready when both checks succeed. Authentication to a different GitHub account is a common cause of repository or workflow lookup failures. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/github-deployment-secrets.md b/infrastructure/environments/setup/github-deployment-secrets.md new file mode 100644 index 000000000..70778c8ef --- /dev/null +++ b/infrastructure/environments/setup/github-deployment-secrets.md @@ -0,0 +1,35 @@ +# GitHub Deployment Secrets + +Add deployment secrets under **Settings > Environments > Environment secrets** in the private deployment repository. + +For the normal prod-only path, use `aws-prod`. If you choose the optional staging deployment, add separate secrets to `aws-staging` too. + +For the recommended OIDC setup, add: + +- `AWS_ROLE_TO_ASSUME`: the environment's GitHub deploy-role ARN +- `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN`: the environment's CloudFormation execution-role ARN +- `AWS_APP_CONFIG_SECRET_JSON`: the backend application configuration as one valid JSON object + +Use the installer to generate or reuse the app-config JSON with random `jwtSecret` and `encryptionKey` values, then write all required GitHub Environment secrets: + +```bash +yarn installer:app-config-secret -- --environment=prod --environment-dir=../b1admin-deploy/environments/prod --customer-file=../b1admin-deploy/customer-values.json --write=true --output=markdown +yarn installer:github-setup -- --environment=prod --repo=/ --account-id= --deploy-env-dir=../b1admin-deploy/environments --write=true --write-secrets=true --output=markdown +yarn installer:github-readiness -- --environment=prod --repo=/ --write=true --output=markdown +``` + +If you use staging, repeat the same command pattern with `--environment=staging`. Use different generated secret values for staging and prod. + +Add `API_REPO_CHECKOUT_TOKEN` only when the workflow's default token cannot read the private Api repository. + +Add `B1ADMIN_REPO_CHECKOUT_TOKEN` only when the workflow's default token cannot read the B1Admin source repository. If both source repositories are private and the same read-only token can read both, you may reuse the same token value for both secrets. + +Do not add static AWS keys when OIDC is configured. If your organization cannot use OIDC yet, use `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in place of `AWS_ROLE_TO_ASSUME`; treat that as a temporary fallback. + +The normal installer path keeps first-admin credentials out of GitHub. Do not add `AWS_BOOTSTRAP_ADMIN_SECRET_JSON` unless you have deliberately chosen runner-side admin bootstrap. + +If you deploy both staging and prod, the values should be separate, especially role ARNs and application secrets. Never commit `app-config-secret.json` to either application repository. + +You are ready when each GitHub Environment has its own AWS role values and app-config JSON. See the [GitHub Actions AWS setup guide](../github-actions-setup.md) for OIDC trust details, static-key fallback, and secret-sync commands. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/github-environments.md b/infrastructure/environments/setup/github-environments.md new file mode 100644 index 000000000..369703550 --- /dev/null +++ b/infrastructure/environments/setup/github-environments.md @@ -0,0 +1,34 @@ +# GitHub Environments + +In the private deployment repository, open **Settings > Environments** and create the environments you plan to deploy. + +For the normal prod-only path, create: + +- `aws-prod` + +If you choose the optional staging deployment, also create: + +- `aws-staging` + +You can also create or update the environments from the B1Admin checkout: + +```bash +yarn installer:github-setup -- --repo=/ --write=true --output=markdown +``` + +The deployment workflow selects `aws-staging` for a staging run and `aws-prod` for a production run. Environment-level secrets keep the two deployments separate even though they use the same workflow. + +For production, consider adding required reviewers so a workflow cannot deploy until an authorized person approves it. Add branch restrictions if your organization requires deployments to come from a protected branch. + +Do not rename the environments unless you also update the workflow. The checked-in workflow expects the `aws-` naming pattern. + +You are ready when the needed environments appear in the repository settings. After IAM role files and app-config secrets are ready, the same helper can write the required GitHub Environment secrets. + +For prod: + +```bash +yarn installer:github-setup -- --environment=prod --repo=/ --account-id= --deploy-env-dir=../b1admin-deploy/environments --write=true --write-secrets=true --output=markdown +yarn installer:github-readiness -- --environment=prod --repo=/ --write=true --output=markdown +``` + +[Configure deployment secrets](./github-deployment-secrets.md) | [Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/setup/local-runtime.md b/infrastructure/environments/setup/local-runtime.md new file mode 100644 index 000000000..ccae3c1b0 --- /dev/null +++ b/infrastructure/environments/setup/local-runtime.md @@ -0,0 +1,35 @@ +# Local Runtime + +Install Node.js and npm on the machine where you will run the installer commands. + +Use the version supported by this repository's `package.json`. If your organization already has a standard Node installer or version manager, use that. Otherwise, install the current long-term support Node.js release. + +Confirm the local tools are available: + +```bash +node --version +npm --version +git --version +gh --version +aws --version +``` + +Each command should print a version number. If a command says it was not found, install that tool and open a new terminal before continuing. + +The first installer step can create the private deployment workspace before dependencies are installed. When the guided flow reaches first-admin bootstrap or browser smoke, install dependencies from the B1Admin checkout: + +```bash +yarn install +``` + +You can run the local readiness report any time after dependencies are installed: + +```bash +yarn installer:doctor -- --output=markdown +``` + +Before dependencies are installed, the doctor may report dependency-related TODO items. That is expected early in the install. + +You are ready for the first installer steps when `node`, `npm`, `git`, `gh`, and `aws` are installed and the `B1Admin` source repository is on your computer. You are ready for first-admin bootstrap and browser smoke when the doctor also shows the B1Admin dependency checks as complete. + +[Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/staging/README.md b/infrastructure/environments/staging/README.md new file mode 100644 index 000000000..d88be6d98 --- /dev/null +++ b/infrastructure/environments/staging/README.md @@ -0,0 +1,48 @@ +# Staging Environment Starter + +This folder contains the checked-in staging starter files used by the AWS installer. + +For a real install, start with [`../start-here.md`](../start-here.md). Do not use this folder as the live customer configuration location unless you are deliberately testing inside the B1Admin repo. + +The normal customer path copies these files into a private deployment repository with: + +```bash +yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown +``` + +Then configure the private copy: + +```bash +yarn installer:configure -- \ + --environment=staging \ + --environment-dir=../b1admin-deploy/environments/staging \ + --account-id= \ + --root-domain= \ + --support-phone= \ + --write=true +``` + +Required starter files: + +- `bootstrap-parameters.json` +- `backend-parameters.json` +- `frontend-parameters.json` +- `app-config-secret.template.json` +- `deploy-split-stack.sh` + +The installer may create `app-config-secret.json` in the private deployment repo. That file is ignored by the private deployment repo `.gitignore` and should not be committed. + +Useful reference commands: + +```bash +yarn installer:preflight -- --environment=staging --environment-dir=../b1admin-deploy/environments/staging --repo=/ --output=markdown +yarn installer:deploy -- --environment=staging --environment-dir=../b1admin-deploy/environments/staging --repo=/ --preview-only=true +yarn installer:deploy -- --environment=staging --environment-dir=../b1admin-deploy/environments/staging --repo=/ --confirm=true +yarn installer:verify -- --environment=staging --region=us-east-1 --output=markdown +``` + +For reset help, use: + +```bash +yarn reset:staging -- --region=us-east-1 --dry-run=true +``` diff --git a/infrastructure/environments/staging/app-config-secret.template.json b/infrastructure/environments/staging/app-config-secret.template.json new file mode 100644 index 000000000..b00b0ed0d --- /dev/null +++ b/infrastructure/environments/staging/app-config-secret.template.json @@ -0,0 +1,21 @@ +{ + "jwtSecret": "replace-me-long-random-jwt-secret", + "encryptionKey": "replace-me-long-random-encryption-key", + "hubspotKey": "", + "mauticUrl": "", + "mauticUser": "", + "mauticPassword": "", + "youTubeApiKey": "", + "pexelsKey": "", + "vimeoToken": "", + "apiBibleKey": "", + "youVersionApiKey": "", + "praiseChartsConsumerKey": "", + "praiseChartsConsumerSecret": "", + "googleRecaptchaSecretKey": "", + "openRouterApiKey": "", + "openAiApiKey": "", + "webPushPublicKey": "", + "webPushPrivateKey": "", + "webPushSubject": "mailto:support@example.com" +} diff --git a/infrastructure/environments/staging/backend-parameters.json b/infrastructure/environments/staging/backend-parameters.json new file mode 100644 index 000000000..8c311f25f --- /dev/null +++ b/infrastructure/environments/staging/backend-parameters.json @@ -0,0 +1,81 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "staging", + "LambdaCodeS3Bucket": "replace-me-staging-artifact-bucket", + "LambdaCodeS3Key": "b1admin/staging/backend/api.zip", + "LambdaHandler": "lambda.web", + "LambdaRuntime": "nodejs22.x", + "LambdaArchitecture": "arm64", + "LambdaMemorySize": "1024", + "LambdaTimeout": "30", + "LambdaReservedConcurrency": "0", + "DependenciesLayerArn": "", + "ObservabilityLayerArn": "", + "LambdaNodeOptions": "", + "EnableWebSocketApi": "true", + "SocketLambdaHandler": "lambda.socket", + "SocketLambdaMemorySize": "1024", + "SocketLambdaTimeout": "30", + "EnableScheduledWorkers": "true", + "Timer15MinLambdaHandler": "lambda.timer15Min", + "TimerMidnightLambdaHandler": "lambda.timerMidnight", + "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", + "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", + "TimerLambdaMemorySize": "256", + "TimerLambdaTimeout": "300", + "RunMigrations": "false", + "MigrationCodeS3Bucket": "", + "MigrationCodeS3Key": "", + "MigrationHandler": "", + "MigrationRuntime": "", + "MigrationMemorySize": "1024", + "MigrationTimeout": "900", + "MigrationTrigger": "", + "DatabaseName": "membership", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting", + "DatabaseEngine": "aurora-mysql", + "DatabasePort": "3306", + "DatabaseMasterUsername": "app_admin", + "DatabaseMinCapacity": "0.5", + "DatabaseMaxCapacity": "2", + "ApiCustomDomainName": "", + "ApiCertificateArn": "", + "ApiHostedZoneId": "", + "CreateNatGateway": "true", + "VpcCidr": "10.31.0.0/16", + "PublicSubnet1Cidr": "10.31.0.0/24", + "PublicSubnet2Cidr": "10.31.1.0/24", + "PrivateSubnet1Cidr": "10.31.10.0/24", + "PrivateSubnet2Cidr": "10.31.11.0/24", + "WebsiteBaseUrl": "https://{subdomain}.staging.example.com", + "ContentRootUrl": "https://content.staging.example.com", + "B1AdminRootUrl": "https://admin.staging.example.com", + "CorsOrigin": "https://admin.staging.example.com", + "FileStore": "S3", + "ManageAssetBucket": "true", + "AssetBucketName": "", + "AppConfigSecretArn": "", + "MailSystem": "SES", + "DeliveryProvider": "aws", + "StoreApiUrl": "https://store.staging.example.com", + "AiProvider": "openrouter", + "EmailOnRegistration": "false", + "CaddyHost": "", + "CaddyPort": "", + "TransferUrl": "https://transfer.staging.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "", + "DomainCnameTarget": "", + "DomainATarget": "", + "DefaultStockPhoto": "", + "GoogleAnalyticsTag": "", + "SentryDsn": "" +} diff --git a/infrastructure/environments/staging/bootstrap-parameters.json b/infrastructure/environments/staging/bootstrap-parameters.json new file mode 100644 index 000000000..bfdd444e9 --- /dev/null +++ b/infrastructure/environments/staging/bootstrap-parameters.json @@ -0,0 +1,7 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "staging", + "TemplateBucketName": "replace-me-staging-template-bucket", + "ArtifactBucketName": "replace-me-staging-artifact-bucket", + "EnableBucketVersioning": "true" +} diff --git a/infrastructure/environments/staging/deploy-split-stack.sh b/infrastructure/environments/staging/deploy-split-stack.sh new file mode 100755 index 000000000..97c570407 --- /dev/null +++ b/infrastructure/environments/staging/deploy-split-stack.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ENV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +AWS_REGION="${AWS_REGION:-us-east-1}" +API_REPO_PATH="${API_REPO_PATH:-../Api}" +PACKAGE_MANIFEST_FILE="${PACKAGE_MANIFEST_FILE:-}" +BACKEND_ARTIFACT_SOURCE_FILE="${BACKEND_ARTIFACT_SOURCE_FILE:-}" +MIGRATION_ARTIFACT_SOURCE_FILE="${MIGRATION_ARTIFACT_SOURCE_FILE:-}" +DEPENDENCIES_LAYER_SOURCE_FILE="${DEPENDENCIES_LAYER_SOURCE_FILE:-}" +PROJECT_NAME="${PROJECT_NAME:-b1admin}" +ENVIRONMENT_NAME="${ENVIRONMENT_NAME:-staging}" +STACK_PREFIX="${STACK_PREFIX:-${PROJECT_NAME}-${ENVIRONMENT_NAME}}" +BOOTSTRAP_STACK_NAME="${BOOTSTRAP_STACK_NAME:-${STACK_PREFIX}-bootstrap}" +SYNC_APP_CONFIG_SECRET="${SYNC_APP_CONFIG_SECRET:-false}" +SYNC_BOOTSTRAP_ADMIN_SECRET="${SYNC_BOOTSTRAP_ADMIN_SECRET:-false}" +RUN_API_MIGRATIONS="${RUN_API_MIGRATIONS:-false}" +RUN_BOOTSTRAP_ADMIN="${RUN_BOOTSTRAP_ADMIN:-false}" +API_MIGRATION_ACTION="${API_MIGRATION_ACTION:-up}" +API_MIGRATION_MODULE="${API_MIGRATION_MODULE:-all}" +API_MIGRATION_RUNNER="${API_MIGRATION_RUNNER:-direct}" +VERIFY_AFTER_DEPLOY="${VERIFY_AFTER_DEPLOY:-true}" +VERIFY_HTTP_AFTER_DEPLOY="${VERIFY_HTTP_AFTER_DEPLOY:-false}" +SAVE_OUTPUTS_AFTER_DEPLOY="${SAVE_OUTPUTS_AFTER_DEPLOY:-true}" +OUTPUTS_CAPTURE_DIR="${OUTPUTS_CAPTURE_DIR:-deployment/${ENVIRONMENT_NAME}}" +PREVIEW_ONLY="${PREVIEW_ONLY:-false}" +PACKAGE_MODE="${PACKAGE_MODE:-layered}" +PACKAGE_BUILD_LAYER="${PACKAGE_BUILD_LAYER:-}" + +BOOTSTRAP_PARAMS="${ENV_DIR}/bootstrap-parameters.json" +BACKEND_PARAMS="${ENV_DIR}/backend-parameters.json" +FRONTEND_PARAMS="${ENV_DIR}/frontend-parameters.json" +APP_CONFIG_SECRET_FILE="${ENV_DIR}/app-config-secret.json" +APP_CONFIG_SECRET_TEMPLATE="${ENV_DIR}/app-config-secret.template.json" +BOOTSTRAP_ADMIN_SECRET_FILE="${ENV_DIR}/bootstrap-admin-secret.json" + +require_file() { + local file_path="$1" + if [[ ! -f "${file_path}" ]]; then + echo "Missing required file: ${file_path}" >&2 + exit 1 + fi +} + +ensure_local_api_repo_readable() { + local repo_path="$1" + local package_json_path="${repo_path}/package.json" + + if [[ ! -e "${repo_path}" ]]; then + echo "Local Api repo path does not exist: ${repo_path}" >&2 + echo "Set API_REPO_PATH to a readable checkout, or switch to PACKAGE_MANIFEST_FILE / BACKEND_ARTIFACT_SOURCE_FILE for this local run." >&2 + exit 1 + fi + + if [[ ! -r "${repo_path}" || ! -x "${repo_path}" ]]; then + echo "Local Api repo path is not readable from this shell: ${repo_path}" >&2 + echo "Use PACKAGE_MANIFEST_FILE or BACKEND_ARTIFACT_SOURCE_FILE for local deploys, or use the GitHub Actions api-repo path if the runner can read the backend repo." >&2 + exit 1 + fi + + if [[ ! -f "${package_json_path}" ]]; then + echo "Local Api repo package.json does not exist: ${package_json_path}" >&2 + echo "Set API_REPO_PATH to a valid Api checkout, or switch to PACKAGE_MANIFEST_FILE / BACKEND_ARTIFACT_SOURCE_FILE for this local run." >&2 + exit 1 + fi + + if [[ ! -r "${package_json_path}" ]]; then + echo "Local Api repo package.json is not readable from this shell: ${package_json_path}" >&2 + echo "Use PACKAGE_MANIFEST_FILE or BACKEND_ARTIFACT_SOURCE_FILE for local deploys, or use the GitHub Actions api-repo path if the runner can read the backend repo." >&2 + exit 1 + fi +} + +run_npm() { + ( + cd "${ROOT_DIR}" + npm run "$@" + ) +} + +run_npm_without_post_deploy_flags() { + ( + cd "${ROOT_DIR}" + env -u RUN_API_MIGRATIONS \ + -u RUN_BOOTSTRAP_ADMIN \ + -u API_MIGRATION_ACTION \ + -u API_MIGRATION_MODULE \ + -u API_MIGRATION_RUNNER \ + npm run "$@" + ) +} + +run_starter_audit() { + if run_npm audit:environment-starter -- \ + --environment="${ENVIRONMENT_NAME}" \ + --only-blockers=true \ + --output=markdown; then + return 0 + fi + + echo "" >&2 + echo "Starter audit failed for ${ENVIRONMENT_NAME}." >&2 + echo "Helpful next commands:" >&2 + echo "- yarn prepare:environment-starter -- --environment=${ENVIRONMENT_NAME} --account-id= --output=json" >&2 + echo "- yarn prepare:environment-starter -- --environment=${ENVIRONMENT_NAME} --account-id= --output=markdown" >&2 + echo "- yarn plan:environment-deploy -- --environment=${ENVIRONMENT_NAME} --output=markdown" >&2 + exit 1 +} + +run_deploy_plan() { + local deployment_source="api-repo" + local plan_args=( + --environment="${ENVIRONMENT_NAME}" + --region="${AWS_REGION}" + --api-repo-path="${API_REPO_PATH}" + --sync-app-config-secret="${SYNC_APP_CONFIG_SECRET}" + --sync-bootstrap-admin-secret="${SYNC_BOOTSTRAP_ADMIN_SECRET}" + --run-api-migrations="${RUN_API_MIGRATIONS}" + --run-bootstrap-admin="${RUN_BOOTSTRAP_ADMIN}" + --api-migration-action="${API_MIGRATION_ACTION}" + --api-migration-module="${API_MIGRATION_MODULE}" + --api-migration-runner="${API_MIGRATION_RUNNER}" + --verify-http-after-deploy="${VERIFY_HTTP_AFTER_DEPLOY}" + --output=markdown + ) + + if [[ -n "${PACKAGE_MANIFEST_FILE}" ]]; then + deployment_source="package-manifest" + plan_args+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") + elif [[ -n "${BACKEND_ARTIFACT_SOURCE_FILE}" ]]; then + deployment_source="backend-artifact" + plan_args+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + if [[ -n "${MIGRATION_ARTIFACT_SOURCE_FILE}" ]]; then + plan_args+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + fi + if [[ -n "${DEPENDENCIES_LAYER_SOURCE_FILE}" ]]; then + plan_args+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + fi + fi + + plan_args+=(--deployment-source="${deployment_source}") + + if run_npm plan:environment-deploy -- "${plan_args[@]}"; then + return 0 + fi + + echo "" >&2 + echo "Deploy plan failed for ${ENVIRONMENT_NAME}. Resolve the reported blockers before continuing." >&2 + exit 1 +} + +require_file "${BOOTSTRAP_PARAMS}" +require_file "${BACKEND_PARAMS}" +require_file "${FRONTEND_PARAMS}" +require_file "${APP_CONFIG_SECRET_TEMPLATE}" + +echo "Auditing staging starter files..." +run_starter_audit + +echo "Planning staging deployment readiness..." +run_deploy_plan + +if [[ "${PREVIEW_ONLY}" == "true" ]]; then + echo "Preview-only mode enabled; stopping after starter audit and deploy plan." + echo "Next live command: ./infrastructure/environments/${ENVIRONMENT_NAME}/deploy-split-stack.sh" + exit 0 +fi + +echo "Validating bootstrap parameters..." +run_npm_without_post_deploy_flags validate:aws-deploy -- \ + --mode=bootstrap \ + --region="${AWS_REGION}" \ + --stack-name="${BOOTSTRAP_STACK_NAME}" \ + --parameters-file="${BOOTSTRAP_PARAMS}" + +echo "Deploying bootstrap stack..." +run_npm_without_post_deploy_flags deploy:bootstrap -- \ + --region="${AWS_REGION}" \ + --stack-name="${BOOTSTRAP_STACK_NAME}" \ + --parameters-file="${BOOTSTRAP_PARAMS}" + +echo "Validating split-stack parameters..." +VALIDATE_ARGS=( + --mode=split-stack + --region="${AWS_REGION}" + --backend-parameters-file="${BACKEND_PARAMS}" + --frontend-parameters-file="${FRONTEND_PARAMS}" +) + +DEPLOY_ARGS=( + --region="${AWS_REGION}" + --project-name="${PROJECT_NAME}" + --environment="${ENVIRONMENT_NAME}" + --bootstrap-stack-name="${BOOTSTRAP_STACK_NAME}" + --backend-parameters-file="${BACKEND_PARAMS}" + --frontend-parameters-file="${FRONTEND_PARAMS}" + --package-mode="${PACKAGE_MODE}" +) + +if [[ -n "${PACKAGE_BUILD_LAYER}" ]]; then + DEPLOY_ARGS+=(--package-build-layer="${PACKAGE_BUILD_LAYER}") +fi + +if [[ -n "${PACKAGE_MANIFEST_FILE}" ]]; then + require_file "${PACKAGE_MANIFEST_FILE}" + VALIDATE_ARGS+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") + DEPLOY_ARGS+=(--package-manifest-file="${PACKAGE_MANIFEST_FILE}") +elif [[ -n "${BACKEND_ARTIFACT_SOURCE_FILE}" ]]; then + require_file "${BACKEND_ARTIFACT_SOURCE_FILE}" + VALIDATE_ARGS+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + DEPLOY_ARGS+=(--backend-artifact-source-file="${BACKEND_ARTIFACT_SOURCE_FILE}") + + if [[ -n "${MIGRATION_ARTIFACT_SOURCE_FILE}" ]]; then + require_file "${MIGRATION_ARTIFACT_SOURCE_FILE}" + VALIDATE_ARGS+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + DEPLOY_ARGS+=(--migration-artifact-source-file="${MIGRATION_ARTIFACT_SOURCE_FILE}") + fi + + if [[ -n "${DEPENDENCIES_LAYER_SOURCE_FILE}" ]]; then + require_file "${DEPENDENCIES_LAYER_SOURCE_FILE}" + VALIDATE_ARGS+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + DEPLOY_ARGS+=(--dependencies-layer-source-file="${DEPENDENCIES_LAYER_SOURCE_FILE}") + fi +else + ensure_local_api_repo_readable "${API_REPO_PATH}" + VALIDATE_ARGS+=(--api-repo-path="${API_REPO_PATH}") + DEPLOY_ARGS+=(--api-repo-path="${API_REPO_PATH}") +fi + +run_npm validate:aws-deploy -- "${VALIDATE_ARGS[@]}" + +if [[ "${SYNC_APP_CONFIG_SECRET}" == "true" ]]; then + require_file "${APP_CONFIG_SECRET_FILE}" + if rg -n "replace-me" "${APP_CONFIG_SECRET_FILE}" >/dev/null 2>&1; then + echo "Found replace-me placeholders in ${APP_CONFIG_SECRET_FILE}. Update them before syncing the app config secret." >&2 + rg -n "replace-me" "${APP_CONFIG_SECRET_FILE}" >&2 + exit 1 + fi +fi + +if [[ "${SYNC_APP_CONFIG_SECRET}" == "true" ]]; then + DEPLOY_ARGS+=(--app-config-secret-file="${APP_CONFIG_SECRET_FILE}") +fi + +if [[ "${SYNC_BOOTSTRAP_ADMIN_SECRET}" == "true" ]]; then + require_file "${BOOTSTRAP_ADMIN_SECRET_FILE}" + if rg -n '"(ChangeMe123!|admin@example.com|examplechurch)"' "${BOOTSTRAP_ADMIN_SECRET_FILE}" >/dev/null 2>&1; then + echo "Found sample bootstrap-admin values in ${BOOTSTRAP_ADMIN_SECRET_FILE}. Update them before enabling bootstrap admin sync." >&2 + rg -n '"(ChangeMe123!|admin@example.com|examplechurch)"' "${BOOTSTRAP_ADMIN_SECRET_FILE}" >&2 + exit 1 + fi + DEPLOY_ARGS+=(--bootstrap-admin-secret-file="${BOOTSTRAP_ADMIN_SECRET_FILE}") +fi + +if [[ "${RUN_API_MIGRATIONS}" == "true" ]]; then + VALIDATE_ARGS+=(--api-migration-runner="${API_MIGRATION_RUNNER}") + DEPLOY_ARGS+=( + --run-api-migrations=true + --api-migration-action="${API_MIGRATION_ACTION}" + --api-migration-module="${API_MIGRATION_MODULE}" + --api-migration-runner="${API_MIGRATION_RUNNER}" + ) +fi + +if [[ "${RUN_BOOTSTRAP_ADMIN}" == "true" ]]; then + DEPLOY_ARGS+=(--run-bootstrap-admin=true) +fi + +echo "Deploying split-stack staging environment..." +run_npm deploy:aws -- "${DEPLOY_ARGS[@]}" + +if [[ "${SAVE_OUTPUTS_AFTER_DEPLOY}" == "true" ]]; then + echo "Saving split-stack staging outputs..." + run_npm save:split-stack-outputs -- \ + --environment="${ENVIRONMENT_NAME}" \ + --region="${AWS_REGION}" \ + --output-dir="${OUTPUTS_CAPTURE_DIR}" +fi + +if [[ "${VERIFY_AFTER_DEPLOY}" == "true" ]]; then + echo "Verifying split-stack staging environment..." + VERIFY_ARGS=( + --region="${AWS_REGION}" + --backend-stack-name="${STACK_PREFIX}-backend" + --frontend-stack-name="${STACK_PREFIX}-frontend" + ) + + if [[ "${VERIFY_HTTP_AFTER_DEPLOY}" == "true" ]]; then + VERIFY_ARGS+=(--check-http=true) + fi + + run_npm verify:split-stack -- "${VERIFY_ARGS[@]}" +fi diff --git a/infrastructure/environments/staging/frontend-parameters.json b/infrastructure/environments/staging/frontend-parameters.json new file mode 100644 index 000000000..552656839 --- /dev/null +++ b/infrastructure/environments/staging/frontend-parameters.json @@ -0,0 +1,9 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "staging", + "BucketName": "", + "AlternateDomainName": "", + "AcmCertificateArn": "", + "HostedZoneId": "", + "PriceClass": "PriceClass_100" +} diff --git a/infrastructure/environments/start-here.md b/infrastructure/environments/start-here.md new file mode 100644 index 000000000..f4286260e --- /dev/null +++ b/infrastructure/environments/start-here.md @@ -0,0 +1,786 @@ +# Start Here + +Use this guide to deploy B1Admin into your AWS account. + +This document is for the person doing the installation. You do not need to write code, and you should not push anything back to the source repository. + +You will work with two repositories: + +- source repository: the B1Admin repository that contains the installer tools. +- user's private repository: the private repository that stores your AWS settings, GitHub workflow, environment files, and deployment evidence. + +Do not push customer settings, generated secrets, or deployment evidence back to the source repository or the Api source repository. + +## Table Of Contents + +Read these sections in order the first time through: + +1. [Need Help?](#need-help) - how to contact Dennis if you get stuck. +2. [Using An AI Helper](#using-an-ai-helper) - optional instructions for using an AI agent safely. +3. [Conventions Used In This Guide](#conventions-used-in-this-guide) - explains placeholders, regions, environments, and folder names used in commands. +4. [What You Need Before You Start](#what-you-need-before-you-start) - confirms AWS, GitHub, and local computer access before any commands run. +5. [Where The Folders Go](#where-the-folders-go) - explains where the source repository and user's private repository live on your computer. +6. [Before You Run Commands Checklist](#before-you-run-commands-checklist) - quick checklist before starting. +7. [The Short Version](#the-short-version) - the main step-by-step path most users should follow. +8. [Who Does What](#who-does-what) - shows when the deployment operator, AWS administrator, and GitHub administrator are involved. +9. [Fill Out One File](#fill-out-one-file) - explains the customer setup questions and the local values file the installer creates. +10. [Domains](#domains) - explains generated URLs and optional custom frontend domains. +11. [Choose Staging Or Prod First](#choose-staging-or-prod-first) - helps you decide whether to skip staging or use it as a practice deployment. +12. [Guided Prod Deploy](#guided-prod-deploy) - explains what the prod runner normally does. +13. [Optional Guided Staging Deploy](#optional-guided-staging-deploy) - use only if you want a practice deployment before prod. +14. [Production Notes](#production-notes) - important production behavior and approval suggestions. +15. [What Costs Money?](#what-costs-money) - shows which parts may create AWS charges. +16. [Final Report](#final-report) - writes the deployment sign-off report. +17. [When You Are Done](#when-you-are-done) - final completion checklist. +18. [Update An Existing Install](#update-an-existing-install) - deploys newer source code into an already installed AWS stack. +19. [Clean Reset](#clean-reset) - removes AWS resources when testing or starting over. +20. [If Something Fails](#if-something-fails) - troubleshooting entry point. +21. [Reference](#reference) - supporting notes and lower-level command references. + +If you are doing the normal prod-only install, follow [The Short Version](#the-short-version), review [What Costs Money?](#what-costs-money), then continue to [Final Report](#final-report) after prod is deployed and tested. + +## Need Help? + +Dennis is available to help with this deployment process. + +Email `dennis.hempler@protonmail.com` and put `B1Admin` in the subject line so the message is easy to find. + +## Using An AI Helper + +An AI helper can be useful for reading command output, explaining errors, and keeping track of which step is next. It is optional. You can complete the install with this guide and the guided installer commands. + +If you use an AI helper, give it this guide and the current terminal output. Do not ask it to guess. Ask it to read the guide, inspect the current folder, and explain before it changes anything. + +You can paste this instruction into the AI helper: + +```text +I am installing B1Admin into my AWS account. + +Please use infrastructure/environments/start-here.md as the main guide. + +Important rules: +- Run installer commands from the B1Admin source repository folder. +- The user's private repository is beside B1Admin, usually ../b1admin-deploy. +- Do not push customer settings, generated secrets, or deployment evidence back to the B1Admin source repository or the Api source repository. +- Do not commit customer-values.json, app-config-secret.json, bootstrap-admin-secret.json, or deployment/. +- Use prod for the normal install. Use staging only if I choose an optional practice deployment. +- The normal backend API uses the generated API Gateway URL. Do not invent a custom internal API domain. +- Stop and explain before approving production deploys, GitHub secret writes, AWS IAM changes, reset commands, first-admin bootstrap, or browser smoke tests. +- If unsure, ask me or tell me to email dennis.hempler@protonmail.com with B1Admin in the subject line. +``` + +The AI helper may help run checks and explain output, but the human installer is still responsible for approving AWS charges, IAM permissions, GitHub secrets, production deploys, resets, and first-admin credentials. + +## Conventions Used In This Guide + +The commands in this guide use examples. Replace them when your install uses different values. + +| Example in guide | What it means | When to change it | +| --- | --- | --- | +| `us-east-1` | AWS region where B1Admin is deployed | Change this to your chosen AWS region. CloudFront frontend certificates still must be in `us-east-1`. | +| `prod` | Production environment | Use `prod` for the normal install with the smallest AWS footprint. Use `staging` only for the optional practice deployment. | +| `staging` | Optional practice environment | Skip staging if you do not want the extra AWS stack and cost. | +| `../b1admin-deploy` | User's private repository folder beside `B1Admin` | Change this if your private repository folder has a different name or path. | +| `/` | GitHub repo name, such as `your-org/b1admin-deploy` | Replace with the user's private repository name. | +| `` | Your 12-digit AWS account ID | Replace with the AWS account where B1Admin will run. | +| `` | Choose one environment value | Type either `staging` or `prod`; do not include the angle brackets. | + +Type or paste the commands into your terminal. Do not type the triple backticks around command examples. + +The word `ref` means the source repository branch, tag, or commit to deploy. Most installs use `main`. + +If a command includes `--environment=prod`, it is for production. If you chose the optional staging path, use `--environment=staging` for the staging run, then repeat the prod command after staging is clean. + +If a command includes `--region=us-east-1`, use the same AWS region you entered in the customer setup questions. Most installs use `us-east-1`. + +## What You Need Before You Start + +Have these ready before you run the first command. The linked pages explain each item in more detail. + +If you are not sure whether something is ready, use the quick checks in this section. A failed check is not a disaster; it usually means the wrong account is signed in, a tool is missing, or an administrator still needs to grant access. + +### AWS + +You need an AWS account where B1Admin is allowed to create resources. + +Have: + +- AWS account ID +- AWS region, normally `us-east-1` +- AWS login with permission to create or approve IAM roles +- permission to deploy CloudFormation stacks +- permission to create S3 buckets, Lambda functions, API Gateway APIs, CloudFront distributions, Secrets Manager secrets, VPC/network resources, and Aurora/RDS resources +- access to Route53 and ACM if you want a custom frontend domain + +Quick checks: + +```bash +aws sts get-caller-identity +aws configure get region +``` + +The account in `aws sts get-caller-identity` should match the AWS account where B1Admin will run. The region should match the region you plan to use, normally `us-east-1`. + +If these commands fail, ask the AWS administrator how to sign in from the AWS CLI. If the account number is wrong, stop and switch AWS accounts before continuing. + +Read: + +- [AWS account access](./setup/aws-account-access.md) +- [AWS IAM deployment roles](./setup/aws-iam-roles.md) +- [AWS CLI](./setup/aws-cli.md) + +### GitHub + +You need a GitHub account that can access the B1Admin and Api source repositories and manage the user's private repository. + +Have: + +- access to the B1Admin source repository +- access to the `Api` source repository +- a new private repository for this install, for example `your-org/b1admin-deploy` +- permission to create GitHub Environments named `aws-prod` and, only if you run staging, `aws-staging` +- permission to add GitHub Environment secrets +- a token or GitHub permission setup that lets GitHub Actions check out the private source repositories + +Quick checks: + +```bash +gh auth status +gh repo view / +``` + +You should see the GitHub account that has access to the user's private repository. If the repo check fails, make sure the private repository exists, your GitHub account has access, and you replaced `/` with the real repo name. + +Also confirm you can open or view the source repositories: + +```bash +gh repo view ChurchApps/B1Admin +gh repo view ChurchApps/Api +``` + +If your organization uses a fork or private mirror, replace those names with the source repository names you will deploy. + +You normally do not need to create your own private Api repository. The user's private repository stores deployment settings and the workflow. The workflow checks out the Api source repository during deployment using read-only access. + +Read: + +- [GitHub CLI](./setup/github-cli.md) +- [Backend Api repository access](./setup/api-repository-access.md) +- [User's private repository](./setup/deployment-repository.md) +- [GitHub Environments](./setup/github-environments.md) +- [GitHub deployment secrets](./setup/github-deployment-secrets.md) + +### Your Computer + +You need a local computer where you can run terminal commands. + +Have: + +- Node.js installed +- yarn installed +- Git installed +- GitHub CLI installed and signed in; the guided runner uses it for GitHub repository, secret, workflow, and evidence steps +- AWS CLI installed and signed in to the target AWS account; the guided runner uses it for AWS readiness, verification, and reset steps +- a local copy of the B1Admin source repository + +Quick checks: + +```bash +node --version +npm --version +git --version +gh --version +aws --version +``` + +Each command should print a version number instead of saying the command was not found. + +After the repositories are cloned, run this from the `B1Admin` source repository folder: + +```bash +yarn installer:doctor -- --output=markdown +``` + +The doctor report is allowed to show later-step items as not ready before the install starts. At this point, focus on whether `node`, `npm`, `git`, `gh`, `aws`, and the folder paths look correct. + +You do not need to run `yarn install` before the first installer command. The installer will ask for `yarn install` later when it reaches local first-admin bootstrap and browser smoke. + +You normally do not need a local copy of the Api source repository for the guided GitHub Actions deployment. A local `../Api` checkout is useful only for advanced local packaging, local migration work, or troubleshooting outside the normal guided path. + +Read: + +- [Local runtime](./setup/local-runtime.md) +- [Customer values worksheet](./setup/customer-values.md) + +## Where The Folders Go + +Pick one normal folder on your computer to hold both repositories. This can be anywhere you normally keep work files. The guide calls this your parent folder. + +Examples: + +- macOS: `/Users/your-name/Documents/B1Admin-Install/` +- macOS: `/Users/your-name/Documents/Repos/` +- Windows: `C:\Work\B1Admin-Install\` + +Inside that parent folder, keep the source repository and the user's private repository side by side. + +Example: + +```text +parent-folder/ + B1Admin/ + b1admin-deploy/ +``` + +The Api source repository does not need to be in this folder for the normal guided install. If an advanced operator does local backend packaging later, they may also clone Api beside B1Admin: + +```text +parent-folder/ + B1Admin/ + b1admin-deploy/ + Api/ <- optional; not needed for the normal guided path +``` + +In that example, `parent-folder` is not a literal folder name you must create. It just means "the folder that contains both repositories." +For example, if you choose `/Users/your-name/Documents/B1Admin-Install/`, then your folders would be: + +```text +/Users/your-name/Documents/B1Admin-Install/ + B1Admin/ + b1admin-deploy/ +``` + +Open a terminal in the `B1Admin` source repository folder before running the commands in this guide. + +That means your terminal is here: + +```text +parent-folder/ + B1Admin/ <- run yarn commands here + b1admin-deploy/ <- user's private repository +``` + +Do not run the installer commands from inside `b1admin-deploy`. +Run them from inside `B1Admin`. + +The commands use `../b1admin-deploy` because `b1admin-deploy` is beside `B1Admin`, not inside it. + +From inside `B1Admin`: + +- `..` means "go up one folder to the parent folder" +- `../b1admin-deploy` means "go up to the parent folder, then into `b1admin-deploy/`" +- `../b1admin-deploy/customer-values.json` is the customer worksheet the installer creates +- `../b1admin-deploy/deployment/` is where local deployment evidence is saved + +If the user's private repository has a different folder name, replace every `../b1admin-deploy` in the commands with your folder path. + +The user's private repository should be private. It is the operator workspace for this customer install. +The generated `.gitignore` in the user's private repository keeps customer values, generated secrets, and deployment evidence out of commits. + +If the repositories are not on your computer yet, ask your GitHub administrator for the two clone URLs. Then open a terminal in your chosen parent folder and clone both repositories. + +Example: + +```bash +git clone B1Admin +git clone https://github.com//.git b1admin-deploy +cd B1Admin +``` + +After `cd B1Admin`, the rest of this guide's commands should work as written. + +If the repositories are already on your computer, do not clone them again. Open a terminal in the existing `B1Admin` folder instead. + +## Before You Run Commands Checklist + +Before starting [The Short Version](#the-short-version), confirm: + +- [ ] You can sign in to the target AWS account. +- [ ] You know the AWS account ID and region. +- [ ] AWS CLI is installed and signed in to the target AWS account. +- [ ] Git is installed. +- [ ] GitHub CLI is installed and signed in. +- [ ] Node.js and npm are installed. +- [ ] You can access the B1Admin source repository. +- [ ] You can access the Api source repository. +- [ ] The user's private repository exists in GitHub and is private. +- [ ] The user's private repository is cloned beside the B1Admin source repository. +- [ ] You know whether you are deploying `prod` only or optional `staging` first. +- [ ] If using a custom frontend domain, you have the hostname, Route53 hosted zone ID, and CloudFront ACM certificate ARN. + +## The Short Version + +Run commands from the `B1Admin` source repository folder. + +If you are not sure where your terminal is, run: + +```bash +pwd +``` + +The output should end with `B1Admin`. If it does not, move into the `B1Admin` source repository folder before continuing. + +Choose your first environment: + +- Smallest AWS footprint: start with `prod` and skip staging. +- Practice rollout: start with `staging`, then repeat for `prod` after staging is clean. + +Staging creates a second AWS stack, so it costs money while it is running. It is useful when you want a practice deployment before production, but it is not required. +If you skip staging, the first real AWS deployment will be prod, so read the preview and preflight output carefully before approving the real deploy. + +1. Make sure the user's private repository exists in GitHub and is cloned beside the `B1Admin` source repository. + +Your folders should look like this before you continue: + +```text +parent-folder/ + B1Admin/ + b1admin-deploy/ +``` + +2. Create the user's private repository workspace files: + +```bash +yarn installer:init -- \ + --deploy-repo-dir=../b1admin-deploy \ + --output=markdown +``` + +3. Commit and push the safe scaffold files from the user's private repository. + +Run these from inside the `B1Admin` source repository folder: + +```bash +git -C ../b1admin-deploy add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments +git -C ../b1admin-deploy commit -m "Add B1Admin deployment scaffold" +git -C ../b1admin-deploy push +``` + +If Git says there is nothing to commit, continue to the next step. + +Do not add `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. + +If Git says it does not know your name or email, set them once and rerun the commit command: + +```bash +git config --global user.name "Your Name" +git config --global user.email "you@example.com" +``` + +4. Answer the customer setup questions: + +```bash +yarn installer:customer-values -- \ + --customer-file=../b1admin-deploy/customer-values.json \ + --write=true \ + --output=markdown +``` + +5. Start the guided runner: + +```bash +yarn installer:run -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +The runner checks what is already done, runs the next installer step, then checks again. +It pauses before approval steps such as GitHub secret writes, production deploys, first-admin bootstrap, browser smoke, and final sign-off. + +Starting the runner does not mean AWS resources are created immediately. The runner will first check files, GitHub setup, AWS readiness, and preflight results. AWS resources are created when you approve the real deploy step. + +When it pauses, read the command it shows. If it looks right, answer `y` to let it continue. +If you are not sure, answer `n`; nothing is lost, and you can run `installer:run` again later. + +If the runner stops because an outside task is needed, such as an AWS administrator creating IAM roles or a GitHub administrator approving production, complete that task and run the same `installer:run` command again. +If the runner changes safe files in the user's private repository, commit and push those safe files before continuing. + +The runner does not create your AWS account, create the GitHub repository, clone repositories to your computer, approve AWS IAM changes, approve protected GitHub deployments, or decide whether a production change is safe. It guides those steps and pauses when a person should review them. + +Example runner pause: + +```text +Step 4: GitHub readiness +Status: confirm GitHub Environments and required secrets +Approval: this step can change AWS/GitHub state, create a login, launch browser testing, or write sign-off evidence. +Command: +yarn installer:github-setup -- --environment=prod --repo=your-org/b1admin-deploy --write=true --write-secrets=true --output=markdown +Run this command now? [y/N]: +``` + +If you understand the command and are ready for it to run, type `y` and press Enter. +If you are not ready, type `n` and press Enter. Complete the outside task, then run `installer:run` again. + +Repeat until the selected environment is deployed, verified, first-admin bootstrapped, and browser-tested. +If you chose staging first, run the same command again with `--environment=prod` after staging is clean. + +The installer will ask you to run `yarn install` when the flow reaches local first-admin bootstrap and browser smoke. + +## Who Does What + +The deployment operator runs the installer commands, answers the customer setup questions, commits safe files to the user's private repository, dispatches workflows, and checks the final report. + +The AWS administrator creates or approves the IAM roles from the generated handoff document. They do not need to edit B1Admin code. + +When the installer creates this file, send it to the AWS administrator: + +```text +../b1admin-deploy/aws-admin-handoff.md +``` + +That file contains the AWS IAM commands and role ARN values needed by the deployment. + +The GitHub administrator may need to create the user's private repository, grant repository access, approve production environment protection rules, or create source-repository read tokens. + +## Fill Out One File + +`installer:init` creates this local file: + +```text +../b1admin-deploy/customer-values.json +``` + +That file is for the installer to read. Use this command to update it: + +```bash +yarn installer:customer-values -- \ + --customer-file=../b1admin-deploy/customer-values.json \ + --write=true \ + --output=markdown +``` + +It asks for: + +- AWS account ID +- AWS region, normally `us-east-1` +- user's private repository, for example `your-org/b1admin-deploy` +- root domain, for example `example.com`; this is used for generated settings and support values even if you do not use a custom frontend hostname +- support email and support phone +- first admin email, temporary password, and church name +- B1Admin source repository/ref, normally `ChurchApps/B1Admin` and `main` +- Api source repository/ref, normally `ChurchApps/Api` and `main` +- optional frontend custom domain values + +Keep this file local. The generated `.gitignore` prevents it from being committed. + +## Domains + +The normal install uses the generated API Gateway URL for the backend API. Leave these backend API custom-domain values blank: + +- `ApiCustomDomainName` +- `ApiCertificateArn` +- `ApiHostedZoneId` + +For the frontend, you have two choices: + +- Leave frontend domain values blank and use the generated CloudFront hostname for infrastructure smoke testing. +- Provide a frontend hostname such as `admin.example.com` for the cleanest login-ready install. + +If you use a custom frontend hostname, provide: + +- `frontendDomain` +- `frontendCertificateArn` +- `frontendHostedZoneId` + +The CloudFront certificate must be an ACM certificate in `us-east-1`. + +## Choose Staging Or Prod First + +You do not have to deploy staging. + +Use `prod` first when: + +- you want the smallest AWS footprint +- this AWS account is only for the real production install +- you are comfortable using preflight and preview checks before the real deploy + +Use `staging` first when: + +- you want to practice before production +- a separate test environment is required by your team +- you want to prove GitHub, IAM, AWS, app config, migrations, first-admin bootstrap, and browser login before touching prod + +Both paths use the same guided runner. The only difference is the `--environment=` value. + +For a normal prod-only install, use `prod` only. You can still keep the generated staging files in the user's private repository; they do not create AWS resources unless you run the staging deploy. + +## Guided Prod Deploy + +Use this for the smallest AWS footprint, or after staging is clean. + +Run: + +```bash +yarn installer:run -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +The guided runner checks the current state, runs the next installer step, and keeps going until it reaches an approval gate or an outside task. +If you are troubleshooting and need to see only the next recommendation without running it, use `yarn installer:next -- --customer-file=../b1admin-deploy/customer-values.json --environment=prod --output=markdown`. + +The prod flow normally does this: + +1. Generate the AWS admin handoff. +2. Configure prod parameter files from `customer-values.json`. +3. Generate the local app-config secret. +4. Create GitHub Environments and store environment secrets. +5. Run local preflight. +6. Dispatch a prod preview workflow. +7. Observe the preview and download evidence. +8. Dispatch the real prod deploy. +9. Observe the deploy, download evidence, and verify URLs. +10. If using a generated CloudFront frontend URL, adopt that frontend origin into the backend parameters and rerun the real deploy. +11. Bootstrap the first admin from the operator machine. +12. Run browser smoke. + +Commit and push only safe files in the user's private repository when the installer asks for it. Do not commit `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. + +## Optional Guided Staging Deploy + +Use this only when you want a practice deployment before prod. + +Run: + +```bash +yarn installer:run -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=staging \ + --output=markdown +``` + +The staging flow follows the same pattern as prod, but uses staging-specific parameter files, GitHub Environment secrets, workflow runs, evidence, first-admin bootstrap, and browser smoke. + +After staging is clean, run the prod command from the previous section. + +## Production Notes + +If prod does not use a custom frontend domain yet, the first deploy creates a generated CloudFront URL. The installer will then ask you to run `yarn installer:adopt-frontend-origin`, commit and push the private parameter-file change, and rerun the real prod deploy. That second deploy lets browser login work from the generated CloudFront URL. + +For production, consider adding required reviewers to the `aws-prod` GitHub Environment before the first prod deploy. + +## What Costs Money? + +AWS charges depend on your account, region, usage, and current AWS pricing. In general: + +- The prod deployment creates AWS resources and can cost money while it is running. +- Optional staging creates a second AWS stack and can cost extra while it is running. +- Aurora/RDS database resources are usually one of the most important cost items. +- CloudFront, S3, Lambda, API Gateway, Secrets Manager, Route53, NAT/networking, and logs may also create charges. +- Retained Aurora final snapshots may continue to cost money after a reset until they are removed. +- ACM public certificates are usually free, but Route53 hosted zones and DNS queries can cost money. + +To keep the AWS footprint smaller, deploy `prod` only, skip staging, and delete temporary testing resources when you are finished testing. + +## Final Report + +After your selected environments are deployed, observed, verified, and browser-tested, generate the rollout report. + +For the prod-only path with the smallest AWS footprint, run: + +```bash +yarn installer:report -- \ + --environment=prod \ + --deployment-root=../b1admin-deploy/deployment \ + --write=true \ + --check-http=true \ + --output=markdown +``` + +If you deployed both staging and prod, run: + +```bash +yarn installer:report -- \ + --environment=all \ + --deployment-root=../b1admin-deploy/deployment \ + --write=true \ + --check-http=true \ + --output=markdown +``` + +The report writes: + +```text +../b1admin-deploy/deployment/deployment-report.md +``` + +It summarizes: + +- staging and/or prod URLs +- GitHub Actions run IDs +- source commit SHAs +- stack names +- saved output files +- verification results +- browser smoke results +- first-admin bootstrap status + +The report reads saved dispatch, source metadata, bootstrap, and browser-smoke evidence automatically. If an older deployment evidence folder is missing `source-metadata.json`, rerun the latest deploy or provide the source SHAs with the report command. + +Use [first-rollout-checklist.md](./first-rollout-checklist.md) for the detailed verification checklist. + +## When You Are Done + +Before considering the install complete, confirm: + +- [ ] The final report exists at `../b1admin-deploy/deployment/deployment-report.md`. +- [ ] The report status is complete or all remaining notes are understood. +- [ ] Browser smoke passed for each deployed environment. +- [ ] The first admin can sign in. +- [ ] The first admin temporary password has been changed or handed off securely. +- [ ] The deployed frontend URL is the URL the customer will use. +- [ ] The backend accepts the deployed frontend URL for browser login. +- [ ] Safe private repository changes have been committed and pushed. +- [ ] `customer-values.json`, app secret files, bootstrap secret files, and `deployment/` were not committed. +- [ ] If staging was only used for practice, staging has been reset or intentionally left running. +- [ ] Any retained database snapshots are either intentionally kept or cleaned up. + +## Update An Existing Install + +Use this when B1Admin source code has changed and you want to update an already installed AWS deployment. + +The user's private repository remains the deployment control center. The user should not push customer settings or deployment evidence back to the B1Admin source repository or the Api source repository. + +Open a terminal in the local `B1Admin` source repository folder. + +If you are not sure you are in the right place, run: + +```bash +pwd +``` + +The output should end with `B1Admin`. + +Then run one command: + +```bash +yarn installer:update -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +The update command will: + +- ask before pulling the latest B1Admin source repository code +- refresh the user's private repository scaffold +- show private repository changes if safe scaffold files changed +- offer to commit and push safe private repository scaffold changes +- start the guided prod deployment runner + +The update command still pauses before approval steps. Read each prompt before answering. + +If your team deploys a specific branch, tag, or commit instead of latest `main`, switch to that approved version before running `installer:update`, or run with `--skip-pull=true`. + +Important: `installer:update` is a guided update command, not a zero-downtime guarantee. For a production environment with active users, update optional staging first when available, verify login and key workflows, confirm backups or snapshots exist, review the prod preflight and preview output, and run prod during an approved low-traffic or maintenance window. Database migrations, CloudFormation replacements, API changes, and frontend/backend compatibility changes can affect live users if they are not planned carefully. + +Do not add `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. + +If you also maintain the optional staging environment, update staging first: + +```bash +yarn installer:update -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=staging \ + --output=markdown +``` + +After staging is verified, run the prod update command. + +## Clean Reset + +Use reset only when you intentionally want to remove an environment and start over. +If you deployed prod only, use only the prod reset command. Run staging reset only if you actually deployed staging. +If you used a region other than `us-east-1`, replace `us-east-1` in these commands. + +For a prod-only install, preview prod reset first: + +```bash +yarn reset:prod -- --region=us-east-1 --dry-run=true +``` + +Run the real prod reset only after the preview list is understood: + +```bash +yarn reset:prod -- --region=us-east-1 +``` + +If you deployed staging, preview and run staging reset separately: + +```bash +yarn reset:staging -- --region=us-east-1 --dry-run=true +yarn reset:staging -- --region=us-east-1 +``` + +The reset scripts remove CloudFormation stacks, retained buckets, Lambda log groups, and generated Secrets Manager secrets. Aurora final snapshots may remain by design. + +## If Something Fails + +Start with the guided command again: + +```bash +yarn installer:run -- \ + --deploy-repo-dir=../b1admin-deploy \ + --deploy-env-dir=../b1admin-deploy/environments \ + --deployment-root=../b1admin-deploy/deployment \ + --customer-file=../b1admin-deploy/customer-values.json \ + --environment=prod \ + --output=markdown +``` + +Then use the specific checks below: + +```bash +yarn installer:doctor -- \ + --customer-file=../b1admin-deploy/customer-values.json \ + --deploy-env-dir=../b1admin-deploy/environments \ + --output=markdown + +yarn installer:preflight -- \ + --environment= \ + --customer-file=../b1admin-deploy/customer-values.json \ + --output=markdown + +yarn show:rollout-status -- --output=markdown + +gh run view --repo / --log-failed +``` + +If you are stuck, email `dennis.hempler@protonmail.com` with `B1Admin` in the subject line. + +Common causes: + +- wrong GitHub account is authenticated in `gh` +- user's private repository is not private or not accessible +- needed GitHub Environment is missing, normally `aws-prod` and, only if you use staging, `aws-staging` +- GitHub Environment secrets are missing +- AWS IAM role trust policy names the wrong repo or environment +- Api source repository cannot be checked out by the workflow +- frontend ACM certificate is not in `us-east-1` +- frontend URL and backend CORS/root URL values do not match after switching from generated CloudFront to a custom domain + +## Reference + +Use these only when you need detail beyond the guided path: + +- [User's private repository guide](./private-deployment-repo.md) +- [GitHub Actions setup guide](./github-actions-setup.md) +- [Deployment workbook](./deployment-workbook.md) +- [IAM setup guide](../iam/README.md) diff --git a/infrastructure/examples/app-config-secret.sample.json b/infrastructure/examples/app-config-secret.sample.json new file mode 100644 index 000000000..e005932e8 --- /dev/null +++ b/infrastructure/examples/app-config-secret.sample.json @@ -0,0 +1,21 @@ +{ + "jwtSecret": "replace-me", + "encryptionKey": "replace-me", + "hubspotKey": "", + "mauticUrl": "", + "mauticUser": "", + "mauticPassword": "", + "youTubeApiKey": "", + "pexelsKey": "", + "vimeoToken": "", + "apiBibleKey": "", + "youVersionApiKey": "", + "praiseChartsConsumerKey": "", + "praiseChartsConsumerSecret": "", + "googleRecaptchaSecretKey": "", + "openRouterApiKey": "", + "openAiApiKey": "", + "webPushPublicKey": "", + "webPushPrivateKey": "", + "webPushSubject": "mailto:support@example.com" +} diff --git a/infrastructure/examples/audit-environment-starter-output.sample.json b/infrastructure/examples/audit-environment-starter-output.sample.json new file mode 100644 index 000000000..12d9f87ae --- /dev/null +++ b/infrastructure/examples/audit-environment-starter-output.sample.json @@ -0,0 +1,341 @@ +{ + "ok": false, + "environment": "staging", + "onlyBlockers": false, + "environmentDir": "infrastructure/environments/staging", + "summary": { + "placeholderCount": 5, + "unsafeDefaultCount": 10, + "requiredBlankCount": 0, + "optionalBlankCount": 41 + }, + "blockerSummary": { + "placeholderCount": 5, + "unsafeDefaultCount": 10, + "requiredBlankCount": 0, + "blockerCount": 15 + }, + "nextSteps": [ + { + "file": "infrastructure/environments/staging/bootstrap-parameters.json", + "action": "Replace or fill 2 blocker values.", + "keys": [ + "TemplateBucketName", + "ArtifactBucketName" + ] + }, + { + "file": "infrastructure/environments/staging/backend-parameters.json", + "action": "Replace or fill 10 blocker values.", + "keys": [ + "LambdaCodeS3Bucket", + "WebsiteBaseUrl", + "ContentRootUrl", + "B1AdminRootUrl", + "CorsOrigin", + "StoreApiUrl", + "TransferUrl", + "SupportEmail", + "SupportPhone", + "SupportSiteUrl" + ] + }, + { + "file": "infrastructure/environments/staging/app-config-secret.template.json", + "action": "Replace or fill 3 blocker values.", + "keys": [ + "jwtSecret", + "encryptionKey", + "webPushSubject" + ] + } + ], + "suggestions": [ + { + "file": "infrastructure/environments/staging/bootstrap-parameters.json", + "recommendation": "Choose globally unique S3 bucket names for the template and artifact buckets.", + "example": "b1admin-staging-templates- and b1admin-staging-artifacts-" + }, + { + "file": "infrastructure/environments/staging/backend-parameters.json", + "recommendation": "Point LambdaCodeS3Bucket at the same artifact bucket chosen in bootstrap-parameters.json.", + "example": "b1admin-staging-artifacts-" + }, + { + "file": "infrastructure/environments/staging/backend-parameters.json", + "recommendation": "Replace the checked-in starter hostnames and support values with real environment URLs and contact details before the first deploy.", + "example": "B1AdminRootUrl=https://admin-staging.yourdomain.com, CorsOrigin=https://admin-staging.yourdomain.com, SupportEmail=support@yourdomain.com" + }, + { + "file": "infrastructure/environments/staging/app-config-secret.template.json", + "recommendation": "Copy the template to app-config-secret.json, replace jwtSecret and encryptionKey with long random values, and set webPushSubject to the real support mailbox before syncing the secret.", + "example": "cp infrastructure/environments/staging/app-config-secret.template.json infrastructure/environments/staging/app-config-secret.json" + } + ], + "files": [ + { + "fileName": "bootstrap-parameters.json", + "relativePath": "infrastructure/environments/staging/bootstrap-parameters.json", + "placeholders": [ + { + "key": "TemplateBucketName", + "value": "replace-me-b1admin-staging-templates-123456789012" + }, + { + "key": "ArtifactBucketName", + "value": "replace-me-b1admin-staging-artifacts-123456789012" + } + ], + "unsafeDefaults": [], + "requiredBlankValues": [], + "optionalBlankValues": [], + "resolvedBySecretFile": [] + }, + { + "fileName": "backend-parameters.json", + "relativePath": "infrastructure/environments/staging/backend-parameters.json", + "placeholders": [ + { + "key": "LambdaCodeS3Bucket", + "value": "replace-me-b1admin-staging-artifacts-123456789012" + } + ], + "unsafeDefaults": [ + { + "key": "WebsiteBaseUrl", + "value": "https://{subdomain}.example.com" + }, + { + "key": "ContentRootUrl", + "value": "https://content-staging.example.com" + }, + { + "key": "B1AdminRootUrl", + "value": "https://admin-staging.example.com" + }, + { + "key": "CorsOrigin", + "value": "https://admin-staging.example.com" + }, + { + "key": "StoreApiUrl", + "value": "https://store-staging.example.com" + }, + { + "key": "TransferUrl", + "value": "https://transfer-staging.example.com" + }, + { + "key": "SupportEmail", + "value": "support@example.com" + }, + { + "key": "SupportPhone", + "value": "555-555-5555" + }, + { + "key": "SupportSiteUrl", + "value": "https://support.example.com" + } + ], + "requiredBlankValues": [], + "optionalBlankValues": [ + { + "key": "DependenciesLayerArn", + "value": "" + }, + { + "key": "ObservabilityLayerArn", + "value": "" + }, + { + "key": "MigrationCodeS3Bucket", + "value": "" + }, + { + "key": "MigrationCodeS3Key", + "value": "" + }, + { + "key": "MigrationHandler", + "value": "" + }, + { + "key": "MigrationRuntime", + "value": "" + }, + { + "key": "MigrationTrigger", + "value": "" + }, + { + "key": "ApiCustomDomainName", + "value": "" + }, + { + "key": "ApiCertificateArn", + "value": "" + }, + { + "key": "ApiHostedZoneId", + "value": "" + }, + { + "key": "AssetBucketName", + "value": "" + }, + { + "key": "AppConfigSecretArn", + "value": "" + }, + { + "key": "CaddyHost", + "value": "" + }, + { + "key": "CaddyPort", + "value": "" + }, + { + "key": "MobileAppUrl", + "value": "" + }, + { + "key": "DomainCnameTarget", + "value": "" + }, + { + "key": "DomainATarget", + "value": "" + }, + { + "key": "DefaultStockPhoto", + "value": "" + }, + { + "key": "GoogleAnalyticsTag", + "value": "" + }, + { + "key": "SentryDsn", + "value": "" + } + ], + "resolvedBySecretFile": [] + }, + { + "fileName": "frontend-parameters.json", + "relativePath": "infrastructure/environments/staging/frontend-parameters.json", + "placeholders": [], + "unsafeDefaults": [], + "requiredBlankValues": [], + "optionalBlankValues": [ + { + "key": "BucketName", + "value": "" + }, + { + "key": "AlternateDomainName", + "value": "" + }, + { + "key": "AcmCertificateArn", + "value": "" + }, + { + "key": "HostedZoneId", + "value": "" + } + ], + "resolvedBySecretFile": [] + }, + { + "fileName": "app-config-secret.template.json", + "relativePath": "infrastructure/environments/staging/app-config-secret.template.json", + "placeholders": [ + { + "key": "jwtSecret", + "value": "replace-me-long-random-jwt-secret" + }, + { + "key": "encryptionKey", + "value": "replace-me-long-random-encryption-key" + } + ], + "unsafeDefaults": [ + { + "key": "webPushSubject", + "value": "mailto:support@example.com" + } + ], + "requiredBlankValues": [], + "optionalBlankValues": [ + { + "key": "hubspotKey", + "value": "" + }, + { + "key": "mauticUrl", + "value": "" + }, + { + "key": "mauticUser", + "value": "" + }, + { + "key": "mauticPassword", + "value": "" + }, + { + "key": "youTubeApiKey", + "value": "" + }, + { + "key": "pexelsKey", + "value": "" + }, + { + "key": "vimeoToken", + "value": "" + }, + { + "key": "apiBibleKey", + "value": "" + }, + { + "key": "youVersionApiKey", + "value": "" + }, + { + "key": "praiseChartsConsumerKey", + "value": "" + }, + { + "key": "praiseChartsConsumerSecret", + "value": "" + }, + { + "key": "googleRecaptchaSecretKey", + "value": "" + }, + { + "key": "openRouterApiKey", + "value": "" + }, + { + "key": "openAiApiKey", + "value": "" + }, + { + "key": "webPushPublicKey", + "value": "" + }, + { + "key": "webPushPrivateKey", + "value": "" + } + ], + "resolvedBySecretFile": [] + } + ] +} diff --git a/infrastructure/examples/backend-outputs.sample.json b/infrastructure/examples/backend-outputs.sample.json new file mode 100644 index 000000000..0730ae397 --- /dev/null +++ b/infrastructure/examples/backend-outputs.sample.json @@ -0,0 +1,14 @@ +{ + "ApiBaseUrl": "https://api.example.com", + "ContentRootUrl": "https://content.example.com", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "LessonsApiUrl": "https://lessons-api.example.com", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" +} diff --git a/infrastructure/examples/backend-parameters.sample.json b/infrastructure/examples/backend-parameters.sample.json new file mode 100644 index 000000000..5e5b141ac --- /dev/null +++ b/infrastructure/examples/backend-parameters.sample.json @@ -0,0 +1,81 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "LambdaCodeS3Bucket": "my-artifacts-bucket", + "LambdaCodeS3Key": "b1admin/backend/api.zip", + "LambdaHandler": "lambda.web", + "LambdaRuntime": "nodejs22.x", + "LambdaArchitecture": "arm64", + "LambdaMemorySize": "1024", + "LambdaTimeout": "30", + "LambdaReservedConcurrency": "0", + "DependenciesLayerArn": "", + "ObservabilityLayerArn": "", + "LambdaNodeOptions": "--import @sentry/aws-serverless/awslambda-auto", + "EnableWebSocketApi": "true", + "SocketLambdaHandler": "lambda.socket", + "SocketLambdaMemorySize": "1024", + "SocketLambdaTimeout": "30", + "EnableScheduledWorkers": "true", + "Timer15MinLambdaHandler": "lambda.timer15Min", + "TimerMidnightLambdaHandler": "lambda.timerMidnight", + "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", + "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", + "TimerLambdaMemorySize": "256", + "TimerLambdaTimeout": "300", + "RunMigrations": "false", + "MigrationCodeS3Bucket": "", + "MigrationCodeS3Key": "", + "MigrationHandler": "", + "MigrationRuntime": "", + "MigrationMemorySize": "1024", + "MigrationTimeout": "900", + "MigrationTrigger": "", + "DatabaseName": "membership", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting", + "DatabaseEngine": "aurora-mysql", + "DatabasePort": "3306", + "DatabaseMasterUsername": "app_admin", + "DatabaseMinCapacity": "0.5", + "DatabaseMaxCapacity": "2", + "ApiCustomDomainName": "api.example.com", + "ApiCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "ApiHostedZoneId": "Z1234567890ABC", + "CreateNatGateway": "true", + "VpcCidr": "10.30.0.0/16", + "PublicSubnet1Cidr": "10.30.0.0/24", + "PublicSubnet2Cidr": "10.30.1.0/24", + "PrivateSubnet1Cidr": "10.30.10.0/24", + "PrivateSubnet2Cidr": "10.30.11.0/24", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "ContentRootUrl": "", + "B1AdminRootUrl": "https://admin.example.com", + "CorsOrigin": "*", + "FileStore": "S3", + "ManageAssetBucket": "true", + "AssetBucketName": "", + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "MailSystem": "SES", + "DeliveryProvider": "aws", + "StoreApiUrl": "https://api.example-store.com", + "AiProvider": "openrouter", + "EmailOnRegistration": "false", + "CaddyHost": "", + "CaddyPort": "", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", + "GoogleAnalyticsTag": "", + "SentryDsn": "" +} diff --git a/infrastructure/examples/backend-stack-outputs.sample.json b/infrastructure/examples/backend-stack-outputs.sample.json new file mode 100644 index 000000000..47ac2514e --- /dev/null +++ b/infrastructure/examples/backend-stack-outputs.sample.json @@ -0,0 +1,13 @@ +{ + "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", + "DatabaseClusterArn": "arn:aws:rds:us-east-1:123456789012:cluster:b1admin-prod-cluster", + "DatabasePort": "3306", + "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting" +} diff --git a/infrastructure/examples/bootstrap-admin-secret.sample.json b/infrastructure/examples/bootstrap-admin-secret.sample.json new file mode 100644 index 000000000..fc3ba6e90 --- /dev/null +++ b/infrastructure/examples/bootstrap-admin-secret.sample.json @@ -0,0 +1,16 @@ +{ + "email": "admin@example.com", + "password": "ChangeMe123!", + "firstName": "Admin", + "lastName": "User", + "displayName": "Admin User", + "churchName": "Example Church", + "churchSubdomain": "examplechurch", + "address1": "123 Main St", + "address2": "", + "city": "Springfield", + "state": "IL", + "zip": "62701", + "country": "USA", + "membershipStatus": "Staff" +} diff --git a/infrastructure/examples/bootstrap-parameters.sample.json b/infrastructure/examples/bootstrap-parameters.sample.json new file mode 100644 index 000000000..61aab3f41 --- /dev/null +++ b/infrastructure/examples/bootstrap-parameters.sample.json @@ -0,0 +1,7 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "TemplateBucketName": "b1admin-prod-templates-123456789012", + "ArtifactBucketName": "b1admin-prod-artifacts-123456789012", + "EnableBucketVersioning": "true" +} diff --git a/infrastructure/examples/database-secret.sample.json b/infrastructure/examples/database-secret.sample.json new file mode 100644 index 000000000..8c17b2217 --- /dev/null +++ b/infrastructure/examples/database-secret.sample.json @@ -0,0 +1,4 @@ +{ + "username": "churchapps", + "password": "replace-me" +} diff --git a/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json b/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json new file mode 100644 index 000000000..52902ac06 --- /dev/null +++ b/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json @@ -0,0 +1,62 @@ +{ + "region": "us-east-1", + "environment": "prod", + "projectName": "b1admin", + "bootstrapStackName": "", + "backendStackName": "b1admin-prod-backend", + "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", + "frontendStackName": "example-frontend", + "frontendOutputsFile": "", + "frontendPublishBucket": "", + "frontendPublishDistributionId": "", + "frontendPublishAppUrl": "", + "frontendInfrastructureOnly": true, + "publishFrontendAssets": false, + "skipBackend": true, + "skipFrontend": false, + "skipBuild": false, + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "resolvedArtifactBucket": "", + "resolvedLambdaCodeS3Key": "", + "resolvedMigrationBucket": "", + "resolvedMigrationCodeS3Key": "", + "resolvedDependenciesLayerArn": "", + "resolvedAppConfigSecretArn": "", + "backendArtifactUpload": null, + "migrationArtifactUpload": null, + "backend": null, + "frontend": { + "stackName": "example-frontend", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "skipBuild": false, + "infrastructureOnly": true, + "frontendPublished": false + }, + "frontendPublish": null +} diff --git a/infrastructure/examples/deploy-aws-full-output.sample.json b/infrastructure/examples/deploy-aws-full-output.sample.json new file mode 100644 index 000000000..7311c1ec0 --- /dev/null +++ b/infrastructure/examples/deploy-aws-full-output.sample.json @@ -0,0 +1,108 @@ +{ + "region": "us-east-1", + "environment": "prod", + "projectName": "b1admin", + "bootstrapStackName": "", + "backendStackName": "example-backend", + "backendOutputsFile": "", + "frontendStackName": "example-frontend", + "frontendOutputsFile": "", + "frontendPublishBucket": "", + "frontendPublishDistributionId": "", + "frontendPublishAppUrl": "", + "frontendInfrastructureOnly": false, + "publishFrontendAssets": false, + "skipBackend": false, + "skipFrontend": false, + "skipBuild": false, + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "resolvedArtifactBucket": "my-artifacts-bucket", + "resolvedLambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "resolvedMigrationBucket": "my-artifacts-bucket", + "resolvedMigrationCodeS3Key": "", + "resolvedDependenciesLayerArn": "", + "resolvedAppConfigSecretArn": "", + "backendArtifactUpload": null, + "migrationArtifactUpload": null, + "backend": { + "stackName": "example-backend", + "region": "us-east-1", + "environmentName": "prod", + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "outputs": { + "ApiBaseUrl": "https://api.example.com", + "ContentRootUrl": "https://content.example.com", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "LessonsApiUrl": "https://lessons-api.example.com", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", + "DatabasePort": "3306", + "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting" + }, + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "lambdaCodeS3Bucket": "my-artifacts-bucket", + "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "migrationCodeS3Bucket": "", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "templateBucket": "", + "apiMigrationRunner": "direct", + "runBootstrapAdmin": false, + "bootstrapAdmin": null + }, + "frontend": { + "stackName": "example-frontend", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "skipBuild": false, + "infrastructureOnly": false, + "frontendPublished": true + }, + "frontendPublish": null +} diff --git a/infrastructure/examples/deploy-aws-publish-build-output.sample.json b/infrastructure/examples/deploy-aws-publish-build-output.sample.json new file mode 100644 index 000000000..cdb6548c7 --- /dev/null +++ b/infrastructure/examples/deploy-aws-publish-build-output.sample.json @@ -0,0 +1,61 @@ +{ + "region": "us-east-1", + "environment": "prod", + "projectName": "b1admin", + "bootstrapStackName": "", + "backendStackName": "b1admin-prod-backend", + "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", + "frontendStackName": "b1admin-prod-frontend", + "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", + "frontendPublishBucket": "", + "frontendPublishDistributionId": "", + "frontendPublishAppUrl": "", + "frontendInfrastructureOnly": false, + "publishFrontendAssets": true, + "skipBackend": true, + "skipFrontend": true, + "skipBuild": false, + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "resolvedArtifactBucket": "", + "resolvedLambdaCodeS3Key": "", + "resolvedMigrationBucket": "", + "resolvedMigrationCodeS3Key": "", + "resolvedDependenciesLayerArn": "", + "resolvedAppConfigSecretArn": "", + "backendArtifactUpload": null, + "migrationArtifactUpload": null, + "backend": null, + "frontend": null, + "frontendPublish": { + "stackName": "", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "skipBuild": false, + "frontendPublished": true + } +} diff --git a/infrastructure/examples/deploy-aws-publish-output.sample.json b/infrastructure/examples/deploy-aws-publish-output.sample.json new file mode 100644 index 000000000..708d59009 --- /dev/null +++ b/infrastructure/examples/deploy-aws-publish-output.sample.json @@ -0,0 +1,48 @@ +{ + "region": "us-east-1", + "environment": "prod", + "projectName": "b1admin", + "bootstrapStackName": "", + "backendStackName": "b1admin-prod-backend", + "backendOutputsFile": "", + "frontendStackName": "b1admin-prod-frontend", + "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", + "frontendPublishBucket": "", + "frontendPublishDistributionId": "", + "frontendPublishAppUrl": "", + "frontendInfrastructureOnly": false, + "publishFrontendAssets": true, + "skipBackend": true, + "skipFrontend": true, + "skipBuild": true, + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "resolvedArtifactBucket": "", + "resolvedLambdaCodeS3Key": "", + "resolvedMigrationBucket": "", + "resolvedMigrationCodeS3Key": "", + "resolvedDependenciesLayerArn": "", + "resolvedAppConfigSecretArn": "", + "backendArtifactUpload": null, + "migrationArtifactUpload": null, + "backend": null, + "frontend": null, + "frontendPublish": { + "stackName": "", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": {}, + "skipBuild": true, + "frontendPublished": true + } +} diff --git a/infrastructure/examples/deploy-backend-output.sample.json b/infrastructure/examples/deploy-backend-output.sample.json new file mode 100644 index 000000000..1b9ff65e9 --- /dev/null +++ b/infrastructure/examples/deploy-backend-output.sample.json @@ -0,0 +1,36 @@ +{ + "stackName": "example-backend", + "region": "us-east-1", + "environmentName": "prod", + "resolvedPackageManifestFile": "infrastructure/examples/package-manifest.sample.json", + "resolvedBackendArtifactSourceFile": "/api-prod-self-contained.zip", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "outputs": { + "ApiBaseUrl": "https://api.example.com", + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", + "DatabasePort": "3306", + "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting" + }, + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "lambdaCodeS3Bucket": "my-artifacts-bucket", + "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "migrationCodeS3Bucket": "", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "templateBucket": "", + "apiMigrationRunner": "direct", + "runBootstrapAdmin": false, + "bootstrapAdmin": null +} diff --git a/infrastructure/examples/deploy-bootstrap-output.sample.json b/infrastructure/examples/deploy-bootstrap-output.sample.json new file mode 100644 index 000000000..042be26ae --- /dev/null +++ b/infrastructure/examples/deploy-bootstrap-output.sample.json @@ -0,0 +1,15 @@ +{ + "stackName": "example-bootstrap", + "region": "us-east-1", + "parameters": { + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "TemplateBucketName": "b1admin-prod-templates-123456789012", + "ArtifactBucketName": "b1admin-prod-artifacts-123456789012", + "EnableBucketVersioning": "true" + }, + "outputs": { + "TemplateBucketName": "b1admin-prod-templates-123456789012", + "ArtifactBucketName": "b1admin-prod-artifacts-123456789012" + } +} diff --git a/infrastructure/examples/deploy-frontend-output.sample.json b/infrastructure/examples/deploy-frontend-output.sample.json new file mode 100644 index 000000000..6b2c55e6e --- /dev/null +++ b/infrastructure/examples/deploy-frontend-output.sample.json @@ -0,0 +1,17 @@ +{ + "stackName": "example-frontend", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": {}, + "skipBuild": false, + "infrastructureOnly": true, + "frontendPublished": false +} diff --git a/infrastructure/examples/deploy-frontend-publish-output.sample.json b/infrastructure/examples/deploy-frontend-publish-output.sample.json new file mode 100644 index 000000000..6ed684c16 --- /dev/null +++ b/infrastructure/examples/deploy-frontend-publish-output.sample.json @@ -0,0 +1,30 @@ +{ + "stackName": "example-frontend", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "skipBuild": false, + "infrastructureOnly": false, + "frontendPublished": true +} diff --git a/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json b/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json new file mode 100644 index 000000000..839923615 --- /dev/null +++ b/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json @@ -0,0 +1,64 @@ +{ + "stackName": "example-full-stack", + "region": "us-east-1", + "environmentName": "prod", + "outputs": { + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", + "FrontendBucketName": "example-frontend-bucket", + "FrontendDistributionId": "EXAMPLE123", + "FrontendAppUrl": "https://admin.example.com", + "PublicApiBaseUrl": "https://api.example.com", + "ContentRootUrl": "https://content.example.com", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "LessonsApiUrl": "https://lessons-api.example.com", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" + }, + "infrastructureOnly": false, + "frontendInfrastructureOnly": true, + "publishFrontendAssets": false, + "skipInfrastructure": false, + "skipBuild": false, + "bootstrapStackName": "", + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "backendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/backend-api.yaml", + "frontendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/frontend-site.yaml", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", + "lambdaCodeS3Bucket": "my-artifacts-bucket", + "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "migrationCodeS3Bucket": "my-artifacts-bucket", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "frontendPublished": false, + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123", + "frontendAppUrl": "https://admin.example.com", + "frontendEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "runBootstrapAdmin": false, + "bootstrapAdmin": null +} diff --git a/infrastructure/examples/deploy-full-stack-full-output.sample.json b/infrastructure/examples/deploy-full-stack-full-output.sample.json new file mode 100644 index 000000000..5b0d171a0 --- /dev/null +++ b/infrastructure/examples/deploy-full-stack-full-output.sample.json @@ -0,0 +1,64 @@ +{ + "stackName": "example-full-stack", + "region": "us-east-1", + "environmentName": "prod", + "outputs": { + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", + "FrontendBucketName": "example-frontend-bucket", + "FrontendDistributionId": "EXAMPLE123", + "FrontendAppUrl": "https://admin.example.com", + "PublicApiBaseUrl": "https://api.example.com", + "ContentRootUrl": "https://content.example.com", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "LessonsApiUrl": "https://lessons-api.example.com", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" + }, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "publishFrontendAssets": false, + "skipInfrastructure": false, + "skipBuild": false, + "bootstrapStackName": "", + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "backendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/backend-api.yaml", + "frontendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/frontend-site.yaml", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", + "lambdaCodeS3Bucket": "my-artifacts-bucket", + "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", + "migrationCodeS3Bucket": "my-artifacts-bucket", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "frontendPublished": true, + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123", + "frontendAppUrl": "https://admin.example.com", + "frontendEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "runBootstrapAdmin": false, + "bootstrapAdmin": null +} diff --git a/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json b/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json new file mode 100644 index 000000000..4c638d41f --- /dev/null +++ b/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json @@ -0,0 +1,47 @@ +{ + "stackName": "", + "region": "us-east-1", + "environmentName": "prod", + "outputs": {}, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "publishFrontendAssets": true, + "skipInfrastructure": true, + "skipBuild": false, + "bootstrapStackName": "", + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "backendTemplateUrl": "", + "frontendTemplateUrl": "", + "appConfigSecretArn": "", + "lambdaCodeS3Bucket": "", + "lambdaCodeS3Key": "", + "migrationCodeS3Bucket": "", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "frontendPublished": true, + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123", + "frontendAppUrl": "https://admin.example.com", + "frontendEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "runBootstrapAdmin": false, + "bootstrapAdmin": null +} diff --git a/infrastructure/examples/deploy-full-stack-publish-output.sample.json b/infrastructure/examples/deploy-full-stack-publish-output.sample.json new file mode 100644 index 000000000..ae02c7aa1 --- /dev/null +++ b/infrastructure/examples/deploy-full-stack-publish-output.sample.json @@ -0,0 +1,34 @@ +{ + "stackName": "", + "region": "us-east-1", + "environmentName": "prod", + "outputs": {}, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "publishFrontendAssets": true, + "skipInfrastructure": true, + "skipBuild": true, + "bootstrapStackName": "", + "resolvedPackageManifestFile": "", + "resolvedBackendArtifactSourceFile": "", + "resolvedMigrationArtifactSourceFile": "", + "resolvedDependenciesLayerSourceFile": "", + "backendTemplateUrl": "", + "frontendTemplateUrl": "", + "appConfigSecretArn": "", + "lambdaCodeS3Bucket": "", + "lambdaCodeS3Key": "", + "migrationCodeS3Bucket": "", + "migrationCodeS3Key": "", + "dependenciesLayerArn": "", + "syncLegacySsm": false, + "runApiMigrations": false, + "apiMigrations": null, + "frontendPublished": true, + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123", + "frontendAppUrl": "https://admin.example.com", + "frontendEnv": {}, + "runBootstrapAdmin": false, + "bootstrapAdmin": null +} diff --git a/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json b/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json new file mode 100644 index 000000000..d4be393c1 --- /dev/null +++ b/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json @@ -0,0 +1,38 @@ +{ + "ok": true, + "action": "validated", + "environment": "staging", + "workflowEnvironmentName": "aws-staging", + "deploymentSource": "package-manifest", + "previewOnly": false, + "syncAppConfigSecret": true, + "secretSync": { + "attempted": true, + "performed": false, + "command": "yarn sync:github-app-config-secret -- --environment=staging --secret-file=.tmp-dispatch-github-deploy-env/app-config-secret.json" + }, + "dispatchCommand": "gh workflow run deploy-aws-self-hosted.yml --repo 'ChurchApps/B1Admin' -f environment='staging' -f aws_region='us-east-1' -f deployment_source='package-manifest' -f api_repo='' -f api_ref='' -f package_manifest_file='.tmp-dispatch-github-deploy-env/package-manifest.json' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='true' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='false'", + "followUpCommands": { + "listRuns": "gh run list --workflow deploy-aws-self-hosted.yml --limit 5 --repo 'ChurchApps/B1Admin'", + "watchLatestRun": "gh run watch $(gh run list --workflow deploy-aws-self-hosted.yml --limit 1 --json databaseId --jq '.[0].databaseId' --repo 'ChurchApps/B1Admin') --compact --exit-status --repo 'ChurchApps/B1Admin'", + "viewLatestRun": "gh run view $(gh run list --workflow deploy-aws-self-hosted.yml --limit 1 --json databaseId --jq '.[0].databaseId' --repo 'ChurchApps/B1Admin') --repo 'ChurchApps/B1Admin'" + }, + "workflowInputs": { + "environment": "staging", + "aws_region": "us-east-1", + "deployment_source": "package-manifest", + "api_repo": "", + "api_ref": "", + "package_manifest_file": ".tmp-dispatch-github-deploy-env/package-manifest.json", + "backend_artifact_source_file": "", + "migration_artifact_source_file": "", + "dependencies_layer_source_file": "", + "sync_app_config_secret": "true", + "run_api_migrations": "false", + "api_migration_action": "up", + "api_migration_module": "all", + "verify_http_after_deploy": "false", + "preview_only": "false" + }, + "blockers": [] +} diff --git a/infrastructure/examples/frontend-outputs.sample.json b/infrastructure/examples/frontend-outputs.sample.json new file mode 100644 index 000000000..07640c9a4 --- /dev/null +++ b/infrastructure/examples/frontend-outputs.sample.json @@ -0,0 +1,5 @@ +{ + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" +} diff --git a/infrastructure/examples/frontend-parameters.sample.json b/infrastructure/examples/frontend-parameters.sample.json new file mode 100644 index 000000000..5efdffcd5 --- /dev/null +++ b/infrastructure/examples/frontend-parameters.sample.json @@ -0,0 +1,9 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "BucketName": "", + "AlternateDomainName": "admin.example.com", + "AcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "HostedZoneId": "Z1234567890ABC", + "PriceClass": "PriceClass_100" +} diff --git a/infrastructure/examples/full-stack-parameters.sample.json b/infrastructure/examples/full-stack-parameters.sample.json new file mode 100644 index 000000000..71f932747 --- /dev/null +++ b/infrastructure/examples/full-stack-parameters.sample.json @@ -0,0 +1,88 @@ +{ + "ProjectName": "b1admin", + "EnvironmentName": "prod", + "BackendTemplateUrl": "https://my-template-bucket.s3.amazonaws.com/b1admin/backend-api.yaml", + "FrontendTemplateUrl": "https://my-template-bucket.s3.amazonaws.com/b1admin/frontend-site.yaml", + "LambdaCodeS3Bucket": "my-artifacts-bucket", + "LambdaCodeS3Key": "b1admin/backend/api.zip", + "LambdaHandler": "lambda.web", + "LambdaRuntime": "nodejs22.x", + "LambdaArchitecture": "arm64", + "LambdaMemorySize": "1024", + "LambdaTimeout": "30", + "LambdaReservedConcurrency": "0", + "DependenciesLayerArn": "", + "ObservabilityLayerArn": "", + "LambdaNodeOptions": "--import @sentry/aws-serverless/awslambda-auto", + "EnableWebSocketApi": "true", + "SocketLambdaHandler": "lambda.socket", + "SocketLambdaMemorySize": "1024", + "SocketLambdaTimeout": "30", + "EnableScheduledWorkers": "true", + "Timer15MinLambdaHandler": "lambda.timer15Min", + "TimerMidnightLambdaHandler": "lambda.timerMidnight", + "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", + "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", + "TimerLambdaMemorySize": "256", + "TimerLambdaTimeout": "300", + "RunMigrations": "false", + "MigrationCodeS3Bucket": "", + "MigrationCodeS3Key": "", + "MigrationHandler": "", + "MigrationRuntime": "", + "MigrationMemorySize": "1024", + "MigrationTimeout": "900", + "MigrationTrigger": "", + "DatabaseName": "membership", + "MembershipDatabaseName": "membership", + "AttendanceDatabaseName": "attendance", + "ContentDatabaseName": "content", + "GivingDatabaseName": "giving", + "MessagingDatabaseName": "messaging", + "DoingDatabaseName": "doing", + "ReportingDatabaseName": "reporting", + "DatabaseEngine": "aurora-mysql", + "DatabasePort": "3306", + "DatabaseMasterUsername": "app_admin", + "DatabaseMinCapacity": "0.5", + "DatabaseMaxCapacity": "2", + "ApiCustomDomainName": "api.example.com", + "ApiCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "ApiHostedZoneId": "Z1234567890ABC", + "CreateNatGateway": "true", + "VpcCidr": "10.30.0.0/16", + "PublicSubnet1Cidr": "10.30.0.0/24", + "PublicSubnet2Cidr": "10.30.1.0/24", + "PrivateSubnet1Cidr": "10.30.10.0/24", + "PrivateSubnet2Cidr": "10.30.11.0/24", + "FrontendBucketName": "", + "FrontendAlternateDomainName": "admin.example.com", + "FrontendAcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "FrontendHostedZoneId": "Z1234567890ABC", + "FrontendPriceClass": "PriceClass_100", + "WebsiteBaseUrl": "https://{subdomain}.example.com", + "ContentRootUrl": "", + "B1AdminRootUrl": "https://admin.example.com", + "CorsOrigin": "*", + "FileStore": "S3", + "ManageAssetBucket": "true", + "AssetBucketName": "", + "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "MailSystem": "SES", + "DeliveryProvider": "aws", + "StoreApiUrl": "https://api.example-store.com", + "AiProvider": "openrouter", + "EmailOnRegistration": "false", + "CaddyHost": "", + "CaddyPort": "", + "TransferUrl": "https://transfer.example.com", + "SupportEmail": "support@example.com", + "SupportPhone": "555-555-5555", + "SupportSiteUrl": "https://support.example.com", + "MobileAppUrl": "https://example.com/app", + "DomainCnameTarget": "proxy.example.com", + "DomainATarget": "203.0.113.10", + "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", + "GoogleAnalyticsTag": "", + "SentryDsn": "" +} diff --git a/infrastructure/examples/package-api-backend-output.sample.json b/infrastructure/examples/package-api-backend-output.sample.json new file mode 100644 index 000000000..afee46c93 --- /dev/null +++ b/infrastructure/examples/package-api-backend-output.sample.json @@ -0,0 +1,32 @@ +{ + "apiRepoPath": "", + "projectName": "b1admin", + "packageMode": "self-contained", + "environment": "prod", + "build": false, + "buildCommand": "build:prod", + "buildLayer": false, + "buildLayerCommand": "", + "backendArtifactPath": "api-prod-self-contained.zip", + "migrationArtifactPath": "", + "dependenciesLayerArtifactPath": "", + "manifestPath": "infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "recommendedBackendArtifactKey": "b1admin/prod/backend/api.zip", + "recommendedMigrationArtifactKey": "b1admin/prod/backend/migrations.zip", + "recommendedNextSteps": { + "uploadBackendArtifact": "yarn upload:backend-artifact -- --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", + "uploadMigrationArtifact": "", + "publishDependenciesLayer": "", + "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployFullStack": "yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployMode": "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + }, + "includedBackendEntries": [ + "config", + "dist", + "lambda.js", + "package.json", + "node_modules" + ] +} diff --git a/infrastructure/examples/package-manifest.sample.json b/infrastructure/examples/package-manifest.sample.json new file mode 100644 index 000000000..d836eadb5 --- /dev/null +++ b/infrastructure/examples/package-manifest.sample.json @@ -0,0 +1,32 @@ +{ + "apiRepoPath": "", + "projectName": "b1admin", + "packageMode": "self-contained", + "environment": "prod", + "build": true, + "buildCommand": "build:prod", + "buildLayer": false, + "buildLayerCommand": "", + "backendArtifactPath": "api-prod-self-contained.zip", + "migrationArtifactPath": "", + "dependenciesLayerArtifactPath": "", + "manifestPath": "package-manifest.sample.json", + "recommendedBackendArtifactKey": "b1admin/prod/backend/api.zip", + "recommendedMigrationArtifactKey": "b1admin/prod/backend/migrations.zip", + "recommendedNextSteps": { + "uploadBackendArtifact": "yarn upload:backend-artifact -- --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", + "uploadMigrationArtifact": "", + "publishDependenciesLayer": "", + "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployFullStack": "yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", + "deployMode": "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + }, + "includedBackendEntries": [ + "config", + "dist", + "lambda.js", + "package.json", + "node_modules" + ] +} diff --git a/infrastructure/examples/plan-environment-deploy-output.sample.json b/infrastructure/examples/plan-environment-deploy-output.sample.json new file mode 100644 index 000000000..ff97a9f55 --- /dev/null +++ b/infrastructure/examples/plan-environment-deploy-output.sample.json @@ -0,0 +1,190 @@ +{ + "ok": false, + "region": "us-east-1", + "environment": "staging", + "projectName": "b1admin", + "environmentDir": "infrastructure/environments/staging", + "environmentDirArg": "", + "deploymentSource": "api-repo", + "githubAuthMode": "oidc", + "workflowEnvironmentName": "aws-staging", + "appConfigSecretFilePresent": false, + "stackNames": { + "bootstrap": "b1admin-staging-bootstrap", + "backend": "b1admin-staging-backend", + "frontend": "b1admin-staging-frontend" + }, + "starterSummary": { + "placeholderCount": 5, + "unsafeDefaultCount": 10, + "requiredBlankCount": 0, + "blockerCount": 15 + }, + "inputBlockers": [], + "warnings": [ + "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", + "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." + ], + "blockers": [ + { + "type": "starter-file", + "file": "infrastructure/environments/staging/bootstrap-parameters.json", + "keys": [ + "TemplateBucketName", + "ArtifactBucketName" + ], + "summary": "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName" + }, + { + "type": "starter-file", + "file": "infrastructure/environments/staging/backend-parameters.json", + "keys": [ + "LambdaCodeS3Bucket", + "WebsiteBaseUrl", + "ContentRootUrl", + "B1AdminRootUrl", + "CorsOrigin", + "StoreApiUrl", + "TransferUrl", + "SupportEmail", + "SupportPhone", + "SupportSiteUrl" + ], + "summary": "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl" + }, + { + "type": "starter-file", + "file": "infrastructure/environments/staging/app-config-secret.template.json", + "keys": [ + "jwtSecret", + "encryptionKey", + "webPushSubject" + ], + "summary": "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" + } + ], + "localExecution": { + "ok": false, + "blockerCount": 3, + "blockers": [ + "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", + "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", + "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" + ] + }, + "githubActionsExecution": { + "ok": false, + "blockerCount": 3, + "blockers": [ + "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", + "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", + "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" + ] + }, + "localGithubDispatch": { + "ok": false, + "blockerCount": 1, + "blockers": [ + "GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again." + ] + }, + "localEnv": { + "AWS_REGION": "us-east-1", + "SYNC_APP_CONFIG_SECRET": "false", + "RUN_API_MIGRATIONS": "false", + "VERIFY_HTTP_AFTER_DEPLOY": "false", + "API_REPO_PATH": "." + }, + "workflowInputs": { + "environment": "staging", + "aws_region": "us-east-1", + "deployment_source": "api-repo", + "api_repo": "ChurchApps/Api", + "api_ref": "main", + "package_manifest_file": "", + "backend_artifact_source_file": "", + "migration_artifact_source_file": "", + "dependencies_layer_source_file": "", + "sync_app_config_secret": "false", + "run_api_migrations": "false", + "api_migration_action": "up", + "api_migration_module": "all", + "verify_http_after_deploy": "false" + }, + "requiredGithubSecrets": [ + "AWS_ROLE_TO_ASSUME" + ], + "optionalGithubSecrets": [ + "API_REPO_CHECKOUT_TOKEN" + ], + "commands": { + "audit": "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "localPreview": "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", + "local": "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' ./infrastructure/environments/staging/deploy-split-stack.sh", + "githubActionsWrapperPreview": "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --preview-only=true", + "githubActionsPreview": "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='true'", + "githubActions": "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" + }, + "starterPrepCommands": { + "dryRun": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "commands": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", + "markdown": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=markdown", + "write": "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true" + }, + "localFallbackCommands": null, + "nextSteps": [ + "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + "Decide whether app-config-secret should be synced on the first live run or introduced later." + ], + "recommendedExecution": { + "path": "none", + "reason": "Shared starter or input blockers still exist, so neither the local path nor the GitHub Actions path is ready yet." + }, + "recommendedCommands": { + "primary": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "alternates": [ + "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", + "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", + "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --preview-only=true", + "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='true'", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' ./infrastructure/environments/staging/deploy-split-stack.sh", + "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" + ] + }, + "preflightCommands": { + "auditStarter": "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "auditApiRepoContract": "yarn audit:api-repo-contract -- --api-repo-path=. --output=markdown" + }, + "postDeployCommands": { + "verify": "yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend", + "verifyWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend --check-http=true", + "saveOutputsWithHelper": "yarn save:split-stack-outputs -- --environment=staging --region=us-east-1", + "showSavedSummary": "yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown", + "ensureOutputsDir": "mkdir -p deployment/staging", + "saveBackendOutputs": "mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-backend --region us-east-1 --output json > deployment/staging/backend-outputs.json", + "saveFrontendOutputs": "mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-frontend --region us-east-1 --output json > deployment/staging/frontend-outputs.json", + "verifyFromSavedOutputs": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json", + "verifyFromSavedOutputsWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json --check-http=true", + "publishFromSavedOutputs": "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json --skip-backend --skip-frontend --publish-frontend-assets", + "publishFrontendAssetsFromSavedOutputs": "yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json", + "checklist": "Open infrastructure/environments/first-rollout-checklist.md and work through the post-deploy checks." + }, + "githubPostDeploy": { + "artifactName": "aws-staging-deployment-evidence", + "artifactPath": "deployment/staging/", + "failureArtifactName": "aws-staging-preflight-plan", + "failureArtifactPath": "deployment/staging/preflight-plan.md", + "summaryIncludes": [ + "preflight deploy plan", + "resolved stack names", + "API base URL", + "frontend app URL", + "saved-output follow-up commands" + ], + "note": "After a successful GitHub Actions run, review the job summary for the preflight plan plus resolved values and download the deployment-evidence artifact if you need the saved output files outside the runner. If the deploy step fails earlier, GitHub still uploads the preflight-plan artifact so the computed blocker list is recoverable." + }, + "githubSecretSyncCommand": "" +} diff --git a/infrastructure/examples/prepare-environment-starter-output.sample.json b/infrastructure/examples/prepare-environment-starter-output.sample.json new file mode 100644 index 000000000..67e0d46fd --- /dev/null +++ b/infrastructure/examples/prepare-environment-starter-output.sample.json @@ -0,0 +1,61 @@ +{ + "ok": true, + "environment": "staging", + "write": false, + "writeSecretFile": true, + "accountId": "123456789012", + "generatedSecrets": true, + "usedExistingSecretFile": false, + "recommendedCommands": [ + "yarn prepare:environment-starter -- --environment=staging --account-id=123456789012 --write=true", + "yarn audit:environment-starter -- --environment=staging --only-blockers=true", + "yarn validate:aws-deploy -- --mode=bootstrap --region=us-east-1 --stack-name=b1admin-staging-bootstrap --parameters-file=infrastructure/environments/staging/bootstrap-parameters.json", + "yarn validate:aws-deploy -- --mode=split-stack --region=us-east-1 --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json", + "./infrastructure/environments/staging/deploy-split-stack.sh" + ], + "changes": [ + { + "file": "infrastructure/environments/staging/bootstrap-parameters.json", + "key": "TemplateBucketName", + "currentValue": "replace-me-b1admin-staging-templates-123456789012", + "nextValue": "b1admin-staging-templates-123456789012" + }, + { + "file": "infrastructure/environments/staging/bootstrap-parameters.json", + "key": "ArtifactBucketName", + "currentValue": "replace-me-b1admin-staging-artifacts-123456789012", + "nextValue": "b1admin-staging-artifacts-123456789012" + }, + { + "file": "infrastructure/environments/staging/backend-parameters.json", + "key": "LambdaCodeS3Bucket", + "currentValue": "replace-me-b1admin-staging-artifacts-123456789012", + "nextValue": "b1admin-staging-artifacts-123456789012" + }, + { + "file": "infrastructure/environments/staging/app-config-secret.json", + "key": "app-config-secret.json", + "currentValue": "", + "nextValue": "will be created from template" + }, + { + "file": "infrastructure/environments/staging/app-config-secret.json", + "key": "jwtSecret", + "currentValue": "replace-me-long-random-jwt-secret", + "nextValue": "" + }, + { + "file": "infrastructure/environments/staging/app-config-secret.json", + "key": "encryptionKey", + "currentValue": "replace-me-long-random-encryption-key", + "nextValue": "" + } + ], + "nextSteps": [ + "Review the proposed bucket values written to infrastructure/environments/staging/bootstrap-parameters.json and infrastructure/environments/staging/backend-parameters.json.", + "If your backend URLs mostly follow a shared DNS pattern, re-run with --root-domain= to derive the common admin/content/store/transfer values automatically.", + "If you want to prep optional custom-domain fields too, re-run with --frontend-domain/--frontend-certificate-arn/--frontend-hosted-zone-id and --api-domain/--api-certificate-arn/--api-hosted-zone-id.", + "If you already know the real staging/prod URLs and support contact values, re-run with --admin-root-url, --cors-origin, --content-root-url, --store-api-url, --transfer-url, --support-email, --support-phone, and --support-site-url to clear those starter defaults too.", + "Re-run with --write=true to apply these starter-file changes and create infrastructure/environments/staging/app-config-secret.json." + ] +} diff --git a/infrastructure/examples/publish-frontend-output.sample.json b/infrastructure/examples/publish-frontend-output.sample.json new file mode 100644 index 000000000..d9128ff7a --- /dev/null +++ b/infrastructure/examples/publish-frontend-output.sample.json @@ -0,0 +1,29 @@ +{ + "stackName": "", + "region": "us-east-1", + "environmentName": "prod", + "bucket": "example-frontend-bucket", + "distributionId": "EXAMPLE123", + "appUrl": "https://admin.example.com", + "outputs": { + "SiteBucketName": "example-frontend-bucket", + "CloudFrontDistributionId": "EXAMPLE123", + "AppUrl": "https://admin.example.com" + }, + "backendBuildEnv": { + "REACT_APP_API_BASE": "https://api.example.com", + "REACT_APP_CONTENT_ROOT": "https://content.example.com", + "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", + "REACT_APP_LESSONS_API": "https://lessons-api.example.com", + "REACT_APP_TRANSFER_URL": "https://transfer.example.com", + "REACT_APP_SUPPORT_EMAIL": "support@example.com", + "REACT_APP_SUPPORT_PHONE": "555-555-5555", + "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", + "REACT_APP_MOBILE_APP_URL": "https://example.com/app", + "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", + "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", + "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" + }, + "skipBuild": false, + "frontendPublished": true +} diff --git a/infrastructure/examples/publish-lambda-layer-output.sample.json b/infrastructure/examples/publish-lambda-layer-output.sample.json new file mode 100644 index 000000000..a6dfc3a8e --- /dev/null +++ b/infrastructure/examples/publish-lambda-layer-output.sample.json @@ -0,0 +1,18 @@ +{ + "Content": { + "Location": "https://lambda.us-east-1.amazonaws.com/2018-10-31/layers/b1admin-prod-dependencies/versions/3", + "CodeSha256": "examplecodesha256value=", + "CodeSize": 12345 + }, + "LayerArn": "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies", + "LayerVersionArn": "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3", + "Description": "Published by B1Admin AWS deployment tooling", + "CreatedDate": "2026-01-15T12:34:56.000+0000", + "Version": 3, + "CompatibleRuntimes": [ + "nodejs22.x" + ], + "CompatibleArchitectures": [ + "arm64" + ] +} diff --git a/infrastructure/examples/run-api-migrations-output.sample.json b/infrastructure/examples/run-api-migrations-output.sample.json new file mode 100644 index 000000000..85437d515 --- /dev/null +++ b/infrastructure/examples/run-api-migrations-output.sample.json @@ -0,0 +1,28 @@ +{ + "apiRepoPath": "", + "region": "us-east-1", + "stackName": "", + "outputsFile": "infrastructure/examples/backend-stack-outputs.sample.json", + "action": "status", + "module": "attendance", + "dryRun": true, + "command": " migrate --action=status --module=attendance", + "databaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", + "databasePort": "3306", + "resolvedDbSecretSource": "/abs/path/to/infrastructure/examples/database-secret.sample.json", + "apiRepoMigrationModules": [ + "attendance" + ], + "apiRepoMigrationDirectories": [ + "attendance" + ], + "effectiveModules": [ + "attendance" + ], + "skippedConfiguredModules": [], + "warnings": [], + "connectionStrings": { + "ATTENDANCE_CONNECTION_STRING": "mysql://churchapps:***@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance" + }, + "executed": false +} diff --git a/infrastructure/examples/save-split-stack-outputs-output.sample.json b/infrastructure/examples/save-split-stack-outputs-output.sample.json new file mode 100644 index 000000000..1b9c1b922 --- /dev/null +++ b/infrastructure/examples/save-split-stack-outputs-output.sample.json @@ -0,0 +1,32 @@ +{ + "ok": true, + "environment": "staging", + "projectName": "b1admin", + "environmentName": "staging", + "region": "us-east-1", + "stackNames": { + "backend": "b1admin-staging-backend", + "frontend": "b1admin-staging-frontend" + }, + "outputDir": ".tmp-save-split-stack-contract", + "files": { + "backendOutputsFile": ".tmp-save-split-stack-contract/backend-outputs.json", + "frontendOutputsFile": ".tmp-save-split-stack-contract/frontend-outputs.json", + "summaryFile": ".tmp-save-split-stack-contract/deployment-summary.json", + "preflightPlanFile": ".tmp-save-split-stack-contract/preflight-plan.md" + }, + "resolved": { + "apiBaseUrl": "https://api.example.com", + "frontendAppUrl": "https://admin.example.com", + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" + }, + "followUpCommands": { + "showDeploymentSummary": "yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown", + "verifyFromSavedOutputs": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json", + "verifyFromSavedOutputsWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --check-http=true", + "publishFrontendAssetsFromSavedOutputs": "yarn publish:frontend-assets -- --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json", + "publishFromSavedOutputs": "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --skip-backend --skip-frontend --publish-frontend-assets" + } +} diff --git a/infrastructure/examples/show-rollout-status-output.sample.json b/infrastructure/examples/show-rollout-status-output.sample.json new file mode 100644 index 000000000..1bf5883a0 --- /dev/null +++ b/infrastructure/examples/show-rollout-status-output.sample.json @@ -0,0 +1,223 @@ +{ + "ok": false, + "deploymentIntent": "all", + "ignoredBlockerCategories": [], + "environmentCount": 2, + "readyEnvironmentCount": 0, + "blockedEnvironmentCount": 2, + "readyEnvironments": [], + "blockedEnvironments": [ + "staging", + "prod" + ], + "recommendedNextCommand": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "commandSummary": { + "global": [ + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json" + ], + "all": [ + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", + "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", + "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", + "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", + "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" + ], + "byEnvironment": { + "staging": [ + "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", + "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", + "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" + ], + "prod": [ + "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", + "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" + ] + } + }, + "blockerCategories": { + "starterOrInput": { + "environmentCount": 1, + "environments": [ + "staging" + ] + }, + "localExecution": { + "environmentCount": 2, + "environments": [ + "staging", + "prod" + ] + }, + "githubActionsExecution": { + "environmentCount": 2, + "environments": [ + "staging", + "prod" + ] + }, + "localGithubDispatch": { + "environmentCount": 0, + "environments": [] + } + }, + "intentBlockerCategories": { + "starterOrInput": { + "environmentCount": 1, + "environments": [ + "staging" + ] + }, + "localExecution": { + "environmentCount": 2, + "environments": [ + "staging", + "prod" + ] + }, + "githubActionsExecution": { + "environmentCount": 2, + "environments": [ + "staging", + "prod" + ] + }, + "localGithubDispatch": { + "environmentCount": 0, + "environments": [] + } + }, + "overallHighlightedBlockers": [ + "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", + "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", + "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject", + "Local api-repo path is not readable from this workspace: ../Api", + "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact.", + "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." + ], + "recommendedNextSteps": [ + "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", + "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper." + ], + "environments": [ + { + "ok": true, + "environment": "staging", + "status": "blocked", + "githubFocusedStatus": "blocked", + "recommendedPath": "none", + "recommendedReason": "Shared starter or input blockers still exist, so neither the local path nor the GitHub Actions path is ready yet.", + "primaryCommand": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "alternateCommands": [ + "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", + "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", + "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" + ], + "starterBlockerCount": 15, + "inputBlockerCount": 0, + "starterAndInputBlockerCount": 15, + "localExecutionOk": false, + "localExecutionBlockerCount": 5, + "githubActionsExecutionOk": false, + "githubActionsExecutionBlockerCount": 3, + "localGithubDispatchOk": true, + "localGithubDispatchBlockerCount": 0, + "appConfigSecretFilePresent": false, + "warnings": [ + "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", + "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." + ], + "highlightedBlockers": [ + "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", + "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", + "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject", + "Local api-repo path is not readable from this workspace: ../Api", + "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact." + ], + "githubFocusedHighlightedBlockers": [ + "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", + "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", + "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" + ], + "nextSteps": [ + "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", + "If the local Api repo is unreadable here, switch the local run to package-manifest or backend-artifact mode with the fallback commands below.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + "Decide whether app-config-secret should be synced on the first live run or introduced later." + ], + "githubFocusedNextSteps": [ + "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + "Decide whether app-config-secret should be synced on the first live run or introduced later." + ] + }, + { + "ok": true, + "environment": "prod", + "status": "blocked", + "githubFocusedStatus": "blocked", + "recommendedPath": "none", + "recommendedReason": "Execution-specific blockers still need to be cleared before running a deploy.", + "primaryCommand": "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", + "alternateCommands": [ + "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", + "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", + "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", + "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" + ], + "starterBlockerCount": 0, + "inputBlockerCount": 0, + "starterAndInputBlockerCount": 0, + "localExecutionOk": false, + "localExecutionBlockerCount": 2, + "githubActionsExecutionOk": false, + "githubActionsExecutionBlockerCount": 1, + "localGithubDispatchOk": true, + "localGithubDispatchBlockerCount": 0, + "appConfigSecretFilePresent": true, + "warnings": [ + "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", + "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." + ], + "highlightedBlockers": [ + "Local api-repo path is not readable from this workspace: ../Api", + "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact.", + "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." + ], + "githubFocusedHighlightedBlockers": [ + "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." + ], + "nextSteps": [ + "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper.", + "If the local Api repo is unreadable here, switch the local run to package-manifest or backend-artifact mode with the fallback commands below.", + "If you want the GitHub Actions path, run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` to populate `AWS_APP_CONFIG_SECRET_JSON` from this checkout.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + "If you want the GitHub Actions path, enable sync-app-config-secret and set AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment so the runner can recreate app-config-secret.json." + ], + "githubFocusedNextSteps": [ + "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper.", + "If you want the GitHub Actions path, run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` to populate `AWS_APP_CONFIG_SECRET_JSON` from this checkout.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + "If you want the GitHub Actions path, enable sync-app-config-secret and set AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment so the runner can recreate app-config-secret.json." + ] + } + ] +} diff --git a/infrastructure/examples/sync-app-config-secret-output.sample.json b/infrastructure/examples/sync-app-config-secret-output.sample.json new file mode 100644 index 000000000..f9320a7c8 --- /dev/null +++ b/infrastructure/examples/sync-app-config-secret-output.sample.json @@ -0,0 +1,6 @@ +{ + "action": "created", + "arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "name": "b1admin-prod-app-config", + "versionId": "11111111-2222-3333-4444-555555555555" +} diff --git a/infrastructure/examples/sync-github-app-config-secret-output.sample.json b/infrastructure/examples/sync-github-app-config-secret-output.sample.json new file mode 100644 index 000000000..eaa6546b4 --- /dev/null +++ b/infrastructure/examples/sync-github-app-config-secret-output.sample.json @@ -0,0 +1,12 @@ +{ + "action": "stored", + "secretName": "AWS_APP_CONFIG_SECRET_JSON", + "githubEnvironment": "aws-staging", + "repo": "ChurchApps/B1Admin", + "scope": "environment", + "app": "actions", + "sourceFile": "infrastructure/examples/app-config-secret.sample.json", + "keyCount": 19, + "normalizedJsonLength": 433, + "commandPreview": "gh secret set 'AWS_APP_CONFIG_SECRET_JSON' --env 'aws-staging' --app 'actions' --repo 'ChurchApps/B1Admin' < 'infrastructure/examples/app-config-secret.sample.json'" +} diff --git a/infrastructure/examples/sync-legacy-ssm-output.sample.json b/infrastructure/examples/sync-legacy-ssm-output.sample.json new file mode 100644 index 000000000..281a40e08 --- /dev/null +++ b/infrastructure/examples/sync-legacy-ssm-output.sample.json @@ -0,0 +1,51 @@ +{ + "stackName": "example-backend", + "region": "us-east-1", + "environment": "prod", + "prefix": "/prod", + "overwrite": true, + "dryRun": true, + "parameterCount": 10, + "parameters": [ + { + "name": "/prod/jwtSecret", + "value": "replace-me" + }, + { + "name": "/prod/encryptionKey", + "value": "replace-me" + }, + { + "name": "/prod/membershipApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/membership" + }, + { + "name": "/prod/attendanceApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance" + }, + { + "name": "/prod/contentApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/content" + }, + { + "name": "/prod/givingApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/giving" + }, + { + "name": "/prod/messagingApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/messaging" + }, + { + "name": "/prod/doingApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/doing" + }, + { + "name": "/prod/reportingApi/connectionString", + "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/reporting" + }, + { + "name": "/prod/webPushSubject", + "value": "mailto:support@example.com" + } + ] +} diff --git a/infrastructure/examples/upload-backend-artifact-output.sample.json b/infrastructure/examples/upload-backend-artifact-output.sample.json new file mode 100644 index 000000000..108ed1a31 --- /dev/null +++ b/infrastructure/examples/upload-backend-artifact-output.sample.json @@ -0,0 +1,9 @@ +{ + "artifactLabel": "Backend artifact", + "region": "us-east-1", + "bucket": "my-artifacts-bucket", + "key": "b1admin/backend/api.zip", + "sourceFile": "/abs/path/to/api.zip", + "s3Uri": "s3://my-artifacts-bucket/b1admin/backend/api.zip", + "bootstrapStackName": "example-bootstrap" +} diff --git a/infrastructure/examples/validate-api-migrations-output.sample.json b/infrastructure/examples/validate-api-migrations-output.sample.json new file mode 100644 index 000000000..903374296 --- /dev/null +++ b/infrastructure/examples/validate-api-migrations-output.sample.json @@ -0,0 +1,69 @@ +{ + "ok": true, + "mode": "api-migrations", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": true, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "", + "artifactKey": "", + "migrationBucket": "", + "migrationKey": "", + "frontendDomain": "", + "frontendCert": "", + "frontendZone": "", + "apiDomain": "", + "apiCert": "", + "apiZone": "", + "appConfigSecretArn": "", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [ + "membership", + "attendance" + ], + "apiRepoMigrationDirectories": [ + "attendance" + ] + }, + "info": [ + "Mode: api-migrations", + "Region: us-east-1", + "Standalone Api CLI migration validation", + "API repo path: ", + "API migration DB secret file: /abs/path/to/database-secret.json", + "API migration action: status", + "API migration module: attendance", + "API migration repo path: ", + "API migration outputs file: /abs/path/to/outputs.json", + "API migration DB secret file: /abs/path/to/database-secret.json", + "Standalone API migration helper is in dry-run mode.", + "API repo migration modules: membership, attendance", + "API repo migration directories: attendance" + ], + "warnings": [], + "errors": [], + "nextSteps": [ + "yarn run:api-migrations -- --api-repo-path= --action=status --module=attendance --region=us-east-1 --outputs-file=/abs/path/to/outputs.json --db-secret-file=/abs/path/to/database-secret.json --dry-run=true" + ] +} diff --git a/infrastructure/examples/validate-backend-output.sample.json b/infrastructure/examples/validate-backend-output.sample.json new file mode 100644 index 000000000..22efea0a0 --- /dev/null +++ b/infrastructure/examples/validate-backend-output.sample.json @@ -0,0 +1,58 @@ +{ + "ok": true, + "mode": "backend", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "example-backend", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", + "frontendParametersFile": "", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "infrastructure/examples/package-manifest.sample.json", + "backendArtifactSource": "/api-prod-self-contained.zip", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/prod/backend/api.zip", + "migrationBucket": "", + "migrationKey": "", + "frontendDomain": "", + "frontendCert": "", + "frontendZone": "", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: backend", + "Region: us-east-1", + "Backend parameters file: /abs/path/to/infrastructure/examples/backend-parameters.sample.json", + "Package manifest file: /abs/path/to/infrastructure/examples/package-manifest.sample.json", + "Lambda artifact bucket: my-artifacts-bucket", + "Lambda artifact key: b1admin/prod/backend/api.zip" + ], + "warnings": [], + "errors": [], + "nextSteps": [ + "yarn upload:backend-artifact -- --source-file=/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", + "yarn deploy:backend -- --stack-name=example-backend --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json --lambda-code-s3-bucket=my-artifacts-bucket --lambda-code-s3-key=b1admin/prod/backend/api.zip" + ] +} diff --git a/infrastructure/examples/validate-bootstrap-output.sample.json b/infrastructure/examples/validate-bootstrap-output.sample.json new file mode 100644 index 000000000..b12a44c72 --- /dev/null +++ b/infrastructure/examples/validate-bootstrap-output.sample.json @@ -0,0 +1,55 @@ +{ + "ok": true, + "mode": "bootstrap", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": true, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "infrastructure/examples/bootstrap-parameters.sample.json", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "infrastructure/examples/bootstrap-parameters.sample.json", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "b1admin-prod-templates-123456789012", + "artifactBucket": "b1admin-prod-artifacts-123456789012", + "artifactKey": "", + "migrationBucket": "b1admin-prod-artifacts-123456789012", + "migrationKey": "", + "frontendDomain": "", + "frontendCert": "", + "frontendZone": "", + "apiDomain": "", + "apiCert": "", + "apiZone": "", + "appConfigSecretArn": "", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: bootstrap", + "Region: us-east-1", + "Template bucket: b1admin-prod-templates-123456789012", + "Artifact bucket: b1admin-prod-artifacts-123456789012" + ], + "warnings": [], + "errors": [], + "nextSteps": [ + "yarn deploy:bootstrap -- --region=us-east-1 --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json --stack-name=" + ] +} diff --git a/infrastructure/examples/validate-frontend-output.sample.json b/infrastructure/examples/validate-frontend-output.sample.json new file mode 100644 index 000000000..1e5e3cbb0 --- /dev/null +++ b/infrastructure/examples/validate-frontend-output.sample.json @@ -0,0 +1,51 @@ +{ + "ok": true, + "mode": "frontend", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "", + "artifactKey": "", + "migrationBucket": "", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "", + "apiCert": "", + "apiZone": "", + "appConfigSecretArn": "", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: frontend", + "Region: us-east-1" + ], + "warnings": [], + "errors": [], + "nextSteps": [] +} diff --git a/infrastructure/examples/validate-frontend-publish-output.sample.json b/infrastructure/examples/validate-frontend-publish-output.sample.json new file mode 100644 index 000000000..8ca7312dd --- /dev/null +++ b/infrastructure/examples/validate-frontend-publish-output.sample.json @@ -0,0 +1,59 @@ +{ + "ok": true, + "mode": "frontend-publish", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": true, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "", + "artifactKey": "", + "migrationBucket": "", + "migrationKey": "", + "frontendDomain": "", + "frontendCert": "", + "frontendZone": "", + "apiDomain": "", + "apiCert": "", + "apiZone": "", + "appConfigSecretArn": "", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: frontend-publish", + "Region: us-east-1", + "Frontend asset publish validation", + "Backend outputs file: /abs/path/to/infrastructure/examples/backend-outputs.sample.json", + "Frontend publish bucket: example-frontend-bucket", + "Frontend distribution ID: EXAMPLE123" + ], + "warnings": [ + "Frontend publish will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." + ], + "errors": [], + "nextSteps": [ + "yarn publish:frontend-assets -- --bucket=example-frontend-bucket --distribution-id=EXAMPLE123 --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json" + ] +} diff --git a/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json b/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json new file mode 100644 index 000000000..74d0342ca --- /dev/null +++ b/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json @@ -0,0 +1,58 @@ +{ + "ok": true, + "mode": "full-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": true, + "stackName": "", + "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "my-template-bucket", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: full-stack", + "Region: us-east-1", + "Frontend infrastructure-only deploy requested.", + "Template bucket: my-template-bucket", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", + "Full-stack validation will assume backend plus frontend hosting infrastructure now, with frontend asset publishing deferred." + ], + "warnings": [], + "errors": [], + "nextSteps": [] +} diff --git a/infrastructure/examples/validate-full-stack-output.sample.json b/infrastructure/examples/validate-full-stack-output.sample.json new file mode 100644 index 000000000..13282300e --- /dev/null +++ b/infrastructure/examples/validate-full-stack-output.sample.json @@ -0,0 +1,56 @@ +{ + "ok": true, + "mode": "full-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "my-template-bucket", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: full-stack", + "Region: us-east-1", + "Template bucket: my-template-bucket", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided." + ], + "warnings": [], + "errors": [], + "nextSteps": [] +} diff --git a/infrastructure/examples/validate-full-stack-publish-output.sample.json b/infrastructure/examples/validate-full-stack-publish-output.sample.json new file mode 100644 index 000000000..3888a72dc --- /dev/null +++ b/infrastructure/examples/validate-full-stack-publish-output.sample.json @@ -0,0 +1,62 @@ +{ + "ok": true, + "mode": "full-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": false, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": true, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "bootstrapStackName": "", + "backendParametersFile": "", + "frontendParametersFile": "", + "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: full-stack", + "Region: us-east-1", + "Full-stack publish-only follow-up: infrastructure changes will be skipped.", + "Infrastructure deploy step will be skipped.", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "Frontend outputs file: /abs/path/to/infrastructure/examples/frontend-outputs.sample.json", + "Backend outputs file: /abs/path/to/infrastructure/examples/backend-outputs.sample.json" + ], + "warnings": [ + "Full-stack publish-only follow-up will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." + ], + "errors": [], + "nextSteps": [ + "yarn deploy:full-stack -- --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json --parameters-file=infrastructure/examples/full-stack-parameters.sample.json --skip-infrastructure --publish-frontend-assets" + ] +} diff --git a/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json b/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json new file mode 100644 index 000000000..503721b6a --- /dev/null +++ b/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json @@ -0,0 +1,59 @@ +{ + "ok": true, + "mode": "split-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": true, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": true, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", + "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: split-stack", + "Region: us-east-1", + "Split-stack validation: backend + frontend", + "Frontend infrastructure-only deploy requested.", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", + "Split-stack validation will assume backend deploy now, with frontend hosting provisioned but frontend asset publishing deferred.", + "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" + ], + "warnings": [], + "errors": [], + "nextSteps": [] +} diff --git a/infrastructure/examples/validate-split-stack-output.sample.json b/infrastructure/examples/validate-split-stack-output.sample.json new file mode 100644 index 000000000..1bcad7aed --- /dev/null +++ b/infrastructure/examples/validate-split-stack-output.sample.json @@ -0,0 +1,57 @@ +{ + "ok": true, + "mode": "split-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": true, + "splitStackPublishOnly": false, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", + "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: split-stack", + "Region: us-east-1", + "Split-stack validation: backend + frontend", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", + "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" + ], + "warnings": [], + "errors": [], + "nextSteps": [] +} diff --git a/infrastructure/examples/validate-split-stack-publish-output.sample.json b/infrastructure/examples/validate-split-stack-publish-output.sample.json new file mode 100644 index 000000000..f4641976c --- /dev/null +++ b/infrastructure/examples/validate-split-stack-publish-output.sample.json @@ -0,0 +1,64 @@ +{ + "ok": true, + "mode": "split-stack", + "region": "us-east-1", + "projectName": "b1admin", + "environmentName": "prod", + "bootstrapMode": false, + "apiMigrationsMode": false, + "splitStackMode": true, + "splitStackPublishOnly": true, + "frontendPublishMode": false, + "fullStackPublishOnly": false, + "checkAws": false, + "infrastructureOnly": false, + "frontendInfrastructureOnly": false, + "stackName": "", + "parametersFile": "", + "bootstrapStackName": "", + "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", + "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", + "fullStackParametersFile": "", + "resolved": { + "packageManifestFile": "", + "backendArtifactSource": "", + "migrationArtifactSource": "", + "dependenciesLayerSource": "", + "templateBucket": "", + "artifactBucket": "my-artifacts-bucket", + "artifactKey": "b1admin/backend/api.zip", + "migrationBucket": "my-artifacts-bucket", + "migrationKey": "", + "frontendDomain": "admin.example.com", + "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "frontendZone": "Z1234567890ABC", + "apiDomain": "api.example.com", + "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "apiZone": "Z1234567890ABC", + "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "dependenciesLayerArn": "", + "observabilityLayerArn": "", + "apiRepoMigrationModules": [], + "apiRepoMigrationDirectories": [] + }, + "info": [ + "Mode: split-stack", + "Region: us-east-1", + "Split-stack validation: backend + frontend", + "Split-stack publish-only follow-up: backend and frontend deploy steps will be skipped.", + "Artifact bucket: my-artifacts-bucket", + "Artifact key: b1admin/backend/api.zip", + "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + "Frontend outputs file: /abs/path/to/infrastructure/examples/frontend-outputs.sample.json", + "Backend deploy step will be skipped.", + "Frontend deploy step will be skipped.", + "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" + ], + "warnings": [ + "Split-stack publish-only follow-up will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." + ], + "errors": [], + "nextSteps": [ + "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=prod --frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json --skip-backend --skip-frontend --publish-frontend-assets" + ] +} diff --git a/infrastructure/examples/verify-split-stack-output.sample.json b/infrastructure/examples/verify-split-stack-output.sample.json new file mode 100644 index 000000000..7f4dd3c86 --- /dev/null +++ b/infrastructure/examples/verify-split-stack-output.sample.json @@ -0,0 +1,76 @@ +{ + "ok": true, + "mode": "split-stack", + "region": "us-east-1", + "backendStackName": "", + "frontendStackName": "", + "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", + "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", + "checkAws": false, + "checkHttp": false, + "resolved": { + "apiBaseUrl": "https://api.example.com", + "contentRootUrl": "https://content.example.com", + "websiteBaseUrl": "https://{subdomain}.example.com", + "frontendAppUrl": "https://admin.example.com", + "frontendBucketName": "example-frontend-bucket", + "frontendDistributionId": "EXAMPLE123" + }, + "checks": [ + { + "name": "backend outputs source", + "ok": true, + "detail": "Loaded backend outputs file /abs/path/to/B1Admin/infrastructure/examples/backend-outputs.sample.json" + }, + { + "name": "frontend outputs source", + "ok": true, + "detail": "Loaded frontend outputs file /abs/path/to/B1Admin/infrastructure/examples/frontend-outputs.sample.json" + }, + { + "name": "api base url output", + "ok": true, + "detail": "Resolved from ApiBaseUrl/PublicApiBaseUrl: https://api.example.com" + }, + { + "name": "frontend app url output", + "ok": true, + "detail": "Resolved from AppUrl/FrontendAppUrl: https://admin.example.com" + }, + { + "name": "frontend bucket output", + "ok": true, + "detail": "Resolved from SiteBucketName/FrontendBucketName: example-frontend-bucket" + }, + { + "name": "frontend distribution output", + "ok": true, + "detail": "Resolved from CloudFrontDistributionId/FrontendDistributionId: EXAMPLE123" + }, + { + "name": "frontend bucket aws reachability", + "ok": true, + "skipped": true, + "detail": "Skipped because --check-aws=false." + }, + { + "name": "frontend distribution aws reachability", + "ok": true, + "skipped": true, + "detail": "Skipped because --check-aws=false." + }, + { + "name": "frontend app http reachability", + "ok": true, + "skipped": true, + "detail": "Skipped because --check-http=false." + }, + { + "name": "api probe http reachability", + "ok": true, + "skipped": true, + "detail": "Skipped because --check-http=false." + } + ], + "errors": [] +} diff --git a/infrastructure/iam/README.md b/infrastructure/iam/README.md new file mode 100644 index 000000000..523939128 --- /dev/null +++ b/infrastructure/iam/README.md @@ -0,0 +1,72 @@ +# IAM Roles For GitHub AWS Deploys + +Use these templates when you want the GitHub workflow to assume a narrow OIDC deploy role that passes a separate CloudFormation execution role. + +This is the recommended least-privilege model for B1Admin AWS rollouts. + +## Roles + +Create two roles per environment: + +1. GitHub OIDC deploy role +2. CloudFormation execution role + +For staging, the suggested names are: + +- `b1admin-staging-github-deploy` +- `b1admin-staging-cfn-exec` + +## Files + +- [`github-oidc-deploy-role-trust.sample.json`](./github-oidc-deploy-role-trust.sample.json) +- [`github-oidc-deploy-policy.sample.json`](./github-oidc-deploy-policy.sample.json) +- [`cloudformation-execution-role-trust.sample.json`](./cloudformation-execution-role-trust.sample.json) +- [`cloudformation-execution-policy.sample.json`](./cloudformation-execution-policy.sample.json) + +## Replace These Placeholders + +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` + +To avoid hand-editing these files, render environment-specific copies with: + +```bash +yarn installer:aws-roles -- --environment=staging --account-id= --repo=/ --output-dir=../b1admin-deploy/iam/staging --write=true --output=markdown +``` + +The generated AWS commands use the GitHub OIDC provider URL `https://token.actions.githubusercontent.com` and audience `sts.amazonaws.com`. If the provider already exists in the AWS account, skip the provider creation command. + +## GitHub Environment Secret + +When you use the two-role model, store these secrets in the GitHub Environment: + +- `AWS_ROLE_TO_ASSUME` +- `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` + +The workflow now supports `AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN` directly and passes it to the deploy scripts through `CLOUDFORMATION_EXECUTION_ROLE_ARN`. + +Before you paste those values manually, you can ask the repo to discover the exact ARNs from AWS: + +`yarn discover:github-aws-roles -- --environment=staging --output=markdown` + +That helper looks for the expected role names: + +- `b1admin-staging-github-deploy` +- `b1admin-staging-cfn-exec` + +and prints copy-paste `gh secret set ... --body ''` commands. + +## Notes + +- The GitHub OIDC deploy role is intentionally narrow. It handles workflow-side orchestration, artifact upload, frontend publish, and app-config secret sync. +- The CloudFormation execution role handles stack resource creation and mutation. +- These roles are not created by the GitHub deploy/bootstrap workflow itself. They must exist before GitHub can assume AWS access, so they stay as an explicit pre-deploy IAM setup step rather than a later bootstrap side effect. +- If you use the optional initial-admin bootstrap helper, the GitHub deploy role also needs `rds-data:BeginTransaction`, `rds-data:CommitTransaction`, `rds-data:ExecuteStatement`, `rds-data:RollbackTransaction`, and `secretsmanager:GetSecretValue` so the workflow can seed the first admin user directly through the Aurora Data API after migrations complete. +- If you are doing a first pass without custom domains, you can often remove or defer Route53- and ACM-related permissions until later. +- First live staging rollout note: the CloudFormation execution role must include `secretsmanager:GetRandomPassword`, API Gateway management actions such as `apigateway:POST`, `GET`, `PATCH`, `PUT`, `DELETE`, `TagResource`, and `UntagResource`, and S3 bucket configuration actions including `s3:PutBucketOwnershipControls` and `s3:PutBucketCORS`. Aurora creation also needed `iam:CreateServiceLinkedRole` for `rds.amazonaws.com`, rollback snapshot creation needed `rds:CreateDBClusterSnapshot`, Lambda-role inline policy wiring needed `iam:GetRolePolicy`, layered Lambda deploys needed `lambda:GetLayerVersion`, and versioned Lambda artifact rollouts needed `s3:GetObjectVersion` on the artifact bucket objects. The sample policy includes them now because the June 23-25, 2026 staging deploys failed without those permissions. diff --git a/infrastructure/iam/cloudformation-execution-policy.sample.json b/infrastructure/iam/cloudformation-execution-policy.sample.json new file mode 100644 index 000000000..c30306682 --- /dev/null +++ b/infrastructure/iam/cloudformation-execution-policy.sample.json @@ -0,0 +1,194 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CloudFormationStackOperations", + "Effect": "Allow", + "Action": [ + "cloudformation:*" + ], + "Resource": "*" + }, + { + "Sid": "VpcAndNetworking", + "Effect": "Allow", + "Action": [ + "ec2:AllocateAddress", + "ec2:AssociateRouteTable", + "ec2:AttachInternetGateway", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateInternetGateway", + "ec2:CreateNatGateway", + "ec2:CreateRoute", + "ec2:CreateRouteTable", + "ec2:CreateSecurityGroup", + "ec2:CreateSubnet", + "ec2:CreateTags", + "ec2:CreateVpc", + "ec2:CreateVpcEndpoint", + "ec2:DeleteInternetGateway", + "ec2:DeleteNatGateway", + "ec2:DeleteRoute", + "ec2:DeleteRouteTable", + "ec2:DeleteSecurityGroup", + "ec2:DeleteSubnet", + "ec2:DeleteVpc", + "ec2:DeleteVpcEndpoints", + "ec2:Describe*", + "ec2:DetachInternetGateway", + "ec2:DisassociateRouteTable", + "ec2:ModifySubnetAttribute", + "ec2:ModifyVpcAttribute", + "ec2:ReleaseAddress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress" + ], + "Resource": "*" + }, + { + "Sid": "RdsAndSecrets", + "Effect": "Allow", + "Action": [ + "rds:AddTagsToResource", + "rds:CreateDBCluster", + "rds:CreateDBClusterSnapshot", + "rds:CreateDBInstance", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBCluster", + "rds:DeleteDBInstance", + "rds:DeleteDBSubnetGroup", + "rds:Describe*", + "rds:ModifyDBCluster", + "rds:ModifyDBInstance", + "rds:RemoveTagsFromResource", + "rds-data:*", + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:GetRandomPassword", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:UpdateSecret" + ], + "Resource": "*" + }, + { + "Sid": "IamForStackManagedRoles", + "Effect": "Allow", + "Action": [ + "iam:AttachRolePolicy", + "iam:CreateServiceLinkedRole", + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:PassRole", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateRole" + ], + "Resource": "*", + "Condition": { + "StringEqualsIfExists": { + "iam:AWSServiceName": [ + "rds.amazonaws.com" + ] + } + } + }, + { + "Sid": "LambdaLogsApiEvents", + "Effect": "Allow", + "Action": [ + "apigateway:DELETE", + "apigateway:GET", + "apigateway:PATCH", + "apigateway:POST", + "apigateway:PUT", + "apigateway:TagResource", + "apigateway:UntagResource", + "lambda:AddPermission", + "lambda:CreateFunction", + "lambda:DeleteFunction", + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:GetLayerVersion", + "lambda:PublishVersion", + "lambda:RemovePermission", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:DescribeLogGroups", + "logs:ListTagsLogGroup", + "logs:PutRetentionPolicy", + "logs:TagLogGroup", + "logs:UntagLogGroup", + "apigatewayv2:*", + "events:*" + ], + "Resource": "*" + }, + { + "Sid": "BucketsAndCloudFront", + "Effect": "Allow", + "Action": [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:DeleteBucketPolicy", + "s3:DeleteObject", + "s3:GetBucketCors", + "s3:GetBucketLocation", + "s3:GetBucketPolicy", + "s3:GetBucketTagging", + "s3:GetEncryptionConfiguration", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListBucket", + "s3:PutBucketCORS", + "s3:PutBucketOwnershipControls", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketTagging", + "s3:PutLifecycleConfiguration", + "s3:PutBucketVersioning", + "s3:PutEncryptionConfiguration", + "s3:PutObject", + "cloudfront:CreateDistribution", + "cloudfront:CreateOriginAccessControl", + "cloudfront:DeleteDistribution", + "cloudfront:DeleteOriginAccessControl", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:GetOriginAccessControl", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:UpdateDistribution", + "cloudfront:UpdateOriginAccessControl" + ], + "Resource": "*" + }, + { + "Sid": "OptionalDnsAndCertificates", + "Effect": "Allow", + "Action": [ + "route53:ChangeResourceRecordSets", + "route53:GetChange", + "route53:GetHostedZone", + "route53:ListHostedZonesByName", + "acm:DescribeCertificate" + ], + "Resource": "*" + } + ] +} diff --git a/infrastructure/iam/cloudformation-execution-role-trust.sample.json b/infrastructure/iam/cloudformation-execution-role-trust.sample.json new file mode 100644 index 000000000..1cafa8e89 --- /dev/null +++ b/infrastructure/iam/cloudformation-execution-role-trust.sample.json @@ -0,0 +1,12 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "cloudformation.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/infrastructure/iam/github-oidc-deploy-policy.sample.json b/infrastructure/iam/github-oidc-deploy-policy.sample.json new file mode 100644 index 000000000..b8b3321f9 --- /dev/null +++ b/infrastructure/iam/github-oidc-deploy-policy.sample.json @@ -0,0 +1,118 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "StsRead", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": "*" + }, + { + "Sid": "CloudFormationValidateAndDescribe", + "Effect": "Allow", + "Action": [ + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "cloudformation:DescribeStackResources", + "cloudformation:DescribeStackResource", + "cloudformation:DescribeChangeSet", + "cloudformation:GetTemplate", + "cloudformation:GetTemplateSummary", + "cloudformation:ListStackResources", + "cloudformation:ValidateTemplate" + ], + "Resource": "*" + }, + { + "Sid": "CloudFormationDeployForEnvironment", + "Effect": "Allow", + "Action": [ + "cloudformation:CreateStack", + "cloudformation:UpdateStack", + "cloudformation:DeleteStack", + "cloudformation:CreateChangeSet", + "cloudformation:ExecuteChangeSet", + "cloudformation:DeleteChangeSet" + ], + "Resource": [ + "arn:aws:cloudformation:::stack/--*/*", + "arn:aws:cloudformation:::changeSet/*" + ] + }, + { + "Sid": "PassCloudFormationExecutionRole", + "Effect": "Allow", + "Action": [ + "iam:PassRole" + ], + "Resource": "arn:aws:iam:::role/" + }, + { + "Sid": "ArtifactAndSiteBuckets", + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetBucketLocation" + ], + "Resource": [ + "arn:aws:s3:::--*" + ] + }, + { + "Sid": "ArtifactAndSiteObjects", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ], + "Resource": [ + "arn:aws:s3:::--*/*" + ] + }, + { + "Sid": "CloudFrontPublish", + "Effect": "Allow", + "Action": [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:CreateInvalidation" + ], + "Resource": "arn:aws:cloudfront:::distribution/*" + }, + { + "Sid": "AppConfigSecretWrite", + "Effect": "Allow", + "Action": [ + "secretsmanager:DescribeSecret", + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecret", + "secretsmanager:TagResource" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret://*", + "arn:aws:secretsmanager:::secret:--*" + ] + }, + { + "Sid": "OptionalValidationAndExtensions", + "Effect": "Allow", + "Action": [ + "acm:DescribeCertificate", + "lambda:PublishLayerVersion", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:ExecuteStatement", + "rds-data:RollbackTransaction", + "secretsmanager:GetSecretValue", + "ssm:PutParameter", + "ssm:AddTagsToResource" + ], + "Resource": "*" + } + ] +} diff --git a/infrastructure/iam/github-oidc-deploy-role-trust.sample.json b/infrastructure/iam/github-oidc-deploy-role-trust.sample.json new file mode 100644 index 000000000..ff18969c0 --- /dev/null +++ b/infrastructure/iam/github-oidc-deploy-role-trust.sample.json @@ -0,0 +1,20 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:/:environment:" + } + } + } + ] +} diff --git a/package.json b/package.json index c5853e8b6..d22e4b6cd 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,60 @@ "build": "vite build", "serve": "serve -s dist -l ${PORT:-3101}", "preview": "vite preview", + "deploy:bootstrap": "node scripts/deploy-bootstrap.mjs", + "audit:api-repo-contract": "node scripts/audit-api-repo-contract.mjs", + "package:api-backend": "node scripts/package-api-backend.mjs", + "prepare:environment-starter": "node scripts/prepare-environment-starter.mjs", + "installer:init": "node scripts/installer-init.mjs", + "installer:customer-values": "node scripts/installer-customer-values.mjs", + "installer:update": "node scripts/installer-update.mjs", + "installer:next": "node scripts/installer-start.mjs", + "installer:run": "node scripts/installer-run.mjs", + "installer:start": "node scripts/installer-start.mjs", + "installer:setup": "node scripts/setup-private-deployment-repo.mjs", + "installer:app-config-secret": "node scripts/installer-app-config-secret.mjs", + "installer:aws-handoff": "node scripts/installer-aws-handoff.mjs", + "installer:aws-roles": "node scripts/installer-aws-roles.mjs", + "installer:configure": "node scripts/installer-configure.mjs", + "installer:doctor": "node scripts/installer-doctor.mjs", + "installer:aws-preflight": "node scripts/installer-aws-preflight.mjs", + "installer:preflight": "node scripts/installer-preflight.mjs", + "installer:deploy": "node scripts/installer-deploy.mjs", + "installer:observe": "node scripts/installer-observe.mjs", + "installer:report": "node scripts/installer-report.mjs", + "installer:verify": "node scripts/installer-verify.mjs", + "installer:bootstrap-admin": "node scripts/installer-bootstrap-admin.mjs", + "installer:adopt-frontend-origin": "node scripts/installer-adopt-frontend-origin.mjs", + "installer:github-setup": "node scripts/installer-github-setup.mjs", + "installer:github-readiness": "node scripts/installer-github-readiness.mjs", + "installer:browser-smoke": "node scripts/installer-browser-smoke.mjs", + "guide:environment-setup": "node scripts/show-environment-setup-guide.mjs", + "wizard:environment-setup": "node scripts/environment-setup-wizard.mjs", + "launch:staging": "node scripts/launch-staging.mjs", + "reset:staging": "node scripts/reset-staging.mjs", + "reset:prod": "node scripts/reset-prod.mjs", + "discover:github-aws-roles": "node scripts/discover-github-aws-role-arns.mjs", + "plan:environment-deploy": "node scripts/plan-environment-deploy.mjs", + "show:rollout-status": "node scripts/show-rollout-status.mjs", + "dispatch:github-aws-deploy": "node scripts/dispatch-github-aws-deploy.mjs", + "save:split-stack-outputs": "node scripts/save-split-stack-outputs.mjs", + "show:deployment-summary": "node scripts/show-deployment-summary.mjs", + "run:api-migrations": "node scripts/run-api-migrations.mjs", + "run:bootstrap-admin": "node scripts/bootstrap-initial-admin.mjs", + "publish:lambda-layer": "node scripts/publish-lambda-layer.mjs", + "sync:app-config-secret": "node scripts/sync-app-config-secret.mjs", + "sync:github-app-config-secret": "node scripts/sync-github-app-config-secret.mjs", + "sync:legacy-ssm": "node scripts/sync-legacy-ssm-parameters.mjs", + "upload:backend-artifact": "node scripts/upload-backend-artifact.mjs", + "publish:frontend-assets": "node scripts/publish-frontend-assets.mjs", + "verify:split-stack": "node scripts/verify-split-stack.mjs", + "audit:environment-starter": "node scripts/audit-environment-starter.mjs", + "validate:aws-deploy": "node scripts/validate-aws-deploy.mjs", + "smoke:aws-tooling": "node scripts/smoke-aws-tooling.mjs", + "deploy:backend": "node scripts/deploy-backend.mjs", + "deploy:frontend": "node scripts/deploy-frontend.mjs", + "deploy:aws": "node scripts/deploy-aws.mjs", + "deploy:full-stack": "node scripts/deploy-full-stack.mjs", "pretest": "node tests/setup/pretest.mjs", "test": "playwright test", "test:ui": "playwright test --ui", diff --git a/scripts/audit-api-repo-contract.mjs b/scripts/audit-api-repo-contract.mjs new file mode 100644 index 000000000..5dfd0879a --- /dev/null +++ b/scripts/audit-api-repo-contract.mjs @@ -0,0 +1,317 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function exists(targetPath) { + return fs.existsSync(targetPath); +} + +function canRead(targetPath) { + try { + fs.accessSync(targetPath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function tryReadText(targetPath) { + try { + return { + ok: true, + value: fs.readFileSync(targetPath, "utf8"), + error: "", + }; + } catch (error) { + return { + ok: false, + value: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function tryReadJson(targetPath) { + const text = tryReadText(targetPath); + if (!text.ok) { + return { + ok: false, + value: null, + error: text.error, + }; + } + + try { + return { + ok: true, + value: JSON.parse(text.value), + error: "", + }; + } catch (error) { + return { + ok: false, + value: null, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function extractModules(apiRepoPath) { + const kyselyConfigPath = path.join(apiRepoPath, "tools", "kysely-config.ts"); + if (exists(kyselyConfigPath) && canRead(kyselyConfigPath)) { + const source = fs.readFileSync(kyselyConfigPath, "utf8"); + const match = source.match(/const\s+MODULES\s*=\s*\[(.*?)\]\s+as const/s); + if (match) { + return Array.from(match[1].matchAll(/"([^"]+)"/g)).map((item) => item[1]); + } + } + + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (exists(migrationsRoot) && canRead(migrationsRoot)) { + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch { + return []; + } + } + + return []; +} + +function renderMarkdown(result) { + const lines = [ + `# Api Repo Contract Audit`, + "", + `- Status: ${result.ok ? "ready" : "blocked"}`, + `- Api repo: \`${result.apiRepoPath}\``, + `- Package mode recommendation: \`${result.recommended.packageMode}\``, + `- Auto-package ready: ${result.packaging.autoPackageReady ? "yes" : "no"}`, + `- Deployment contract ready: ${result.contract.ready ? "yes" : "no"}`, + `- Migration support detected: ${result.migrations.detected ? "yes" : "no"}`, + ]; + + if (result.errors.length > 0) { + lines.push("", "## Errors", ""); + result.errors.forEach((error) => lines.push(`- ${error}`)); + } + + if (result.warnings.length > 0) { + lines.push("", "## Warnings", ""); + result.warnings.forEach((warning) => lines.push(`- ${warning}`)); + } + + lines.push("", "## Findings", ""); + lines.push(`- Runtime hint: \`${result.contract.runtimeHint}\``); + lines.push(`- HTTP handler hint: \`${result.contract.httpHandlerHint}\``); + lines.push(`- Socket handler detected: ${result.contract.socketHandlerDetected ? "yes" : "no"}`); + lines.push(`- Timer handlers detected: ${result.contract.timerHandlersDetected ? "yes" : "no"}`); + lines.push(`- Membership connection string wiring detected: ${result.contract.membershipConnectionDetected ? "yes" : "no"}`); + lines.push(`- Modules: ${result.migrations.modules.length > 0 ? `\`${result.migrations.modules.join("`, `")}\`` : ""}`); + lines.push(`- Build script \`build:prod\`: ${result.packaging.buildScriptPresent ? "yes" : "no"}`); + lines.push(`- Build script \`build-layer\`: ${result.packaging.buildLayerScriptPresent ? "yes" : "no"}`); + lines.push(`- node_modules present: ${result.packaging.nodeModulesPresent ? "yes" : "no"}`); + lines.push(`- dist present: ${result.packaging.distPresent ? "yes" : "no"}`); + lines.push(`- layer present: ${result.packaging.layerPresent ? "yes" : "no"}`); + + if (result.recommended.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.recommended.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const apiRepoPath = path.resolve(rootDir, getArg("api-repo-path", "../Api")); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const errors = []; + const warnings = []; + const info = []; + + const packageJsonPath = path.join(apiRepoPath, "package.json"); + const serverlessPath = path.join(apiRepoPath, "serverless.yml"); + const lambdaPath = path.join(apiRepoPath, "lambda.js"); + const configPath = path.join(apiRepoPath, "config"); + const distPath = path.join(apiRepoPath, "dist"); + const nodeModulesPath = path.join(apiRepoPath, "node_modules"); + const layerPath = path.join(apiRepoPath, "layer"); + + if (!exists(apiRepoPath)) { + errors.push(`API repo path not found: ${apiRepoPath}`); + } else if (!canRead(apiRepoPath)) { + errors.push(`API repo path is not readable: ${apiRepoPath}`); + } + + const packageJson = tryReadJson(packageJsonPath); + const serverless = tryReadText(serverlessPath); + + if (!packageJson.ok) { + errors.push(`API package.json could not be read: ${packageJsonPath} (${packageJson.error})`); + } + + if (!serverless.ok) { + warnings.push(`API serverless.yml could not be read: ${serverlessPath} (${serverless.error})`); + } + + const packageScripts = packageJson.value?.scripts || {}; + const modules = errors.length === 0 ? extractModules(apiRepoPath) : []; + const serverlessText = serverless.value || ""; + + const runtimeHint = /runtime:\s*nodejs22\.x/m.test(serverlessText) ? "nodejs22.x" : ""; + const httpHandlerHint = /handler:\s+lambda\.web/m.test(serverlessText) ? "lambda.web" : ""; + const socketHandlerDetected = /handler:\s+lambda\.socket/m.test(serverlessText); + const timerHandlersDetected = /handler:\s+lambda\.timer(?:15Min|Midnight|ScheduledTasks|Webhooks)/m.test(serverlessText); + const membershipConnectionDetected = /MEMBERSHIP_CONNECTION_STRING/m.test(serverlessText); + + const buildScriptPresent = typeof packageScripts["build:prod"] === "string"; + const buildLayerScriptPresent = typeof packageScripts["build-layer"] === "string"; + const nodeModulesPresent = exists(nodeModulesPath) && canRead(nodeModulesPath); + const distPresent = exists(distPath) && canRead(distPath); + const layerPresent = exists(layerPath) && canRead(layerPath); + const lambdaPresent = exists(lambdaPath) && canRead(lambdaPath); + const configPresent = exists(configPath) && canRead(configPath); + + if (!buildScriptPresent) { + warnings.push("Expected build:prod script was not found in Api package.json."); + } + if (!lambdaPresent) { + errors.push(`Required Lambda entrypoint is missing or unreadable: ${lambdaPath}`); + } + if (!configPresent) { + errors.push(`Required config directory is missing or unreadable: ${configPath}`); + } + if (!serverless.ok) { + warnings.push("Serverless-driven handler/runtime checks were skipped."); + } else { + if (!runtimeHint) warnings.push("serverless.yml did not show runtime nodejs22.x."); + if (!httpHandlerHint) warnings.push("serverless.yml did not show handler lambda.web."); + if (!socketHandlerDetected) warnings.push("serverless.yml did not show handler lambda.socket."); + if (!timerHandlersDetected) warnings.push("serverless.yml did not show the expected timer handlers."); + if (!membershipConnectionDetected) warnings.push("serverless.yml did not show MEMBERSHIP_CONNECTION_STRING wiring."); + } + if (modules.length === 0) { + warnings.push("No migration modules were detected from tools/kysely-config.ts or tools/migrations/."); + } + if (!nodeModulesPresent) { + warnings.push("node_modules is missing or unreadable. Auto-packaging will fail until dependencies are installed."); + } + if (!distPresent) { + warnings.push("dist is missing or unreadable. Build output is not present yet."); + } + + const layeredSupported = buildLayerScriptPresent || layerPresent; + const selfContainedSupported = lambdaPresent && configPresent; + const autoPackageReady = selfContainedSupported && buildScriptPresent && nodeModulesPresent; + const contractReady = runtimeHint === "nodejs22.x" + && httpHandlerHint === "lambda.web" + && socketHandlerDetected + && timerHandlersDetected + && membershipConnectionDetected; + + const packageMode = layeredSupported ? "layered-or-self-contained" : "self-contained"; + const nextSteps = []; + + if (!nodeModulesPresent) { + nextSteps.push(`Run \`corepack yarn install\` in ${apiRepoPath}.`); + } + if (!distPresent) { + nextSteps.push(`Run \`corepack yarn build:prod\` in ${apiRepoPath}, or use \`yarn package:api-backend -- --api-repo-path=${apiRepoPath}\` once dependencies are installed.`); + } + if (layeredSupported && !layerPresent) { + nextSteps.push("If you want layered packaging, run the Api repo layer build before using --package-mode=layered."); + } + if (!contractReady) { + nextSteps.push("Review serverless/runtime assumptions before relying on the current CloudFormation backend contract."); + } + if (autoPackageReady) { + nextSteps.push(`Run \`yarn package:api-backend -- --api-repo-path=${apiRepoPath}\` from B1Admin to produce a deploy manifest.`); + } + + const result = { + ok: errors.length === 0, + apiRepoPath, + errors, + warnings, + info, + packaging: { + autoPackageReady, + buildScriptPresent, + buildLayerScriptPresent, + nodeModulesPresent, + distPresent, + layerPresent, + lambdaPresent, + configPresent, + supportedModes: [ + ...(selfContainedSupported ? ["self-contained"] : []), + ...(layeredSupported ? ["layered"] : []), + ], + }, + contract: { + ready: contractReady, + runtimeHint, + httpHandlerHint, + socketHandlerDetected, + timerHandlersDetected, + membershipConnectionDetected, + }, + migrations: { + detected: modules.length > 0, + modules, + }, + recommended: { + packageMode, + nextSteps, + }, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (markdownOutput) { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Api repo contract audit: ${apiRepoPath}`); + console.log(`Status: ${result.ok ? "ready" : "blocked"}`); + console.log(`Package mode recommendation: ${packageMode}`); + console.log(`Auto-package ready: ${autoPackageReady ? "yes" : "no"}`); + console.log(`Deployment contract ready: ${contractReady ? "yes" : "no"}`); + if (errors.length > 0) { + console.log("Errors:"); + errors.forEach((error) => console.log(`- ${error}`)); + } + if (warnings.length > 0) { + console.log("Warnings:"); + warnings.forEach((warning) => console.log(`- ${warning}`)); + } + if (nextSteps.length > 0) { + console.log("Next steps:"); + nextSteps.forEach((step) => console.log(`- ${step}`)); + } + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/audit-environment-starter.mjs b/scripts/audit-environment-starter.mjs new file mode 100644 index 000000000..16cb74759 --- /dev/null +++ b/scripts/audit-environment-starter.mjs @@ -0,0 +1,352 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + optionalBlankKeys, + requiredFiles, + unsafeDefaultMatchers, +} from "./lib/environment-setup-metadata.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function isPlaceholder(value) { + return typeof value === "string" && value.includes("replace-me"); +} + +function isBlankString(value) { + return typeof value === "string" && value.trim() === ""; +} + +function isResolvedSecretValue(value) { + return typeof value === "string" && value.trim() !== "" && !value.includes("replace-me"); +} + +function isUnsafeDefault(fileName, key, value) { + const matcher = unsafeDefaultMatchers[fileName]?.[key]; + return typeof matcher === "function" ? matcher(value) : false; +} + +function auditFile(fileName, filePath, options = {}) { + const parsed = readJson(filePath); + const placeholders = []; + const unsafeDefaults = []; + const requiredBlankValues = []; + const optionalBlankValues = []; + const optionalBlankSet = optionalBlankKeys[fileName] || new Set(); + const resolvedBySecretFile = []; + const secretOverride = options.secretOverride || null; + + Object.entries(parsed).forEach(([key, value]) => { + const overrideValue = secretOverride?.[key]; + const resolvedByOverride = isResolvedSecretValue(overrideValue); + + if (isPlaceholder(value)) { + if (resolvedByOverride) { + resolvedBySecretFile.push({ key, value: overrideValue }); + return; + } + placeholders.push({ key, value }); + return; + } + + if (isUnsafeDefault(fileName, key, value)) { + if (resolvedByOverride) { + resolvedBySecretFile.push({ key, value: overrideValue }); + return; + } + unsafeDefaults.push({ key, value }); + return; + } + + if (isBlankString(value)) { + if (resolvedByOverride) { + resolvedBySecretFile.push({ key, value: overrideValue }); + return; + } + if (optionalBlankSet.has(key)) { + optionalBlankValues.push({ key, value }); + } else { + requiredBlankValues.push({ key, value }); + } + } + }); + + return { + fileName, + relativePath: path.relative(rootDir, filePath), + placeholders, + unsafeDefaults, + requiredBlankValues, + optionalBlankValues, + resolvedBySecretFile, + }; +} + +function buildNextSteps(displayedFiles) { + const steps = []; + + displayedFiles.forEach((fileAudit) => { + const blockerKeys = [ + ...fileAudit.placeholders.map((entry) => entry.key), + ...fileAudit.unsafeDefaults.map((entry) => entry.key), + ...fileAudit.requiredBlankValues.map((entry) => entry.key), + ]; + + if (blockerKeys.length === 0) return; + + steps.push({ + file: fileAudit.relativePath, + action: `Replace or fill ${blockerKeys.length} blocker value${blockerKeys.length === 1 ? "" : "s"}.`, + keys: blockerKeys, + }); + }); + + return steps; +} + +function buildSuggestions(environment, displayedFiles) { + const suggestions = []; + + displayedFiles.forEach((fileAudit) => { + const placeholderKeys = new Set(fileAudit.placeholders.map((entry) => entry.key)); + const unsafeDefaultKeys = new Set(fileAudit.unsafeDefaults.map((entry) => entry.key)); + const requiredBlankKeys = new Set(fileAudit.requiredBlankValues.map((entry) => entry.key)); + + if (fileAudit.fileName === "bootstrap-parameters.json" && (placeholderKeys.has("TemplateBucketName") || placeholderKeys.has("ArtifactBucketName") || requiredBlankKeys.has("TemplateBucketName") || requiredBlankKeys.has("ArtifactBucketName"))) { + suggestions.push({ + file: fileAudit.relativePath, + recommendation: "Choose globally unique S3 bucket names for the template and artifact buckets.", + example: `b1admin-${environment}-templates- and b1admin-${environment}-artifacts-`, + }); + } + + if (fileAudit.fileName === "backend-parameters.json" && (placeholderKeys.has("LambdaCodeS3Bucket") || requiredBlankKeys.has("LambdaCodeS3Bucket"))) { + suggestions.push({ + file: fileAudit.relativePath, + recommendation: "Point LambdaCodeS3Bucket at the same artifact bucket chosen in bootstrap-parameters.json.", + example: `b1admin-${environment}-artifacts-`, + }); + } + + if (fileAudit.fileName === "backend-parameters.json" && unsafeDefaultKeys.size > 0) { + suggestions.push({ + file: fileAudit.relativePath, + recommendation: "Replace the checked-in starter hostnames and support values with real environment URLs and contact details before the first deploy.", + example: `B1AdminRootUrl=https://admin-${environment}.yourdomain.com, CorsOrigin=https://admin-${environment}.yourdomain.com, SupportEmail=support@yourdomain.com`, + }); + } + + if (fileAudit.fileName === "app-config-secret.template.json" && ( + placeholderKeys.has("jwtSecret") + || placeholderKeys.has("encryptionKey") + || requiredBlankKeys.has("jwtSecret") + || requiredBlankKeys.has("encryptionKey") + || unsafeDefaultKeys.has("webPushSubject") + )) { + suggestions.push({ + file: fileAudit.relativePath, + recommendation: "Copy the template to app-config-secret.json, replace jwtSecret and encryptionKey with long random values, and set webPushSubject to the real support mailbox before syncing the secret.", + example: `cp infrastructure/environments/${environment}/app-config-secret.template.json infrastructure/environments/${environment}/app-config-secret.json`, + }); + } + }); + + return suggestions; +} + +function renderMarkdown(result) { + const lines = [ + `# Environment Starter Audit: ${result.environment}`, + "", + `- Status: ${result.ok ? "ready" : "blocked"}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Placeholders: ${result.summary.placeholderCount}`, + `- Unsafe starter defaults: ${result.summary.unsafeDefaultCount}`, + `- Required blanks: ${result.summary.requiredBlankCount}`, + `- Optional blanks: ${result.summary.optionalBlankCount}`, + ]; + + if (result.onlyBlockers) { + lines.push(`- Blocker-only view: yes (${result.blockerSummary.blockerCount} blockers)`); + } + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => { + lines.push(`- ${step.action} \`${step.file}\``); + lines.push(` Keys: ${step.keys.join(", ")}`); + }); + } + + if (result.suggestions.length > 0) { + lines.push("", "## Suggestions", ""); + result.suggestions.forEach((suggestion) => { + lines.push(`- ${suggestion.recommendation} \`${suggestion.file}\``); + lines.push(` Example: \`${suggestion.example}\``); + }); + } + + if (result.files.length > 0) { + lines.push("", "## Findings", ""); + result.files.forEach((fileAudit) => { + lines.push(`### ${fileAudit.relativePath}`, ""); + fileAudit.placeholders.forEach((entry) => lines.push(`- Placeholder: \`${entry.key}=${entry.value}\``)); + fileAudit.unsafeDefaults.forEach((entry) => lines.push(`- Unsafe starter default: \`${entry.key}=${entry.value}\``)); + fileAudit.requiredBlankValues.forEach((entry) => lines.push(`- Required blank: \`${entry.key}\``)); + fileAudit.optionalBlankValues.forEach((entry) => lines.push(`- Optional blank: \`${entry.key}\``)); + if (fileAudit.placeholders.length === 0 && fileAudit.unsafeDefaults.length === 0 && fileAudit.requiredBlankValues.length === 0 && fileAudit.optionalBlankValues.length === 0) { + lines.push("- No unresolved values."); + } + lines.push(""); + }); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const onlyBlockers = getArg("only-blockers", "false").toLowerCase() === "true"; + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + if (!fs.existsSync(environmentDir)) { + const message = `Unknown environment starter "${environment}". Expected a directory under infrastructure/environments/.`; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, environment, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const missingFiles = requiredFiles.filter((fileName) => !fs.existsSync(path.join(environmentDir, fileName))); + if (missingFiles.length > 0) { + const message = `Environment starter "${environment}" is missing required files: ${missingFiles.join(", ")}`; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, environment, missingFiles, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const secretOverridePath = path.join(environmentDir, "app-config-secret.json"); + const secretOverride = fs.existsSync(secretOverridePath) ? readJson(secretOverridePath) : null; + const fileAudits = requiredFiles.map((fileName) => auditFile( + fileName, + path.join(environmentDir, fileName), + fileName === "app-config-secret.template.json" ? { secretOverride } : {}, + )); + const summary = { + placeholderCount: fileAudits.reduce((total, fileAudit) => total + fileAudit.placeholders.length, 0), + unsafeDefaultCount: fileAudits.reduce((total, fileAudit) => total + fileAudit.unsafeDefaults.length, 0), + requiredBlankCount: fileAudits.reduce((total, fileAudit) => total + fileAudit.requiredBlankValues.length, 0), + optionalBlankCount: fileAudits.reduce((total, fileAudit) => total + fileAudit.optionalBlankValues.length, 0), + }; + const blockerSummary = { + placeholderCount: summary.placeholderCount, + unsafeDefaultCount: summary.unsafeDefaultCount, + requiredBlankCount: summary.requiredBlankCount, + blockerCount: summary.placeholderCount + summary.unsafeDefaultCount + summary.requiredBlankCount, + }; + const ok = summary.placeholderCount === 0 && summary.unsafeDefaultCount === 0 && summary.requiredBlankCount === 0; + const displayedFiles = onlyBlockers + ? fileAudits + .filter((fileAudit) => fileAudit.placeholders.length > 0 || fileAudit.unsafeDefaults.length > 0 || fileAudit.requiredBlankValues.length > 0) + .map((fileAudit) => ({ + ...fileAudit, + optionalBlankValues: [], + })) + : fileAudits; + + const result = { + ok, + environment, + onlyBlockers, + environmentDir: path.relative(rootDir, environmentDir), + summary, + blockerSummary, + nextSteps: buildNextSteps(displayedFiles), + suggestions: buildSuggestions(environment, displayedFiles), + files: displayedFiles, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (markdownOutput) { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Environment starter audit: ${environment}`); + console.log(`Path: ${result.environmentDir}`); + console.log(`Placeholders: ${summary.placeholderCount}`); + console.log(`Unsafe starter defaults: ${summary.unsafeDefaultCount}`); + console.log(`Required blanks: ${summary.requiredBlankCount}`); + console.log(`Optional blanks: ${summary.optionalBlankCount}`); + if (onlyBlockers) { + console.log(`Showing blocker-only view (${blockerSummary.blockerCount} items).`); + } + + if (result.nextSteps.length > 0) { + console.log("\nNext steps:"); + result.nextSteps.forEach((step) => { + console.log(`- ${step.action} ${step.file}`); + console.log(` Keys: ${step.keys.join(", ")}`); + }); + } + + if (result.suggestions.length > 0) { + console.log("\nSuggestions:"); + result.suggestions.forEach((suggestion) => { + console.log(`- ${suggestion.recommendation} ${suggestion.file}`); + console.log(` Example: ${suggestion.example}`); + }); + } + + displayedFiles.forEach((fileAudit) => { + if (fileAudit.placeholders.length === 0 && fileAudit.unsafeDefaults.length === 0 && fileAudit.requiredBlankValues.length === 0 && fileAudit.optionalBlankValues.length === 0) { + return; + } + + console.log(`\n${fileAudit.relativePath}`); + fileAudit.placeholders.forEach((entry) => console.log(` placeholder: ${entry.key}=${entry.value}`)); + fileAudit.unsafeDefaults.forEach((entry) => console.log(` unsafe starter default: ${entry.key}=${entry.value}`)); + fileAudit.requiredBlankValues.forEach((entry) => console.log(` required blank: ${entry.key}`)); + fileAudit.optionalBlankValues.forEach((entry) => console.log(` optional blank: ${entry.key}`)); + }); + } + + process.exit(ok ? 0 : 1); +} + +main(); diff --git a/scripts/bootstrap-initial-admin.mjs b/scripts/bootstrap-initial-admin.mjs new file mode 100644 index 000000000..2484c894a --- /dev/null +++ b/scripts/bootstrap-initial-admin.mjs @@ -0,0 +1,798 @@ +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import bcrypt from "bcryptjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function parseBoolean(value, fallback) { + if (value === "") return fallback; + return String(value).toLowerCase() === "true"; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function loadJsonFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return JSON.parse(fs.readFileSync(resolved, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function runAwsJson(args, failureLabel) { + try { + return JSON.parse(execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`${failureLabel}: ${message}`); + } +} + +function getStackOutputs(stackName, region) { + const response = runAwsJson([ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], `Could not read stack "${stackName}"`); + + return normalizeOutputs(response); +} + +function getSecretJson(secretId, region) { + const result = runAwsJson([ + "secretsmanager", + "get-secret-value", + "--secret-id", + secretId, + "--region", + region, + "--output", + "json", + ], `Could not read Secrets Manager secret "${secretId}"`); + + if (!result.SecretString) fail(`Secret does not contain SecretString: ${secretId}`); + + try { + return JSON.parse(result.SecretString); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`SecretString for "${secretId}" is not valid JSON: ${message}`); + } +} + +function requireValue(name, value) { + if (!value) fail(`Missing required value: ${name}`); + return value; +} + +function escapeSqlString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''"); +} + +function sqlValue(value) { + if (value === null || value === undefined) return "NULL"; + return `'${escapeSqlString(value)}'`; +} + +function sqlOptionalString(value) { + if (value === null || value === undefined || value === "") return "NULL"; + return sqlValue(value); +} + +function randomId(length = 11) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = crypto.randomBytes(length); + let result = ""; + for (let index = 0; index < length; index += 1) { + result += alphabet[bytes[index] % alphabet.length]; + } + return result; +} + +function slugifySubdomain(input) { + return String(input) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "") + .slice(0, 99); +} + +function decodeField(field) { + if (!field || typeof field !== "object") return null; + if (field.isNull) return null; + if ("stringValue" in field) return field.stringValue; + if ("longValue" in field) return field.longValue; + if ("doubleValue" in field) return field.doubleValue; + if ("booleanValue" in field) return field.booleanValue; + return null; +} + +function executeStatement({ region, resourceArn, secretArn, database, sql, transactionId, includeResultMetadata = false }) { + const args = [ + "rds-data", + "execute-statement", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--database", + database, + "--sql", + sql, + "--output", + "json", + ]; + if (transactionId) { + args.push("--transaction-id", transactionId); + } + if (includeResultMetadata) { + args.push("--include-result-metadata"); + } + return runAwsJson(args, "Could not execute bootstrap SQL statement"); +} + +function queryRows(context, sql) { + const result = executeStatement({ ...context, sql, includeResultMetadata: true }); + const columns = (result.columnMetadata || []).map((column) => column.name || ""); + const records = result.records || []; + return records.map((record) => { + const row = {}; + record.forEach((field, index) => { + row[columns[index] || `column${index + 1}`] = decodeField(field); + }); + return row; + }); +} + +function beginTransaction({ region, resourceArn, secretArn, database }) { + const result = runAwsJson([ + "rds-data", + "begin-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--database", + database, + "--output", + "json", + ], "Could not start bootstrap transaction"); + return result.transactionId; +} + +function commitTransaction({ region, resourceArn, secretArn, transactionId }) { + runAwsJson([ + "rds-data", + "commit-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--transaction-id", + transactionId, + "--output", + "json", + ], "Could not commit bootstrap transaction"); +} + +function rollbackTransaction({ region, resourceArn, secretArn, transactionId }) { + try { + runAwsJson([ + "rds-data", + "rollback-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--transaction-id", + transactionId, + "--output", + "json", + ], "Could not roll back bootstrap transaction"); + } catch (_error) { + // Best-effort rollback only. + } +} + +function ensureSingleRolePermission(context, { churchId, roleId, apiName, contentType, action }) { + const roleClause = roleId ? `roleId=${sqlValue(roleId)}` : "roleId IS NULL"; + const existing = queryRows(context, ` + SELECT id + FROM rolePermissions + WHERE churchId=${sqlValue(churchId)} + AND ${roleClause} + AND apiName=${sqlValue(apiName)} + AND contentType=${sqlValue(contentType)} + AND action=${sqlValue(action)} + LIMIT 1 + `); + + if (existing.length > 0) return { id: existing[0].id, created: false }; + + const id = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO rolePermissions (id, churchId, roleId, apiName, contentType, contentId, action) + VALUES (${sqlValue(id)}, ${sqlValue(churchId)}, ${roleId ? sqlValue(roleId) : "NULL"}, ${sqlValue(apiName)}, ${sqlValue(contentType)}, NULL, ${sqlValue(action)}) + `, + }); + return { id, created: true }; +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name"); + const outputsFile = getArg("outputs-file"); + const dbClusterArn = getArg("db-cluster-arn"); + const dbSecretArn = getArg("db-secret-arn"); + const bootstrapSecretFile = getArg("bootstrap-admin-secret-file"); + const bootstrapSecretArn = getArg("bootstrap-admin-secret-arn"); + const dryRun = parseBoolean(getArg("dry-run", "false"), false); + const resetPassword = parseBoolean(getArg("bootstrap-admin-reset-password", "true"), true); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + + if (!stackName && !outputsFile) { + fail("Provide --stack-name or --outputs-file."); + } + + const outputs = stackName + ? getStackOutputs(stackName, region) + : normalizeOutputs(loadJsonFile(outputsFile, "stack outputs file")); + + const resolvedDbClusterArn = dbClusterArn || outputs.DatabaseClusterArn || ""; + const resolvedDbSecretArn = dbSecretArn || outputs.DatabaseSecretArn || ""; + const membershipDatabaseName = outputs.MembershipDatabaseName || outputs.DatabaseName || "membership"; + + requireValue("db-cluster-arn or DatabaseClusterArn output", resolvedDbClusterArn); + requireValue("db-secret-arn or DatabaseSecretArn output", resolvedDbSecretArn); + + let bootstrapSecret = {}; + if (bootstrapSecretFile) bootstrapSecret = loadJsonFile(bootstrapSecretFile, "bootstrap admin secret file"); + else if (bootstrapSecretArn) bootstrapSecret = getSecretJson(bootstrapSecretArn, region); + + const adminEmail = getArg("bootstrap-admin-email", bootstrapSecret.email || "").trim().toLowerCase(); + const adminPassword = getArg("bootstrap-admin-password", bootstrapSecret.password || ""); + const adminFirstName = getArg("bootstrap-admin-first-name", bootstrapSecret.firstName || "Admin").trim(); + const adminLastName = getArg("bootstrap-admin-last-name", bootstrapSecret.lastName || "User").trim(); + const adminDisplayName = getArg( + "bootstrap-admin-display-name", + bootstrapSecret.displayName || `${adminFirstName} ${adminLastName}`.trim() + ).trim(); + const churchName = getArg("bootstrap-church-name", bootstrapSecret.churchName || "").trim(); + const churchSubdomain = getArg( + "bootstrap-church-subdomain", + bootstrapSecret.churchSubdomain || slugifySubdomain(churchName) + ).trim().toLowerCase(); + const churchAddress1 = getArg("bootstrap-church-address1", bootstrapSecret.address1 || ""); + const churchAddress2 = getArg("bootstrap-church-address2", bootstrapSecret.address2 || ""); + const churchCity = getArg("bootstrap-church-city", bootstrapSecret.city || ""); + const churchState = getArg("bootstrap-church-state", bootstrapSecret.state || ""); + const churchZip = getArg("bootstrap-church-zip", bootstrapSecret.zip || ""); + const churchCountry = getArg("bootstrap-church-country", bootstrapSecret.country || "USA"); + const membershipStatus = getArg("bootstrap-membership-status", bootstrapSecret.membershipStatus || "Staff"); + + requireValue("bootstrap-admin-email", adminEmail); + requireValue("bootstrap-admin-password", adminPassword); + requireValue("bootstrap-church-name", churchName); + requireValue("bootstrap-church-subdomain", churchSubdomain); + + if (!/^[a-z0-9]{1,99}$/.test(churchSubdomain)) { + fail(`bootstrap-church-subdomain must contain only lowercase letters and numbers: "${churchSubdomain}"`); + } + + const summary = { + ok: true, + stackName, + outputsFile, + region, + dryRun, + resetPassword, + databaseClusterArn: resolvedDbClusterArn, + databaseSecretArn: resolvedDbSecretArn, + membershipDatabaseName, + bootstrapSecretSource: bootstrapSecretFile + ? path.resolve(rootDir, bootstrapSecretFile) + : bootstrapSecretArn || "", + adminEmail, + adminDisplayName, + churchName, + churchSubdomain, + membershipStatus, + created: { + church: false, + user: false, + person: false, + userChurch: false, + domainAdminsRole: false, + allMembersRole: false, + domainAdminMembership: false, + allMembersMembership: false, + everyoneEditSelfPermission: false, + everyoneAttendancePermission: false, + domainAdminPermission: false, + allMembersPermission: false, + }, + updated: { + church: false, + user: false, + person: false, + userChurch: false, + }, + ids: {}, + }; + + if (dryRun) { + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + + console.log("\nBootstrap initial admin helper ready."); + console.log(`Admin email: ${adminEmail}`); + console.log(`Church: ${churchName} (${churchSubdomain})`); + console.log(`Database: ${membershipDatabaseName}`); + console.log("Dry run only. No changes were made."); + return; + } + + const hashedPassword = bcrypt.hashSync(adminPassword, 10); + const transactionId = beginTransaction({ + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + database: membershipDatabaseName, + }); + const context = { + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + database: membershipDatabaseName, + transactionId, + }; + + try { + let churchRow = queryRows(context, ` + SELECT id, name, subDomain + FROM churches + WHERE subDomain=${sqlValue(churchSubdomain)} + LIMIT 1 + `)[0]; + + if (!churchRow) { + const churchId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO churches (id, name, subDomain, registrationDate, address1, address2, city, state, zip, country) + VALUES ( + ${sqlValue(churchId)}, + ${sqlValue(churchName)}, + ${sqlValue(churchSubdomain)}, + NOW(), + ${sqlOptionalString(churchAddress1)}, + ${sqlOptionalString(churchAddress2)}, + ${sqlOptionalString(churchCity)}, + ${sqlOptionalString(churchState)}, + ${sqlOptionalString(churchZip)}, + ${sqlOptionalString(churchCountry)} + ) + `, + }); + churchRow = { id: churchId, name: churchName, subDomain: churchSubdomain }; + summary.created.church = true; + } else { + executeStatement({ + ...context, + sql: ` + UPDATE churches + SET + name=${sqlValue(churchName)}, + address1=${sqlOptionalString(churchAddress1)}, + address2=${sqlOptionalString(churchAddress2)}, + city=${sqlOptionalString(churchCity)}, + state=${sqlOptionalString(churchState)}, + zip=${sqlOptionalString(churchZip)}, + country=${sqlOptionalString(churchCountry)}, + archivedDate=NULL + WHERE id=${sqlValue(churchRow.id)} + `, + }); + summary.updated.church = true; + } + summary.ids.churchId = churchRow.id; + + let userRow = queryRows(context, ` + SELECT id + FROM users + WHERE email=${sqlValue(adminEmail)} + LIMIT 1 + `)[0]; + + if (!userRow) { + const userId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO users (id, email, password, authGuid, displayName, registrationDate, lastLogin, firstName, lastName) + VALUES ( + ${sqlValue(userId)}, + ${sqlValue(adminEmail)}, + ${sqlValue(hashedPassword)}, + '', + ${sqlValue(adminDisplayName)}, + NOW(), + NULL, + ${sqlValue(adminFirstName)}, + ${sqlValue(adminLastName)} + ) + `, + }); + userRow = { id: userId }; + summary.created.user = true; + } else { + const updateSegments = [ + `email=${sqlValue(adminEmail)}`, + `displayName=${sqlValue(adminDisplayName)}`, + `firstName=${sqlValue(adminFirstName)}`, + `lastName=${sqlValue(adminLastName)}`, + ]; + if (resetPassword) updateSegments.push(`password=${sqlValue(hashedPassword)}`); + executeStatement({ + ...context, + sql: ` + UPDATE users + SET ${updateSegments.join(", ")} + WHERE id=${sqlValue(userRow.id)} + `, + }); + summary.updated.user = true; + } + summary.ids.userId = userRow.id; + + let domainAdminsRole = queryRows(context, ` + SELECT id + FROM roles + WHERE churchId=${sqlValue(churchRow.id)} + AND name='Domain Admins' + LIMIT 1 + `)[0]; + if (!domainAdminsRole) { + const roleId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO roles (id, churchId, name) + VALUES (${sqlValue(roleId)}, ${sqlValue(churchRow.id)}, 'Domain Admins') + `, + }); + domainAdminsRole = { id: roleId }; + summary.created.domainAdminsRole = true; + } + + let allMembersRole = queryRows(context, ` + SELECT id + FROM roles + WHERE churchId=${sqlValue(churchRow.id)} + AND name='All Members' + LIMIT 1 + `)[0]; + if (!allMembersRole) { + const roleId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO roles (id, churchId, name) + VALUES (${sqlValue(roleId)}, ${sqlValue(churchRow.id)}, 'All Members') + `, + }); + allMembersRole = { id: roleId }; + summary.created.allMembersRole = true; + } + + summary.ids.domainAdminsRoleId = domainAdminsRole.id; + summary.ids.allMembersRoleId = allMembersRole.id; + + const domainAdminPermission = ensureSingleRolePermission(context, { + churchId: churchRow.id, + roleId: domainAdminsRole.id, + apiName: "MembershipApi", + contentType: "Domain", + action: "Admin", + }); + summary.ids.domainAdminPermissionId = domainAdminPermission.id; + summary.created.domainAdminPermission = domainAdminPermission.created; + + const allMembersPermissionRows = queryRows(context, ` + SELECT id + FROM rolePermissions + WHERE churchId=${sqlValue(churchRow.id)} + AND roleId=${sqlValue(allMembersRole.id)} + AND apiName='MembershipApi' + AND contentType='People' + AND action='View Members' + LIMIT 1 + `); + if (allMembersPermissionRows.length === 0) { + const permissionId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO rolePermissions (id, churchId, roleId, apiName, contentType, contentId, action) + VALUES (${sqlValue(permissionId)}, ${sqlValue(churchRow.id)}, ${sqlValue(allMembersRole.id)}, 'MembershipApi', 'People', NULL, 'View Members') + `, + }); + summary.created.allMembersPermission = true; + summary.ids.allMembersPermissionId = permissionId; + } else { + summary.ids.allMembersPermissionId = allMembersPermissionRows[0].id; + } + + const everyoneEditSelfRows = queryRows(context, ` + SELECT id + FROM rolePermissions + WHERE churchId=${sqlValue(churchRow.id)} + AND roleId IS NULL + AND apiName='MembershipApi' + AND contentType='People' + AND action='Edit Self' + LIMIT 1 + `); + if (everyoneEditSelfRows.length === 0) { + const permissionId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO rolePermissions (id, churchId, roleId, apiName, contentType, contentId, action) + VALUES (${sqlValue(permissionId)}, ${sqlValue(churchRow.id)}, NULL, 'MembershipApi', 'People', NULL, 'Edit Self') + `, + }); + summary.created.everyoneEditSelfPermission = true; + summary.ids.everyoneEditSelfPermissionId = permissionId; + } else { + summary.ids.everyoneEditSelfPermissionId = everyoneEditSelfRows[0].id; + } + + const everyoneAttendanceRows = queryRows(context, ` + SELECT id + FROM rolePermissions + WHERE churchId=${sqlValue(churchRow.id)} + AND roleId IS NULL + AND apiName='AttendanceApi' + AND contentType='Attendance' + AND action='Checkin' + LIMIT 1 + `); + if (everyoneAttendanceRows.length === 0) { + const permissionId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO rolePermissions (id, churchId, roleId, apiName, contentType, contentId, action) + VALUES (${sqlValue(permissionId)}, ${sqlValue(churchRow.id)}, NULL, 'AttendanceApi', 'Attendance', NULL, 'Checkin') + `, + }); + summary.created.everyoneAttendancePermission = true; + summary.ids.everyoneAttendancePermissionId = permissionId; + } else { + summary.ids.everyoneAttendancePermissionId = everyoneAttendanceRows[0].id; + } + + let personRow = queryRows(context, ` + SELECT id + FROM people + WHERE churchId=${sqlValue(churchRow.id)} + AND userId=${sqlValue(userRow.id)} + LIMIT 1 + `)[0]; + + if (!personRow) { + personRow = queryRows(context, ` + SELECT id + FROM people + WHERE churchId=${sqlValue(churchRow.id)} + AND email=${sqlValue(adminEmail)} + LIMIT 1 + `)[0]; + } + + if (!personRow) { + const personId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO people (id, churchId, userId, displayName, firstName, lastName, email, membershipStatus, removed) + VALUES ( + ${sqlValue(personId)}, + ${sqlValue(churchRow.id)}, + ${sqlValue(userRow.id)}, + ${sqlValue(adminDisplayName)}, + ${sqlValue(adminFirstName)}, + ${sqlValue(adminLastName)}, + ${sqlValue(adminEmail)}, + ${sqlValue(membershipStatus)}, + b'0' + ) + `, + }); + personRow = { id: personId }; + summary.created.person = true; + } else { + executeStatement({ + ...context, + sql: ` + UPDATE people + SET + userId=${sqlValue(userRow.id)}, + displayName=${sqlValue(adminDisplayName)}, + firstName=${sqlValue(adminFirstName)}, + lastName=${sqlValue(adminLastName)}, + email=${sqlValue(adminEmail)}, + membershipStatus=${sqlValue(membershipStatus)}, + removed=b'0' + WHERE id=${sqlValue(personRow.id)} + AND churchId=${sqlValue(churchRow.id)} + `, + }); + summary.updated.person = true; + } + summary.ids.personId = personRow.id; + + const userChurchRow = queryRows(context, ` + SELECT id + FROM userChurches + WHERE churchId=${sqlValue(churchRow.id)} + AND userId=${sqlValue(userRow.id)} + LIMIT 1 + `)[0]; + + if (!userChurchRow) { + const userChurchId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO userChurches (id, userId, churchId, personId, lastAccessed) + VALUES (${sqlValue(userChurchId)}, ${sqlValue(userRow.id)}, ${sqlValue(churchRow.id)}, ${sqlValue(personRow.id)}, NOW()) + `, + }); + summary.created.userChurch = true; + summary.ids.userChurchId = userChurchId; + } else { + executeStatement({ + ...context, + sql: ` + UPDATE userChurches + SET + personId=${sqlValue(personRow.id)}, + lastAccessed=NOW() + WHERE id=${sqlValue(userChurchRow.id)} + `, + }); + summary.updated.userChurch = true; + summary.ids.userChurchId = userChurchRow.id; + } + + const domainRoleMemberRows = queryRows(context, ` + SELECT id + FROM roleMembers + WHERE churchId=${sqlValue(churchRow.id)} + AND roleId=${sqlValue(domainAdminsRole.id)} + AND userId=${sqlValue(userRow.id)} + LIMIT 1 + `); + if (domainRoleMemberRows.length === 0) { + const roleMemberId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO roleMembers (id, churchId, roleId, userId, dateAdded, addedBy) + VALUES (${sqlValue(roleMemberId)}, ${sqlValue(churchRow.id)}, ${sqlValue(domainAdminsRole.id)}, ${sqlValue(userRow.id)}, NOW(), ${sqlValue(userRow.id)}) + `, + }); + summary.created.domainAdminMembership = true; + summary.ids.domainAdminMembershipId = roleMemberId; + } else { + summary.ids.domainAdminMembershipId = domainRoleMemberRows[0].id; + } + + const allMembersRoleMemberRows = queryRows(context, ` + SELECT id + FROM roleMembers + WHERE churchId=${sqlValue(churchRow.id)} + AND roleId=${sqlValue(allMembersRole.id)} + AND userId=${sqlValue(userRow.id)} + LIMIT 1 + `); + if (allMembersRoleMemberRows.length === 0) { + const roleMemberId = randomId(); + executeStatement({ + ...context, + sql: ` + INSERT INTO roleMembers (id, churchId, roleId, userId, dateAdded, addedBy) + VALUES (${sqlValue(roleMemberId)}, ${sqlValue(churchRow.id)}, ${sqlValue(allMembersRole.id)}, ${sqlValue(userRow.id)}, NOW(), ${sqlValue(userRow.id)}) + `, + }); + summary.created.allMembersMembership = true; + summary.ids.allMembersMembershipId = roleMemberId; + } else { + summary.ids.allMembersMembershipId = allMembersRoleMemberRows[0].id; + } + + commitTransaction({ + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + transactionId, + }); + } catch (error) { + rollbackTransaction({ + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + transactionId, + }); + throw error; + } + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + + console.log("\nBootstrap initial admin complete."); + console.log(`Admin email: ${summary.adminEmail}`); + console.log(`Church: ${summary.churchName} (${summary.churchSubdomain})`); + console.log(`Membership database: ${summary.membershipDatabaseName}`); + console.log(`Reset password on rerun: ${summary.resetPassword ? "yes" : "no"}`); +} + +main(); diff --git a/scripts/deploy-aws.mjs b/scripts/deploy-aws.mjs new file mode 100644 index 000000000..f914528a4 --- /dev/null +++ b/scripts/deploy-aws.mjs @@ -0,0 +1,811 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createProgressLogger } from "./lib/progress-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function getBooleanArgString(name, fallback = "") { + return hasFlag(name) ? "true" : getArg(name, fallback); +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(scriptPath, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> node ${scriptPath} ${args.join(" ")}`); + + try { + return execFileSync("node", [scriptPath, ...args], { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runNodeJson(scriptPath, args) { + try { + return JSON.parse(execFileSync("node", [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + exitForCommandError(error, true); + } +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function addArg(args, name, value) { + if (value !== undefined && value !== null && value !== "") { + args.push(`--${name}=${value}`); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function deriveArtifactKey(projectName, environmentName, fileName) { + return `${projectName}/${environmentName}/backend/${fileName}`; +} + +function ensureFrontendPublishPrerequisites(skipBuild) { + const distDir = path.join(rootDir, "dist"); + const serviceWorkerPath = path.join(distDir, "sw.js"); + + if (skipBuild) { + if (!fs.existsSync(distDir)) { + fail(`Build output not found: ${distDir}`); + } + if (!fs.existsSync(serviceWorkerPath)) { + fail(`Expected service worker not found: ${serviceWorkerPath}`); + } + return; + } + + const nodeModulesPath = path.join(rootDir, "node_modules"); + const viteCliPath = path.join(nodeModulesPath, "vite", "dist", "node", "cli.js"); + if (!fs.existsSync(nodeModulesPath) || !fs.existsSync(viteCliPath)) { + fail(`Frontend dependencies are not installed: ${nodeModulesPath}`); + } +} + +function loadApiRepoMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); + } +} + +function validateApiMigrationArgs(action, moduleName) { + const validActions = ["up", "down", "status"]; + const validModules = ["all", "membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; + + if (action && !validActions.includes(action)) { + fail(`Invalid api-migration-action "${action}". Use up, down, or status.`); + } + + if (moduleName && !validModules.includes(moduleName)) { + fail(`Invalid api-migration-module "${moduleName}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); + } +} + +function validateApiMigrationRunner(runner) { + if (runner && !["direct", "data-api"].includes(runner)) { + fail(`Invalid api-migration-runner "${runner}". Use direct or data-api.`); + } +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function normalizeParameters(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((parameter) => [parameter.ParameterKey, parameter.ParameterValue])); + if (raw.Stacks?.[0]?.Parameters) return normalizeParameters(raw.Stacks[0].Parameters); + if (raw.Parameters) return normalizeParameters(raw.Parameters); + return raw; +} + +function describeStackSafe(stackName, region) { + try { + return runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + } catch (error) { + const stderr = String(error?.stderr || ""); + if (stderr.includes("does not exist")) return null; + throw error; + } +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function getStackParametersSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return normalizeParameters(describeStackSafe(stackName, region)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}" parameters: ${message}`); + } +} + +function loadJson(filePath, label = "JSON file") { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function resolveManifestArtifactPath(manifestFilePath, artifactPath) { + if (!artifactPath) return ""; + if (path.isAbsolute(artifactPath)) return artifactPath; + return path.resolve(path.dirname(manifestFilePath), artifactPath); +} + +function loadParamsFromFile(filePath) { + if (!filePath) return {}; + + try { + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + + if (Array.isArray(data)) { + return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + } + + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load parameters file "${filePath}": ${message}`); + } +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const backendParametersFile = getArg("backend-parameters-file"); + const frontendParametersFile = getArg("frontend-parameters-file"); + const backendFileParams = loadParamsFromFile(backendParametersFile); + const frontendFileParams = loadParamsFromFile(frontendParametersFile); + const environment = getArg("environment", backendFileParams.EnvironmentName || frontendFileParams.EnvironmentName || "prod"); + const projectName = getArg("project-name", backendFileParams.ProjectName || frontendFileParams.ProjectName || "b1admin"); + const bootstrapStackName = getArg("bootstrap-stack-name"); + const backendStackName = getArg("backend-stack-name", `${projectName}-${environment}-backend`); + const backendOutputsFile = getArg("backend-outputs-file"); + const frontendStackName = getArg("frontend-stack-name", `${projectName}-${environment}-frontend`); + const packageManifestFile = getArg("package-manifest-file"); + const frontendOutputsFile = getArg("frontend-outputs-file"); + const frontendPublishBucket = getArg("bucket"); + const frontendPublishDistributionId = getArg("distribution-id"); + const frontendPublishAppUrl = getArg("app-url"); + const apiRepoPath = getArg("api-repo-path"); + const packageApiBackend = !packageManifestFile && (apiRepoPath !== "" || getArg("package-api-backend", "false").toLowerCase() === "true"); + const packageMode = getArg("package-mode", "self-contained"); + const packageOutputDir = getArg("package-output-dir", "infrastructure/artifacts/api"); + const packageBuild = getArg("package-build", "true"); + const packageBuildLayer = getArg("package-build-layer", packageMode === "layered" ? "true" : "false"); + const backendArtifactSourceFile = getArg("backend-artifact-source-file"); + const backendArtifactKey = getArg("backend-artifact-key"); + const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); + const lambdaCodeS3Bucket = getArg("lambda-code-s3-bucket"); + let resolvedLambdaCodeS3Key = getArg("lambda-code-s3-key", backendArtifactKey); + const runMigrations = getArg("run-migrations"); + const migrationCodeS3Bucket = getArg("migration-code-s3-bucket"); + let resolvedMigrationCodeS3Key = getArg("migration-code-s3-key"); + const migrationHandler = getArg("migration-handler"); + const migrationRuntime = getArg("migration-runtime"); + const migrationMemorySize = getArg("migration-memory-size"); + const migrationTimeout = getArg("migration-timeout"); + const migrationTrigger = getArg("migration-trigger"); + const dependenciesLayerArn = getArg("dependencies-layer-arn"); + const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); + const dependenciesLayerName = getArg("dependencies-layer-name", `${projectName}-${environment}-dependencies`); + const dependenciesLayerDescription = getArg("dependencies-layer-description", `${projectName} ${environment} backend dependencies`); + const dependenciesLayerLicenseInfo = getArg("dependencies-layer-license-info"); + const dependenciesLayerCompatibleRuntimes = getArg("dependencies-layer-compatible-runtimes", "nodejs22.x"); + const dependenciesLayerCompatibleArchitectures = getArg("dependencies-layer-compatible-architectures", "arm64"); + const observabilityLayerArn = getArg("observability-layer-arn"); + const lambdaNodeOptions = getArg("lambda-node-options"); + const membershipDatabaseName = getArg("membership-database-name"); + const attendanceDatabaseName = getArg("attendance-database-name"); + const contentDatabaseName = getArg("content-database-name"); + const givingDatabaseName = getArg("giving-database-name"); + const messagingDatabaseName = getArg("messaging-database-name"); + const doingDatabaseName = getArg("doing-database-name"); + const reportingDatabaseName = getArg("reporting-database-name"); + const enableWebSocketApi = getArg("enable-web-socket-api"); + const socketLambdaHandler = getArg("socket-lambda-handler"); + const socketLambdaMemorySize = getArg("socket-lambda-memory-size"); + const socketLambdaTimeout = getArg("socket-lambda-timeout"); + const enableScheduledWorkers = getArg("enable-scheduled-workers"); + const timer15MinLambdaHandler = getArg("timer15-min-lambda-handler"); + const timerMidnightLambdaHandler = getArg("timer-midnight-lambda-handler"); + const timerScheduledTasksLambdaHandler = getArg("timer-scheduled-tasks-lambda-handler"); + const timerWebhooksLambdaHandler = getArg("timer-webhooks-lambda-handler"); + const timerLambdaMemorySize = getArg("timer-lambda-memory-size"); + const timerLambdaTimeout = getArg("timer-lambda-timeout"); + const apiCustomDomainName = getArg("api-custom-domain-name"); + const apiCertificateArn = getArg("api-certificate-arn"); + const apiHostedZoneId = getArg("api-hosted-zone-id"); + const b1AdminRootUrl = getArg("b1-admin-root-url"); + const corsOrigin = getArg("cors-origin"); + const fileStore = getArg("file-store"); + const manageAssetBucket = getArg("manage-asset-bucket"); + const assetBucketName = getArg("asset-bucket-name"); + const appConfigSecretArn = getArg("app-config-secret-arn"); + const appConfigSecretFile = getArg("app-config-secret-file"); + const appConfigSecretName = getArg("app-config-secret-name", `${projectName}/${environment}/app-config`); + const appConfigSecretId = getArg("app-config-secret-id"); + const appConfigSecretDescription = getArg("app-config-secret-description", `${projectName} ${environment} backend app config`); + const appConfigSecretKmsKeyId = getArg("app-config-secret-kms-key-id"); + const syncLegacySsm = getBooleanArgString("sync-legacy-ssm"); + const runApiMigrations = getBooleanArgString("run-api-migrations"); + const apiMigrationAction = getArg("api-migration-action"); + const apiMigrationModule = getArg("api-migration-module"); + const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path"); + const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); + const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); + const apiMigrationDryRun = getArg("api-migration-dry-run"); + const runBootstrapAdmin = getBooleanArgString("run-bootstrap-admin"); + const bootstrapAdminSecretFile = getArg("bootstrap-admin-secret-file"); + const bootstrapAdminSecretArn = getArg("bootstrap-admin-secret-arn"); + const bootstrapAdminEmail = getArg("bootstrap-admin-email"); + const bootstrapAdminPassword = getArg("bootstrap-admin-password"); + const bootstrapAdminFirstName = getArg("bootstrap-admin-first-name"); + const bootstrapAdminLastName = getArg("bootstrap-admin-last-name"); + const bootstrapAdminDisplayName = getArg("bootstrap-admin-display-name"); + const bootstrapChurchName = getArg("bootstrap-church-name"); + const bootstrapChurchSubdomain = getArg("bootstrap-church-subdomain"); + const bootstrapChurchAddress1 = getArg("bootstrap-church-address1"); + const bootstrapChurchAddress2 = getArg("bootstrap-church-address2"); + const bootstrapChurchCity = getArg("bootstrap-church-city"); + const bootstrapChurchState = getArg("bootstrap-church-state"); + const bootstrapChurchZip = getArg("bootstrap-church-zip"); + const bootstrapChurchCountry = getArg("bootstrap-church-country"); + const bootstrapMembershipStatus = getArg("bootstrap-membership-status"); + const bootstrapAdminResetPassword = getArg("bootstrap-admin-reset-password"); + const legacySsmPrefix = getArg("legacy-ssm-prefix"); + const legacySsmIncludeEmpty = getArg("legacy-ssm-include-empty"); + const legacySsmOverwrite = getArg("legacy-ssm-overwrite"); + const mailSystem = getArg("mail-system"); + const deliveryProvider = getArg("delivery-provider"); + const storeApiUrl = getArg("store-api-url"); + const aiProvider = getArg("ai-provider"); + const emailOnRegistration = getArg("email-on-registration"); + const caddyHost = getArg("caddy-host"); + const caddyPort = getArg("caddy-port"); + const frontendBucketName = getArg("frontend-bucket-name", frontendFileParams.BucketName); + const frontendAlternateDomainName = getArg("frontend-alternate-domain-name", frontendFileParams.AlternateDomainName); + const frontendAcmCertificateArn = getArg("frontend-acm-certificate-arn", frontendFileParams.AcmCertificateArn); + const frontendHostedZoneId = getArg("frontend-hosted-zone-id", frontendFileParams.HostedZoneId); + const frontendPriceClass = getArg("frontend-price-class", frontendFileParams.PriceClass); + const frontendInfrastructureOnly = hasFlag("frontend-infrastructure-only"); + const publishFrontendAssets = hasFlag("publish-frontend-assets"); + const skipBackend = hasFlag("skip-backend"); + const skipFrontend = hasFlag("skip-frontend"); + const skipBuild = hasFlag("skip-build"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const withProgress = createProgressLogger({ quiet: jsonOutput }); + + if (publishFrontendAssets && frontendInfrastructureOnly) { + console.error("--publish-frontend-assets cannot be combined with --frontend-infrastructure-only. Use --frontend-infrastructure-only for the hosting-only phase, then run a later publish follow-up with --skip-frontend --publish-frontend-assets."); + process.exit(1); + } + + if (publishFrontendAssets && !skipFrontend) { + console.error("--publish-frontend-assets only applies to staged frontend follow-up runs. Use it in a later phase with --skip-frontend after the frontend hosting stack already exists."); + process.exit(1); + } + + if (frontendInfrastructureOnly && skipFrontend) { + console.error("--frontend-infrastructure-only and --skip-frontend cannot be used together. Skipping the frontend step prevents frontend hosting from being provisioned."); + process.exit(1); + } + + if (skipBuild && !publishFrontendAssets && !skipFrontend && !frontendInfrastructureOnly) { + console.error("--skip-build only applies when frontend assets are being published. Use it with a normal frontend deploy, a staged publish follow-up, or publish:frontend-assets directly."); + process.exit(1); + } + + if (skipBuild && !publishFrontendAssets && (skipFrontend || frontendInfrastructureOnly)) { + console.error("--skip-build has no effect when frontend publishing is deferred. Remove it or use it later during the publish phase."); + process.exit(1); + } + + if (runApiMigrations && skipBackend) { + console.error("--run-api-migrations=true requires the backend deploy step. Remove --skip-backend or run yarn run:api-migrations separately afterward."); + process.exit(1); + } + + if (runBootstrapAdmin && skipBackend) { + console.error("--run-bootstrap-admin=true requires the backend deploy step. Remove --skip-backend or run yarn run:bootstrap-admin separately afterward."); + process.exit(1); + } + + if (runApiMigrations) { + validateApiMigrationArgs(apiMigrationAction || "up", apiMigrationModule || "all"); + validateApiMigrationRunner(apiMigrationRunner); + } + + if (skipBackend && skipFrontend && !publishFrontendAssets) { + console.error("Nothing to do: both backend and frontend deploy steps are skipped, and no staged frontend publish was requested."); + process.exit(1); + } + + if (runApiMigrations) { + const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath || apiRepoPath || "../Api"); + if (!fs.existsSync(resolvedApiMigrationRepoPath)) { + fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); + } + if (apiMigrationRunner === "direct" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { + fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); + } + if (apiMigrationRunner === "direct" && apiMigrationDryRun !== "true" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { + fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); + } + if (apiMigrationRunner === "data-api" && !fs.existsSync(path.join(rootDir, "node_modules", "typescript", "package.json"))) { + fail(`B1Admin dependencies are not installed: ${path.join(rootDir, "node_modules", "typescript", "package.json")}`); + } + if (apiMigrationDryRun !== "true" && apiMigrationModule && apiMigrationModule !== "all") { + const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(resolvedApiMigrationRepoPath); + if (apiRepoMigrationDirectories.length > 0 && !apiRepoMigrationDirectories.includes(apiMigrationModule)) { + fail(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. Refusing to deploy with --run-api-migrations=true for an unsupported migration target.`); + } + } + } + + const willPublishFrontendAssets = (!skipFrontend && !frontendInfrastructureOnly) || (publishFrontendAssets && (skipFrontend || frontendInfrastructureOnly)); + if (willPublishFrontendAssets) { + ensureFrontendPublishPrerequisites(skipBuild); + } + + const bootstrapOutputs = !skipBackend + ? getStackOutputsSafe(bootstrapStackName, region, "bootstrap stack") + : {}; + const currentBackendStackParams = backendParametersFile + ? {} + : getStackParametersSafe(backendStackName, region, "backend stack"); + const resolvedArtifactBucket = lambdaCodeS3Bucket || bootstrapOutputs.ArtifactBucketName || ""; + const resolvedMigrationBucket = migrationCodeS3Bucket || resolvedArtifactBucket; + let resolvedDependenciesLayerArn = dependenciesLayerArn; + let resolvedAppConfigSecretArn = appConfigSecretArn; + let resolvedBackendArtifactSourceFile = backendArtifactSourceFile; + let resolvedMigrationArtifactSourceFile = migrationArtifactSourceFile; + let resolvedDependenciesLayerSourceFile = dependenciesLayerSourceFile; + let resolvedPackageManifestFile = ""; + let backendArtifactUploadResult = null; + let migrationArtifactUploadResult = null; + let resolvedLambdaCodeS3ObjectVersion = getArg("lambda-code-s3-object-version"); + let resolvedMigrationCodeS3ObjectVersion = getArg("migration-code-s3-object-version"); + let resolvedBackendResult = null; + let resolvedFrontendResult = null; + let frontendPublishResult = null; + let resolvedObservabilityLayerArn = observabilityLayerArn || backendFileParams.ObservabilityLayerArn || ""; + + if (!skipBackend && packageManifestFile) { + const manifestFilePath = path.resolve(rootDir, packageManifestFile); + resolvedPackageManifestFile = manifestFilePath; + const packageResult = loadJson(manifestFilePath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.dependenciesLayerArtifactPath); + } + + if (!skipBackend && packageApiBackend) { + const manifestName = `api-${environment}-${packageMode}.manifest.json`; + const manifestPath = path.resolve(rootDir, packageOutputDir, manifestName); + resolvedPackageManifestFile = manifestPath; + const packageArgs = []; + addArg(packageArgs, "api-repo-path", apiRepoPath || "../Api"); + addArg(packageArgs, "environment", environment); + addArg(packageArgs, "package-mode", packageMode); + addArg(packageArgs, "output-dir", packageOutputDir); + addArg(packageArgs, "build", packageBuild); + addArg(packageArgs, "build-layer", packageBuildLayer); + addArg(packageArgs, "manifest-name", manifestName); + withProgress("Backend packaging", () => { + run("scripts/package-api-backend.mjs", packageArgs, { quiet: jsonOutput }); + }, `${apiRepoPath || "../Api"} (${packageMode})`); + const packageResult = loadJson(manifestPath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.dependenciesLayerArtifactPath); + } + + if (!skipBackend && !resolvedLambdaCodeS3Key && resolvedBackendArtifactSourceFile) { + resolvedLambdaCodeS3Key = deriveArtifactKey(projectName, environment, "api.zip"); + } + + if (!skipBackend && !resolvedMigrationCodeS3Key && resolvedMigrationArtifactSourceFile) { + resolvedMigrationCodeS3Key = deriveArtifactKey(projectName, environment, "migrations.zip"); + } + + if (!skipBackend && !resolvedDependenciesLayerArn && !resolvedDependenciesLayerSourceFile) { + resolvedDependenciesLayerArn = currentBackendStackParams.DependenciesLayerArn || ""; + } + + if (!skipBackend && !resolvedObservabilityLayerArn) { + resolvedObservabilityLayerArn = currentBackendStackParams.ObservabilityLayerArn || ""; + } + + if (!skipBackend && resolvedBackendArtifactSourceFile) { + const artifactArgs = []; + addArg(artifactArgs, "region", region); + addArg(artifactArgs, "bootstrap-stack-name", bootstrapStackName); + addArg(artifactArgs, "artifact-bucket", resolvedArtifactBucket); + addArg(artifactArgs, "source-file", resolvedBackendArtifactSourceFile); + addArg(artifactArgs, "artifact-key", resolvedLambdaCodeS3Key); + addArg(artifactArgs, "artifact-label", "Backend artifact"); + addArg(artifactArgs, "output", "json"); + backendArtifactUploadResult = withProgress("Backend artifact upload", () => ( + runNodeJson("scripts/upload-backend-artifact.mjs", artifactArgs) + ), resolvedLambdaCodeS3Key); + resolvedLambdaCodeS3ObjectVersion = backendArtifactUploadResult?.versionId || resolvedLambdaCodeS3ObjectVersion; + if (!jsonOutput) { + console.log("\nBackend artifact upload complete."); + console.log(`Bucket: ${backendArtifactUploadResult.bucket}`); + console.log(`Key: ${backendArtifactUploadResult.key}`); + if (backendArtifactUploadResult.versionId) console.log(`VersionId: ${backendArtifactUploadResult.versionId}`); + console.log(`S3 URI: ${backendArtifactUploadResult.s3Uri}`); + } + } + + if (!skipBackend && resolvedMigrationArtifactSourceFile) { + const artifactArgs = []; + addArg(artifactArgs, "region", region); + addArg(artifactArgs, "bootstrap-stack-name", bootstrapStackName); + addArg(artifactArgs, "artifact-bucket", resolvedMigrationBucket); + addArg(artifactArgs, "source-file", resolvedMigrationArtifactSourceFile); + addArg(artifactArgs, "artifact-key", resolvedMigrationCodeS3Key); + addArg(artifactArgs, "artifact-label", "Migration artifact"); + addArg(artifactArgs, "output", "json"); + migrationArtifactUploadResult = withProgress("Migration artifact upload", () => ( + runNodeJson("scripts/upload-backend-artifact.mjs", artifactArgs) + ), resolvedMigrationCodeS3Key); + resolvedMigrationCodeS3ObjectVersion = migrationArtifactUploadResult?.versionId || resolvedMigrationCodeS3ObjectVersion; + if (!jsonOutput) { + console.log("\nMigration artifact upload complete."); + console.log(`Bucket: ${migrationArtifactUploadResult.bucket}`); + console.log(`Key: ${migrationArtifactUploadResult.key}`); + if (migrationArtifactUploadResult.versionId) console.log(`VersionId: ${migrationArtifactUploadResult.versionId}`); + console.log(`S3 URI: ${migrationArtifactUploadResult.s3Uri}`); + } + } + + if (!skipBackend && resolvedDependenciesLayerSourceFile) { + const dependenciesLayerArtifactKey = deriveArtifactKey(projectName, environment, "dependencies-layer.zip"); + const dependenciesLayerUploadResult = withProgress("Dependencies layer artifact upload", () => ( + runNodeJson("scripts/upload-backend-artifact.mjs", [ + `--region=${region}`, + `--artifact-bucket=${resolvedArtifactBucket}`, + `--source-file=${resolvedDependenciesLayerSourceFile}`, + `--artifact-key=${dependenciesLayerArtifactKey}`, + "--artifact-label=Dependencies layer artifact", + "--output=json", + ]) + ), dependenciesLayerArtifactKey); + if (!jsonOutput) { + console.log("\nDependencies layer artifact upload complete."); + console.log(`Bucket: ${dependenciesLayerUploadResult.bucket}`); + console.log(`Key: ${dependenciesLayerUploadResult.key}`); + if (dependenciesLayerUploadResult.versionId) console.log(`VersionId: ${dependenciesLayerUploadResult.versionId}`); + console.log(`S3 URI: ${dependenciesLayerUploadResult.s3Uri}`); + } + const layerResponse = withProgress("Dependencies layer publish", () => ( + runNodeJson("scripts/publish-lambda-layer.mjs", [ + `--region=${region}`, + `--layer-name=${dependenciesLayerName}`, + `--content-bucket=${resolvedArtifactBucket}`, + `--content-key=${dependenciesLayerArtifactKey}`, + ...(dependenciesLayerUploadResult.versionId ? [`--content-object-version=${dependenciesLayerUploadResult.versionId}`] : []), + `--description=${dependenciesLayerDescription}`, + `--compatible-runtimes=${dependenciesLayerCompatibleRuntimes}`, + `--compatible-architectures=${dependenciesLayerCompatibleArchitectures}`, + `--output=json`, + ...(dependenciesLayerLicenseInfo ? [`--license-info=${dependenciesLayerLicenseInfo}`] : []), + ]) + ), dependenciesLayerName); + resolvedDependenciesLayerArn = layerResponse.LayerVersionArn || resolvedDependenciesLayerArn; + } + + if (!skipBackend && appConfigSecretFile) { + const secretResponse = withProgress("App config secret sync", () => ( + runNodeJson("scripts/sync-app-config-secret.mjs", [ + `--region=${region}`, + `--secret-file=${appConfigSecretFile}`, + `--secret-name=${appConfigSecretName}`, + ...(appConfigSecretId ? [`--secret-id=${appConfigSecretId}`] : []), + ...(appConfigSecretDescription ? [`--description=${appConfigSecretDescription}`] : []), + ...(appConfigSecretKmsKeyId ? [`--kms-key-id=${appConfigSecretKmsKeyId}`] : []), + "--output=json", + ]) + ), appConfigSecretName); + resolvedAppConfigSecretArn = secretResponse.arn || resolvedAppConfigSecretArn; + } + + if (!skipBackend && !resolvedAppConfigSecretArn && !appConfigSecretFile) { + resolvedAppConfigSecretArn = currentBackendStackParams.AppConfigSecretArn || ""; + } + + if (!skipBackend) { + const backendArgs = []; + addArg(backendArgs, "stack-name", backendStackName); + addArg(backendArgs, "region", region); + addArg(backendArgs, "environment", environment); + addArg(backendArgs, "project-name", projectName); + addArg(backendArgs, "bootstrap-stack-name", bootstrapStackName); + addArg(backendArgs, "cloudformation-execution-role-arn", cloudformationExecutionRoleArn); + addArg(backendArgs, "parameters-file", backendParametersFile); + addArg(backendArgs, "lambda-code-s3-bucket", resolvedArtifactBucket); + addArg(backendArgs, "lambda-code-s3-key", resolvedLambdaCodeS3Key); + addArg(backendArgs, "lambda-code-s3-object-version", resolvedLambdaCodeS3ObjectVersion); + addArg(backendArgs, "dependencies-layer-arn", resolvedDependenciesLayerArn); + addArg(backendArgs, "observability-layer-arn", resolvedObservabilityLayerArn); + addArg(backendArgs, "lambda-node-options", lambdaNodeOptions); + addArg(backendArgs, "enable-web-socket-api", enableWebSocketApi); + addArg(backendArgs, "socket-lambda-handler", socketLambdaHandler); + addArg(backendArgs, "socket-lambda-memory-size", socketLambdaMemorySize); + addArg(backendArgs, "socket-lambda-timeout", socketLambdaTimeout); + addArg(backendArgs, "enable-scheduled-workers", enableScheduledWorkers); + addArg(backendArgs, "timer15-min-lambda-handler", timer15MinLambdaHandler); + addArg(backendArgs, "timer-midnight-lambda-handler", timerMidnightLambdaHandler); + addArg(backendArgs, "timer-scheduled-tasks-lambda-handler", timerScheduledTasksLambdaHandler); + addArg(backendArgs, "timer-webhooks-lambda-handler", timerWebhooksLambdaHandler); + addArg(backendArgs, "timer-lambda-memory-size", timerLambdaMemorySize); + addArg(backendArgs, "timer-lambda-timeout", timerLambdaTimeout); + addArg(backendArgs, "run-migrations", runMigrations); + addArg(backendArgs, "migration-code-s3-bucket", resolvedMigrationBucket); + addArg(backendArgs, "migration-code-s3-key", resolvedMigrationCodeS3Key); + addArg(backendArgs, "migration-code-s3-object-version", resolvedMigrationCodeS3ObjectVersion); + addArg(backendArgs, "migration-handler", migrationHandler); + addArg(backendArgs, "migration-runtime", migrationRuntime); + addArg(backendArgs, "migration-memory-size", migrationMemorySize); + addArg(backendArgs, "migration-timeout", migrationTimeout); + addArg(backendArgs, "migration-trigger", migrationTrigger); + addArg(backendArgs, "membership-database-name", membershipDatabaseName); + addArg(backendArgs, "attendance-database-name", attendanceDatabaseName); + addArg(backendArgs, "content-database-name", contentDatabaseName); + addArg(backendArgs, "giving-database-name", givingDatabaseName); + addArg(backendArgs, "messaging-database-name", messagingDatabaseName); + addArg(backendArgs, "doing-database-name", doingDatabaseName); + addArg(backendArgs, "reporting-database-name", reportingDatabaseName); + addArg(backendArgs, "api-custom-domain-name", apiCustomDomainName); + addArg(backendArgs, "api-certificate-arn", apiCertificateArn); + addArg(backendArgs, "api-hosted-zone-id", apiHostedZoneId); + addArg(backendArgs, "b1-admin-root-url", b1AdminRootUrl); + addArg(backendArgs, "cors-origin", corsOrigin); + addArg(backendArgs, "file-store", fileStore); + addArg(backendArgs, "manage-asset-bucket", manageAssetBucket); + addArg(backendArgs, "asset-bucket-name", assetBucketName); + addArg(backendArgs, "app-config-secret-arn", resolvedAppConfigSecretArn); + addArg(backendArgs, "sync-legacy-ssm", syncLegacySsm); + addArg(backendArgs, "run-api-migrations", runApiMigrations); + addArg(backendArgs, "api-migration-action", apiMigrationAction); + addArg(backendArgs, "api-migration-module", apiMigrationModule); + addArg(backendArgs, "api-migration-runner", apiMigrationRunner); + addArg(backendArgs, "api-migration-api-repo-path", apiMigrationApiRepoPath); + addArg(backendArgs, "api-migration-db-secret-arn", apiMigrationDbSecretArn); + addArg(backendArgs, "api-migration-db-secret-file", apiMigrationDbSecretFile); + addArg(backendArgs, "api-migration-dry-run", apiMigrationDryRun); + addArg(backendArgs, "run-bootstrap-admin", runBootstrapAdmin); + addArg(backendArgs, "bootstrap-admin-secret-file", bootstrapAdminSecretFile); + addArg(backendArgs, "bootstrap-admin-secret-arn", bootstrapAdminSecretArn); + addArg(backendArgs, "bootstrap-admin-email", bootstrapAdminEmail); + addArg(backendArgs, "bootstrap-admin-password", bootstrapAdminPassword); + addArg(backendArgs, "bootstrap-admin-first-name", bootstrapAdminFirstName); + addArg(backendArgs, "bootstrap-admin-last-name", bootstrapAdminLastName); + addArg(backendArgs, "bootstrap-admin-display-name", bootstrapAdminDisplayName); + addArg(backendArgs, "bootstrap-church-name", bootstrapChurchName); + addArg(backendArgs, "bootstrap-church-subdomain", bootstrapChurchSubdomain); + addArg(backendArgs, "bootstrap-church-address1", bootstrapChurchAddress1); + addArg(backendArgs, "bootstrap-church-address2", bootstrapChurchAddress2); + addArg(backendArgs, "bootstrap-church-city", bootstrapChurchCity); + addArg(backendArgs, "bootstrap-church-state", bootstrapChurchState); + addArg(backendArgs, "bootstrap-church-zip", bootstrapChurchZip); + addArg(backendArgs, "bootstrap-church-country", bootstrapChurchCountry); + addArg(backendArgs, "bootstrap-membership-status", bootstrapMembershipStatus); + addArg(backendArgs, "bootstrap-admin-reset-password", bootstrapAdminResetPassword); + addArg(backendArgs, "legacy-ssm-prefix", legacySsmPrefix); + addArg(backendArgs, "legacy-ssm-include-empty", legacySsmIncludeEmpty); + addArg(backendArgs, "legacy-ssm-overwrite", legacySsmOverwrite); + addArg(backendArgs, "mail-system", mailSystem); + addArg(backendArgs, "delivery-provider", deliveryProvider); + addArg(backendArgs, "store-api-url", storeApiUrl); + addArg(backendArgs, "ai-provider", aiProvider); + addArg(backendArgs, "email-on-registration", emailOnRegistration); + addArg(backendArgs, "caddy-host", caddyHost); + addArg(backendArgs, "caddy-port", caddyPort); + if (jsonOutput) addArg(backendArgs, "output", "json"); + resolvedBackendResult = withProgress("Backend infrastructure deploy", () => ( + jsonOutput + ? runNodeJson("scripts/deploy-backend.mjs", backendArgs) + : run("scripts/deploy-backend.mjs", backendArgs) + ), backendStackName); + } + + if (!skipFrontend) { + const frontendArgs = []; + addArg(frontendArgs, "stack-name", frontendStackName); + addArg(frontendArgs, "region", region); + addArg(frontendArgs, "environment", environment); + addArg(frontendArgs, "project-name", projectName); + addArg(frontendArgs, "bootstrap-stack-name", bootstrapStackName); + addArg(frontendArgs, "cloudformation-execution-role-arn", cloudformationExecutionRoleArn); + addArg(frontendArgs, "parameters-file", frontendParametersFile); + if (backendOutputsFile) addArg(frontendArgs, "backend-outputs-file", backendOutputsFile); + else addArg(frontendArgs, "backend-stack-name", backendStackName); + addArg(frontendArgs, "bucket-name", frontendBucketName); + addArg(frontendArgs, "alternate-domain-name", frontendAlternateDomainName); + addArg(frontendArgs, "acm-certificate-arn", frontendAcmCertificateArn); + addArg(frontendArgs, "hosted-zone-id", frontendHostedZoneId); + addArg(frontendArgs, "price-class", frontendPriceClass); + if (frontendInfrastructureOnly) frontendArgs.push("--infrastructure-only"); + if (skipBuild) frontendArgs.push("--skip-build"); + if (jsonOutput) addArg(frontendArgs, "output", "json"); + resolvedFrontendResult = withProgress("Frontend deploy and publish", () => ( + jsonOutput + ? runNodeJson("scripts/deploy-frontend.mjs", frontendArgs) + : run("scripts/deploy-frontend.mjs", frontendArgs) + ), frontendStackName); + } + + if (publishFrontendAssets && (skipFrontend || frontendInfrastructureOnly)) { + const publishArgs = []; + if (!frontendOutputsFile && (!frontendPublishBucket || !frontendPublishDistributionId)) { + addArg(publishArgs, "stack-name", frontendStackName); + } + addArg(publishArgs, "frontend-outputs-file", frontendOutputsFile); + addArg(publishArgs, "bucket", frontendPublishBucket); + addArg(publishArgs, "distribution-id", frontendPublishDistributionId); + addArg(publishArgs, "app-url", frontendPublishAppUrl); + addArg(publishArgs, "region", region); + addArg(publishArgs, "environment", environment); + if (!skipBuild) { + if (backendOutputsFile) addArg(publishArgs, "backend-outputs-file", backendOutputsFile); + else addArg(publishArgs, "backend-stack-name", backendStackName); + } + if (skipBuild) publishArgs.push("--skip-build"); + if (jsonOutput) addArg(publishArgs, "output", "json"); + frontendPublishResult = withProgress("Frontend publish follow-up", () => ( + jsonOutput + ? runNodeJson("scripts/publish-frontend-assets.mjs", publishArgs) + : run("scripts/publish-frontend-assets.mjs", publishArgs) + ), frontendPublishBucket || frontendStackName); + } + + if (jsonOutput) { + const result = { + region, + environment, + projectName, + bootstrapStackName, + backendStackName, + backendOutputsFile, + frontendStackName, + frontendOutputsFile, + frontendPublishBucket, + frontendPublishDistributionId, + frontendPublishAppUrl, + frontendInfrastructureOnly, + publishFrontendAssets, + skipBackend, + skipFrontend, + skipBuild, + resolvedPackageManifestFile, + resolvedBackendArtifactSourceFile, + resolvedMigrationArtifactSourceFile, + resolvedDependenciesLayerSourceFile, + resolvedArtifactBucket, + resolvedLambdaCodeS3Key, + resolvedMigrationBucket, + resolvedMigrationCodeS3Key, + resolvedDependenciesLayerArn: resolvedDependenciesLayerArn || "", + resolvedAppConfigSecretArn: resolvedAppConfigSecretArn || "", + backendArtifactUpload: backendArtifactUploadResult, + migrationArtifactUpload: migrationArtifactUploadResult, + backend: resolvedBackendResult || null, + frontend: resolvedFrontendResult || null, + frontendPublish: frontendPublishResult || null, + }; + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } +} + +main(); diff --git a/scripts/deploy-backend.mjs b/scripts/deploy-backend.mjs new file mode 100644 index 000000000..8a92bc652 --- /dev/null +++ b/scripts/deploy-backend.mjs @@ -0,0 +1,712 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "backend-api.yaml"); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runNodeJson(scriptPath, args) { + try { + return JSON.parse(execFileSync("node", [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + exitForCommandError(error, true); + } +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function normalizeParameters(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((parameter) => [parameter.ParameterKey, parameter.ParameterValue])); + if (raw.Stacks?.[0]?.Parameters) return normalizeParameters(raw.Stacks[0].Parameters); + if (raw.Parameters) return normalizeParameters(raw.Parameters); + return raw; +} + +function describeStackSafe(stackName, region) { + try { + return JSON.parse(execFileSync("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const stderr = String(error?.stderr || ""); + if (stderr.includes("does not exist")) return null; + throw error; + } +} + +function ensureStackIsDeployable(stackName, region, quiet = false) { + const stack = describeStackSafe(stackName, region)?.Stacks?.[0]; + const recoverableStatuses = new Set(["ROLLBACK_COMPLETE", "ROLLBACK_FAILED"]); + if (!stack || !recoverableStatuses.has(stack.StackStatus)) return; + + if (!quiet) { + console.log(`\nStack ${stackName} is in ${stack.StackStatus}. Deleting it before retrying the deploy.`); + } + + run("aws", [ + "cloudformation", + "delete-stack", + "--stack-name", + stackName, + "--region", + region, + ], { quiet }); + + run("aws", [ + "cloudformation", + "wait", + "stack-delete-complete", + "--stack-name", + stackName, + "--region", + region, + ], { quiet }); +} + +function getStackOutputs(stackName, region) { + const response = JSON.parse(execFileSync("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function getStackParametersSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return normalizeParameters(describeStackSafe(stackName, region)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}" parameters: ${message}`); + } +} + +function loadJson(filePath, label = "JSON file") { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function resolveManifestArtifactPath(manifestFilePath, artifactPath) { + if (!artifactPath) return ""; + if (path.isAbsolute(artifactPath)) return artifactPath; + return path.resolve(path.dirname(manifestFilePath), artifactPath); +} + +function toParameterOverrides(params) { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${key}=${value}`); +} + +function loadParamsFromFile(filePath) { + if (!filePath) return {}; + + try { + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + + if (Array.isArray(data)) { + return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + } + + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load parameters file "${filePath}": ${message}`); + } +} + +function addArg(args, name, value) { + if (value !== undefined && value !== null && value !== "") { + args.push(`--${name}=${value}`); + } +} + +function deriveArtifactKey(projectName, environmentName, fileName) { + return `${projectName}/${environmentName}/backend/${fileName}`; +} + +function loadApiRepoMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); + } +} + +function validateApiMigrationArgs(action, moduleName) { + const validActions = ["up", "down", "status"]; + const validModules = ["all", "membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; + + if (!validActions.includes(action)) { + fail(`Invalid api-migration-action "${action}". Use up, down, or status.`); + } + + if (!validModules.includes(moduleName)) { + fail(`Invalid api-migration-module "${moduleName}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); + } +} + +function validateApiMigrationRunner(runner) { + if (!["direct", "data-api"].includes(runner)) { + fail(`Invalid api-migration-runner "${runner}". Use direct or data-api.`); + } +} + +function main() { + const stackName = getArg("stack-name"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const templateFile = path.resolve(rootDir, getArg("template-file", defaultTemplatePath)); + const paramsFile = getArg("parameters-file"); + const fileParams = loadParamsFromFile(paramsFile); + const bootstrapStackName = getArg("bootstrap-stack-name"); + const apiRepoPath = getArg("api-repo-path"); + const packageManifestFile = getArg("package-manifest-file"); + const packageApiBackend = !packageManifestFile && (apiRepoPath !== "" || getArg("package-api-backend", "false").toLowerCase() === "true"); + const packageMode = getArg("package-mode", "self-contained"); + const packageOutputDir = getArg("package-output-dir", "infrastructure/artifacts/api"); + const packageBuild = getArg("package-build", "true"); + const packageBuildLayer = getArg("package-build-layer", packageMode === "layered" ? "true" : "false"); + const projectName = getArg("project-name", fileParams.ProjectName || "b1admin"); + const environmentName = getArg("environment", fileParams.EnvironmentName || "prod"); + const backendArtifactSourceFile = getArg("backend-artifact-source-file"); + const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); + const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); + const dependenciesLayerName = getArg("dependencies-layer-name"); + const dependenciesLayerDescription = getArg("dependencies-layer-description"); + const dependenciesLayerLicenseInfo = getArg("dependencies-layer-license-info"); + const dependenciesLayerCompatibleRuntimes = getArg("dependencies-layer-compatible-runtimes", "nodejs22.x"); + const dependenciesLayerCompatibleArchitectures = getArg("dependencies-layer-compatible-architectures", "arm64"); + const appConfigSecretFile = getArg("app-config-secret-file"); + const appConfigSecretName = getArg("app-config-secret-name"); + const appConfigSecretId = getArg("app-config-secret-id"); + const appConfigSecretDescription = getArg("app-config-secret-description"); + const appConfigSecretKmsKeyId = getArg("app-config-secret-kms-key-id"); + const syncLegacySsm = getArg("sync-legacy-ssm", "false").toLowerCase() === "true"; + const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; + const apiMigrationAction = getArg("api-migration-action", "up"); + const apiMigrationModule = getArg("api-migration-module", "all"); + const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path", apiRepoPath || "../Api"); + const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); + const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); + const apiMigrationDryRun = getArg("api-migration-dry-run", "false").toLowerCase() === "true"; + const runBootstrapAdmin = getArg("run-bootstrap-admin", "false").toLowerCase() === "true"; + const bootstrapAdminSecretFile = getArg("bootstrap-admin-secret-file"); + const bootstrapAdminSecretArn = getArg("bootstrap-admin-secret-arn"); + const bootstrapAdminEmail = getArg("bootstrap-admin-email"); + const bootstrapAdminPassword = getArg("bootstrap-admin-password"); + const bootstrapAdminFirstName = getArg("bootstrap-admin-first-name"); + const bootstrapAdminLastName = getArg("bootstrap-admin-last-name"); + const bootstrapAdminDisplayName = getArg("bootstrap-admin-display-name"); + const bootstrapChurchName = getArg("bootstrap-church-name"); + const bootstrapChurchSubdomain = getArg("bootstrap-church-subdomain"); + const bootstrapChurchAddress1 = getArg("bootstrap-church-address1"); + const bootstrapChurchAddress2 = getArg("bootstrap-church-address2"); + const bootstrapChurchCity = getArg("bootstrap-church-city"); + const bootstrapChurchState = getArg("bootstrap-church-state"); + const bootstrapChurchZip = getArg("bootstrap-church-zip"); + const bootstrapChurchCountry = getArg("bootstrap-church-country"); + const bootstrapMembershipStatus = getArg("bootstrap-membership-status"); + const bootstrapAdminResetPassword = getArg("bootstrap-admin-reset-password", "true"); + const legacySsmPrefix = getArg("legacy-ssm-prefix"); + const legacySsmIncludeEmpty = getArg("legacy-ssm-include-empty"); + const legacySsmOverwrite = getArg("legacy-ssm-overwrite"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + + if (runApiMigrations) { + validateApiMigrationArgs(apiMigrationAction, apiMigrationModule); + validateApiMigrationRunner(apiMigrationRunner); + const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath); + if (!fs.existsSync(resolvedApiMigrationRepoPath)) { + fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); + } + if (apiMigrationRunner === "direct" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { + fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); + } + if (apiMigrationRunner === "direct" && !apiMigrationDryRun && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { + fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); + } + if (apiMigrationRunner === "data-api" && !fs.existsSync(path.join(rootDir, "node_modules", "typescript", "package.json"))) { + fail(`B1Admin dependencies are not installed: ${path.join(rootDir, "node_modules", "typescript", "package.json")}`); + } + if (!apiMigrationDryRun && apiMigrationModule !== "all") { + const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(resolvedApiMigrationRepoPath); + if (apiRepoMigrationDirectories.length > 0 && !apiRepoMigrationDirectories.includes(apiMigrationModule)) { + fail(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. Refusing to deploy with --run-api-migrations=true for an unsupported migration target.`); + } + } + } + + requireValue("stack-name", stackName); + requireValue("template-file", templateFile); + const bootstrapOutputs = getStackOutputsSafe(bootstrapStackName, region, "bootstrap stack"); + const currentStackParams = paramsFile + ? {} + : getStackParametersSafe(stackName, region, "backend stack"); + const resolvedTemplateBucket = getArg("template-bucket", bootstrapOutputs.TemplateBucketName || ""); + const resolvedArtifactBucket = getArg("lambda-code-s3-bucket", fileParams.LambdaCodeS3Bucket || bootstrapOutputs.ArtifactBucketName || ""); + const resolvedMigrationBucket = getArg("migration-code-s3-bucket", fileParams.MigrationCodeS3Bucket || resolvedArtifactBucket); + + let resolvedBackendArtifactSourceFile = backendArtifactSourceFile; + let resolvedMigrationArtifactSourceFile = migrationArtifactSourceFile; + let resolvedDependenciesLayerSourceFile = dependenciesLayerSourceFile; + let resolvedDependenciesLayerArn = getArg("dependencies-layer-arn", fileParams.DependenciesLayerArn); + let resolvedLambdaCodeS3Key = getArg("lambda-code-s3-key", fileParams.LambdaCodeS3Key); + let resolvedLambdaCodeS3ObjectVersion = getArg("lambda-code-s3-object-version", fileParams.LambdaCodeS3ObjectVersion); + let resolvedMigrationCodeS3Key = getArg("migration-code-s3-key", fileParams.MigrationCodeS3Key); + let resolvedMigrationCodeS3ObjectVersion = getArg("migration-code-s3-object-version", fileParams.MigrationCodeS3ObjectVersion); + let resolvedPackageManifestFile = ""; + let resolvedObservabilityLayerArn = getArg("observability-layer-arn", fileParams.ObservabilityLayerArn); + + if (packageManifestFile) { + const manifestFilePath = path.resolve(rootDir, packageManifestFile); + resolvedPackageManifestFile = manifestFilePath; + const packageResult = loadJson(manifestFilePath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.dependenciesLayerArtifactPath); + } + + if (packageApiBackend) { + const manifestName = `api-${environmentName}-${packageMode}.manifest.json`; + const manifestPath = path.resolve(rootDir, packageOutputDir, manifestName); + resolvedPackageManifestFile = manifestPath; + const packageArgs = []; + addArg(packageArgs, "api-repo-path", apiRepoPath || "../Api"); + addArg(packageArgs, "environment", environmentName); + addArg(packageArgs, "package-mode", packageMode); + addArg(packageArgs, "output-dir", packageOutputDir); + addArg(packageArgs, "build", packageBuild); + addArg(packageArgs, "build-layer", packageBuildLayer); + addArg(packageArgs, "manifest-name", manifestName); + run("node", ["scripts/package-api-backend.mjs", ...packageArgs], { quiet: jsonOutput }); + const packageResult = loadJson(manifestPath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.dependenciesLayerArtifactPath); + } + + if (!resolvedLambdaCodeS3Key && resolvedBackendArtifactSourceFile) { + resolvedLambdaCodeS3Key = deriveArtifactKey(projectName, environmentName, "api.zip"); + } + + if (!resolvedMigrationCodeS3Key && resolvedMigrationArtifactSourceFile) { + resolvedMigrationCodeS3Key = deriveArtifactKey(projectName, environmentName, "migrations.zip"); + } + + if (!resolvedDependenciesLayerArn && !resolvedDependenciesLayerSourceFile) { + resolvedDependenciesLayerArn = currentStackParams.DependenciesLayerArn || ""; + } + + if (!resolvedObservabilityLayerArn) { + resolvedObservabilityLayerArn = currentStackParams.ObservabilityLayerArn || ""; + } + + if (resolvedDependenciesLayerSourceFile) { + requireValue("lambda-code-s3-bucket", resolvedArtifactBucket); + const dependenciesLayerArtifactKey = deriveArtifactKey(projectName, environmentName, "dependencies-layer.zip"); + const dependenciesLayerUploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ + `--region=${region}`, + `--artifact-bucket=${resolvedArtifactBucket}`, + `--source-file=${resolvedDependenciesLayerSourceFile}`, + `--artifact-key=${dependenciesLayerArtifactKey}`, + "--artifact-label=Dependencies layer artifact", + "--output=json", + ]); + const layerResponse = runNodeJson("scripts/publish-lambda-layer.mjs", [ + `--region=${region}`, + `--layer-name=${dependenciesLayerName || `${projectName}-${environmentName}-dependencies`}`, + `--content-bucket=${resolvedArtifactBucket}`, + `--content-key=${dependenciesLayerArtifactKey}`, + ...(dependenciesLayerUploadResult.versionId ? [`--content-object-version=${dependenciesLayerUploadResult.versionId}`] : []), + `--description=${dependenciesLayerDescription || `${projectName} ${environmentName} backend dependencies`}`, + `--compatible-runtimes=${dependenciesLayerCompatibleRuntimes}`, + `--compatible-architectures=${dependenciesLayerCompatibleArchitectures}`, + `--output=json`, + ...(dependenciesLayerLicenseInfo ? [`--license-info=${dependenciesLayerLicenseInfo}`] : []), + ]); + resolvedDependenciesLayerArn = layerResponse.LayerVersionArn || resolvedDependenciesLayerArn; + } + + let resolvedAppConfigSecretArn = getArg("app-config-secret-arn", fileParams.AppConfigSecretArn); + if (appConfigSecretFile) { + const secretResponse = runNodeJson("scripts/sync-app-config-secret.mjs", [ + `--region=${region}`, + `--secret-file=${appConfigSecretFile}`, + ...(appConfigSecretName ? [`--secret-name=${appConfigSecretName}`] : [`--secret-name=${projectName}/${environmentName}/app-config`]), + ...(appConfigSecretId ? [`--secret-id=${appConfigSecretId}`] : []), + ...(appConfigSecretDescription ? [`--description=${appConfigSecretDescription}`] : []), + ...(appConfigSecretKmsKeyId ? [`--kms-key-id=${appConfigSecretKmsKeyId}`] : []), + "--output=json", + ]); + resolvedAppConfigSecretArn = secretResponse.arn || resolvedAppConfigSecretArn; + } + + if (!resolvedAppConfigSecretArn && !appConfigSecretFile) { + resolvedAppConfigSecretArn = currentStackParams.AppConfigSecretArn || ""; + } + + const params = { + ProjectName: projectName, + EnvironmentName: environmentName, + LambdaCodeS3Bucket: resolvedArtifactBucket, + LambdaCodeS3Key: resolvedLambdaCodeS3Key, + LambdaCodeS3ObjectVersion: resolvedLambdaCodeS3ObjectVersion, + LambdaHandler: getArg("lambda-handler", fileParams.LambdaHandler), + LambdaRuntime: getArg("lambda-runtime", fileParams.LambdaRuntime), + LambdaArchitecture: getArg("lambda-architecture", fileParams.LambdaArchitecture), + LambdaMemorySize: getArg("lambda-memory-size", fileParams.LambdaMemorySize), + LambdaTimeout: getArg("lambda-timeout", fileParams.LambdaTimeout), + LambdaReservedConcurrency: getArg("lambda-reserved-concurrency", fileParams.LambdaReservedConcurrency), + DependenciesLayerArn: resolvedDependenciesLayerArn, + ObservabilityLayerArn: resolvedObservabilityLayerArn, + LambdaNodeOptions: getArg("lambda-node-options", fileParams.LambdaNodeOptions), + EnableWebSocketApi: getArg("enable-web-socket-api", fileParams.EnableWebSocketApi), + SocketLambdaHandler: getArg("socket-lambda-handler", fileParams.SocketLambdaHandler), + SocketLambdaMemorySize: getArg("socket-lambda-memory-size", fileParams.SocketLambdaMemorySize), + SocketLambdaTimeout: getArg("socket-lambda-timeout", fileParams.SocketLambdaTimeout), + EnableScheduledWorkers: getArg("enable-scheduled-workers", fileParams.EnableScheduledWorkers), + Timer15MinLambdaHandler: getArg("timer15-min-lambda-handler", fileParams.Timer15MinLambdaHandler), + TimerMidnightLambdaHandler: getArg("timer-midnight-lambda-handler", fileParams.TimerMidnightLambdaHandler), + TimerScheduledTasksLambdaHandler: getArg("timer-scheduled-tasks-lambda-handler", fileParams.TimerScheduledTasksLambdaHandler), + TimerWebhooksLambdaHandler: getArg("timer-webhooks-lambda-handler", fileParams.TimerWebhooksLambdaHandler), + TimerLambdaMemorySize: getArg("timer-lambda-memory-size", fileParams.TimerLambdaMemorySize), + TimerLambdaTimeout: getArg("timer-lambda-timeout", fileParams.TimerLambdaTimeout), + RunMigrations: getArg("run-migrations", fileParams.RunMigrations), + MigrationCodeS3Bucket: resolvedMigrationBucket, + MigrationCodeS3Key: resolvedMigrationCodeS3Key, + MigrationCodeS3ObjectVersion: resolvedMigrationCodeS3ObjectVersion, + MigrationHandler: getArg("migration-handler", fileParams.MigrationHandler), + MigrationRuntime: getArg("migration-runtime", fileParams.MigrationRuntime), + MigrationMemorySize: getArg("migration-memory-size", fileParams.MigrationMemorySize), + MigrationTimeout: getArg("migration-timeout", fileParams.MigrationTimeout), + MigrationTrigger: getArg("migration-trigger", fileParams.MigrationTrigger), + DatabaseName: getArg("database-name", fileParams.DatabaseName), + MembershipDatabaseName: getArg("membership-database-name", fileParams.MembershipDatabaseName), + AttendanceDatabaseName: getArg("attendance-database-name", fileParams.AttendanceDatabaseName), + ContentDatabaseName: getArg("content-database-name", fileParams.ContentDatabaseName), + GivingDatabaseName: getArg("giving-database-name", fileParams.GivingDatabaseName), + MessagingDatabaseName: getArg("messaging-database-name", fileParams.MessagingDatabaseName), + DoingDatabaseName: getArg("doing-database-name", fileParams.DoingDatabaseName), + ReportingDatabaseName: getArg("reporting-database-name", fileParams.ReportingDatabaseName), + DatabaseEngine: getArg("database-engine", fileParams.DatabaseEngine), + DatabasePort: getArg("database-port", fileParams.DatabasePort), + DatabaseMasterUsername: getArg("database-master-username", fileParams.DatabaseMasterUsername), + DatabaseMinCapacity: getArg("database-min-capacity", fileParams.DatabaseMinCapacity), + DatabaseMaxCapacity: getArg("database-max-capacity", fileParams.DatabaseMaxCapacity), + ApiCustomDomainName: getArg("api-custom-domain-name", fileParams.ApiCustomDomainName), + ApiCertificateArn: getArg("api-certificate-arn", fileParams.ApiCertificateArn), + ApiHostedZoneId: getArg("api-hosted-zone-id", fileParams.ApiHostedZoneId), + CreateNatGateway: getArg("create-nat-gateway", fileParams.CreateNatGateway), + VpcCidr: getArg("vpc-cidr", fileParams.VpcCidr), + PublicSubnet1Cidr: getArg("public-subnet-1-cidr", fileParams.PublicSubnet1Cidr), + PublicSubnet2Cidr: getArg("public-subnet-2-cidr", fileParams.PublicSubnet2Cidr), + PrivateSubnet1Cidr: getArg("private-subnet-1-cidr", fileParams.PrivateSubnet1Cidr), + PrivateSubnet2Cidr: getArg("private-subnet-2-cidr", fileParams.PrivateSubnet2Cidr), + WebsiteBaseUrl: getArg("website-base-url", fileParams.WebsiteBaseUrl), + ContentRootUrl: getArg("content-root-url", fileParams.ContentRootUrl), + B1AdminRootUrl: getArg("b1-admin-root-url", fileParams.B1AdminRootUrl), + CorsOrigin: getArg("cors-origin", fileParams.CorsOrigin), + FileStore: getArg("file-store", fileParams.FileStore), + ManageAssetBucket: getArg("manage-asset-bucket", fileParams.ManageAssetBucket), + AssetBucketName: getArg("asset-bucket-name", fileParams.AssetBucketName), + AppConfigSecretArn: resolvedAppConfigSecretArn, + MailSystem: getArg("mail-system", fileParams.MailSystem), + DeliveryProvider: getArg("delivery-provider", fileParams.DeliveryProvider), + StoreApiUrl: getArg("store-api-url", fileParams.StoreApiUrl), + AiProvider: getArg("ai-provider", fileParams.AiProvider), + EmailOnRegistration: getArg("email-on-registration", fileParams.EmailOnRegistration), + CaddyHost: getArg("caddy-host", fileParams.CaddyHost), + CaddyPort: getArg("caddy-port", fileParams.CaddyPort), + TransferUrl: getArg("transfer-url", fileParams.TransferUrl), + SupportEmail: getArg("support-email", fileParams.SupportEmail), + SupportPhone: getArg("support-phone", fileParams.SupportPhone), + SupportSiteUrl: getArg("support-site-url", fileParams.SupportSiteUrl), + MobileAppUrl: getArg("mobile-app-url", fileParams.MobileAppUrl), + DomainCnameTarget: getArg("domain-cname-target", fileParams.DomainCnameTarget), + DomainATarget: getArg("domain-a-target", fileParams.DomainATarget), + DefaultStockPhoto: getArg("default-stock-photo", fileParams.DefaultStockPhoto), + GoogleAnalyticsTag: getArg("google-analytics-tag", fileParams.GoogleAnalyticsTag), + SentryDsn: getArg("sentry-dsn", fileParams.SentryDsn), + }; + + if (resolvedBackendArtifactSourceFile && !params.LambdaCodeS3Bucket) { + console.error("A backend artifact source file was provided or generated, but LambdaCodeS3Bucket is still missing."); + process.exit(1); + } + + if (resolvedMigrationArtifactSourceFile && !params.MigrationCodeS3Bucket) { + console.error("A migration artifact source file was provided or generated, but MigrationCodeS3Bucket is still missing."); + process.exit(1); + } + + if (resolvedBackendArtifactSourceFile) { + requireValue("lambda-code-s3-key", params.LambdaCodeS3Key); + const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ + `--region=${region}`, + `--artifact-bucket=${params.LambdaCodeS3Bucket}`, + `--source-file=${resolvedBackendArtifactSourceFile}`, + `--artifact-key=${params.LambdaCodeS3Key}`, + "--artifact-label=Backend artifact", + "--output=json", + ]); + resolvedLambdaCodeS3ObjectVersion = uploadResult.versionId || resolvedLambdaCodeS3ObjectVersion; + params.LambdaCodeS3ObjectVersion = resolvedLambdaCodeS3ObjectVersion; + } + + if (resolvedMigrationArtifactSourceFile) { + requireValue("migration-code-s3-key", params.MigrationCodeS3Key); + const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ + `--region=${region}`, + `--artifact-bucket=${params.MigrationCodeS3Bucket}`, + `--source-file=${resolvedMigrationArtifactSourceFile}`, + `--artifact-key=${params.MigrationCodeS3Key}`, + "--artifact-label=Migration artifact", + "--output=json", + ]); + resolvedMigrationCodeS3ObjectVersion = uploadResult.versionId || resolvedMigrationCodeS3ObjectVersion; + params.MigrationCodeS3ObjectVersion = resolvedMigrationCodeS3ObjectVersion; + } + + requireValue("lambda-code-s3-bucket", params.LambdaCodeS3Bucket); + requireValue("lambda-code-s3-key", params.LambdaCodeS3Key); + ensureStackIsDeployable(stackName, region, jsonOutput); + + const deployArgs = [ + "cloudformation", + "deploy", + "--stack-name", + stackName, + "--template-file", + templateFile, + "--region", + region, + "--no-fail-on-empty-changeset", + "--capabilities", + "CAPABILITY_NAMED_IAM", + "--parameter-overrides", + ...toParameterOverrides(params), + ]; + if (resolvedTemplateBucket) { + deployArgs.push("--s3-bucket", resolvedTemplateBucket); + } + if (cloudformationExecutionRoleArn) { + deployArgs.push("--role-arn", cloudformationExecutionRoleArn); + } + run("aws", deployArgs, { quiet: jsonOutput }); + + if (syncLegacySsm) { + const legacySsmArgs = [ + `--stack-name=${stackName}`, + `--environment=${environmentName}`, + `--region=${region}`, + ]; + if (legacySsmPrefix) legacySsmArgs.push(`--prefix=${legacySsmPrefix}`); + if (legacySsmIncludeEmpty) legacySsmArgs.push(`--include-empty=${legacySsmIncludeEmpty}`); + if (legacySsmOverwrite) legacySsmArgs.push(`--overwrite=${legacySsmOverwrite}`); + if (appConfigSecretFile) legacySsmArgs.push(`--app-config-secret-file=${appConfigSecretFile}`); + else if (resolvedAppConfigSecretArn) legacySsmArgs.push(`--app-config-secret-arn=${resolvedAppConfigSecretArn}`); + run("node", ["scripts/sync-legacy-ssm-parameters.mjs", ...legacySsmArgs], { quiet: jsonOutput }); + } + + const outputs = getStackOutputsSafe(stackName, region, "backend stack"); + let apiMigrationsResult = null; + if (runApiMigrations) { + const migrationScript = apiMigrationRunner === "data-api" + ? "scripts/run-api-migrations-data-api.mjs" + : "scripts/run-api-migrations.mjs"; + const migrationArgs = [ + `--stack-name=${stackName}`, + `--region=${region}`, + `--api-repo-path=${apiMigrationApiRepoPath}`, + `--action=${apiMigrationAction}`, + `--module=${apiMigrationModule}`, + ]; + if (apiMigrationDbSecretArn) migrationArgs.push(`--db-secret-arn=${apiMigrationDbSecretArn}`); + if (apiMigrationDbSecretFile) migrationArgs.push(`--db-secret-file=${apiMigrationDbSecretFile}`); + if (apiMigrationDryRun) migrationArgs.push("--dry-run=true"); + if (jsonOutput) migrationArgs.push("--output=json"); + apiMigrationsResult = jsonOutput + ? runNodeJson(migrationScript, migrationArgs) + : run("node", [migrationScript, ...migrationArgs]); + } + let bootstrapAdminResult = null; + if (runBootstrapAdmin) { + const bootstrapArgs = [ + `--stack-name=${stackName}`, + `--region=${region}`, + `--bootstrap-admin-reset-password=${bootstrapAdminResetPassword}`, + ]; + if (bootstrapAdminSecretFile) bootstrapArgs.push(`--bootstrap-admin-secret-file=${bootstrapAdminSecretFile}`); + if (bootstrapAdminSecretArn) bootstrapArgs.push(`--bootstrap-admin-secret-arn=${bootstrapAdminSecretArn}`); + if (bootstrapAdminEmail) bootstrapArgs.push(`--bootstrap-admin-email=${bootstrapAdminEmail}`); + if (bootstrapAdminPassword) bootstrapArgs.push(`--bootstrap-admin-password=${bootstrapAdminPassword}`); + if (bootstrapAdminFirstName) bootstrapArgs.push(`--bootstrap-admin-first-name=${bootstrapAdminFirstName}`); + if (bootstrapAdminLastName) bootstrapArgs.push(`--bootstrap-admin-last-name=${bootstrapAdminLastName}`); + if (bootstrapAdminDisplayName) bootstrapArgs.push(`--bootstrap-admin-display-name=${bootstrapAdminDisplayName}`); + if (bootstrapChurchName) bootstrapArgs.push(`--bootstrap-church-name=${bootstrapChurchName}`); + if (bootstrapChurchSubdomain) bootstrapArgs.push(`--bootstrap-church-subdomain=${bootstrapChurchSubdomain}`); + if (bootstrapChurchAddress1) bootstrapArgs.push(`--bootstrap-church-address1=${bootstrapChurchAddress1}`); + if (bootstrapChurchAddress2) bootstrapArgs.push(`--bootstrap-church-address2=${bootstrapChurchAddress2}`); + if (bootstrapChurchCity) bootstrapArgs.push(`--bootstrap-church-city=${bootstrapChurchCity}`); + if (bootstrapChurchState) bootstrapArgs.push(`--bootstrap-church-state=${bootstrapChurchState}`); + if (bootstrapChurchZip) bootstrapArgs.push(`--bootstrap-church-zip=${bootstrapChurchZip}`); + if (bootstrapChurchCountry) bootstrapArgs.push(`--bootstrap-church-country=${bootstrapChurchCountry}`); + if (bootstrapMembershipStatus) bootstrapArgs.push(`--bootstrap-membership-status=${bootstrapMembershipStatus}`); + if (jsonOutput) bootstrapArgs.push("--output=json"); + bootstrapAdminResult = jsonOutput + ? runNodeJson("scripts/bootstrap-initial-admin.mjs", bootstrapArgs) + : run("node", ["scripts/bootstrap-initial-admin.mjs", ...bootstrapArgs]); + } + const result = { + stackName, + region, + environmentName, + resolvedPackageManifestFile, + resolvedBackendArtifactSourceFile, + resolvedMigrationArtifactSourceFile, + resolvedDependenciesLayerSourceFile, + outputs, + templateBucket: resolvedTemplateBucket, + appConfigSecretArn: resolvedAppConfigSecretArn || outputs.AppConfigSecretArn || "", + lambdaCodeS3Bucket: params.LambdaCodeS3Bucket, + lambdaCodeS3Key: params.LambdaCodeS3Key, + migrationCodeS3Bucket: params.MigrationCodeS3Bucket || "", + migrationCodeS3Key: params.MigrationCodeS3Key || "", + dependenciesLayerArn: resolvedDependenciesLayerArn || "", + syncLegacySsm, + runApiMigrations, + apiMigrationRunner, + apiMigrations: apiMigrationsResult, + runBootstrapAdmin, + bootstrapAdmin: bootstrapAdminResult, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nBackend deployment complete."); + console.log(`Stack: ${stackName}`); +} + +main(); diff --git a/scripts/deploy-bootstrap.mjs b/scripts/deploy-bootstrap.mjs new file mode 100644 index 000000000..ad40367af --- /dev/null +++ b/scripts/deploy-bootstrap.mjs @@ -0,0 +1,205 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const templatePath = path.join(rootDir, "infrastructure", "cloudformation", "bootstrap.yaml"); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function validateS3BucketName(label, value) { + if (!value) return; + + const looksLikeIpv4Address = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value); + const validCharacters = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value); + const hasAdjacentPeriods = value.includes(".."); + const hasDashPeriodCombo = value.includes("-.") || value.includes(".-"); + + if (value.length < 3 || value.length > 63 || looksLikeIpv4Address || !validCharacters || hasAdjacentPeriods || hasDashPeriodCombo) { + fail(`${label} must be a valid S3 bucket name when provided explicitly.`); + } +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + const response = JSON.parse(execFileSync("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function loadParamsFromFile(filePath) { + if (!filePath) return {}; + + try { + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + + if (Array.isArray(data)) { + return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + } + + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load parameters file "${filePath}": ${message}`); + } +} + +function toParameterOverrides(params) { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => `${key}=${value}`); +} + +function main() { + const stackName = getArg("stack-name"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const paramsFile = getArg("parameters-file"); + const fileParams = loadParamsFromFile(paramsFile); + const projectName = getArg("project-name", fileParams.ProjectName || "b1admin"); + const environmentName = getArg("environment", fileParams.EnvironmentName || "prod"); + const output = getArg("output", "text").toLowerCase(); + const jsonOutput = output === "json"; + + requireValue("stack-name", stackName); + + const params = { + ProjectName: projectName, + EnvironmentName: environmentName, + TemplateBucketName: getArg("template-bucket-name", fileParams.TemplateBucketName), + ArtifactBucketName: getArg("artifact-bucket-name", fileParams.ArtifactBucketName), + EnableBucketVersioning: getArg("enable-bucket-versioning", fileParams.EnableBucketVersioning || "true"), + }; + + validateS3BucketName("TemplateBucketName", params.TemplateBucketName); + validateS3BucketName("ArtifactBucketName", params.ArtifactBucketName); + + if (params.TemplateBucketName && params.TemplateBucketName === params.ArtifactBucketName) { + fail("TemplateBucketName and ArtifactBucketName must be different when both are set explicitly."); + } + + const deployArgs = [ + "cloudformation", + "deploy", + "--stack-name", + stackName, + "--template-file", + templatePath, + "--region", + region, + "--no-fail-on-empty-changeset", + "--parameter-overrides", + ...toParameterOverrides(params), + ]; + if (cloudformationExecutionRoleArn) { + deployArgs.push("--role-arn", cloudformationExecutionRoleArn); + } + run("aws", deployArgs, { quiet: jsonOutput }); + + const outputs = getStackOutputsSafe(stackName, region, "bootstrap stack"); + const result = { + stackName, + region, + parameters: params, + outputs, + }; + + if (output === "json") { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log("\nBootstrap deployment complete."); + console.log(`Stack: ${stackName}`); + if (outputs.TemplateBucketName) console.log(`Template bucket: ${outputs.TemplateBucketName}`); + if (outputs.ArtifactBucketName) console.log(`Artifact bucket: ${outputs.ArtifactBucketName}`); +} + +main(); diff --git a/scripts/deploy-frontend.mjs b/scripts/deploy-frontend.mjs new file mode 100644 index 000000000..dfafd3441 --- /dev/null +++ b/scripts/deploy-frontend.mjs @@ -0,0 +1,353 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const templatePath = path.join(rootDir, "infrastructure", "cloudformation", "frontend-site.yaml"); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function runNodeJson(scriptPath, args) { + try { + return JSON.parse(execFileSync("node", [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + exitForCommandError(error, true); + } +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function loadParamsFromFile(filePath) { + if (!filePath) return {}; + + try { + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + + if (Array.isArray(data)) { + return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + } + + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load parameters file "${filePath}": ${message}`); + } +} + +function buildParameterOverrides(params) { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => `${key}=${value}`); +} + +function describeStack(stackName, region) { + return runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + return normalizeOutputs(describeStack(stackName, region)); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function readOutputsFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return normalizeOutputs(JSON.parse(fs.readFileSync(resolved, "utf8"))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function getOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function compactObject(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== "")); +} + +function ensureFrontendPublishPrerequisites({ skipBuild, jsonOutput }) { + const distDir = path.join(rootDir, "dist"); + const serviceWorkerPath = path.join(distDir, "sw.js"); + + if (skipBuild) { + if (!fs.existsSync(distDir)) { + fail(`Build output not found: ${distDir}`); + } + if (!fs.existsSync(serviceWorkerPath)) { + fail(`Expected service worker not found: ${serviceWorkerPath}`); + } + return; + } + + const nodeModulesPath = path.join(rootDir, "node_modules"); + const viteCliPath = path.join(nodeModulesPath, "vite", "dist", "node", "cli.js"); + if (!fs.existsSync(nodeModulesPath) || !fs.existsSync(viteCliPath)) { + fail(`Frontend dependencies are not installed: ${nodeModulesPath}`); + } + if (!jsonOutput) { + // No-op branch to keep signature parallel with publish helper; normal logs continue later. + } +} + +function getBackendBuildEnv(region) { + const backendStackName = getArg("backend-stack-name"); + const backendOutputsFile = getArg("backend-outputs-file"); + + let outputs = {}; + if (backendStackName) outputs = getStackOutputsSafe(backendStackName, region, "backend stack"); + else if (backendOutputsFile) outputs = readOutputsFile(backendOutputsFile, "backend outputs file"); + + return compactObject({ + REACT_APP_API_BASE: getOutputValue(outputs, ["ReactAppApiBase", "ApiBaseUrl", "ApiBase", "PublicApiBaseUrl"]), + REACT_APP_CONTENT_ROOT: getOutputValue(outputs, ["ReactAppContentRoot", "ContentRootUrl", "ContentRoot", "PublicContentRootUrl"]), + REACT_APP_B1_WEBSITE_URL: getOutputValue(outputs, ["ReactAppB1WebsiteUrl", "WebsiteBaseUrl", "WebsiteUrlPattern", "PublicWebsiteUrlPattern"]), + REACT_APP_LESSONS_API: getOutputValue(outputs, ["ReactAppLessonsApi", "LessonsApiUrl", "LessonsApi"]), + REACT_APP_GOOGLE_ANALYTICS: getOutputValue(outputs, ["ReactAppGoogleAnalytics", "GoogleAnalyticsTag"]), + REACT_APP_SENTRY_DSN: getOutputValue(outputs, ["ReactAppSentryDsn", "SentryDsn"]), + REACT_APP_TRANSFER_URL: getOutputValue(outputs, ["ReactAppTransferUrl", "TransferUrl"]), + REACT_APP_SUPPORT_EMAIL: getOutputValue(outputs, ["ReactAppSupportEmail", "SupportEmail"]), + REACT_APP_SUPPORT_PHONE: getOutputValue(outputs, ["ReactAppSupportPhone", "SupportPhone"]), + REACT_APP_SUPPORT_SITE_URL: getOutputValue(outputs, ["ReactAppSupportSiteUrl", "SupportSiteUrl"]), + REACT_APP_MOBILE_APP_URL: getOutputValue(outputs, ["ReactAppMobileAppUrl", "MobileAppUrl"]), + REACT_APP_DOMAIN_CNAME_TARGET: getOutputValue(outputs, ["ReactAppDomainCnameTarget", "DomainCnameTarget"]), + REACT_APP_DOMAIN_A_TARGET: getOutputValue(outputs, ["ReactAppDomainATarget", "DomainATarget"]), + REACT_APP_DEFAULT_STOCK_PHOTO: getOutputValue(outputs, ["ReactAppDefaultStockPhoto", "DefaultStockPhoto"]), + }); +} + +function main() { + if (!fs.existsSync(templatePath)) { + console.error(`Template not found: ${templatePath}`); + process.exit(1); + } + + const stackName = getArg("stack-name"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const paramsFile = getArg("parameters-file"); + const fileParams = loadParamsFromFile(paramsFile); + const projectName = getArg("project-name", fileParams.ProjectName || "b1admin"); + const environmentName = getArg("environment", fileParams.EnvironmentName || process.env.REACT_APP_STAGE || "prod"); + const bucketName = getArg("bucket-name", fileParams.BucketName); + const alternateDomainName = getArg("alternate-domain-name", fileParams.AlternateDomainName); + const acmCertificateArn = getArg("acm-certificate-arn", fileParams.AcmCertificateArn); + const hostedZoneId = getArg("hosted-zone-id", fileParams.HostedZoneId); + const priceClass = getArg("price-class", fileParams.PriceClass || "PriceClass_100"); + const skipBuild = hasFlag("skip-build"); + const infrastructureOnly = hasFlag("infrastructure-only"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const backendBuildEnv = skipBuild ? {} : getBackendBuildEnv(region); + + requireValue("stack-name", stackName); + if (skipBuild && infrastructureOnly) { + console.error("--skip-build has no effect when --infrastructure-only is set. Remove it and use --skip-build later during the frontend publish phase."); + process.exit(1); + } + if (!infrastructureOnly) { + ensureFrontendPublishPrerequisites({ skipBuild, jsonOutput }); + } + + const parameterOverrides = buildParameterOverrides({ + ProjectName: projectName, + EnvironmentName: environmentName, + BucketName: bucketName, + AlternateDomainName: alternateDomainName, + AcmCertificateArn: acmCertificateArn, + HostedZoneId: hostedZoneId, + PriceClass: priceClass, + }); + + const deployArgs = [ + "cloudformation", + "deploy", + "--stack-name", + stackName, + "--template-file", + templatePath, + "--region", + region, + "--no-fail-on-empty-changeset", + "--capabilities", + "CAPABILITY_NAMED_IAM", + "--parameter-overrides", + ...parameterOverrides, + ]; + if (cloudformationExecutionRoleArn) { + deployArgs.push("--role-arn", cloudformationExecutionRoleArn); + } + run("aws", deployArgs, { quiet: jsonOutput }); + + const outputs = getStackOutputs(stackName, region); + const bucket = outputs.SiteBucketName; + const distributionId = outputs.CloudFrontDistributionId; + + requireValue("SiteBucketName output", bucket); + requireValue("CloudFrontDistributionId output", distributionId); + + const result = { + stackName, + region, + environmentName, + bucket, + distributionId, + appUrl: outputs.AppUrl || "", + outputs, + backendBuildEnv, + skipBuild, + infrastructureOnly, + frontendPublished: false, + }; + + if (infrastructureOnly) { + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nFrontend infrastructure deployment complete."); + console.log(`Bucket: ${bucket}`); + console.log(`Distribution: ${distributionId}`); + if (outputs.AppUrl) console.log(`URL: ${outputs.AppUrl}`); + return; + } + + const publishArgs = [ + `--region=${region}`, + `--environment=${environmentName}`, + `--bucket=${bucket}`, + `--distribution-id=${distributionId}`, + ]; + if (outputs.AppUrl) publishArgs.push(`--app-url=${outputs.AppUrl}`); + const backendStackName = getArg("backend-stack-name"); + const backendOutputsFile = getArg("backend-outputs-file"); + if (backendStackName) publishArgs.push(`--backend-stack-name=${backendStackName}`); + if (backendOutputsFile) publishArgs.push(`--backend-outputs-file=${backendOutputsFile}`); + if (skipBuild) publishArgs.push("--skip-build"); + + const publishResult = jsonOutput + ? runNodeJson("scripts/publish-frontend-assets.mjs", [...publishArgs, "--output=json"]) + : run("node", ["scripts/publish-frontend-assets.mjs", ...publishArgs]); + + if (jsonOutput) { + result.frontendPublished = publishResult.frontendPublished; + result.backendBuildEnv = publishResult.backendBuildEnv || result.backendBuildEnv; + } else { + result.frontendPublished = true; + } + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nDeployment complete."); + console.log(`Bucket: ${bucket}`); + console.log(`Distribution: ${distributionId}`); + if (outputs.AppUrl) console.log(`URL: ${outputs.AppUrl}`); +} + +main(); diff --git a/scripts/deploy-full-stack.mjs b/scripts/deploy-full-stack.mjs new file mode 100644 index 000000000..c12306d98 --- /dev/null +++ b/scripts/deploy-full-stack.mjs @@ -0,0 +1,921 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fullStackTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "full-stack.yaml"); +const backendTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "backend-api.yaml"); +const frontendTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "frontend-site.yaml"); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runNodeJson(scriptPath, args) { + try { + return JSON.parse(execFileSync("node", [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + exitForCommandError(error, true); + } +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function addArg(args, name, value) { + if (value !== undefined && value !== null && value !== "") { + args.push(`--${name}=${value}`); + } +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function loadParamsFromFile(filePath) { + if (!filePath) return {}; + + try { + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + + if (Array.isArray(data)) { + return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + } + + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load parameters file "${filePath}": ${message}`); + } +} + +function toParameterOverrides(params) { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${key}=${value}`); +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function normalizeParameters(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((parameter) => [parameter.ParameterKey, parameter.ParameterValue])); + if (raw.Stacks?.[0]?.Parameters) return normalizeParameters(raw.Stacks[0].Parameters); + if (raw.Parameters) return normalizeParameters(raw.Parameters); + return raw; +} + +function describeStackSafe(stackName, region) { + try { + return runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + } catch (error) { + const stderr = String(error?.stderr || ""); + if (stderr.includes("does not exist")) return null; + throw error; + } +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function getStackParametersSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return normalizeParameters(describeStackSafe(stackName, region)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}" parameters: ${message}`); + } +} + +function loadJson(filePath, label = "JSON file") { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function resolveManifestArtifactPath(manifestFilePath, artifactPath) { + if (!artifactPath) return ""; + if (path.isAbsolute(artifactPath)) return artifactPath; + return path.resolve(path.dirname(manifestFilePath), artifactPath); +} + +function readOutputsFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return normalizeOutputs(JSON.parse(fs.readFileSync(resolved, "utf8"))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function getOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function compactObject(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== "")); +} + +function deriveArtifactKey(projectName, environmentName, fileName) { + return `${projectName}/${environmentName}/backend/${fileName}`; +} + +function ensureFrontendPublishPrerequisites(skipBuild) { + const distDir = path.join(rootDir, "dist"); + const serviceWorkerPath = path.join(distDir, "sw.js"); + + if (skipBuild) { + if (!fs.existsSync(distDir)) { + fail(`Build output not found: ${distDir}`); + } + if (!fs.existsSync(serviceWorkerPath)) { + fail(`Expected service worker not found: ${serviceWorkerPath}`); + } + return; + } + + const nodeModulesPath = path.join(rootDir, "node_modules"); + const viteCliPath = path.join(nodeModulesPath, "vite", "dist", "node", "cli.js"); + if (!fs.existsSync(nodeModulesPath) || !fs.existsSync(viteCliPath)) { + fail(`Frontend dependencies are not installed: ${nodeModulesPath}`); + } +} + +function loadApiRepoMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); + } +} + +function validateApiMigrationArgs(action, moduleName) { + const validActions = ["up", "down", "status"]; + const validModules = ["all", "membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; + + if (!validActions.includes(action)) { + fail(`Invalid api-migration-action "${action}". Use up, down, or status.`); + } + + if (!validModules.includes(moduleName)) { + fail(`Invalid api-migration-module "${moduleName}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); + } +} + +function buildFrontendEnvFromOutputs(outputs) { + return compactObject({ + REACT_APP_API_BASE: outputs.PublicApiBaseUrl || outputs.ApiBaseUrl || "", + REACT_APP_CONTENT_ROOT: outputs.ContentRootUrl || "", + REACT_APP_B1_WEBSITE_URL: outputs.WebsiteBaseUrl || "", + REACT_APP_LESSONS_API: outputs.LessonsApiUrl || outputs.PublicApiBaseUrl || outputs.ApiBaseUrl || "", + REACT_APP_GOOGLE_ANALYTICS: outputs.GoogleAnalyticsTag || "", + REACT_APP_SENTRY_DSN: outputs.SentryDsn || "", + REACT_APP_TRANSFER_URL: outputs.TransferUrl || "", + REACT_APP_SUPPORT_EMAIL: outputs.SupportEmail || "", + REACT_APP_SUPPORT_PHONE: outputs.SupportPhone || "", + REACT_APP_SUPPORT_SITE_URL: outputs.SupportSiteUrl || "", + REACT_APP_MOBILE_APP_URL: outputs.MobileAppUrl || "", + REACT_APP_DOMAIN_CNAME_TARGET: outputs.DomainCnameTarget || "", + REACT_APP_DOMAIN_A_TARGET: outputs.DomainATarget || "", + REACT_APP_DEFAULT_STOCK_PHOTO: outputs.DefaultStockPhoto || "", + }); +} + +function buildTemplateUrl(bucket, region, key) { + return `https://${bucket}.s3.${region}.amazonaws.com/${key}`; +} + +function uploadTemplate(bucket, region, localPath, s3Key, quiet = false) { + run("aws", [ + "s3", + "cp", + localPath, + `s3://${bucket}/${s3Key}`, + "--region", + region, + ], { quiet }); + return buildTemplateUrl(bucket, region, s3Key); +} + +function main() { + const stackName = getArg("stack-name"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const parametersFile = getArg("parameters-file"); + const fileParams = loadParamsFromFile(parametersFile); + const projectName = getArg("project-name", fileParams.ProjectName || "b1admin"); + const environmentName = getArg("environment", fileParams.EnvironmentName || "prod"); + const bootstrapStackName = getArg("bootstrap-stack-name"); + const templatePrefix = getArg("template-prefix", `${projectName}/${environmentName}/cloudformation`); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const infrastructureOnly = hasFlag("infrastructure-only"); + const frontendInfrastructureOnly = hasFlag("frontend-infrastructure-only"); + const publishFrontendAssets = hasFlag("publish-frontend-assets"); + const skipInfrastructure = hasFlag("skip-infrastructure"); + const skipBuild = hasFlag("skip-build"); + const frontendOutputsFile = getArg("frontend-outputs-file"); + const frontendPublishBucket = getArg("bucket"); + const frontendPublishDistributionId = getArg("distribution-id"); + const frontendPublishAppUrl = getArg("app-url"); + const backendOutputsFile = getArg("backend-outputs-file"); + const apiRepoPath = getArg("api-repo-path"); + const packageManifestFile = getArg("package-manifest-file"); + const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; + const apiMigrationAction = getArg("api-migration-action", "up"); + const apiMigrationModule = getArg("api-migration-module", "all"); + const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path", apiRepoPath || "../Api"); + const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); + const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); + const apiMigrationDryRun = getArg("api-migration-dry-run", "false").toLowerCase() === "true"; + const runBootstrapAdmin = getArg("run-bootstrap-admin", "false").toLowerCase() === "true"; + const bootstrapAdminSecretFile = getArg("bootstrap-admin-secret-file"); + const bootstrapAdminSecretArn = getArg("bootstrap-admin-secret-arn"); + const bootstrapAdminEmail = getArg("bootstrap-admin-email"); + const bootstrapAdminPassword = getArg("bootstrap-admin-password"); + const bootstrapAdminFirstName = getArg("bootstrap-admin-first-name"); + const bootstrapAdminLastName = getArg("bootstrap-admin-last-name"); + const bootstrapAdminDisplayName = getArg("bootstrap-admin-display-name"); + const bootstrapChurchName = getArg("bootstrap-church-name"); + const bootstrapChurchSubdomain = getArg("bootstrap-church-subdomain"); + const bootstrapChurchAddress1 = getArg("bootstrap-church-address1"); + const bootstrapChurchAddress2 = getArg("bootstrap-church-address2"); + const bootstrapChurchCity = getArg("bootstrap-church-city"); + const bootstrapChurchState = getArg("bootstrap-church-state"); + const bootstrapChurchZip = getArg("bootstrap-church-zip"); + const bootstrapChurchCountry = getArg("bootstrap-church-country"); + const bootstrapMembershipStatus = getArg("bootstrap-membership-status"); + const bootstrapAdminResetPassword = getArg("bootstrap-admin-reset-password", "true"); + const packageApiBackend = !packageManifestFile && (apiRepoPath !== "" || getArg("package-api-backend", "false").toLowerCase() === "true"); + const packageMode = getArg("package-mode", "self-contained"); + const packageOutputDir = getArg("package-output-dir", "infrastructure/artifacts/api"); + const packageBuild = getArg("package-build", "true"); + const packageBuildLayer = getArg("package-build-layer", packageMode === "layered" ? "true" : "false"); + const backendArtifactSourceFile = getArg("backend-artifact-source-file"); + const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); + const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); + const bootstrapOutputs = !skipInfrastructure + ? getStackOutputsSafe(bootstrapStackName, region, "bootstrap stack") + : {}; + const templateBucket = getArg("template-bucket", fileParams.TemplateBucketName || bootstrapOutputs.TemplateBucketName || ""); + + const explicitFrontendPublishTarget = Boolean(frontendOutputsFile || (frontendPublishBucket && frontendPublishDistributionId)); + const stackOutputsRequired = !skipInfrastructure || !explicitFrontendPublishTarget || (!skipBuild && !backendOutputsFile); + + if (stackOutputsRequired) { + requireValue("stack-name", stackName); + } + if (skipInfrastructure && !explicitFrontendPublishTarget && !stackName) { + fail("Full-stack publish-only needs --stack-name, --frontend-outputs-file, or both --bucket and --distribution-id."); + } + if (skipInfrastructure && !skipBuild && !stackName && !backendOutputsFile) { + fail("Full-stack publish-only needs --stack-name or --backend-outputs-file when a frontend build is required."); + } + if (skipInfrastructure && !publishFrontendAssets) { + console.error("--skip-infrastructure is only supported together with --publish-frontend-assets."); + process.exit(1); + } + if (publishFrontendAssets && infrastructureOnly) { + console.error("--publish-frontend-assets cannot be combined with --infrastructure-only. Provision infrastructure first, then run a later publish phase with --skip-infrastructure --publish-frontend-assets."); + process.exit(1); + } + if (publishFrontendAssets && frontendInfrastructureOnly) { + console.error("--publish-frontend-assets cannot be combined with --frontend-infrastructure-only. Use --frontend-infrastructure-only for the hosting-only phase, then run a later publish phase with --skip-infrastructure --publish-frontend-assets."); + process.exit(1); + } + if (skipInfrastructure && infrastructureOnly) { + console.error("--skip-infrastructure and --infrastructure-only cannot be used together. Skip-infrastructure is for publish-only follow-up runs, while infrastructure-only skips frontend publishing."); + process.exit(1); + } + if (skipInfrastructure && frontendInfrastructureOnly) { + console.error("--skip-infrastructure and --frontend-infrastructure-only cannot be used together. The former reuses existing infrastructure, while the latter provisions frontend hosting without publishing assets."); + process.exit(1); + } + if (publishFrontendAssets && !skipInfrastructure) { + console.error("--publish-frontend-assets is only needed for the later publish-only phase. Omit it for a normal full-stack deploy, or pair it with --skip-infrastructure for the second phase."); + process.exit(1); + } + if (skipBuild && !publishFrontendAssets && !skipInfrastructure && !frontendInfrastructureOnly && !infrastructureOnly) { + console.error("--skip-build only applies when frontend assets are being published. Use it with a normal full-stack deploy that publishes assets, or with --skip-infrastructure --publish-frontend-assets."); + process.exit(1); + } + if (skipBuild && (infrastructureOnly || frontendInfrastructureOnly) && !publishFrontendAssets) { + console.error("--skip-build has no effect when frontend publishing is deferred. Remove it or use it later during the publish phase."); + process.exit(1); + } + + if (runApiMigrations && skipInfrastructure) { + console.error("--run-api-migrations=true is only supported during the infrastructure deploy phase. Remove --skip-infrastructure or run yarn run:api-migrations separately afterward."); + process.exit(1); + } + + if (runBootstrapAdmin && skipInfrastructure) { + console.error("--run-bootstrap-admin=true is only supported during the infrastructure deploy phase. Remove --skip-infrastructure or run yarn run:bootstrap-admin separately afterward."); + process.exit(1); + } + + if (runApiMigrations) { + validateApiMigrationArgs(apiMigrationAction, apiMigrationModule); + const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath); + if (!fs.existsSync(resolvedApiMigrationRepoPath)) { + fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); + } + if (!fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { + fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); + } + if (!apiMigrationDryRun && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { + fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); + } + if (!apiMigrationDryRun && apiMigrationModule !== "all") { + const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(resolvedApiMigrationRepoPath); + if (apiRepoMigrationDirectories.length > 0 && !apiRepoMigrationDirectories.includes(apiMigrationModule)) { + fail(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. Refusing to deploy with --run-api-migrations=true for an unsupported direct migration target.`); + } + } + } + const willPublishFrontendAssets = (!infrastructureOnly && !frontendInfrastructureOnly && !skipInfrastructure) || (skipInfrastructure && publishFrontendAssets); + if (willPublishFrontendAssets) { + ensureFrontendPublishPrerequisites(skipBuild); + } + + const currentStackParams = parametersFile + ? {} + : getStackParametersSafe(stackName, region, "full-stack"); + + let backendTemplateUrl = ""; + let frontendTemplateUrl = ""; + if (!skipInfrastructure) { + requireValue("template-bucket", templateBucket); + const backendTemplateKey = `${templatePrefix}/backend-api.yaml`; + const frontendTemplateKey = `${templatePrefix}/frontend-site.yaml`; + backendTemplateUrl = uploadTemplate(templateBucket, region, backendTemplatePath, backendTemplateKey, jsonOutput); + frontendTemplateUrl = uploadTemplate(templateBucket, region, frontendTemplatePath, frontendTemplateKey, jsonOutput); + } + const resolvedArtifactBucket = getArg("lambda-code-s3-bucket", fileParams.LambdaCodeS3Bucket || bootstrapOutputs.ArtifactBucketName || ""); + const resolvedMigrationBucket = getArg("migration-code-s3-bucket", fileParams.MigrationCodeS3Bucket || resolvedArtifactBucket); + let resolvedDependenciesLayerArn = getArg("dependencies-layer-arn", fileParams.DependenciesLayerArn); + let resolvedAppConfigSecretArn = getArg("app-config-secret-arn", fileParams.AppConfigSecretArn); + let resolvedLambdaCodeS3ObjectVersion = getArg("lambda-code-s3-object-version", fileParams.LambdaCodeS3ObjectVersion); + let resolvedMigrationCodeS3ObjectVersion = getArg("migration-code-s3-object-version", fileParams.MigrationCodeS3ObjectVersion); + let resolvedBackendArtifactSourceFile = backendArtifactSourceFile; + let resolvedMigrationArtifactSourceFile = migrationArtifactSourceFile; + let resolvedDependenciesLayerSourceFile = dependenciesLayerSourceFile; + let resolvedPackageManifestFile = ""; + let resolvedObservabilityLayerArn = getArg("observability-layer-arn", fileParams.ObservabilityLayerArn); + let resolvedLambdaCodeS3Key = getArg("lambda-code-s3-key", fileParams.LambdaCodeS3Key); + let resolvedMigrationCodeS3Key = getArg("migration-code-s3-key", fileParams.MigrationCodeS3Key); + const appConfigSecretFile = getArg("app-config-secret-file"); + const appConfigSecretName = getArg("app-config-secret-name", `${projectName}/${environmentName}/app-config`); + const appConfigSecretId = getArg("app-config-secret-id"); + const appConfigSecretDescription = getArg("app-config-secret-description", `${projectName} ${environmentName} backend app config`); + const appConfigSecretKmsKeyId = getArg("app-config-secret-kms-key-id"); + const syncLegacySsm = getArg("sync-legacy-ssm", "false").toLowerCase() === "true"; + const legacySsmPrefix = getArg("legacy-ssm-prefix"); + const legacySsmIncludeEmpty = getArg("legacy-ssm-include-empty"); + const legacySsmOverwrite = getArg("legacy-ssm-overwrite"); + const dependenciesLayerName = getArg("dependencies-layer-name", `${projectName}-${environmentName}-dependencies`); + const dependenciesLayerDescription = getArg("dependencies-layer-description", `${projectName} ${environmentName} backend dependencies`); + const dependenciesLayerLicenseInfo = getArg("dependencies-layer-license-info"); + const dependenciesLayerCompatibleRuntimes = getArg("dependencies-layer-compatible-runtimes", fileParams.DependenciesLayerCompatibleRuntimes || "nodejs22.x"); + const dependenciesLayerCompatibleArchitectures = getArg("dependencies-layer-compatible-architectures", fileParams.DependenciesLayerCompatibleArchitectures || "arm64"); + + if (!skipInfrastructure && packageManifestFile) { + const manifestFilePath = path.resolve(rootDir, packageManifestFile); + resolvedPackageManifestFile = manifestFilePath; + const packageResult = loadJson(manifestFilePath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.dependenciesLayerArtifactPath); + } + + if (!skipInfrastructure && packageApiBackend) { + const manifestName = `api-${environmentName}-${packageMode}.manifest.json`; + const manifestPath = path.resolve(rootDir, packageOutputDir, manifestName); + resolvedPackageManifestFile = manifestPath; + const packageArgs = []; + addArg(packageArgs, "api-repo-path", apiRepoPath || "../Api"); + addArg(packageArgs, "environment", environmentName); + addArg(packageArgs, "package-mode", packageMode); + addArg(packageArgs, "output-dir", packageOutputDir); + addArg(packageArgs, "build", packageBuild); + addArg(packageArgs, "build-layer", packageBuildLayer); + addArg(packageArgs, "manifest-name", manifestName); + run("node", ["scripts/package-api-backend.mjs", ...packageArgs], { quiet: jsonOutput }); + const packageResult = loadJson(manifestPath, "package manifest"); + resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.backendArtifactPath); + resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.migrationArtifactPath); + resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.dependenciesLayerArtifactPath); + } + + if (!skipInfrastructure && !resolvedLambdaCodeS3Key && resolvedBackendArtifactSourceFile) { + resolvedLambdaCodeS3Key = deriveArtifactKey(projectName, environmentName, "api.zip"); + } + + if (!skipInfrastructure && !resolvedMigrationCodeS3Key && resolvedMigrationArtifactSourceFile) { + resolvedMigrationCodeS3Key = deriveArtifactKey(projectName, environmentName, "migrations.zip"); + } + + if (!resolvedDependenciesLayerArn && !resolvedDependenciesLayerSourceFile) { + resolvedDependenciesLayerArn = currentStackParams.DependenciesLayerArn || ""; + } + + if (!resolvedObservabilityLayerArn) { + resolvedObservabilityLayerArn = currentStackParams.ObservabilityLayerArn || ""; + } + + if (!skipInfrastructure && resolvedDependenciesLayerSourceFile) { + const layerResponse = runNodeJson("scripts/publish-lambda-layer.mjs", [ + `--region=${region}`, + `--layer-name=${dependenciesLayerName}`, + `--source-file=${resolvedDependenciesLayerSourceFile}`, + `--description=${dependenciesLayerDescription}`, + `--compatible-runtimes=${dependenciesLayerCompatibleRuntimes}`, + `--compatible-architectures=${dependenciesLayerCompatibleArchitectures}`, + `--output=json`, + ...(dependenciesLayerLicenseInfo ? [`--license-info=${dependenciesLayerLicenseInfo}`] : []), + ]); + resolvedDependenciesLayerArn = layerResponse.LayerVersionArn || resolvedDependenciesLayerArn; + } + + if (!skipInfrastructure && appConfigSecretFile) { + const secretResponse = runNodeJson("scripts/sync-app-config-secret.mjs", [ + `--region=${region}`, + `--secret-file=${appConfigSecretFile}`, + `--secret-name=${appConfigSecretName}`, + ...(appConfigSecretId ? [`--secret-id=${appConfigSecretId}`] : []), + ...(appConfigSecretDescription ? [`--description=${appConfigSecretDescription}`] : []), + ...(appConfigSecretKmsKeyId ? [`--kms-key-id=${appConfigSecretKmsKeyId}`] : []), + "--output=json", + ]); + resolvedAppConfigSecretArn = secretResponse.arn || resolvedAppConfigSecretArn; + } + + if (!resolvedAppConfigSecretArn && !appConfigSecretFile) { + resolvedAppConfigSecretArn = currentStackParams.AppConfigSecretArn || ""; + } + + const params = { + ProjectName: getArg("project-name", fileParams.ProjectName || projectName), + EnvironmentName: getArg("environment", fileParams.EnvironmentName || environmentName), + BackendTemplateUrl: backendTemplateUrl, + FrontendTemplateUrl: frontendTemplateUrl, + LambdaCodeS3Bucket: resolvedArtifactBucket, + LambdaCodeS3Key: resolvedLambdaCodeS3Key, + LambdaCodeS3ObjectVersion: resolvedLambdaCodeS3ObjectVersion, + LambdaHandler: getArg("lambda-handler", fileParams.LambdaHandler), + LambdaRuntime: getArg("lambda-runtime", fileParams.LambdaRuntime), + LambdaArchitecture: getArg("lambda-architecture", fileParams.LambdaArchitecture), + LambdaMemorySize: getArg("lambda-memory-size", fileParams.LambdaMemorySize), + LambdaTimeout: getArg("lambda-timeout", fileParams.LambdaTimeout), + LambdaReservedConcurrency: getArg("lambda-reserved-concurrency", fileParams.LambdaReservedConcurrency), + DependenciesLayerArn: resolvedDependenciesLayerArn, + ObservabilityLayerArn: resolvedObservabilityLayerArn, + LambdaNodeOptions: getArg("lambda-node-options", fileParams.LambdaNodeOptions), + EnableWebSocketApi: getArg("enable-web-socket-api", fileParams.EnableWebSocketApi), + SocketLambdaHandler: getArg("socket-lambda-handler", fileParams.SocketLambdaHandler), + SocketLambdaMemorySize: getArg("socket-lambda-memory-size", fileParams.SocketLambdaMemorySize), + SocketLambdaTimeout: getArg("socket-lambda-timeout", fileParams.SocketLambdaTimeout), + EnableScheduledWorkers: getArg("enable-scheduled-workers", fileParams.EnableScheduledWorkers), + Timer15MinLambdaHandler: getArg("timer15-min-lambda-handler", fileParams.Timer15MinLambdaHandler), + TimerMidnightLambdaHandler: getArg("timer-midnight-lambda-handler", fileParams.TimerMidnightLambdaHandler), + TimerScheduledTasksLambdaHandler: getArg("timer-scheduled-tasks-lambda-handler", fileParams.TimerScheduledTasksLambdaHandler), + TimerWebhooksLambdaHandler: getArg("timer-webhooks-lambda-handler", fileParams.TimerWebhooksLambdaHandler), + TimerLambdaMemorySize: getArg("timer-lambda-memory-size", fileParams.TimerLambdaMemorySize), + TimerLambdaTimeout: getArg("timer-lambda-timeout", fileParams.TimerLambdaTimeout), + RunMigrations: getArg("run-migrations", fileParams.RunMigrations), + MigrationCodeS3Bucket: resolvedMigrationBucket, + MigrationCodeS3Key: resolvedMigrationCodeS3Key, + MigrationCodeS3ObjectVersion: resolvedMigrationCodeS3ObjectVersion, + MigrationHandler: getArg("migration-handler", fileParams.MigrationHandler), + MigrationRuntime: getArg("migration-runtime", fileParams.MigrationRuntime), + MigrationMemorySize: getArg("migration-memory-size", fileParams.MigrationMemorySize), + MigrationTimeout: getArg("migration-timeout", fileParams.MigrationTimeout), + MigrationTrigger: getArg("migration-trigger", fileParams.MigrationTrigger), + DatabaseName: getArg("database-name", fileParams.DatabaseName), + MembershipDatabaseName: getArg("membership-database-name", fileParams.MembershipDatabaseName), + AttendanceDatabaseName: getArg("attendance-database-name", fileParams.AttendanceDatabaseName), + ContentDatabaseName: getArg("content-database-name", fileParams.ContentDatabaseName), + GivingDatabaseName: getArg("giving-database-name", fileParams.GivingDatabaseName), + MessagingDatabaseName: getArg("messaging-database-name", fileParams.MessagingDatabaseName), + DoingDatabaseName: getArg("doing-database-name", fileParams.DoingDatabaseName), + ReportingDatabaseName: getArg("reporting-database-name", fileParams.ReportingDatabaseName), + DatabaseEngine: getArg("database-engine", fileParams.DatabaseEngine), + DatabasePort: getArg("database-port", fileParams.DatabasePort), + DatabaseMasterUsername: getArg("database-master-username", fileParams.DatabaseMasterUsername), + DatabaseMinCapacity: getArg("database-min-capacity", fileParams.DatabaseMinCapacity), + DatabaseMaxCapacity: getArg("database-max-capacity", fileParams.DatabaseMaxCapacity), + ApiCustomDomainName: getArg("api-custom-domain-name", fileParams.ApiCustomDomainName), + ApiCertificateArn: getArg("api-certificate-arn", fileParams.ApiCertificateArn), + ApiHostedZoneId: getArg("api-hosted-zone-id", fileParams.ApiHostedZoneId), + CreateNatGateway: getArg("create-nat-gateway", fileParams.CreateNatGateway), + VpcCidr: getArg("vpc-cidr", fileParams.VpcCidr), + PublicSubnet1Cidr: getArg("public-subnet-1-cidr", fileParams.PublicSubnet1Cidr), + PublicSubnet2Cidr: getArg("public-subnet-2-cidr", fileParams.PublicSubnet2Cidr), + PrivateSubnet1Cidr: getArg("private-subnet-1-cidr", fileParams.PrivateSubnet1Cidr), + PrivateSubnet2Cidr: getArg("private-subnet-2-cidr", fileParams.PrivateSubnet2Cidr), + FrontendBucketName: getArg("frontend-bucket-name", fileParams.FrontendBucketName), + FrontendAlternateDomainName: getArg("frontend-alternate-domain-name", fileParams.FrontendAlternateDomainName), + FrontendAcmCertificateArn: getArg("frontend-acm-certificate-arn", fileParams.FrontendAcmCertificateArn), + FrontendHostedZoneId: getArg("frontend-hosted-zone-id", fileParams.FrontendHostedZoneId), + FrontendPriceClass: getArg("frontend-price-class", fileParams.FrontendPriceClass), + WebsiteBaseUrl: getArg("website-base-url", fileParams.WebsiteBaseUrl), + ContentRootUrl: getArg("content-root-url", fileParams.ContentRootUrl), + B1AdminRootUrl: getArg("b1-admin-root-url", fileParams.B1AdminRootUrl), + CorsOrigin: getArg("cors-origin", fileParams.CorsOrigin), + FileStore: getArg("file-store", fileParams.FileStore), + ManageAssetBucket: getArg("manage-asset-bucket", fileParams.ManageAssetBucket), + AssetBucketName: getArg("asset-bucket-name", fileParams.AssetBucketName), + AppConfigSecretArn: resolvedAppConfigSecretArn, + MailSystem: getArg("mail-system", fileParams.MailSystem), + DeliveryProvider: getArg("delivery-provider", fileParams.DeliveryProvider), + StoreApiUrl: getArg("store-api-url", fileParams.StoreApiUrl), + AiProvider: getArg("ai-provider", fileParams.AiProvider), + EmailOnRegistration: getArg("email-on-registration", fileParams.EmailOnRegistration), + CaddyHost: getArg("caddy-host", fileParams.CaddyHost), + CaddyPort: getArg("caddy-port", fileParams.CaddyPort), + TransferUrl: getArg("transfer-url", fileParams.TransferUrl), + SupportEmail: getArg("support-email", fileParams.SupportEmail), + SupportPhone: getArg("support-phone", fileParams.SupportPhone), + SupportSiteUrl: getArg("support-site-url", fileParams.SupportSiteUrl), + MobileAppUrl: getArg("mobile-app-url", fileParams.MobileAppUrl), + DomainCnameTarget: getArg("domain-cname-target", fileParams.DomainCnameTarget), + DomainATarget: getArg("domain-a-target", fileParams.DomainATarget), + DefaultStockPhoto: getArg("default-stock-photo", fileParams.DefaultStockPhoto), + GoogleAnalyticsTag: getArg("google-analytics-tag", fileParams.GoogleAnalyticsTag), + SentryDsn: getArg("sentry-dsn", fileParams.SentryDsn), + }; + + if (!skipInfrastructure) { + requireValue("lambda-code-s3-bucket", params.LambdaCodeS3Bucket); + requireValue("lambda-code-s3-key", params.LambdaCodeS3Key); + } + + if (!skipInfrastructure && resolvedBackendArtifactSourceFile) { + const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ + "scripts/upload-backend-artifact.mjs", + `--region=${region}`, + `--bootstrap-stack-name=${bootstrapStackName}`, + `--artifact-bucket=${params.LambdaCodeS3Bucket}`, + `--source-file=${resolvedBackendArtifactSourceFile}`, + `--artifact-key=${params.LambdaCodeS3Key}`, + "--artifact-label=Backend artifact", + "--output=json", + ].filter((arg) => !arg.endsWith("="))); + resolvedLambdaCodeS3ObjectVersion = uploadResult.versionId || resolvedLambdaCodeS3ObjectVersion; + params.LambdaCodeS3ObjectVersion = resolvedLambdaCodeS3ObjectVersion; + if (!jsonOutput) { + console.log("\nBackend artifact upload complete."); + console.log(`Bucket: ${uploadResult.bucket}`); + console.log(`Key: ${uploadResult.key}`); + if (uploadResult.versionId) console.log(`VersionId: ${uploadResult.versionId}`); + console.log(`S3 URI: ${uploadResult.s3Uri}`); + } + } + + if (!skipInfrastructure && resolvedMigrationArtifactSourceFile) { + const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ + "scripts/upload-backend-artifact.mjs", + `--region=${region}`, + `--bootstrap-stack-name=${bootstrapStackName}`, + `--artifact-bucket=${params.MigrationCodeS3Bucket}`, + `--source-file=${resolvedMigrationArtifactSourceFile}`, + `--artifact-key=${params.MigrationCodeS3Key}`, + "--artifact-label=Migration artifact", + "--output=json", + ].filter((arg) => !arg.endsWith("="))); + resolvedMigrationCodeS3ObjectVersion = uploadResult.versionId || resolvedMigrationCodeS3ObjectVersion; + params.MigrationCodeS3ObjectVersion = resolvedMigrationCodeS3ObjectVersion; + if (!jsonOutput) { + console.log("\nMigration artifact upload complete."); + console.log(`Bucket: ${uploadResult.bucket}`); + console.log(`Key: ${uploadResult.key}`); + if (uploadResult.versionId) console.log(`VersionId: ${uploadResult.versionId}`); + console.log(`S3 URI: ${uploadResult.s3Uri}`); + } + } + + if (!skipInfrastructure) { + const deployArgs = [ + "cloudformation", + "deploy", + "--stack-name", + stackName, + "--template-file", + fullStackTemplatePath, + "--region", + region, + "--no-fail-on-empty-changeset", + "--capabilities", + "CAPABILITY_NAMED_IAM", + "--parameter-overrides", + ...toParameterOverrides(params), + ]; + if (cloudformationExecutionRoleArn) { + deployArgs.push("--role-arn", cloudformationExecutionRoleArn); + } + run("aws", deployArgs, { quiet: jsonOutput }); + } + + if (!skipInfrastructure && syncLegacySsm) { + const legacySsmArgs = [ + `--stack-name=${stackName}`, + `--environment=${environmentName}`, + `--region=${region}`, + ]; + if (legacySsmPrefix) legacySsmArgs.push(`--prefix=${legacySsmPrefix}`); + if (legacySsmIncludeEmpty) legacySsmArgs.push(`--include-empty=${legacySsmIncludeEmpty}`); + if (legacySsmOverwrite) legacySsmArgs.push(`--overwrite=${legacySsmOverwrite}`); + if (appConfigSecretFile) legacySsmArgs.push(`--app-config-secret-file=${appConfigSecretFile}`); + else if (resolvedAppConfigSecretArn) legacySsmArgs.push(`--app-config-secret-arn=${resolvedAppConfigSecretArn}`); + run("node", ["scripts/sync-legacy-ssm-parameters.mjs", ...legacySsmArgs], { quiet: jsonOutput }); + } + const outputs = stackOutputsRequired ? getStackOutputsSafe(stackName, region, "full-stack") : {}; + const publishTargetOutputs = frontendOutputsFile ? readOutputsFile(frontendOutputsFile, "frontend outputs file") : outputs; + const buildEnvOutputs = backendOutputsFile ? readOutputsFile(backendOutputsFile, "backend outputs file") : outputs; + let apiMigrationsResult = null; + if (!skipInfrastructure && runApiMigrations) { + const migrationArgs = [ + `--stack-name=${stackName}`, + `--region=${region}`, + `--api-repo-path=${apiMigrationApiRepoPath}`, + `--action=${apiMigrationAction}`, + `--module=${apiMigrationModule}`, + ]; + if (apiMigrationDbSecretArn) migrationArgs.push(`--db-secret-arn=${apiMigrationDbSecretArn}`); + if (apiMigrationDbSecretFile) migrationArgs.push(`--db-secret-file=${apiMigrationDbSecretFile}`); + if (apiMigrationDryRun) migrationArgs.push("--dry-run=true"); + if (jsonOutput) migrationArgs.push("--output=json"); + apiMigrationsResult = jsonOutput + ? runNodeJson("scripts/run-api-migrations.mjs", migrationArgs) + : run("node", ["scripts/run-api-migrations.mjs", ...migrationArgs]); + } + let bootstrapAdminResult = null; + if (!skipInfrastructure && runBootstrapAdmin) { + const bootstrapArgs = [ + `--stack-name=${stackName}`, + `--region=${region}`, + `--bootstrap-admin-reset-password=${bootstrapAdminResetPassword}`, + ]; + if (bootstrapAdminSecretFile) bootstrapArgs.push(`--bootstrap-admin-secret-file=${bootstrapAdminSecretFile}`); + if (bootstrapAdminSecretArn) bootstrapArgs.push(`--bootstrap-admin-secret-arn=${bootstrapAdminSecretArn}`); + if (bootstrapAdminEmail) bootstrapArgs.push(`--bootstrap-admin-email=${bootstrapAdminEmail}`); + if (bootstrapAdminPassword) bootstrapArgs.push(`--bootstrap-admin-password=${bootstrapAdminPassword}`); + if (bootstrapAdminFirstName) bootstrapArgs.push(`--bootstrap-admin-first-name=${bootstrapAdminFirstName}`); + if (bootstrapAdminLastName) bootstrapArgs.push(`--bootstrap-admin-last-name=${bootstrapAdminLastName}`); + if (bootstrapAdminDisplayName) bootstrapArgs.push(`--bootstrap-admin-display-name=${bootstrapAdminDisplayName}`); + if (bootstrapChurchName) bootstrapArgs.push(`--bootstrap-church-name=${bootstrapChurchName}`); + if (bootstrapChurchSubdomain) bootstrapArgs.push(`--bootstrap-church-subdomain=${bootstrapChurchSubdomain}`); + if (bootstrapChurchAddress1) bootstrapArgs.push(`--bootstrap-church-address1=${bootstrapChurchAddress1}`); + if (bootstrapChurchAddress2) bootstrapArgs.push(`--bootstrap-church-address2=${bootstrapChurchAddress2}`); + if (bootstrapChurchCity) bootstrapArgs.push(`--bootstrap-church-city=${bootstrapChurchCity}`); + if (bootstrapChurchState) bootstrapArgs.push(`--bootstrap-church-state=${bootstrapChurchState}`); + if (bootstrapChurchZip) bootstrapArgs.push(`--bootstrap-church-zip=${bootstrapChurchZip}`); + if (bootstrapChurchCountry) bootstrapArgs.push(`--bootstrap-church-country=${bootstrapChurchCountry}`); + if (bootstrapMembershipStatus) bootstrapArgs.push(`--bootstrap-membership-status=${bootstrapMembershipStatus}`); + if (jsonOutput) bootstrapArgs.push("--output=json"); + bootstrapAdminResult = jsonOutput + ? runNodeJson("scripts/bootstrap-initial-admin.mjs", bootstrapArgs) + : run("node", ["scripts/bootstrap-initial-admin.mjs", ...bootstrapArgs]); + } + const result = { + stackName, + region, + environmentName, + outputs, + infrastructureOnly, + frontendInfrastructureOnly, + publishFrontendAssets, + skipInfrastructure, + skipBuild, + bootstrapStackName, + resolvedPackageManifestFile, + resolvedBackendArtifactSourceFile, + resolvedMigrationArtifactSourceFile, + resolvedDependenciesLayerSourceFile, + backendTemplateUrl, + frontendTemplateUrl, + appConfigSecretArn: resolvedAppConfigSecretArn || outputs.AppConfigSecretArn || "", + lambdaCodeS3Bucket: params.LambdaCodeS3Bucket, + lambdaCodeS3Key: params.LambdaCodeS3Key, + migrationCodeS3Bucket: params.MigrationCodeS3Bucket || "", + migrationCodeS3Key: params.MigrationCodeS3Key || "", + dependenciesLayerArn: resolvedDependenciesLayerArn || "", + syncLegacySsm, + runApiMigrations, + apiMigrations: apiMigrationsResult, + runBootstrapAdmin, + bootstrapAdmin: bootstrapAdminResult, + frontendPublished: false, + }; + + if (infrastructureOnly && !publishFrontendAssets) { + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nFull-stack infrastructure deployment complete."); + console.log(`Stack: ${stackName}`); + console.log(`Backend template URL: ${backendTemplateUrl}`); + console.log(`Frontend template URL: ${frontendTemplateUrl}`); + if (bootstrapStackName) console.log(`Bootstrap stack: ${bootstrapStackName}`); + return; + } + + const frontendEnv = buildFrontendEnvFromOutputs(buildEnvOutputs); + const bucketName = frontendPublishBucket || getOutputValue(publishTargetOutputs, ["FrontendBucketName", "SiteBucketName"]); + const distributionId = frontendPublishDistributionId || getOutputValue(publishTargetOutputs, ["FrontendDistributionId", "CloudFrontDistributionId"]); + const frontendAppUrl = frontendPublishAppUrl || getOutputValue(publishTargetOutputs, ["FrontendAppUrl", "AppUrl"]); + + requireValue("FrontendBucketName output", bucketName); + requireValue("FrontendDistributionId output", distributionId); + + if (frontendInfrastructureOnly && !publishFrontendAssets) { + result.frontendBucketName = bucketName; + result.frontendDistributionId = distributionId; + result.frontendAppUrl = frontendAppUrl || ""; + result.frontendEnv = frontendEnv; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nFull-stack infrastructure deployment complete."); + console.log(`Stack: ${stackName}`); + console.log(`Backend template URL: ${backendTemplateUrl}`); + console.log(`Frontend template URL: ${frontendTemplateUrl}`); + if (bootstrapStackName) console.log(`Bootstrap stack: ${bootstrapStackName}`); + console.log(`Frontend bucket: ${bucketName}`); + console.log(`Frontend distribution: ${distributionId}`); + if (frontendAppUrl) console.log(`URL: ${frontendAppUrl}`); + return; + } + + let publishResult = null; + const publishArgs = [ + `--bucket=${bucketName}`, + `--distribution-id=${distributionId}`, + `--region=${region}`, + `--environment=${environmentName}`, + ]; + if (frontendAppUrl) publishArgs.push(`--app-url=${frontendAppUrl}`); + if (skipBuild) publishArgs.push("--skip-build"); + + let tempDir = ""; + try { + if (!skipBuild) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "b1admin-full-stack-")); + const backendOutputsPath = path.join(tempDir, "backend-outputs.json"); + fs.writeFileSync(backendOutputsPath, `${JSON.stringify(buildEnvOutputs, null, 2)}\n`); + publishArgs.push(`--backend-outputs-file=${backendOutputsPath}`); + } + + publishResult = jsonOutput + ? runNodeJson("scripts/publish-frontend-assets.mjs", [...publishArgs, "--output=json"]) + : run("node", ["scripts/publish-frontend-assets.mjs", ...publishArgs]); + } finally { + if (tempDir) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + } + + result.frontendPublished = jsonOutput ? publishResult.frontendPublished : true; + result.frontendBucketName = bucketName; + result.frontendDistributionId = distributionId; + result.frontendAppUrl = frontendAppUrl || ""; + result.frontendEnv = jsonOutput ? (publishResult.backendBuildEnv || frontendEnv) : frontendEnv; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + if (skipInfrastructure) { + console.log("\nFull-stack frontend asset publish complete."); + } else { + console.log("\nFull-stack application deployment complete."); + } + if (frontendAppUrl) console.log(`URL: ${frontendAppUrl}`); +} + +main(); diff --git a/scripts/discover-github-aws-role-arns.mjs b/scripts/discover-github-aws-role-arns.mjs new file mode 100644 index 000000000..2046ca6e3 --- /dev/null +++ b/scripts/discover-github-aws-role-arns.mjs @@ -0,0 +1,174 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function runAws(args) { + try { + return execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 10 * 1024 * 1024, + }).trim(); + } catch (error) { + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : ""; + const message = error instanceof Error ? error.message : String(error); + throw new Error(stderr.trim() || message); + } +} + +function getRole(roleName) { + try { + const stdout = runAws(["iam", "get-role", "--role-name", roleName, "--output", "json"]); + const parsed = JSON.parse(stdout); + return { + found: true, + roleName, + arn: parsed?.Role?.Arn || "", + }; + } catch (_error) { + return { + found: false, + roleName, + arn: "", + }; + } +} + +function listCandidateRoles(projectName, environment) { + const stdout = runAws(["iam", "list-roles", "--output", "json"]); + const parsed = JSON.parse(stdout); + const roles = Array.isArray(parsed?.Roles) ? parsed.Roles : []; + return roles + .filter((role) => { + const name = String(role?.RoleName || ""); + return ( + name.includes(projectName) + || name.includes(environment) + || name.includes("github") + || name.includes("oidc") + || name.includes("cfn") + ); + }) + .map((role) => ({ + roleName: role.RoleName, + arn: role.Arn, + })); +} + +function renderText(result) { + const lines = [ + `GitHub AWS role discovery: ${result.environment}`, + `Project: ${result.projectName}`, + `Suggested deploy role name: ${result.expected.deployRoleName}`, + `Suggested CloudFormation execution role name: ${result.expected.cfnRoleName}`, + "", + `Deploy role: ${result.deployRole.found ? result.deployRole.arn : "not found"}`, + `CloudFormation execution role: ${result.cfnRole.found ? result.cfnRole.arn : "not found"}`, + ]; + + if (result.deployRole.found || result.cfnRole.found) { + lines.push("", "GitHub secret commands:"); + if (result.deployRole.found) { + lines.push(`- gh secret set AWS_ROLE_TO_ASSUME --env aws-${result.environment} --repo / --body '${result.deployRole.arn}'`); + } + if (result.cfnRole.found) { + lines.push(`- gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --env aws-${result.environment} --repo / --body '${result.cfnRole.arn}'`); + } + } + + if (result.candidates.length > 0) { + lines.push("", "Nearby IAM roles:"); + result.candidates.forEach((candidate) => lines.push(`- ${candidate.roleName} :: ${candidate.arn}`)); + } + + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(result) { + const lines = [ + `# GitHub AWS Role Discovery: ${result.environment}`, + "", + `- Project: \`${result.projectName}\``, + `- Suggested deploy role name: \`${result.expected.deployRoleName}\``, + `- Suggested CloudFormation execution role name: \`${result.expected.cfnRoleName}\``, + "", + "## Resolved Roles", + "", + `- Deploy role: ${result.deployRole.found ? `\`${result.deployRole.arn}\`` : "not found"}`, + `- CloudFormation execution role: ${result.cfnRole.found ? `\`${result.cfnRole.arn}\`` : "not found"}`, + ]; + + if (result.deployRole.found || result.cfnRole.found) { + lines.push("", "## GitHub Secret Commands", ""); + if (result.deployRole.found) { + lines.push(`- \`gh secret set AWS_ROLE_TO_ASSUME --env aws-${result.environment} --repo / --body '${result.deployRole.arn}'\``); + } + if (result.cfnRole.found) { + lines.push(`- \`gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --env aws-${result.environment} --repo / --body '${result.cfnRole.arn}'\``); + } + } + + if (result.candidates.length > 0) { + lines.push("", "## Nearby IAM Roles", ""); + result.candidates.forEach((candidate) => lines.push(`- \`${candidate.roleName}\` :: \`${candidate.arn}\``)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const projectName = getArg("project-name", "b1admin"); + const output = getArg("output", "text").toLowerCase(); + + const expected = { + deployRoleName: `${projectName}-${environment}-github-deploy`, + cfnRoleName: `${projectName}-${environment}-cfn-exec`, + }; + + const deployRole = getRole(expected.deployRoleName); + const cfnRole = getRole(expected.cfnRoleName); + const candidates = listCandidateRoles(projectName, environment); + + const result = { + ok: deployRole.found || cfnRole.found, + environment, + projectName, + expected, + deployRole, + cfnRole, + candidates, + }; + + if (output === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + if (output === "markdown") { + process.stdout.write(renderMarkdown(result)); + return; + } + + process.stdout.write(renderText(result)); +} + +main(); diff --git a/scripts/dispatch-github-aws-deploy.mjs b/scripts/dispatch-github-aws-deploy.mjs new file mode 100644 index 000000000..88117785f --- /dev/null +++ b/scripts/dispatch-github-aws-deploy.mjs @@ -0,0 +1,312 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getGithubCliReadiness } from "./lib/github-cli-readiness.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function parseGithubRepo(remoteUrl) { + const match = String(remoteUrl || "").trim().match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/i); + if (!match) return ""; + return `${match[1]}/${match[2]}`; +} + +function resolveGithubRepo(explicitRepo = "") { + if (explicitRepo) return explicitRepo; + try { + const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 1024 * 1024, + }).trim(); + return parseGithubRepo(remoteUrl); + } catch { + return ""; + } +} + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function hasArg(name) { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + return process.argv.some((arg) => arg === bareFlag || arg.startsWith(prefix)); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function runJsonNodeScript(scriptPath, args) { + const result = execFileSync(process.execPath, [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + return JSON.parse(result); +} + +function runNodeScript(scriptPath, args) { + try { + execFileSync(process.execPath, [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + } catch (error) { + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : ""; + const message = error instanceof Error ? error.message : String(error); + fail(stderr.trim() || message); + } +} + +function runGhWorkflow(args) { + try { + execFileSync("gh", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + fail("GitHub CLI is not installed or not available on PATH."); + } + + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : ""; + const message = error instanceof Error ? error.message : String(error); + fail(`GitHub workflow dispatch failed: ${stderr.trim() || message}`); + } +} + +function ensureGhAuth() { + const readiness = getGithubCliReadiness({ + cwd: rootDir, + connectivityAction: "dispatching the workflow from here.", + }); + + if (!readiness.ok) { + fail(readiness.blockers[0] || "GitHub CLI auth check failed."); + } +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function buildDispatchCommand(repo, workflowInputs) { + const parts = ["gh", "workflow", "run", "deploy-aws-self-hosted.yml"]; + if (repo) { + parts.push("--repo", shellQuote(repo)); + } + Object.entries(workflowInputs).forEach(([key, value]) => { + parts.push("-f", `${key}=${shellQuote(value)}`); + }); + return parts.join(" "); +} + +function buildGhRunCommand(repo, commandParts) { + const parts = ["gh", "run", ...commandParts]; + if (repo) { + parts.push("--repo", shellQuote(repo)); + } + return parts.join(" "); +} + +function buildFollowUpCommands(repo) { + const listRuns = buildGhRunCommand(repo, ["list", "--workflow", "deploy-aws-self-hosted.yml", "--limit", "5"]); + const latestRunIdExpr = "$(gh run list --workflow deploy-aws-self-hosted.yml --limit 1 --json databaseId --jq '.[0].databaseId'"; + const latestRunIdWithRepoExpr = repo + ? `${latestRunIdExpr} --repo ${shellQuote(repo)})` + : `${latestRunIdExpr})`; + + return { + listRuns, + watchLatestRun: `${buildGhRunCommand(repo, ["watch", latestRunIdWithRepoExpr, "--compact", "--exit-status"])}`, + viewLatestRun: `${buildGhRunCommand(repo, ["view", latestRunIdWithRepoExpr])}`, + }; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + const region = getArg("region", "us-east-1"); + const deploymentSource = getArg("deployment-source", "api-repo"); + const githubAuthMode = getArg("github-auth-mode", "oidc"); + const apiRepoPath = getArg("api-repo-path", "../Api"); + const b1adminRepo = getArg("b1admin-repo", "ChurchApps/B1Admin"); + const b1adminRef = getArg("b1admin-ref", "main"); + const apiRepo = getArg("api-repo", "ChurchApps/Api"); + const apiRef = getArg("api-ref", "main"); + const packageManifestFile = getArg("package-manifest-file"); + const backendArtifactSourceFile = getArg("backend-artifact-source-file"); + const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); + const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); + const runApiMigrations = getArg("run-api-migrations", "false"); + const apiMigrationAction = getArg("api-migration-action", "up"); + const apiMigrationModule = getArg("api-migration-module", "all"); + const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const verifyHttpAfterDeploy = getArg("verify-http-after-deploy", "false"); + const previewOnly = getArg("preview-only", "false").toLowerCase() === "true"; + const outputMode = getArg("output", "text").toLowerCase(); + const dryRun = getArg("dry-run", "false").toLowerCase() === "true"; + const skipGhAuthCheck = getArg("skip-gh-auth-check", "false").toLowerCase() === "true"; + const repo = resolveGithubRepo(getArg("repo")); + const syncGithubSecret = getArg("sync-github-secret", "true").toLowerCase() === "true"; + const explicitSyncAppConfigSecret = hasArg("sync-app-config-secret"); + const explicitSyncBootstrapAdminSecret = hasArg("sync-bootstrap-admin-secret"); + const secretFileExists = fs.existsSync(path.join(environmentDir, "app-config-secret.json")); + const syncAppConfigSecret = explicitSyncAppConfigSecret + ? getArg("sync-app-config-secret", "false").toLowerCase() === "true" + : secretFileExists; + const syncBootstrapAdminSecret = explicitSyncBootstrapAdminSecret + ? getArg("sync-bootstrap-admin-secret", "false").toLowerCase() === "true" + : false; + const runBootstrapAdmin = getArg("run-bootstrap-admin", "false"); + + const planArgs = [ + "scripts/plan-environment-deploy.mjs", + `--environment=${environment}`, + `--region=${region}`, + `--deployment-source=${deploymentSource}`, + `--github-auth-mode=${githubAuthMode}`, + `--api-repo-path=${apiRepoPath}`, + `--b1admin-repo=${b1adminRepo}`, + `--b1admin-ref=${b1adminRef}`, + `--api-repo=${apiRepo}`, + `--api-ref=${apiRef}`, + `--run-api-migrations=${runApiMigrations}`, + `--sync-bootstrap-admin-secret=${syncBootstrapAdminSecret ? "true" : "false"}`, + `--run-bootstrap-admin=${runBootstrapAdmin}`, + `--api-migration-action=${apiMigrationAction}`, + `--api-migration-module=${apiMigrationModule}`, + `--api-migration-runner=${apiMigrationRunner}`, + `--verify-http-after-deploy=${verifyHttpAfterDeploy}`, + `--preview-only=${previewOnly ? "true" : "false"}`, + `--sync-app-config-secret=${syncAppConfigSecret ? "true" : "false"}`, + "--output=json", + ]; + + if (environmentDirArg) planArgs.push(`--environment-dir=${environmentDirArg}`); + if (packageManifestFile) planArgs.push(`--package-manifest-file=${packageManifestFile}`); + if (backendArtifactSourceFile) planArgs.push(`--backend-artifact-source-file=${backendArtifactSourceFile}`); + if (migrationArtifactSourceFile) planArgs.push(`--migration-artifact-source-file=${migrationArtifactSourceFile}`); + if (dependenciesLayerSourceFile) planArgs.push(`--dependencies-layer-source-file=${dependenciesLayerSourceFile}`); + + let plan; + try { + plan = runJsonNodeScript(planArgs[0], planArgs.slice(1)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not compute GitHub deploy plan: ${message}`); + } + + if (!plan.githubActionsExecution?.ok) { + const blockers = (plan.githubActionsExecution?.blockers || []).map((entry) => `- ${entry}`).join("\n"); + fail(`GitHub deploy is not ready yet.\n${blockers}`); + } + + const workflowInputs = { + ...(plan.workflowInputs || {}), + preview_only: previewOnly ? "true" : "false", + }; + + const dispatchArgs = ["workflow", "run", "deploy-aws-self-hosted.yml"]; + if (repo) { + dispatchArgs.push("--repo", repo); + } + Object.entries(workflowInputs).forEach(([key, value]) => { + dispatchArgs.push("-f", `${key}=${value}`); + }); + + let secretSync = { + attempted: false, + performed: false, + command: plan.githubSecretSyncCommand || "", + }; + + if (!skipGhAuthCheck) { + ensureGhAuth(); + } + + if (syncGithubSecret && plan.githubSecretSyncCommand) { + secretSync.attempted = true; + const syncArgs = [ + "scripts/sync-github-app-config-secret.mjs", + `--environment=${environment}`, + `--secret-file=${plan.environmentDir}/app-config-secret.json`, + "--output=json", + ]; + if (environmentDirArg) syncArgs.push(`--environment-dir=${environmentDirArg}`); + if (repo) syncArgs.push(`--repo=${repo}`); + if (dryRun) syncArgs.push("--dry-run=true"); + if (skipGhAuthCheck) syncArgs.push("--skip-gh-auth-check=true"); + runNodeScript(syncArgs[0], syncArgs.slice(1)); + secretSync.performed = !dryRun; + } + + if (!dryRun) { + runGhWorkflow(dispatchArgs); + } + + const result = { + ok: true, + action: dryRun ? "validated" : "dispatched", + environment, + workflowEnvironmentName: plan.workflowEnvironmentName, + deploymentSource, + previewOnly, + syncAppConfigSecret, + secretSync, + dispatchCommand: buildDispatchCommand(repo, workflowInputs), + followUpCommands: buildFollowUpCommands(repo), + workflowInputs, + blockers: [], + }; + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log(dryRun ? "\nGitHub deploy validation complete." : "\nGitHub deploy dispatched."); + console.log(`Environment: ${result.environment}`); + console.log(`Workflow environment: ${result.workflowEnvironmentName}`); + console.log(`Deployment source: ${result.deploymentSource}`); + console.log(`Preview only: ${result.previewOnly ? "true" : "false"}`); + if (secretSync.attempted) { + console.log(`App-config GitHub secret sync: ${dryRun ? "validated" : "performed"}`); + } + console.log(`Dispatch command: ${result.dispatchCommand}`); + console.log(`List recent runs: ${result.followUpCommands.listRuns}`); + console.log(`Watch latest run: ${result.followUpCommands.watchLatestRun}`); + console.log(`View latest run: ${result.followUpCommands.viewLatestRun}`); +} + +main(); diff --git a/scripts/environment-setup-wizard.mjs b/scripts/environment-setup-wizard.mjs new file mode 100644 index 000000000..8163a2d98 --- /dev/null +++ b/scripts/environment-setup-wizard.mjs @@ -0,0 +1,358 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import readline from "node:readline/promises"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function deriveRootDomain(environment, backend) { + const adminUrl = backend.B1AdminRootUrl || ""; + const supportEmail = backend.SupportEmail || ""; + + const adminMatch = adminUrl.match(/^https:\/\/admin(?:-[^.]+)?\.(.+)$/); + if (adminMatch) return adminMatch[1]; + + const supportParts = supportEmail.split("@"); + if (supportParts.length === 2) return supportParts[1]; + + return environment === "prod" ? "yourdomain.com" : ""; +} + +function stripProtocol(value) { + return typeof value === "string" ? value.replace(/^https?:\/\//, "") : value; +} + +function parseAccountId(bootstrap) { + const match = String(bootstrap.TemplateBucketName || "").match(/(\d{12})$/); + return match ? match[1] : ""; +} + +async function promptText(rl, label, defaultValue = "", options = {}) { + const suffix = defaultValue !== "" ? ` [${defaultValue}]` : ""; + const raw = await rl.question(`${label}${suffix}: `); + const value = raw.trim(); + if (value === "") return defaultValue; + if (options.allowBlankToken && value.toLowerCase() === "blank") return ""; + return value; +} + +async function promptYesNo(rl, label, defaultValue = true) { + const suffix = defaultValue ? " [Y/n]" : " [y/N]"; + const raw = (await rl.question(`${label}${suffix}: `)).trim().toLowerCase(); + if (raw === "") return defaultValue; + if (["y", "yes"].includes(raw)) return true; + if (["n", "no"].includes(raw)) return false; + return promptYesNo(rl, label, defaultValue); +} + +function buildPrepareArgs(config, writeChanges) { + const args = [ + path.join(rootDir, "scripts", "prepare-environment-starter.mjs"), + `--environment=${config.environment}`, + `--project-name=${config.projectName}`, + `--write=${writeChanges ? "true" : "false"}`, + "--output=markdown", + `--generate-secrets=${config.generateSecrets ? "true" : "false"}`, + `--write-secret-file=${config.writeSecretFile ? "true" : "false"}`, + ]; + + if (config.accountId) args.push(`--account-id=${config.accountId}`); + if (config.force) args.push("--force=true"); + if (config.rootDomain) args.push(`--root-domain=${config.rootDomain}`); + + const keyedArgs = { + websiteBaseUrl: "website-base-url", + contentRootUrl: "content-root-url", + adminRootUrl: "admin-root-url", + corsOrigin: "cors-origin", + frontendDomain: "frontend-domain", + frontendCertificateArn: "frontend-certificate-arn", + frontendHostedZoneId: "frontend-hosted-zone-id", + apiDomain: "api-domain", + apiCertificateArn: "api-certificate-arn", + apiHostedZoneId: "api-hosted-zone-id", + storeApiUrl: "store-api-url", + transferUrl: "transfer-url", + supportEmail: "support-email", + supportPhone: "support-phone", + supportSiteUrl: "support-site-url", + mobileAppUrl: "mobile-app-url", + domainCnameTarget: "domain-cname-target", + domainATarget: "domain-a-target", + defaultStockPhoto: "default-stock-photo", + googleAnalyticsTag: "google-analytics-tag", + }; + + Object.entries(keyedArgs).forEach(([key, argName]) => { + if (config[key] !== undefined && config[key] !== null && config[key] !== "") { + args.push(`--${argName}=${config[key]}`); + } + }); + + return args; +} + +function runPrepare(config, writeChanges) { + return spawnSync(process.execPath, buildPrepareArgs(config, writeChanges), { + cwd: rootDir, + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }); +} + +function runGuide(environment) { + return spawnSync( + process.execPath, + [ + path.join(rootDir, "scripts", "show-environment-setup-guide.mjs"), + `--environment=${environment}`, + "--output=markdown", + ], + { + cwd: rootDir, + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }, + ); +} + +function buildIamRoleDiscoveryCommand(environment, projectName) { + return `yarn discover:github-aws-roles -- --environment=${environment} --project-name=${projectName} --output=markdown`; +} + +async function main() { + const requestedEnvironment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const environmentDir = resolveEnvironmentDir(requestedEnvironment, environmentDirArg); + + if (!fs.existsSync(environmentDir)) { + console.error(`Unknown environment starter "${requestedEnvironment}".`); + process.exit(1); + } + + const bootstrap = readJson(path.join(environmentDir, "bootstrap-parameters.json")); + const backend = readJson(path.join(environmentDir, "backend-parameters.json")); + const frontend = readJson(path.join(environmentDir, "frontend-parameters.json")); + const templateSecret = readJson(path.join(environmentDir, "app-config-secret.template.json")); + const secretPath = path.join(environmentDir, "app-config-secret.json"); + const existingSecret = fs.existsSync(secretPath) ? readJson(secretPath) : null; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log(`Environment setup wizard: ${requestedEnvironment}`); + console.log("This will walk through first-deploy values first, then optional custom-domain and integration values."); + console.log("Press Enter to keep the suggested default. Type blank only where the wizard says it is allowed.\n"); + + const environment = await promptText(rl, "Environment", requestedEnvironment); + const projectName = await promptText(rl, "Project name", bootstrap.ProjectName || "b1admin"); + const accountId = await promptText(rl, "AWS account ID", parseAccountId(bootstrap)); + + console.log("\nPhase 1: First AWS deploy"); + const useRootDomain = await promptYesNo( + rl, + "Derive admin/content/store/transfer/support values from a shared root domain", + true, + ); + + const rootDomainDefault = deriveRootDomain(environment, backend); + let rootDomain = ""; + let websiteBaseUrl = ""; + let contentRootUrl = ""; + let adminRootUrl = ""; + let corsOrigin = ""; + let storeApiUrl = ""; + let transferUrl = ""; + let supportEmail = backend.SupportEmail || ""; + + if (useRootDomain) { + rootDomain = await promptText(rl, "Root domain", rootDomainDefault); + supportEmail = await promptText(rl, "Support email", backend.SupportEmail || `support@${rootDomain}`); + } else { + websiteBaseUrl = await promptText(rl, "Website base URL pattern", backend.WebsiteBaseUrl || "https://{subdomain}.yourdomain.com"); + contentRootUrl = await promptText(rl, "Content root URL", backend.ContentRootUrl || ""); + adminRootUrl = await promptText(rl, "Admin root URL", backend.B1AdminRootUrl || ""); + corsOrigin = await promptText(rl, "CORS origin", backend.CorsOrigin || adminRootUrl); + storeApiUrl = await promptText(rl, "Store API URL", backend.StoreApiUrl || ""); + transferUrl = await promptText(rl, "Transfer URL", backend.TransferUrl || ""); + supportEmail = await promptText(rl, "Support email", backend.SupportEmail || ""); + } + + const supportPhone = await promptText(rl, "Support phone", backend.SupportPhone || ""); + const supportSiteUrl = await promptText(rl, "Support site URL", backend.SupportSiteUrl || ""); + + const writeSecretFile = await promptYesNo( + rl, + existingSecret + ? "Keep using and updating app-config-secret.json in this environment folder" + : "Create app-config-secret.json now", + true, + ); + const generateSecrets = writeSecretFile + ? await promptYesNo( + rl, + existingSecret + ? "Regenerate jwtSecret and encryptionKey" + : "Generate jwtSecret and encryptionKey", + existingSecret ? false : true, + ) + : false; + const force = Boolean(existingSecret && generateSecrets); + + console.log("\nPhase 2: Optional custom domains"); + const configureCustomDomains = await promptYesNo( + rl, + "Fill the ACM and Route53 custom-domain fields now", + false, + ); + + let frontendDomain = ""; + let frontendCertificateArn = ""; + let frontendHostedZoneId = ""; + let apiDomain = ""; + let apiCertificateArn = ""; + let apiHostedZoneId = ""; + + if (configureCustomDomains) { + const frontendDomainDefault = frontend.AlternateDomainName || stripProtocol(backend.B1AdminRootUrl); + frontendDomain = await promptText(rl, "Frontend domain", frontendDomainDefault); + frontendCertificateArn = await promptText(rl, "Frontend ACM certificate ARN", frontend.AcmCertificateArn || ""); + frontendHostedZoneId = await promptText(rl, "Frontend Route53 hosted zone ID", frontend.HostedZoneId || ""); + apiDomain = await promptText(rl, "API custom domain", backend.ApiCustomDomainName || ""); + apiCertificateArn = await promptText(rl, "API ACM certificate ARN", backend.ApiCertificateArn || ""); + apiHostedZoneId = await promptText(rl, "API Route53 hosted zone ID", backend.ApiHostedZoneId || ""); + } + + console.log("\nPhase 3: Optional integrations and metadata"); + const reviewOptionalMetadata = await promptYesNo( + rl, + "Review optional runtime metadata now", + false, + ); + + let mobileAppUrl = ""; + let domainCnameTarget = ""; + let domainATarget = ""; + let defaultStockPhoto = ""; + let googleAnalyticsTag = ""; + + if (reviewOptionalMetadata) { + mobileAppUrl = await promptText(rl, "Mobile app URL", backend.MobileAppUrl || "", { allowBlankToken: true }); + domainCnameTarget = await promptText(rl, "Legacy CNAME target", backend.DomainCnameTarget || "", { allowBlankToken: true }); + domainATarget = await promptText(rl, "Legacy A-record target", backend.DomainATarget || "", { allowBlankToken: true }); + defaultStockPhoto = await promptText(rl, "Default stock photo URL", backend.DefaultStockPhoto || "", { allowBlankToken: true }); + googleAnalyticsTag = await promptText(rl, "Google Analytics tag", backend.GoogleAnalyticsTag || "", { allowBlankToken: true }); + } + + const config = { + environment, + projectName, + accountId, + generateSecrets, + writeSecretFile, + force, + rootDomain, + websiteBaseUrl, + contentRootUrl, + adminRootUrl, + corsOrigin, + frontendDomain, + frontendCertificateArn, + frontendHostedZoneId, + apiDomain, + apiCertificateArn, + apiHostedZoneId, + storeApiUrl, + transferUrl, + supportEmail, + supportPhone, + supportSiteUrl, + mobileAppUrl, + domainCnameTarget, + domainATarget, + defaultStockPhoto, + googleAnalyticsTag, + }; + + console.log("\nPreviewing the exact starter-file changes...\n"); + const preview = runPrepare(config, false); + if (preview.status !== 0) { + process.stdout.write(preview.stdout || ""); + process.stderr.write(preview.stderr || ""); + process.exit(preview.status ?? 1); + } + process.stdout.write(preview.stdout); + if (preview.stderr) process.stderr.write(preview.stderr); + + if ((preview.stdout || "").includes("- No changes proposed.")) { + console.log("\nThe starter files already match the answers you gave."); + console.log("\nCurrent readiness snapshot:\n"); + const guide = runGuide(environment); + process.stdout.write(guide.stdout || ""); + if (guide.stderr) process.stderr.write(guide.stderr); + process.exit(guide.status ?? 0); + } + + const applyChanges = await promptYesNo(rl, "\nWrite these changes to the environment files now", true); + if (!applyChanges) { + console.log("No files were changed."); + process.exit(0); + } + + console.log("\nApplying changes...\n"); + const applied = runPrepare(config, true); + if (applied.status !== 0) { + process.stdout.write(applied.stdout || ""); + process.stderr.write(applied.stderr || ""); + process.exit(applied.status ?? 1); + } + process.stdout.write(applied.stdout); + if (applied.stderr) process.stderr.write(applied.stderr); + + console.log("\nUpdated readiness snapshot:\n"); + const guide = runGuide(environment); + process.stdout.write(guide.stdout || ""); + if (guide.stderr) process.stderr.write(guide.stderr); + console.log("\nGitHub AWS role discovery:"); + console.log(buildIamRoleDiscoveryCommand(environment, projectName)); + process.exit(guide.status ?? 0); + } finally { + rl.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/installer-adopt-frontend-origin.mjs b/scripts/installer-adopt-frontend-origin.mjs new file mode 100644 index 000000000..8030a7e41 --- /dev/null +++ b/scripts/installer-adopt-frontend-origin.mjs @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +function readJson(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read ${label} ${filePath}: ${message}`); + } +} + +function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const deploymentRoot = getArg("deployment-root", "deployment"); + const environmentDir = path.resolve(rootDir, getArg("environment-dir", path.join("infrastructure", "environments", environment))); + const summaryFile = path.resolve(rootDir, getArg("summary-file", path.join(deploymentRoot, environment, "deployment-summary.json"))); + const backendParametersFile = path.join(environmentDir, "backend-parameters.json"); + const write = boolArg("write", false); + + const summary = readJson(summaryFile, "deployment summary"); + const frontendAppUrl = String(summary?.resolved?.frontendAppUrl || "").replace(/\/$/, ""); + if (!frontendAppUrl) throw new Error(`Deployment summary does not contain resolved.frontendAppUrl: ${summaryFile}`); + + const params = readJson(backendParametersFile, "backend parameters"); + const before = { + B1AdminRootUrl: params.B1AdminRootUrl || "", + CorsOrigin: params.CorsOrigin || "", + }; + const next = { + B1AdminRootUrl: frontendAppUrl, + CorsOrigin: frontendAppUrl, + }; + const changed = before.B1AdminRootUrl !== next.B1AdminRootUrl || before.CorsOrigin !== next.CorsOrigin; + + if (write && changed) { + params.B1AdminRootUrl = next.B1AdminRootUrl; + params.CorsOrigin = next.CorsOrigin; + fs.writeFileSync(backendParametersFile, `${JSON.stringify(params, null, 2)}\n`); + } + + const result = { + ok: true, + environment, + changed, + written: write && changed, + frontendAppUrl, + backendParametersFile: relativeToRoot(backendParametersFile), + before, + next, + followUp: changed + ? [ + "Commit and push the updated private environment file.", + `Rerun the real ${environment} deploy so CloudFormation updates the backend CORS/root URL.`, + ] + : [], + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + const lines = [ + `# Adopt Frontend Origin: ${environment}`, + "", + `- Status: ${changed ? "updated" : "already current"}`, + `- Written: ${result.written ? "yes" : "no"}`, + `- Frontend app URL: \`${frontendAppUrl}\``, + `- Backend parameters: \`${result.backendParametersFile}\``, + ]; + if (result.followUp.length > 0) { + lines.push("", "## Follow Up", ""); + result.followUp.forEach((item) => lines.push(`- ${item}`)); + } + process.stdout.write(`${lines.join("\n")}\n`); + } else { + console.log(`Adopt frontend origin: ${changed ? "updated" : "already current"}`); + } +} + +main(); diff --git a/scripts/installer-app-config-secret.mjs b/scripts/installer-app-config-secret.mjs new file mode 100644 index 000000000..40137f0aa --- /dev/null +++ b/scripts/installer-app-config-secret.mjs @@ -0,0 +1,181 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + resolveEnvironmentDir, + runNodeJson, +} from "./installer-common.mjs"; + +function randomHex(bytes) { + return crypto.randomBytes(bytes).toString("hex"); +} + +function readJsonFile(filePath, label, outputMode) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + failText(`Could not read ${label}: ${filePath}. ${error instanceof Error ? error.message : String(error)}`, outputMode); + } +} + +function isStarterSecret(value) { + return !value || String(value).startsWith("replace-me"); +} + +function renderMarkdown(result) { + const lines = [ + `# App Config Secret: ${result.environment}`, + "", + `- Status: ${result.ok ? "ok" : "needs attention"}`, + `- Secret file: \`${result.secretFile}\``, + `- File action: ${result.fileAction}`, + `- GitHub sync: ${result.githubSync.action}`, + `- GitHub environment: \`${result.githubSync.githubEnvironment}\``, + `- Secret name: \`${result.githubSync.secretName}\``, + ]; + + if (result.githubSync.commandPreview) { + lines.push("", "## GitHub Secret Command", "", `\`${result.githubSync.commandPreview}\``); + } + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const environmentDir = resolveEnvironmentDir(environment, getArg("environment-dir")); + const write = boolArg("write", false); + const force = boolArg("force", false); + const syncGithubSecret = boolArg("sync-github-secret", false); + const githubDryRun = !boolArg("confirm-github-sync", false); + const repo = inferDeployRepo(getArg("repo")); + const githubEnvironment = getArg("github-environment", `aws-${environment}`); + const secretName = getArg("secret-name", "AWS_APP_CONFIG_SECRET_JSON"); + const skipGhAuthCheck = boolArg("skip-gh-auth-check", false); + + const templateFile = path.join(environmentDir, "app-config-secret.template.json"); + const secretFile = path.join(environmentDir, "app-config-secret.json"); + + if (!fs.existsSync(templateFile)) { + failText(`Missing app config secret template: ${relativeToRoot(templateFile)}`, outputMode); + } + + const template = readJsonFile(templateFile, "app config secret template", outputMode); + const existingSecret = fs.existsSync(secretFile) + ? readJsonFile(secretFile, "existing app config secret", outputMode) + : null; + const sourceSecret = existingSecret && !force ? existingSecret : template; + const supportEmail = getArg("support-email"); + const explicitWebPushSubject = getArg("web-push-subject"); + const webPushSubject = explicitWebPushSubject + || (supportEmail ? `mailto:${supportEmail}` : sourceSecret.webPushSubject); + const generated = { + ...sourceSecret, + jwtSecret: isStarterSecret(sourceSecret.jwtSecret) || force ? randomHex(48) : sourceSecret.jwtSecret, + encryptionKey: isStarterSecret(sourceSecret.encryptionKey) || force ? randomHex(32) : sourceSecret.encryptionKey, + webPushSubject, + }; + + const missingRequired = ["jwtSecret", "encryptionKey", "webPushSubject"] + .filter((key) => typeof generated[key] !== "string" || generated[key].trim() === "" || isStarterSecret(generated[key])); + if (missingRequired.length > 0) { + failText(`App config secret still has unresolved values: ${missingRequired.join(", ")}`, outputMode); + } + + let fileAction = "preview"; + if (existingSecret && !force) fileAction = "reuse-existing"; + if (write && (!existingSecret || force)) { + fs.writeFileSync(secretFile, `${JSON.stringify(generated, null, 2)}\n`); + fileAction = existingSecret ? "replaced" : "created"; + } else if (write && existingSecret && !force) { + fileAction = "kept-existing"; + } + + let githubSync = { + action: syncGithubSecret ? "preview" : "not requested", + secretName, + githubEnvironment, + repo: repo || null, + commandPreview: repo + ? `gh secret set ${secretName} --repo ${repo} --env ${githubEnvironment} --body-file '${relativeToRoot(secretFile)}'` + : `gh secret set ${secretName} --env ${githubEnvironment} --body-file '${relativeToRoot(secretFile)}'`, + }; + + if (syncGithubSecret) { + if (!write && !existingSecret) { + failText("Use --write=true before syncing a newly generated app config secret.", outputMode); + } + + const syncArgs = [ + `--environment=${environment}`, + `--github-environment=${githubEnvironment}`, + `--secret-name=${secretName}`, + `--secret-file=${secretFile}`, + `--dry-run=${githubDryRun ? "true" : "false"}`, + `--skip-gh-auth-check=${skipGhAuthCheck ? "true" : "false"}`, + "--output=json", + ]; + if (repo) syncArgs.push(`--repo=${repo}`); + + const sync = runNodeJson("scripts/sync-github-app-config-secret.mjs", syncArgs); + if (!sync.ok || !sync.parsed) { + failText(sync.stderr.trim() || sync.stdout.trim() || "GitHub app-config secret sync failed.", outputMode); + } + + githubSync = { + action: sync.parsed.action, + secretName: sync.parsed.secretName, + githubEnvironment: sync.parsed.githubEnvironment, + repo: sync.parsed.repo, + commandPreview: sync.parsed.commandPreview, + }; + } + + const nextSteps = []; + if (!write && (!existingSecret || force)) { + nextSteps.push("Re-run with `--write=true` to create `app-config-secret.json`."); + } + if (write && !syncGithubSecret) { + nextSteps.push("Re-run with `--sync-github-secret=true` to validate the GitHub secret sync."); + } + if (syncGithubSecret && githubDryRun) { + nextSteps.push("Re-run with `--sync-github-secret=true --confirm-github-sync=true` to store `AWS_APP_CONFIG_SECRET_JSON` in GitHub."); + } + + const result = { + ok: true, + environment, + environmentDir: relativeToRoot(environmentDir), + secretFile: relativeToRoot(secretFile), + fileAction, + keyCount: Object.keys(generated).length, + githubSync, + nextSteps, + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`App config secret: ${environment}`); + console.log(`Secret file: ${result.secretFile}`); + console.log(`File action: ${fileAction}`); + console.log(`GitHub sync: ${githubSync.action}`); + nextSteps.forEach((step) => console.log(`- ${step}`)); + } +} + +main(); diff --git a/scripts/installer-aws-handoff.mjs b/scripts/installer-aws-handoff.mjs new file mode 100644 index 000000000..d2d110fd4 --- /dev/null +++ b/scripts/installer-aws-handoff.mjs @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function environmentsFromArg() { + const value = getArg("environment", "all"); + if (value === "all") return ["staging", "prod"]; + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function renderMarkdown(result) { + const lines = [ + "# B1Admin AWS Admin Handoff", + "", + "Use this handoff to create the AWS roles that let the private GitHub deployment repository deploy B1Admin.", + "", + "The recommended path uses GitHub OIDC. Do not create long-lived AWS access keys for GitHub unless your organization has deliberately rejected OIDC.", + "", + "## Summary", + "", + `- AWS account: \`${result.accountId}\``, + `- AWS region: \`${result.region}\``, + `- Private deploy repo: \`${result.repo}\``, + `- Role files root: \`${result.roleOutputRoot}\``, + "", + "## AWS Administrator Steps", + "", + "1. Sign in to the target AWS account with IAM administration permissions.", + "2. Confirm whether the GitHub OIDC provider already exists.", + "3. Run the commands below for prod. Run staging too only if the operator wants an optional practice deployment. Skip the create-provider command if the provider already exists.", + "4. Send the role ARNs in the Role ARNs section back to the deployment operator.", + "", + ]; + + result.environments.forEach((environment) => { + lines.push( + `## ${environment.name}`, + "", + `- GitHub Environment: \`${environment.githubEnvironment}\``, + `- Deploy role ARN: \`${environment.roleArns.deployRoleArn}\``, + `- CloudFormation execution role ARN: \`${environment.roleArns.cfnRoleArn}\``, + "", + "### Files", + "", + ); + environment.files.forEach((file) => lines.push(`- \`${file.path}\``)); + lines.push("", "### AWS Commands", "", "```bash"); + environment.awsCommands.forEach((command) => lines.push(command)); + lines.push("```", "", "### Operator GitHub Secret Commands", "", "```bash"); + environment.githubSecretCommands.forEach((command) => lines.push(command)); + lines.push("```", ""); + }); + + lines.push( + "## After AWS Is Ready", + "", + "The deployment operator should run:", + "", + "```bash", + "# Smallest AWS footprint: continue with prod first.", + "yarn installer:next -- --customer-file=../b1admin-deploy/customer-values.json --environment=prod --output=markdown", + "", + "# Optional practice deployment: run staging first, then prod after staging is verified.", + "yarn installer:next -- --customer-file=../b1admin-deploy/customer-values.json --environment=staging --output=markdown", + "```", + "", + "Then continue with the next command shown by the installer.", + "", + ); + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const accountId = getArg("account-id"); + const repo = getArg("repo"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const deployRepoDir = getArg("deploy-repo-dir", "../b1admin-deploy"); + const customerFile = getArg("customer-file", path.join(deployRepoDir, "customer-values.json")); + const roleOutputRoot = getArg("role-output-root", path.join(deployRepoDir, "iam")); + const outputFile = path.resolve(rootDir, getArg("output-file", path.join(deployRepoDir, "aws-admin-handoff.md"))); + const write = boolArg("write", false); + const force = boolArg("force", true); + const environments = environmentsFromArg(); + + const generated = environments.map((environment) => { + const roleOutputDir = path.join(roleOutputRoot, environment); + const result = runNodeJson("scripts/installer-aws-roles.mjs", [ + `--environment=${environment}`, + `--customer-file=${customerFile}`, + `--output-dir=${roleOutputDir}`, + `--write=${write ? "true" : "false"}`, + `--force=${force ? "true" : "false"}`, + "--output=json", + ]); + + if (!result.ok || !result.parsed?.ok) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `Could not generate AWS role handoff for ${environment}.`); + } + + return { + name: environment, + githubEnvironment: result.parsed.githubEnvironment, + roleNames: result.parsed.roleNames, + roleArns: result.parsed.roleArns, + files: result.parsed.files, + awsCommands: result.parsed.awsCommands, + githubSecretCommands: result.parsed.githubSecretCommands, + }; + }); + + const result = { + ok: true, + write, + outputFile: relativeToRoot(outputFile), + accountId, + region, + repo, + customerFile: relativeToRoot(path.resolve(rootDir, customerFile)), + roleOutputRoot, + environments: generated, + }; + + const body = renderMarkdown(result); + if (write) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, body); + } + + if (outputMode === "json") { + printJson({ ...result, markdown: write ? undefined : body }); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(body); + } else { + console.log(write ? `Wrote ${result.outputFile}` : body); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/installer-aws-preflight.mjs b/scripts/installer-aws-preflight.mjs new file mode 100644 index 000000000..ca667f84e --- /dev/null +++ b/scripts/installer-aws-preflight.mjs @@ -0,0 +1,276 @@ +import path from "node:path"; + +import { + boolArg, + failText, + getArg, + printJson, + readJsonFile, + relativeToRoot, + requireEnvironmentDir, + resolveEnvironmentDir, + rootDir, + runCommandJson, +} from "./installer-common.mjs"; + +function roleNameFromArn(arn) { + const match = String(arn || "").match(/^arn:aws:iam::\d{12}:role\/(.+)$/); + return match ? match[1] : ""; +} + +function checkAwsCli(region, expectedAccountId) { + const result = runCommandJson("aws", ["sts", "get-caller-identity", "--output", "json"]); + if (!result.ok) { + return { ok: false, name: "AWS CLI identity", detail: result.stderr.trim() || "Could not read AWS caller identity." }; + } + + const accountId = result.parsed?.Account || ""; + if (expectedAccountId && accountId && expectedAccountId !== accountId) { + return { + ok: false, + name: "AWS CLI identity", + detail: `AWS CLI is authenticated to account ${accountId}, but --account-id expects ${expectedAccountId}.`, + accountId, + region, + }; + } + + return { + ok: true, + name: "AWS CLI identity", + detail: `Authenticated to AWS account ${accountId || ""} for region ${region}.`, + accountId, + region, + }; +} + +function checkCloudFormationRole(roleArn, { skipResourceLookup = false } = {}) { + if (!roleArn) { + return { + ok: true, + skipped: true, + name: "CloudFormation execution role", + detail: "Skipped because --cloudformation-execution-role-arn was not provided.", + }; + } + + const roleName = roleNameFromArn(roleArn); + if (!roleName) { + return { + ok: false, + name: "CloudFormation execution role", + detail: `CloudFormation execution role ARN is not an IAM role ARN: ${roleArn}`, + }; + } + + if (skipResourceLookup) { + return { + ok: true, + skipped: true, + name: "CloudFormation execution role", + detail: `Role ARN format is valid; AWS lookup skipped: ${roleArn}`, + }; + } + + const result = runCommandJson("aws", ["iam", "get-role", "--role-name", roleName, "--output", "json"]); + if (!result.ok) { + return { + ok: false, + name: "CloudFormation execution role", + detail: result.stderr.trim() || `Could not read IAM role ${roleName}.`, + }; + } + + return { + ok: true, + name: "CloudFormation execution role", + detail: `IAM role is readable: ${roleArn}`, + }; +} + +function checkFrontendCertificate(certificateArn, { skipResourceLookup = false } = {}) { + if (!certificateArn) { + return { + ok: true, + skipped: true, + name: "frontend ACM certificate", + detail: "Skipped because no custom frontend domain is configured.", + }; + } + + const regionMatch = String(certificateArn).match(/^arn:aws:acm:([^:]+):\d{12}:certificate\/.+$/); + if (!regionMatch) { + return { + ok: false, + name: "frontend ACM certificate", + detail: `Frontend ACM certificate ARN is not valid: ${certificateArn}`, + }; + } + + if (regionMatch[1] !== "us-east-1") { + return { + ok: false, + name: "frontend ACM certificate", + detail: `CloudFront requires the frontend ACM certificate in us-east-1, but this ARN is in ${regionMatch[1]}.`, + }; + } + + if (skipResourceLookup) { + return { + ok: true, + skipped: true, + name: "frontend ACM certificate", + detail: `Certificate ARN is in us-east-1; AWS lookup skipped: ${certificateArn}`, + }; + } + + const result = runCommandJson("aws", ["acm", "describe-certificate", "--certificate-arn", certificateArn, "--region", "us-east-1", "--output", "json"]); + if (!result.ok) { + return { + ok: false, + name: "frontend ACM certificate", + detail: result.stderr.trim() || `Could not read ACM certificate ${certificateArn}.`, + }; + } + + const status = result.parsed?.Certificate?.Status || ""; + return { + ok: status === "ISSUED", + name: "frontend ACM certificate", + detail: status === "ISSUED" ? "Certificate exists and is ISSUED in us-east-1." : `Certificate exists but status is ${status || ""}.`, + }; +} + +function checkHostedZone(hostedZoneId, label, { skipResourceLookup = false } = {}) { + if (!hostedZoneId) { + return { + ok: true, + skipped: true, + name: label, + detail: "Skipped because no hosted zone ID is configured.", + }; + } + + if (skipResourceLookup) { + return { + ok: true, + skipped: true, + name: label, + detail: `Hosted zone ID is configured; AWS lookup skipped: ${hostedZoneId}`, + }; + } + + const result = runCommandJson("aws", ["route53", "get-hosted-zone", "--id", hostedZoneId, "--output", "json"]); + if (!result.ok) { + return { + ok: false, + name: label, + detail: result.stderr.trim() || `Could not read Route53 hosted zone ${hostedZoneId}.`, + }; + } + + return { + ok: true, + name: label, + detail: `Hosted zone is readable: ${result.parsed?.HostedZone?.Name || hostedZoneId}`, + }; +} + +function renderMarkdown(result) { + const lines = [ + `# Installer AWS Preflight: ${result.environment}`, + "", + `- Status: ${result.ok ? "ready" : "blocked"}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Region: \`${result.region}\``, + "", + "## Checks", + "", + ]; + + result.checks.forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "BLOCKED"; + lines.push(`- ${status}: ${check.name} - ${check.detail}`); + }); + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const expectedAccountId = getArg("account-id"); + const cfnRoleArn = getArg("cloudformation-execution-role-arn", process.env.AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); + const skipAwsIdentityCheck = boolArg("skip-aws-identity-check", false); + const skipAwsResourceLookups = boolArg("skip-aws-resource-lookups", false); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + try { + requireEnvironmentDir(environmentDir); + } catch (error) { + failText(error instanceof Error ? error.message : String(error), outputMode); + } + + if (boolArg("skip-aws-check", false)) { + const result = { + ok: true, + skipped: true, + environment, + environmentDir: relativeToRoot(environmentDir), + region, + checks: [ + { + ok: true, + skipped: true, + name: "AWS checks", + detail: "Skipped by --skip-aws-check=true.", + }, + ], + }; + if (outputMode === "json") printJson(result); + else if (outputMode === "markdown" || outputMode === "md") process.stdout.write(renderMarkdown(result)); + else console.log("Installer AWS preflight skipped by --skip-aws-check=true."); + process.exit(0); + } + + const frontend = readJsonFile(path.join(environmentDir, "frontend-parameters.json")); + const backend = readJsonFile(path.join(environmentDir, "backend-parameters.json")); + const checks = [ + skipAwsIdentityCheck + ? { ok: true, skipped: true, name: "AWS CLI identity", detail: "Skipped by --skip-aws-identity-check=true." } + : checkAwsCli(region, expectedAccountId), + checkCloudFormationRole(cfnRoleArn, { skipResourceLookup: skipAwsResourceLookups }), + checkFrontendCertificate(frontend.AcmCertificateArn, { skipResourceLookup: skipAwsResourceLookups }), + checkHostedZone(frontend.HostedZoneId, "frontend Route53 hosted zone", { skipResourceLookup: skipAwsResourceLookups }), + ]; + + if (backend.ApiCustomDomainName || backend.ApiCertificateArn || backend.ApiHostedZoneId) { + checks.push(checkHostedZone(backend.ApiHostedZoneId, "API Route53 hosted zone", { skipResourceLookup: skipAwsResourceLookups })); + } + + const result = { + ok: checks.every((check) => check.ok), + environment, + environmentDir: relativeToRoot(environmentDir), + region, + checks, + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Installer AWS preflight: ${result.ok ? "ready" : "blocked"}`); + checks.forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "BLOCKED"; + console.log(`[${status}] ${check.name}: ${check.detail}`); + }); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-aws-roles.mjs b/scripts/installer-aws-roles.mjs new file mode 100644 index 000000000..c60f1891c --- /dev/null +++ b/scripts/installer-aws-roles.mjs @@ -0,0 +1,237 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +const iamDir = path.join(rootDir, "infrastructure", "iam"); + +function splitRepo(repo) { + const [owner, name] = String(repo || "").split("/"); + return { owner, name }; +} + +function readTemplate(fileName, outputMode) { + const templatePath = path.join(iamDir, fileName); + try { + return fs.readFileSync(templatePath, "utf8"); + } catch (error) { + failText(`Could not read IAM template ${fileName}: ${error instanceof Error ? error.message : String(error)}`, outputMode); + } +} + +function replacePlaceholders(template, values) { + return Object.entries(values).reduce( + (text, [key, value]) => text.replaceAll(`<${key}>`, value), + template, + ); +} + +function formatJson(text, label, outputMode) { + try { + return `${JSON.stringify(JSON.parse(text), null, 2)}\n`; + } catch (error) { + failText(`Rendered ${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, outputMode); + } +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function fileUri(filePath) { + return `file://${filePath}`; +} + +function renderMarkdown(result) { + const lines = [ + `# AWS IAM Role Setup: ${result.environment}`, + "", + `- Status: ${result.ok ? "ok" : "needs attention"}`, + `- Mode: ${result.write ? "write files" : "preview"}`, + `- AWS account: \`${result.accountId}\``, + `- AWS region: \`${result.region}\``, + `- Private deploy repo: \`${result.repo}\``, + `- GitHub environment: \`${result.githubEnvironment}\``, + `- Deploy role: \`${result.roleNames.deployRoleName}\``, + `- CloudFormation execution role: \`${result.roleNames.cfnRoleName}\``, + "", + "## Files", + "", + ]; + + result.files.forEach((file) => { + lines.push(`- ${file.written ? "wrote" : "planned"} \`${file.path}\``); + }); + + lines.push("", "## AWS Commands", ""); + result.awsCommands.forEach((command) => lines.push(`- \`${command}\``)); + + lines.push("", "## GitHub Secret Commands", ""); + result.githubSecretCommands.forEach((command) => lines.push(`- \`${command}\``)); + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const environment = getArg("environment", "staging"); + const projectName = getArg("project-name", "b1admin"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const accountId = getArg("account-id", process.env.AWS_ACCOUNT_ID || ""); + const repo = inferDeployRepo(getArg("repo")); + const githubEnvironment = getArg("github-environment", `aws-${environment}`); + const deployRoleName = getArg("deploy-role-name", `${projectName}-${environment}-github-deploy`); + const cfnRoleName = getArg("cloudformation-execution-role-name", `${projectName}-${environment}-cfn-exec`); + const outputDir = path.resolve(rootDir, getArg("output-dir", path.join("infrastructure", "iam", "generated", environment))); + const write = boolArg("write", false); + const force = boolArg("force", false); + + if (!accountId.match(/^\d{12}$/)) { + failText("--account-id or AWS_ACCOUNT_ID must be a 12-digit AWS account id.", outputMode); + } + if (!repo) { + failText("DEPLOY_REPO or --repo is required.", outputMode); + } + const { owner, name } = splitRepo(repo); + if (!owner || !name) { + failText(`Repository must use owner/name format: ${repo}`, outputMode); + } + + const replacements = { + "account-id": accountId, + "repo-owner": owner, + "deploy-repo": name, + "github-environment": githubEnvironment, + region, + "project-name": projectName, + environment, + "cloudformation-execution-role-name": cfnRoleName, + }; + + const rendered = [ + { + key: "githubDeployTrust", + fileName: `${deployRoleName}-trust.json`, + template: "github-oidc-deploy-role-trust.sample.json", + label: "GitHub deploy role trust policy", + }, + { + key: "githubDeployPolicy", + fileName: `${deployRoleName}-policy.json`, + template: "github-oidc-deploy-policy.sample.json", + label: "GitHub deploy role inline policy", + }, + { + key: "cloudFormationTrust", + fileName: `${cfnRoleName}-trust.json`, + template: "cloudformation-execution-role-trust.sample.json", + label: "CloudFormation execution role trust policy", + }, + { + key: "cloudFormationPolicy", + fileName: `${cfnRoleName}-policy.json`, + template: "cloudformation-execution-policy.sample.json", + label: "CloudFormation execution role inline policy", + }, + ].map((entry) => { + const body = formatJson(replacePlaceholders(readTemplate(entry.template, outputMode), replacements), entry.label, outputMode); + const targetPath = path.join(outputDir, entry.fileName); + return { + ...entry, + targetPath, + path: relativeToRoot(targetPath), + body, + exists: fs.existsSync(targetPath), + written: false, + }; + }); + + const existing = rendered.filter((file) => file.exists); + if (write && existing.length > 0 && !force) { + failText(`IAM files already exist. Re-run with --force=true to replace: ${existing.map((file) => file.path).join(", ")}`, outputMode); + } + + if (write) { + fs.mkdirSync(outputDir, { recursive: true }); + rendered.forEach((file) => { + fs.writeFileSync(file.targetPath, file.body); + file.written = true; + }); + } + + const deployRoleArn = `arn:aws:iam::${accountId}:role/${deployRoleName}`; + const cfnRoleArn = `arn:aws:iam::${accountId}:role/${cfnRoleName}`; + const fileFor = (key) => rendered.find((file) => file.key === key).path; + const awsCommands = [ + "aws iam list-open-id-connect-providers", + "aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com", + `aws iam create-role --role-name ${deployRoleName} --assume-role-policy-document ${fileUri(fileFor("githubDeployTrust"))}`, + `aws iam put-role-policy --role-name ${deployRoleName} --policy-name ${deployRoleName}-policy --policy-document ${fileUri(fileFor("githubDeployPolicy"))}`, + `aws iam create-role --role-name ${cfnRoleName} --assume-role-policy-document ${fileUri(fileFor("cloudFormationTrust"))}`, + `aws iam put-role-policy --role-name ${cfnRoleName} --policy-name ${cfnRoleName}-policy --policy-document ${fileUri(fileFor("cloudFormationPolicy"))}`, + ]; + const githubSecretCommands = [ + `gh secret set AWS_ROLE_TO_ASSUME --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(deployRoleArn)}`, + `gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(cfnRoleArn)}`, + ]; + const nextSteps = [ + write + ? "Run the AWS commands from an administrator-authenticated shell, skipping the OIDC provider create command if the provider already exists." + : "Re-run with `--write=true` to create the rendered IAM JSON files.", + "Run the GitHub secret commands after the AWS roles exist.", + `Run \`yarn installer:aws-preflight -- --environment=${environment} --account-id=${accountId} --cloudformation-execution-role-arn=${cfnRoleArn} --output=markdown\`.`, + ]; + + const result = { + ok: true, + write, + accountId, + region, + repo, + githubEnvironment, + projectName, + environment, + roleNames: { + deployRoleName, + cfnRoleName, + }, + roleArns: { + deployRoleArn, + cfnRoleArn, + }, + outputDir: relativeToRoot(outputDir), + files: rendered.map(({ label, path: filePath, exists: fileExists, written }) => ({ + label, + path: filePath, + exists: fileExists, + written, + })), + awsCommands, + githubSecretCommands, + nextSteps, + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`AWS IAM role setup: ${environment}`); + result.awsCommands.forEach((command) => console.log(command)); + result.githubSecretCommands.forEach((command) => console.log(command)); + } +} + +main(); diff --git a/scripts/installer-bootstrap-admin.mjs b/scripts/installer-bootstrap-admin.mjs new file mode 100644 index 000000000..8dd6ee8d5 --- /dev/null +++ b/scripts/installer-bootstrap-admin.mjs @@ -0,0 +1,104 @@ +import { + boolArg, + failText, + getArg, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; +import fs from "node:fs"; +import path from "node:path"; + +function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name", `b1admin-${environment}-backend`); + const dryRun = boolArg("dry-run", true); + const writeEvidence = boolArg("write-evidence", true); + const deploymentRoot = getArg("deployment-root", "deployment"); + const outputFile = path.resolve(rootDir, getArg("output-file", path.join(deploymentRoot, environment, "bootstrap-admin.json"))); + const adminEmail = getArg("bootstrap-admin-email") || getArg("first-admin-email"); + const adminPassword = getArg("bootstrap-admin-password") || getArg("first-admin-password"); + const churchName = getArg("bootstrap-church-name") || getArg("first-church-name"); + + const required = [ + ["bootstrap-admin-email or firstAdminEmail", adminEmail], + ["bootstrap-admin-password or firstAdminPassword", adminPassword], + ["bootstrap-church-name or firstChurchName", churchName], + ].filter(([, value]) => !value); + + if (required.length > 0) { + failText(`Missing required first-admin values: ${required.map(([name]) => name).join(", ")}`, outputMode); + } + + const args = [ + `--region=${region}`, + `--stack-name=${stackName}`, + `--bootstrap-admin-email=${adminEmail}`, + `--bootstrap-admin-password=${adminPassword}`, + `--bootstrap-church-name=${churchName}`, + `--dry-run=${dryRun ? "true" : "false"}`, + "--output=json", + ]; + + [ + "bootstrap-admin-first-name", + "bootstrap-admin-last-name", + "bootstrap-admin-display-name", + "bootstrap-church-subdomain", + "bootstrap-church-address1", + "bootstrap-church-address2", + "bootstrap-church-city", + "bootstrap-church-state", + "bootstrap-church-zip", + "bootstrap-church-country", + "bootstrap-membership-status", + "bootstrap-admin-reset-password", + ].forEach((name) => { + const value = getArg(name); + if (value !== "") args.push(`--${name}=${value}`); + }); + + const run = runNodeJson("scripts/bootstrap-initial-admin.mjs", args); + if (!run.ok || !run.parsed) { + failText(run.stderr.trim() || run.stdout.trim() || "Could not run first-admin bootstrap helper.", outputMode); + } + + const bootstrapOk = run.parsed.ok !== false; + + if (writeEvidence && !dryRun) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, `${JSON.stringify({ + ...run.parsed, + evidenceFile: relativeToRoot(outputFile), + writtenAt: new Date().toISOString(), + }, null, 2)}\n`); + run.parsed.evidenceFile = relativeToRoot(outputFile); + } + + if (outputMode === "json") { + printJson(run.parsed); + } else if (outputMode === "markdown" || outputMode === "md") { + const lines = [ + `# Installer First Admin: ${environment}`, + "", + `- Status: ${bootstrapOk ? "ok" : "failed"}`, + `- Dry run: ${run.parsed.dryRun ? "yes" : "no"}`, + `- Stack: \`${stackName}\``, + `- Admin email: \`${run.parsed.adminEmail || adminEmail}\``, + `- Church: \`${run.parsed.churchName || churchName}\``, + ]; + if (run.parsed.evidenceFile) lines.push(`- Evidence: \`${run.parsed.evidenceFile}\``); + process.stdout.write(`${lines.join("\n")}\n`); + } else { + console.log(`Installer first admin: ${bootstrapOk ? "ok" : "failed"}`); + console.log(`Dry run: ${run.parsed.dryRun ? "yes" : "no"}`); + console.log(`Stack: ${stackName}`); + } + + process.exit(bootstrapOk ? 0 : 1); +} + +main(); diff --git a/scripts/installer-browser-smoke.mjs b/scripts/installer-browser-smoke.mjs new file mode 100644 index 000000000..f713e2ffd --- /dev/null +++ b/scripts/installer-browser-smoke.mjs @@ -0,0 +1,214 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + failText, + getArg, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function resolveAppUrl(environment, deploymentRoot) { + const explicit = getArg("app-url"); + if (explicit) return explicit.replace(/\/$/, ""); + const summaryFile = path.resolve(rootDir, deploymentRoot, environment, "deployment-summary.json"); + const summary = readJsonIfExists(summaryFile); + return String(summary?.resolved?.frontendAppUrl || "").replace(/\/$/, ""); +} + +async function hideChatWidgets(page) { + await page.addInitScript(() => { + const css = ` + div[aria-label="Open SuperBee chat"], + div[aria-label="Open Bez chat"], + div[aria-label="Open Doc chat"] { display: none !important; } + `; + const inject = () => { + if (document.head && !document.getElementById("__installer-hide-chat__")) { + const style = document.createElement("style"); + style.id = "__installer-hide-chat__"; + style.textContent = css; + document.head.appendChild(style); + } + }; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", inject); + else inject(); + }); +} + +async function runBrowserSmoke({ appUrl, email, password, churchName, route, headed, timeoutMs, screenshotFile }) { + const { chromium } = await import("playwright"); + const browser = await chromium.launch({ headless: !headed }); + const context = await browser.newContext(); + const page = await context.newPage(); + const steps = []; + + try { + await hideChatWidgets(page); + await page.goto(`${appUrl}/login?forceLogin=1`, { waitUntil: "domcontentloaded", timeout: timeoutMs }); + steps.push({ name: "open login", ok: true, detail: page.url() }); + + const emailInput = page.locator('input[type="email"]'); + await emailInput.waitFor({ state: "visible", timeout: timeoutMs }); + await emailInput.fill(email); + await page.fill('input[type="password"]', password); + await page.click('button[type="submit"]'); + steps.push({ name: "submit credentials", ok: true, detail: email }); + + const churchDialog = page.locator('[role="dialog"]').filter({ hasText: "Select a Church" }); + await Promise.race([ + churchDialog.waitFor({ state: "visible", timeout: timeoutMs }).catch(() => {}), + page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: timeoutMs }).catch(() => {}), + ]); + + if (await churchDialog.isVisible().catch(() => false)) { + const preferredChurch = churchName + ? page.locator('[role="dialog"] h3', { hasText: churchName }).first() + : page.locator('[role="dialog"] h3').first(); + await preferredChurch.click({ timeout: timeoutMs }); + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: timeoutMs }); + steps.push({ name: "select church", ok: true, detail: churchName || "first listed church" }); + } else { + steps.push({ name: "select church", ok: true, skipped: true, detail: "not prompted" }); + } + + await page.goto(`${appUrl}${route}`, { waitUntil: "domcontentloaded", timeout: timeoutMs }); + await page.locator("#primaryNavButton").waitFor({ state: "visible", timeout: timeoutMs }); + steps.push({ name: "load authenticated page", ok: true, detail: page.url() }); + + if (screenshotFile) { + fs.mkdirSync(path.dirname(screenshotFile), { recursive: true }); + await page.screenshot({ path: screenshotFile, fullPage: true }); + steps.push({ name: "capture screenshot", ok: true, detail: relativeToRoot(screenshotFile) }); + } + + return { + ok: true, + finalUrl: page.url(), + steps, + }; + } catch (error) { + if (screenshotFile) { + fs.mkdirSync(path.dirname(screenshotFile), { recursive: true }); + await page.screenshot({ path: screenshotFile, fullPage: true }).catch(() => {}); + } + return { + ok: false, + finalUrl: page.url(), + steps, + errors: [error instanceof Error ? error.message : String(error)], + }; + } finally { + await browser.close(); + } +} + +function renderMarkdown(result) { + const lines = [ + `# Browser Smoke: ${result.environment}`, + "", + `- Status: ${result.ok ? "ok" : "failed"}`, + `- App URL: \`${result.appUrl}\``, + `- Route: \`${result.route}\``, + `- Evidence file: \`${result.outputFile}\``, + `- Screenshot: \`${result.screenshotFile || ""}\``, + "", + "## Steps", + "", + ]; + + result.steps.forEach((step) => { + lines.push(`- ${step.skipped ? "SKIP" : step.ok ? "OK" : "FAIL"}: ${step.name} - ${step.detail || ""}`); + }); + + if (result.errors?.length > 0) { + lines.push("", "## Errors", ""); + result.errors.forEach((error) => lines.push(`- ${error}`)); + } + + return `${lines.join("\n")}\n`; +} + +async function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const deploymentRoot = getArg("deployment-root", "deployment"); + const evidenceDir = path.resolve(rootDir, deploymentRoot, environment); + const outputFile = path.resolve(rootDir, getArg("output-file", path.join(deploymentRoot, environment, "browser-smoke.json"))); + const screenshotFile = boolArg("screenshot", true) + ? path.resolve(rootDir, getArg("screenshot-file", path.join(deploymentRoot, environment, "browser-smoke.png"))) + : ""; + const appUrl = resolveAppUrl(environment, deploymentRoot); + const email = getArg("email", getArg("first-admin-email")); + const password = getArg("password", getArg("first-admin-password")); + const churchName = getArg("church-name", getArg("first-church-name")); + const route = getArg("route", "/people"); + const timeoutMs = Number(getArg("timeout-ms", "30000")); + const dryRun = boolArg("dry-run", false); + + if (!appUrl) failText("--app-url is required, or deployment//deployment-summary.json must contain resolved.frontendAppUrl.", outputMode); + if (!email) failText("--email or firstAdminEmail in --customer-file is required.", outputMode); + if (!password) failText("--password or firstAdminPassword in --customer-file is required.", outputMode); + + let smoke = { + ok: true, + finalUrl: "", + steps: [ + { name: "dry run", ok: true, detail: "Browser was not launched." }, + ], + }; + + if (!dryRun) { + smoke = await runBrowserSmoke({ + appUrl, + email, + password, + churchName, + route, + headed: boolArg("headed", false), + timeoutMs, + screenshotFile, + }); + } + + const result = { + ok: smoke.ok, + environment, + appUrl, + route, + email, + churchName: churchName || "", + outputFile: relativeToRoot(outputFile), + screenshotFile: screenshotFile ? relativeToRoot(screenshotFile) : "", + generatedAt: new Date().toISOString(), + finalUrl: smoke.finalUrl, + steps: smoke.steps, + errors: smoke.errors || [], + }; + + fs.mkdirSync(evidenceDir, { recursive: true }); + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, `${JSON.stringify(result, null, 2)}\n`); + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Browser smoke ${result.ok ? "ok" : "failed"}: ${environment}`); + console.log(`Evidence: ${result.outputFile}`); + } + + process.exit(result.ok ? 0 : 1); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/installer-common.mjs b/scripts/installer-common.mjs new file mode 100644 index 000000000..a50a2e680 --- /dev/null +++ b/scripts/installer-common.mjs @@ -0,0 +1,218 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +let customerValuesCache; + +function rawCliArg(name) { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + return ""; +} + +function toCamelCase(name) { + return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function loadCustomerValues() { + if (customerValuesCache !== undefined) return customerValuesCache; + + const customerFile = rawCliArg("customer-file") || process.env.CUSTOMER_FILE || ""; + if (!customerFile) { + customerValuesCache = null; + return customerValuesCache; + } + + const resolvedFile = path.resolve(rootDir, customerFile); + if (!fs.existsSync(resolvedFile)) { + customerValuesCache = null; + return customerValuesCache; + } + + customerValuesCache = JSON.parse(fs.readFileSync(resolvedFile, "utf8")); + return customerValuesCache; +} + +function lookupCustomerValue(name) { + const values = loadCustomerValues(); + if (!values || typeof values !== "object") return ""; + + const environment = rawCliArg("environment") || process.env.ENVIRONMENT || ""; + const candidates = [name, toCamelCase(name), name.toUpperCase().replace(/-/g, "_")]; + const scopes = [ + environment && values.environments?.[environment], + environment && values[environment], + values, + ].filter(Boolean); + + for (const scope of scopes) { + for (const key of candidates) { + const value = scope?.[key]; + if (value !== undefined && value !== null && value !== "") return String(value); + } + } + + return ""; +} + +export function getArg(name, fallback = "") { + const cliValue = rawCliArg(name); + if (cliValue !== "") return cliValue; + + const customerValue = lookupCustomerValue(name); + if (customerValue !== "") return customerValue; + + if (name === "region") { + const awsRegion = lookupCustomerValue("aws-region"); + if (awsRegion !== "") return awsRegion; + } + + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +export function boolArg(name, fallback = false) { + return getArg(name, fallback ? "true" : "false").toLowerCase() === "true"; +} + +export function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) return path.resolve(rootDir, explicitDir); + const deployEnvDir = process.env.DEPLOY_ENV_DIR; + if (deployEnvDir) return path.resolve(rootDir, deployEnvDir, environment); + return path.join(rootDir, "infrastructure", "environments", environment); +} + +export function relativeToRoot(targetPath) { + return path.relative(rootDir, targetPath) || "."; +} + +export function runNodeJson(scriptPath, args) { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + + let parsed = null; + try { + parsed = JSON.parse(result.stdout || "{}"); + } catch { + parsed = null; + } + + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + parsed, + }; +} + +export function runNodeText(scriptPath, args, options = {}) { + const stdio = options.inherit ? "inherit" : "pipe"; + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + stdio, + maxBuffer: 20 * 1024 * 1024, + }); + + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +export function runCommandJson(command, args) { + const result = spawnSync(command, args, { + cwd: rootDir, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + + let parsed = null; + try { + parsed = JSON.parse(result.stdout || "{}"); + } catch { + parsed = null; + } + + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + parsed, + }; +} + +export function inferDeployRepo(explicitRepo = "") { + if (explicitRepo) return explicitRepo; + if (process.env.DEPLOY_REPO) return process.env.DEPLOY_REPO; + return ""; +} + +export function defaultStackNames(environment) { + return { + backend: `b1admin-${environment}-backend`, + frontend: `b1admin-${environment}-frontend`, + }; +} + +export function requireEnvironmentDir(environmentDir) { + if (fs.existsSync(environmentDir)) return; + throw new Error(`Environment directory does not exist: ${relativeToRoot(environmentDir)}`); +} + +export function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +export function printJson(result) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +export function failText(message, outputMode = "text", extra = {}) { + if (outputMode === "json") { + printJson({ ok: false, errors: [message], ...extra }); + } else { + console.error(message); + } + process.exit(1); +} + +export function latestWorkflowRunId(repo) { + const args = [ + "run", + "list", + "--workflow", + "deploy-aws-self-hosted.yml", + "--limit", + "1", + "--json", + "databaseId", + "--jq", + ".[0].databaseId", + ]; + if (repo) args.push("--repo", repo); + return execFileSync("gh", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 1024 * 1024, + }).trim(); +} diff --git a/scripts/installer-configure.mjs b/scripts/installer-configure.mjs new file mode 100644 index 000000000..ed7bbbaad --- /dev/null +++ b/scripts/installer-configure.mjs @@ -0,0 +1,161 @@ +import { + boolArg, + failText, + getArg, + printJson, + relativeToRoot, + requireEnvironmentDir, + resolveEnvironmentDir, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function passThroughArgs(environment, environmentDirArg) { + const names = [ + "account-id", + "root-domain", + "support-email", + "support-phone", + "support-site-url", + "website-base-url", + "content-root-url", + "admin-root-url", + "cors-origin", + "frontend-domain", + "frontend-certificate-arn", + "frontend-hosted-zone-id", + "store-api-url", + "transfer-url", + "mobile-app-url", + "domain-cname-target", + "domain-a-target", + "default-stock-photo", + "google-analytics-tag", + "project-name", + "force", + "generate-secrets", + "write-secret-file", + ]; + + const args = [ + `--environment=${environment}`, + "--output=json", + ]; + if (environmentDirArg) args.push(`--environment-dir=${environmentDirArg}`); + if (boolArg("write")) args.push("--write=true"); + + names.forEach((name) => { + const value = getArg(name); + if (value !== "") args.push(`--${name}=${value}`); + }); + + return args; +} + +function renderMarkdown(result) { + const lines = [ + `# Installer Configure: ${result.environment}`, + "", + `- Status: ${result.ok ? "configured" : "needs attention"}`, + `- Write mode: ${result.write ? "yes" : "preview only"}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Proposed changes: ${result.changeCount}`, + `- Audit blockers after configure: ${result.auditBlockerCount}`, + "", + "## Next Commands", + "", + `- Preflight: \`${result.nextCommands.preflight}\``, + `- Preview deploy: \`${result.nextCommands.previewDeploy}\``, + `- Real deploy: \`${result.nextCommands.realDeploy}\``, + ]; + + if (result.auditBlockers.length > 0) { + lines.push("", "## Remaining Blockers", ""); + result.auditBlockers.forEach((blocker) => lines.push(`- ${blocker}`)); + } + + return `${lines.join("\n")}\n`; +} + +function installerSafeNextSteps(nextSteps) { + return (nextSteps || []).map((line) => String(line) + .replace("frontend/API custom-domain values", "frontend custom-domain values") + .replace(" and --api-domain/--api-certificate-arn/--api-hosted-zone-id", "")); +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const includeDetails = boolArg("include-details", false); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + try { + requireEnvironmentDir(environmentDir); + } catch (error) { + failText(error instanceof Error ? error.message : String(error), outputMode); + } + + const prepare = runNodeJson("scripts/prepare-environment-starter.mjs", passThroughArgs(environment, environmentDirArg)); + if (!prepare.parsed) { + failText(`Could not parse prepare output.\n${prepare.stderr || prepare.stdout}`, outputMode); + } + + const audit = runNodeJson("scripts/audit-environment-starter.mjs", [ + `--environment=${environment}`, + ...(environmentDirArg ? [`--environment-dir=${environmentDirArg}`] : []), + "--only-blockers=true", + "--output=json", + ]); + + const auditBlockers = (audit.parsed?.nextSteps || []).flatMap((step) => ( + Array.isArray(step.keys) ? step.keys.map((key) => `${step.file}: ${key}`) : [step.action] + )); + + const envDirDisplay = relativeToRoot(environmentDir); + const result = { + ok: prepare.ok && audit.ok, + environment, + environmentDir: envDirDisplay, + write: Boolean(prepare.parsed.write), + changeCount: prepare.parsed.changes?.length || 0, + auditBlockerCount: audit.parsed?.blockerSummary?.blockerCount ?? auditBlockers.length, + auditBlockers, + prepareSummary: { + ok: prepare.parsed.ok, + generatedSecrets: prepare.parsed.generatedSecrets, + usedExistingSecretFile: prepare.parsed.usedExistingSecretFile, + writeSecretFile: prepare.parsed.writeSecretFile, + nextSteps: installerSafeNextSteps(prepare.parsed.nextSteps), + }, + nextCommands: { + preflight: `yarn installer:preflight -- --environment=${environment}${environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""} --output=markdown`, + previewDeploy: `yarn installer:deploy -- --environment=${environment}${environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""} --preview-only=true`, + realDeploy: `yarn installer:deploy -- --environment=${environment}${environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""} --confirm=true`, + }, + }; + + if (includeDetails) { + result.prepare = prepare.parsed; + result.audit = audit.parsed; + } + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Installer configure: ${environment}`); + console.log(`Environment dir: ${envDirDisplay}`); + console.log(`Write mode: ${result.write ? "yes" : "preview only"}`); + console.log(`Proposed changes: ${result.changeCount}`); + console.log(`Audit blockers after configure: ${result.auditBlockerCount}`); + console.log(""); + console.log("Next commands:"); + Object.values(result.nextCommands).forEach((command) => console.log(`- ${command}`)); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-customer-values.mjs b/scripts/installer-customer-values.mjs new file mode 100644 index 000000000..f83a40603 --- /dev/null +++ b/scripts/installer-customer-values.mjs @@ -0,0 +1,227 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import readline from "node:readline/promises"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) return {}; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function setIfPresent(target, key, value) { + if (value !== "") target[key] = value; +} + +function defaultValues(existing = {}) { + return { + awsRegion: existing.awsRegion || "us-east-1", + accountId: existing.accountId || "", + repo: existing.repo || "", + deployRepoDir: existing.deployRepoDir || "../b1admin-deploy", + deployEnvDir: existing.deployEnvDir || "../b1admin-deploy/environments", + rootDomain: existing.rootDomain || "", + supportEmail: existing.supportEmail || "", + supportPhone: existing.supportPhone || "", + firstAdminEmail: existing.firstAdminEmail || "", + firstAdminPassword: existing.firstAdminPassword || "", + firstChurchName: existing.firstChurchName || "", + b1adminRepo: existing.b1adminRepo || "ChurchApps/B1Admin", + b1adminRef: existing.b1adminRef || "main", + apiRepo: existing.apiRepo || "ChurchApps/Api", + apiRef: existing.apiRef || "main", + environments: { + staging: { + frontendDomain: existing.environments?.staging?.frontendDomain || "", + frontendCertificateArn: existing.environments?.staging?.frontendCertificateArn || "", + frontendHostedZoneId: existing.environments?.staging?.frontendHostedZoneId || "", + }, + prod: { + frontendDomain: existing.environments?.prod?.frontendDomain || "", + frontendCertificateArn: existing.environments?.prod?.frontendCertificateArn || "", + frontendHostedZoneId: existing.environments?.prod?.frontendHostedZoneId || "", + }, + }, + }; +} + +function valueFromArgs(key, fallback) { + const dashKey = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + return getArg(dashKey, getArg(key, fallback)); +} + +function mergeCliValues(values) { + [ + "awsRegion", + "accountId", + "repo", + "rootDomain", + "supportEmail", + "supportPhone", + "firstAdminEmail", + "firstAdminPassword", + "firstChurchName", + "b1adminRepo", + "b1adminRef", + "apiRepo", + "apiRef", + ].forEach((key) => { + const value = valueFromArgs(key, values[key]); + if (value !== values[key]) values[key] = value; + }); + + ["staging", "prod"].forEach((environment) => { + setIfPresent(values.environments[environment], "frontendDomain", getArg(`${environment}-frontend-domain`)); + setIfPresent(values.environments[environment], "frontendCertificateArn", getArg(`${environment}-frontend-certificate-arn`)); + setIfPresent(values.environments[environment], "frontendHostedZoneId", getArg(`${environment}-frontend-hosted-zone-id`)); + }); +} + +async function promptText(rl, label, defaultValue = "", options = {}) { + const suffix = defaultValue ? ` [${defaultValue}]` : ""; + const raw = await rl.question(`${label}${suffix}: `); + const value = raw.trim(); + if (!value) return defaultValue; + if (options.allowBlankToken && value.toLowerCase() === "blank") return ""; + return value; +} + +async function promptYesNo(rl, label, defaultValue = false) { + const suffix = defaultValue ? " [Y/n]" : " [y/N]"; + const raw = (await rl.question(`${label}${suffix}: `)).trim().toLowerCase(); + if (!raw) return defaultValue; + if (["y", "yes"].includes(raw)) return true; + if (["n", "no"].includes(raw)) return false; + return promptYesNo(rl, label, defaultValue); +} + +async function collectInteractive(values) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log("B1Admin customer setup"); + console.log("Press Enter to keep the value shown in brackets. Type blank to clear an optional value."); + console.log(""); + + values.awsRegion = await promptText(rl, "AWS region", values.awsRegion); + values.accountId = await promptText(rl, "AWS account ID", values.accountId); + values.repo = await promptText(rl, "User's private repository, for example your-org/b1admin-deploy", values.repo); + values.rootDomain = await promptText(rl, "Root domain, for example example.com", values.rootDomain); + values.supportEmail = await promptText(rl, "Support email", values.supportEmail || (values.rootDomain ? `support@${values.rootDomain}` : "")); + values.supportPhone = await promptText(rl, "Support phone", values.supportPhone); + values.firstAdminEmail = await promptText(rl, "First admin email", values.firstAdminEmail); + values.firstAdminPassword = await promptText(rl, "Temporary first admin password", values.firstAdminPassword); + values.firstChurchName = await promptText(rl, "First church name", values.firstChurchName); + values.b1adminRepo = await promptText(rl, "B1Admin source repository", values.b1adminRepo); + values.b1adminRef = await promptText(rl, "B1Admin source branch or tag", values.b1adminRef); + values.apiRepo = await promptText(rl, "Api source repository", values.apiRepo); + values.apiRef = await promptText(rl, "Api source branch or tag", values.apiRef); + + const configureCustomFrontend = await promptYesNo(rl, "Use a custom frontend domain now", Boolean(values.environments.prod.frontendDomain)); + if (configureCustomFrontend) { + values.environments.prod.frontendDomain = await promptText(rl, "Prod frontend hostname", values.environments.prod.frontendDomain || (values.rootDomain ? `admin.${values.rootDomain}` : "")); + values.environments.prod.frontendCertificateArn = await promptText(rl, "Prod CloudFront ACM certificate ARN in us-east-1", values.environments.prod.frontendCertificateArn, { allowBlankToken: true }); + values.environments.prod.frontendHostedZoneId = await promptText(rl, "Prod Route53 hosted zone ID", values.environments.prod.frontendHostedZoneId, { allowBlankToken: true }); + + const configureStagingDomain = await promptYesNo(rl, "Also set a staging frontend hostname", Boolean(values.environments.staging.frontendDomain)); + if (configureStagingDomain) { + values.environments.staging.frontendDomain = await promptText(rl, "Staging frontend hostname", values.environments.staging.frontendDomain || (values.rootDomain ? `admin-staging.${values.rootDomain}` : "")); + values.environments.staging.frontendCertificateArn = await promptText(rl, "Staging CloudFront ACM certificate ARN in us-east-1", values.environments.staging.frontendCertificateArn, { allowBlankToken: true }); + values.environments.staging.frontendHostedZoneId = await promptText(rl, "Staging Route53 hosted zone ID", values.environments.staging.frontendHostedZoneId, { allowBlankToken: true }); + } + } + } finally { + rl.close(); + } +} + +function requiredMissing(values) { + const required = [ + ["AWS account ID", values.accountId], + ["user's private repository", values.repo], + ["root domain", values.rootDomain], + ["support email", values.supportEmail], + ["support phone", values.supportPhone], + ["first admin email", values.firstAdminEmail], + ["temporary first admin password", values.firstAdminPassword], + ["first church name", values.firstChurchName], + ]; + return required.filter(([, value]) => !String(value || "").trim()).map(([label]) => label); +} + +function renderMarkdown(result) { + const lines = [ + "# B1Admin Customer Values", + "", + `- Status: ${result.ok ? "ready" : "needs attention"}`, + `- Customer file: \`${result.customerFile}\``, + `- Written: ${result.written ? "yes" : "no"}`, + "", + ]; + + if (result.missing.length > 0) { + lines.push("## Missing Values", ""); + result.missing.forEach((item) => lines.push(`- ${item}`)); + lines.push(""); + } + + lines.push( + "## Next Command", + "", + "```bash", + `yarn installer:next -- --customer-file=${result.customerFile} --environment=prod --output=markdown`, + "```", + "", + "Keep this file local. Do not commit it.", + ); + + return `${lines.join("\n")}\n`; +} + +async function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const customerFileArg = getArg("customer-file", "../b1admin-deploy/customer-values.json"); + const customerFile = path.resolve(rootDir, customerFileArg); + const write = boolArg("write", true); + const interactive = boolArg("interactive", outputMode !== "json"); + const values = defaultValues(readJsonIfExists(customerFile)); + + mergeCliValues(values); + if (interactive) await collectInteractive(values); + + const missing = requiredMissing(values); + if (write) { + fs.mkdirSync(path.dirname(customerFile), { recursive: true }); + fs.writeFileSync(customerFile, `${JSON.stringify(values, null, 2)}\n`); + } + + const result = { + ok: missing.length === 0, + written: write, + customerFile: relativeToRoot(customerFile), + missing, + values, + }; + + if (outputMode === "json") { + printJson(result); + } else { + process.stdout.write(renderMarkdown(result)); + } + + process.exit(result.ok ? 0 : 1); +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/installer-deploy.mjs b/scripts/installer-deploy.mjs new file mode 100644 index 000000000..7abebbd71 --- /dev/null +++ b/scripts/installer-deploy.mjs @@ -0,0 +1,207 @@ +import { + boolArg, + defaultStackNames, + failText, + getArg, + inferDeployRepo, + latestWorkflowRunId, + printJson, + relativeToRoot, + requireEnvironmentDir, + resolveEnvironmentDir, + rootDir, + runCommandJson, + runNodeJson, +} from "./installer-common.mjs"; +import fs from "node:fs"; +import path from "node:path"; + +function dispatchArgs(environment, environmentDirArg, repo, previewOnly, dryRun) { + const args = [ + `--environment=${environment}`, + `--region=${getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1")}`, + `--deployment-source=${getArg("deployment-source", "api-repo")}`, + `--b1admin-repo=${getArg("b1admin-repo", "ChurchApps/B1Admin")}`, + `--b1admin-ref=${getArg("b1admin-ref", "main")}`, + `--api-repo=${getArg("api-repo", "ChurchApps/Api")}`, + `--api-ref=${getArg("api-ref", "main")}`, + `--run-api-migrations=${boolArg("run-api-migrations", true) ? "true" : "false"}`, + `--api-migration-runner=${getArg("api-migration-runner", "data-api")}`, + `--verify-http-after-deploy=${boolArg("verify-http-after-deploy", true) ? "true" : "false"}`, + `--sync-app-config-secret=${boolArg("sync-app-config-secret", true) ? "true" : "false"}`, + `--preview-only=${previewOnly ? "true" : "false"}`, + `--dry-run=${dryRun ? "true" : "false"}`, + "--output=json", + ]; + + if (environmentDirArg) args.push(`--environment-dir=${environmentDirArg}`); + if (repo) args.push(`--repo=${repo}`); + if (boolArg("skip-gh-auth-check")) args.push("--skip-gh-auth-check=true"); + return args; +} + +function verifyArgs(environment) { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackNames = defaultStackNames(environment); + return [ + `--region=${region}`, + `--backend-stack-name=${getArg("backend-stack-name", stackNames.backend)}`, + `--frontend-stack-name=${getArg("frontend-stack-name", stackNames.frontend)}`, + "--check-http=true", + "--output=json", + ]; +} + +function renderMarkdown(result) { + const lines = [ + `# Installer Deploy: ${result.environment}`, + "", + `- Action: ${result.action}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Private deploy repo: \`${result.deployRepo || ""}\``, + `- Preview only: ${result.previewOnly ? "yes" : "no"}`, + `- Run id: ${result.runId || ""}`, + ]; + + if (result.dispatch?.dispatchCommand) { + lines.push(`- Dispatch command: \`${result.dispatch.dispatchCommand}\``); + } + + if (result.followUp.length > 0) { + lines.push("", "## Follow Up", ""); + result.followUp.forEach((line) => lines.push(`- ${line}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const repo = inferDeployRepo(getArg("repo")); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + const dryRun = boolArg("dry-run", false); + const confirm = boolArg("confirm", false); + const previewOnly = boolArg("preview-only", !confirm); + const watch = boolArg("watch", false); + const downloadEvidence = boolArg("download-evidence", false); + const verify = boolArg("verify", false); + const writeEvidence = boolArg("write-evidence", true); + const deploymentRoot = getArg("deployment-root", "deployment"); + let observation = null; + + try { + requireEnvironmentDir(environmentDir); + } catch (error) { + failText(error instanceof Error ? error.message : String(error), outputMode); + } + + const preflight = runNodeJson("scripts/installer-preflight.mjs", [ + `--environment=${environment}`, + ...(environmentDirArg ? [`--environment-dir=${environmentDirArg}`] : []), + ...(repo ? [`--repo=${repo}`] : []), + ...(boolArg("skip-github-repo-check") ? ["--skip-github-repo-check=true"] : []), + ...(boolArg("skip-aws-check") ? ["--skip-aws-check=true"] : []), + "--output=json", + ]); + if (!preflight.ok) { + failText("Installer preflight is blocked. Run `yarn installer:preflight -- --output=markdown` for details.", outputMode, { preflight: preflight.parsed }); + } + + const dispatch = runNodeJson("scripts/dispatch-github-aws-deploy.mjs", dispatchArgs(environment, environmentDirArg, repo, previewOnly, dryRun)); + if (!dispatch.ok || !dispatch.parsed?.ok) { + failText(dispatch.stderr.trim() || "GitHub deploy dispatch failed.", outputMode, { dispatch: dispatch.parsed }); + } + + let runId = ""; + const followUp = []; + if (!dryRun) { + try { + runId = latestWorkflowRunId(repo); + } catch { + runId = ""; + } + } + + if (watch && runId) { + const args = ["run", "watch", runId, "--compact", "--exit-status"]; + if (repo) args.push("--repo", repo); + const watched = runCommandJson("gh", args); + if (!watched.ok) { + failText(watched.stderr.trim() || `Workflow run ${runId} did not finish cleanly.`, outputMode); + } + } else if (runId) { + followUp.push(`Observe the run: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot} --run-id=${runId} --watch=true --download-evidence=true --verify=${previewOnly ? "false" : "true"} --output=markdown`); + } + + if (downloadEvidence && runId) { + const observed = runNodeJson("scripts/installer-observe.mjs", [ + `--environment=${environment}`, + ...(repo ? [`--repo=${repo}`] : []), + `--deployment-root=${deploymentRoot}`, + `--run-id=${runId}`, + "--watch=false", + "--download-evidence=true", + `--verify=${verify && !previewOnly ? "true" : "false"}`, + "--output=json", + ]); + observation = observed.parsed; + if (!observed.ok || !observed.parsed?.ok) { + followUp.push(observed.stderr.trim() || `Evidence observe did not complete automatically. Run: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot} --run-id=${runId} --download-evidence=true --verify=${verify && !previewOnly ? "true" : "false"} --output=markdown`); + } + } else if (runId) { + followUp.push(`Download evidence later: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot} --run-id=${runId} --download-evidence=true --verify=false --output=markdown`); + } + + let verification = observation?.verification || null; + if (verify && !previewOnly && !verification) { + const verifyRun = runNodeJson("scripts/verify-split-stack.mjs", verifyArgs(environment)); + verification = verifyRun.parsed; + if (!verifyRun.ok) { + failText("Post-deploy verification failed.", outputMode, { verification }); + } + } else if (!previewOnly) { + followUp.push(`Verify after the workflow succeeds: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot}${runId ? ` --run-id=${runId}` : ""} --download-evidence=true --verify=true --output=markdown`); + } + + const result = { + ok: true, + action: dryRun ? "validated" : previewOnly ? "preview-dispatched" : "deploy-dispatched", + environment, + environmentDir: relativeToRoot(environmentDir), + deployRepo: repo, + previewOnly, + dryRun, + deploymentRoot: relativeToRoot(path.resolve(rootDir, deploymentRoot)), + runId, + dispatch: dispatch.parsed, + observation, + verification, + followUp, + }; + + if (writeEvidence && !dryRun) { + const evidenceFile = path.resolve(rootDir, deploymentRoot, environment, previewOnly ? "last-preview-dispatch.json" : "last-deploy-dispatch.json"); + fs.mkdirSync(path.dirname(evidenceFile), { recursive: true }); + fs.writeFileSync(evidenceFile, `${JSON.stringify({ + ...result, + evidenceFile: relativeToRoot(evidenceFile), + writtenAt: new Date().toISOString(), + }, null, 2)}\n`); + result.dispatchEvidenceFile = relativeToRoot(evidenceFile); + } + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Installer deploy: ${result.action}`); + console.log(`Environment: ${environment}`); + if (runId) console.log(`Run id: ${runId}`); + followUp.forEach((line) => console.log(`- ${line}`)); + } +} + +main(); diff --git a/scripts/installer-doctor.mjs b/scripts/installer-doctor.mjs new file mode 100644 index 000000000..0707f0054 --- /dev/null +++ b/scripts/installer-doctor.mjs @@ -0,0 +1,211 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + resolveEnvironmentDir, + rootDir, +} from "./installer-common.mjs"; + +function commandExists(command) { + const result = spawnSync("command", ["-v", command], { + shell: true, + encoding: "utf8", + }); + return result.status === 0; +} + +function checkFile(filePath, label) { + return { + label, + path: relativeToRoot(filePath), + ok: fs.existsSync(filePath), + }; +} + +function envValue(name) { + const value = process.env[name] || ""; + return { + name, + ok: value.trim() !== "", + value: value.trim() !== "" ? value : "", + }; +} + +function buildEnvironmentChecks(environment, environmentDir) { + return { + environment, + environmentDir: relativeToRoot(environmentDir), + files: [ + checkFile(path.join(environmentDir, "bootstrap-parameters.json"), "bootstrap parameters"), + checkFile(path.join(environmentDir, "backend-parameters.json"), "backend parameters"), + checkFile(path.join(environmentDir, "frontend-parameters.json"), "frontend parameters"), + checkFile(path.join(environmentDir, "app-config-secret.template.json"), "app config secret template"), + checkFile(path.join(environmentDir, "deploy-split-stack.sh"), "split-stack deploy script"), + ], + }; +} + +function statusFromChecks(checks) { + const failed = checks.filter((check) => !check.ok); + return { + ok: failed.length === 0, + failed, + }; +} + +function renderMarkdown(result) { + const lines = [ + "# B1Admin Installer Doctor", + "", + `- Status: ${result.ok ? "ready for the next setup step" : "needs attention"}`, + `- B1Admin checkout: \`${result.rootDir}\``, + `- Private deploy repo: \`${result.deployRepo || ""}\``, + "", + "## Local Tools", + "", + ]; + + result.tools.forEach((tool) => { + lines.push(`- ${tool.ok ? "[x]" : "[ ]"} \`${tool.name}\``); + }); + + lines.push("", "## Shell Values", ""); + result.environmentVariables.forEach((entry) => { + lines.push(`- ${entry.ok ? "[x]" : "[ ]"} \`${entry.name}\`: \`${entry.value}\``); + }); + + lines.push("", "## B1Admin Dependencies", ""); + result.dependencies.forEach((entry) => { + lines.push(`- ${entry.ok ? "[x]" : "[ ]"} ${entry.label}: \`${entry.path}\``); + }); + + lines.push("", "## Private Deployment Files", ""); + result.deployRepoFiles.forEach((entry) => { + lines.push(`- ${entry.ok ? "[x]" : "[ ]"} ${entry.label}: \`${entry.path}\``); + }); + + result.environments.forEach((environment) => { + lines.push("", `## ${environment.environment} Files`, ""); + environment.files.forEach((entry) => { + lines.push(`- ${entry.ok ? "[x]" : "[ ]"} ${entry.label}: \`${entry.path}\``); + }); + }); + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const deployRepo = inferDeployRepo(getArg("repo")); + const deployRepoDir = path.resolve(rootDir, getArg("deploy-repo-dir", "../b1admin-deploy")); + const deployEnvDir = path.resolve(rootDir, getArg("deploy-env-dir", process.env.DEPLOY_ENV_DIR || path.join(deployRepoDir, "environments"))); + const stagingDir = resolveEnvironmentDir("staging", getArg("staging-dir", path.join(deployEnvDir, "staging"))); + const prodDir = resolveEnvironmentDir("prod", getArg("prod-dir", path.join(deployEnvDir, "prod"))); + + const tools = ["node", "npm", "git", "gh", "aws"].map((name) => ({ + name, + ok: commandExists(name), + })); + + const environmentVariables = [ + envValue("AWS_REGION"), + envValue("AWS_ACCOUNT_ID"), + { + name: "DEPLOY_REPO", + ok: deployRepo.trim() !== "", + value: deployRepo.trim() !== "" ? deployRepo : "", + }, + { + name: "DEPLOY_ENV_DIR", + ok: deployEnvDir.trim() !== "", + value: relativeToRoot(deployEnvDir), + }, + ]; + + const dependencies = [ + checkFile(path.join(rootDir, "node_modules"), "node_modules directory"), + checkFile(path.join(rootDir, "node_modules", "vite", "dist", "node", "cli.js"), "Vite CLI dependency"), + ]; + + const deployRepoFiles = [ + checkFile(path.join(deployRepoDir, ".github", "workflows", "deploy-aws-self-hosted.yml"), "GitHub Actions workflow"), + checkFile(path.join(deployRepoDir, ".gitignore"), "private repo .gitignore"), + checkFile(path.join(deployRepoDir, "README.md"), "private repo README"), + ]; + + const environments = [ + buildEnvironmentChecks("staging", stagingDir), + buildEnvironmentChecks("prod", prodDir), + ]; + + const groupedChecks = [ + ...tools, + ...environmentVariables, + ...dependencies, + ...deployRepoFiles, + ...environments.flatMap((environment) => environment.files), + ]; + const status = statusFromChecks(groupedChecks); + const nextSteps = []; + + if (!dependencies.every((entry) => entry.ok)) { + nextSteps.push("Run `yarn install` from the B1Admin checkout."); + } + if (!deployRepoFiles.every((entry) => entry.ok) || environments.some((environment) => environment.files.some((entry) => !entry.ok))) { + nextSteps.push("Run `yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown`."); + } + if (!deployRepo) { + nextSteps.push("Set `DEPLOY_REPO=/`."); + } + if (!process.env.AWS_ACCOUNT_ID) { + nextSteps.push("Set `AWS_ACCOUNT_ID=`."); + } + if (!process.env.AWS_REGION) { + nextSteps.push("Set `AWS_REGION=us-east-1` unless your install intentionally uses another region."); + } + if (!tools.find((tool) => tool.name === "gh")?.ok) { + nextSteps.push("Install GitHub CLI or use the GitHub web UI for environment/secrets/workflow steps."); + } + if (!tools.find((tool) => tool.name === "aws")?.ok) { + nextSteps.push("Install AWS CLI if you want local preflight, verify, or reset commands."); + } + + const result = { + ok: status.ok, + rootDir, + deployRepo, + deployRepoDir, + deployEnvDir, + tools, + environmentVariables, + dependencies, + deployRepoFiles, + environments, + nextSteps, + }; + + if (outputMode === "json") { + printJson(result); + process.exit(result.ok ? 0 : 1); + } + + if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + process.exit(result.ok ? 0 : 1); + } + + console.log(`B1Admin installer doctor: ${result.ok ? "ready" : "needs attention"}`); + result.nextSteps.forEach((step) => console.log(`- ${step}`)); + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-github-readiness.mjs b/scripts/installer-github-readiness.mjs new file mode 100644 index 000000000..aa7084c76 --- /dev/null +++ b/scripts/installer-github-readiness.mjs @@ -0,0 +1,181 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +const requiredSecrets = [ + "AWS_ROLE_TO_ASSUME", + "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN", + "AWS_APP_CONFIG_SECRET_JSON", +]; + +function splitRepo(repo) { + const [owner, name] = String(repo || "").split("/"); + return { owner, name }; +} + +function environmentsFromArg() { + const value = getArg("environment", "all").toLowerCase(); + if (value === "all" || value === "") return ["staging", "prod"]; + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function runGh(args) { + return spawnSync("gh", args, { + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); +} + +function parseJson(result, label) { + try { + return JSON.parse(result.stdout || "{}"); + } catch { + throw new Error(result.stderr.trim() || `Could not parse ${label} output.`); + } +} + +function checkEnvironment(repoParts, environment, skipGhCheck) { + const githubEnvironment = `aws-${environment}`; + if (skipGhCheck) { + return { + environment, + githubEnvironment, + exists: false, + skipped: true, + secrets: requiredSecrets.map((name) => ({ name, present: false, skipped: true })), + missingSecrets: requiredSecrets, + ok: false, + }; + } + + const environmentResult = runGh([ + "api", + `repos/${repoParts.owner}/${repoParts.name}/environments/${githubEnvironment}`, + ]); + const exists = environmentResult.status === 0; + let secretNames = []; + + if (exists) { + const secretsResult = runGh([ + "api", + `repos/${repoParts.owner}/${repoParts.name}/environments/${githubEnvironment}/secrets`, + ]); + if (secretsResult.status !== 0) { + return { + environment, + githubEnvironment, + exists, + ok: false, + error: secretsResult.stderr.trim() || secretsResult.stdout.trim() || `Could not list secrets for ${githubEnvironment}.`, + secrets: requiredSecrets.map((name) => ({ name, present: false })), + missingSecrets: requiredSecrets, + }; + } + const parsed = parseJson(secretsResult, "GitHub environment secrets"); + secretNames = Array.isArray(parsed.secrets) ? parsed.secrets.map((secret) => secret.name) : []; + } + + const secrets = requiredSecrets.map((name) => ({ name, present: secretNames.includes(name) })); + const missingSecrets = secrets.filter((secret) => !secret.present).map((secret) => secret.name); + + return { + environment, + githubEnvironment, + exists, + ok: exists && missingSecrets.length === 0, + error: exists ? "" : (environmentResult.stderr.trim() || environmentResult.stdout.trim() || `${githubEnvironment} does not exist.`), + secrets, + missingSecrets, + }; +} + +function renderMarkdown(result) { + const lines = [ + "# GitHub Deployment Readiness", + "", + `- Status: ${result.ok ? "ready" : "needs attention"}`, + `- Repository: \`${result.repo}\``, + "", + "## Environments", + "", + ]; + + result.environments.forEach((environment) => { + lines.push(`- ${environment.ok ? "OK" : "TODO"}: ${environment.githubEnvironment}${environment.exists ? "" : " - missing environment"}`); + environment.secrets.forEach((secret) => { + lines.push(` - ${secret.present ? "OK" : "TODO"}: ${secret.name}`); + }); + if (environment.error) lines.push(` - Error: ${environment.error}`); + }); + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const repo = inferDeployRepo(getArg("repo")); + const skipGhCheck = boolArg("skip-gh-check", false); + const write = boolArg("write", false); + const requestedEnvironment = getArg("environment", "all").toLowerCase(); + const outputFile = path.resolve(rootDir, getArg( + "output-file", + requestedEnvironment === "all" || requestedEnvironment === "" + ? path.join("deployment", "github-readiness.json") + : path.join("deployment", requestedEnvironment, "github-readiness.json"), + )); + if (!repo) failText("DEPLOY_REPO or --repo is required.", outputMode); + + const repoParts = splitRepo(repo); + if (!repoParts.owner || !repoParts.name) failText(`Repository must use owner/name format: ${repo}`, outputMode); + + const environments = environmentsFromArg().map((environment) => checkEnvironment(repoParts, environment, skipGhCheck)); + const result = { + ok: environments.every((environment) => environment.ok), + repo, + write, + outputFile: relativeToRoot(outputFile), + skipped: skipGhCheck, + requiredSecrets, + environments, + nextSteps: [ + "Run `yarn installer:github-setup -- --write=true --write-secrets=true --output=markdown` for any missing environments or secrets.", + "Run `yarn installer:preflight -- --environment= --output=markdown` after GitHub readiness is clean.", + ], + }; + + if (write) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, `${JSON.stringify(result, null, 2)}\n`); + } + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`GitHub readiness: ${result.ok ? "ready" : "needs attention"}`); + } + + process.exit(result.ok ? 0 : 1); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/installer-github-setup.mjs b/scripts/installer-github-setup.mjs new file mode 100644 index 000000000..6413342f3 --- /dev/null +++ b/scripts/installer-github-setup.mjs @@ -0,0 +1,300 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +function runGh(args, options = {}) { + return spawnSync("gh", args, { + cwd: rootDir, + encoding: "utf8", + input: options.input, + maxBuffer: 20 * 1024 * 1024, + }); +} + +function splitRepo(repo) { + const [owner, name] = String(repo || "").split("/"); + return { owner, name }; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function roleValues({ accountId, projectName, environment }) { + const deployRoleName = `${projectName}-${environment}-github-deploy`; + const cfnRoleName = `${projectName}-${environment}-cfn-exec`; + return { + deployRoleArn: accountId ? `arn:aws:iam::${accountId}:role/${deployRoleName}` : "", + cfnRoleArn: accountId ? `arn:aws:iam::${accountId}:role/${cfnRoleName}` : "", + }; +} + +function environmentSecretFile(deployEnvDir, environment) { + if (!deployEnvDir) return ""; + return path.resolve(rootDir, deployEnvDir, environment, "app-config-secret.json"); +} + +function buildSecretPlans(repo, environment, options) { + const githubEnvironment = `aws-${environment}`; + const roles = roleValues({ + accountId: options.accountId, + projectName: options.projectName, + environment, + }); + const secretFile = environmentSecretFile(options.deployEnvDir, environment); + const hasSecretFile = Boolean(secretFile && fs.existsSync(secretFile)); + const appConfigValue = hasSecretFile ? fs.readFileSync(secretFile, "utf8") : ""; + + return [ + { + name: "AWS_ROLE_TO_ASSUME", + environment, + githubEnvironment, + ready: Boolean(roles.deployRoleArn), + value: roles.deployRoleArn, + source: roles.deployRoleArn ? "derived role ARN" : "missing account id", + command: roles.deployRoleArn + ? `gh secret set AWS_ROLE_TO_ASSUME --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(roles.deployRoleArn)}` + : `gh secret set AWS_ROLE_TO_ASSUME --repo ${repo} --env ${githubEnvironment} --body ''`, + }, + { + name: "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN", + environment, + githubEnvironment, + ready: Boolean(roles.cfnRoleArn), + value: roles.cfnRoleArn, + source: roles.cfnRoleArn ? "derived role ARN" : "missing account id", + command: roles.cfnRoleArn + ? `gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(roles.cfnRoleArn)}` + : `gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --repo ${repo} --env ${githubEnvironment} --body ''`, + }, + { + name: "AWS_APP_CONFIG_SECRET_JSON", + environment, + githubEnvironment, + ready: hasSecretFile, + value: appConfigValue, + source: hasSecretFile ? relativeToRoot(secretFile) : "missing app-config-secret.json", + command: hasSecretFile + ? `gh secret set AWS_APP_CONFIG_SECRET_JSON --repo ${repo} --env ${githubEnvironment} < ${shellQuote(relativeToRoot(secretFile))}` + : `gh secret set AWS_APP_CONFIG_SECRET_JSON --repo ${repo} --env ${githubEnvironment} < ''`, + }, + ]; +} + +function writeSecret(repo, secret) { + const baseArgs = [ + "secret", + "set", + secret.name, + "--repo", + repo, + "--env", + secret.githubEnvironment, + "--app", + "actions", + ]; + + if (secret.name === "AWS_APP_CONFIG_SECRET_JSON") { + return runGh(baseArgs, { input: secret.value }); + } + + return runGh([...baseArgs, "--body", secret.value]); +} + +function renderMarkdown(result) { + const lines = [ + "# Installer GitHub Setup", + "", + `- Status: ${result.ok ? "ok" : "needs attention"}`, + `- Environment mode: ${result.write ? "write" : "preview"}`, + `- Secret mode: ${result.writeSecrets ? "write" : "preview"}`, + `- Repository: \`${result.repo || ""}\``, + `- AWS account: \`${result.accountId || ""}\``, + `- Deploy env dir: \`${result.deployEnvDir || ""}\``, + "", + "## GitHub Environments", + "", + ]; + + result.environments.forEach((environment) => { + lines.push(`- ${environment.name}: ${environment.ok ? environment.action : environment.error}`); + }); + + lines.push("", "## Required Secret Plan", ""); + result.secretPlans.forEach((secret) => { + lines.push(`- ${secret.ready ? "[x]" : "[ ]"} ${secret.githubEnvironment} / ${secret.name}: ${secret.source}`); + lines.push(` \`${secret.command}\``); + }); + + if (result.secretResults.length > 0) { + lines.push("", "## Secret Write Results", ""); + result.secretResults.forEach((secret) => { + lines.push(`- ${secret.ok ? "[x]" : "[ ]"} ${secret.environment} / ${secret.name}: ${secret.ok ? secret.action : secret.error}`); + }); + } + + if (result.optionalSecretCommands.length > 0) { + lines.push("", "## Optional Source Repo Tokens", ""); + result.optionalSecretCommands.forEach((command) => lines.push(`- \`${command}\``)); + } + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const repo = inferDeployRepo(getArg("repo")); + const write = boolArg("write", false); + const writeSecrets = boolArg("write-secrets", false); + const includeCheckoutTokenCommands = boolArg("include-checkout-token-commands", true); + const accountId = getArg("account-id", process.env.AWS_ACCOUNT_ID || ""); + const projectName = getArg("project-name", "b1admin"); + const deployEnvDir = getArg("deploy-env-dir", process.env.DEPLOY_ENV_DIR || ""); + const requestedEnvironment = getArg("environment", "all").toLowerCase(); + const environments = requestedEnvironment === "all" || requestedEnvironment === "" + ? ["staging", "prod"] + : [requestedEnvironment]; + + if (!["all", "staging", "prod", ""].includes(requestedEnvironment)) { + failText("--environment must be staging, prod, or all.", outputMode); + } + + if (!repo) { + failText("DEPLOY_REPO or --repo is required.", outputMode); + } + + const { owner, name } = splitRepo(repo); + if (!owner || !name) { + failText(`Repository must use owner/name format: ${repo}`, outputMode); + } + + const environmentResults = environments.map((environment) => { + const ghEnvironmentName = `aws-${environment}`; + if (!write) { + return { name: ghEnvironmentName, ok: true, action: "would create or update" }; + } + + const result = runGh([ + "api", + "-X", + "PUT", + `repos/${owner}/${name}/environments/${ghEnvironmentName}`, + ]); + + if (result.status !== 0) { + return { + name: ghEnvironmentName, + ok: false, + action: "failed", + error: result.stderr.trim() || result.stdout.trim() || `gh api failed for ${ghEnvironmentName}`, + }; + } + + return { name: ghEnvironmentName, ok: true, action: "created or updated" }; + }); + + const secretPlans = environments.flatMap((environment) => buildSecretPlans(repo, environment, { + accountId, + projectName, + deployEnvDir, + })); + const secretResults = []; + + if (writeSecrets) { + secretPlans.forEach((secret) => { + if (!secret.ready) { + secretResults.push({ + name: secret.name, + environment: secret.githubEnvironment, + ok: false, + action: "missing value", + error: secret.name === "AWS_APP_CONFIG_SECRET_JSON" + ? `Missing app-config secret file for ${secret.environment}. Run installer:app-config-secret first.` + : "Missing AWS account id. Pass --account-id or set AWS_ACCOUNT_ID.", + }); + return; + } + + const run = writeSecret(repo, secret); + if (run.status !== 0) { + secretResults.push({ + name: secret.name, + environment: secret.githubEnvironment, + ok: false, + action: "failed", + error: run.stderr.trim() || run.stdout.trim() || "gh secret set failed", + }); + return; + } + + secretResults.push({ + name: secret.name, + environment: secret.githubEnvironment, + ok: true, + action: "stored", + }); + }); + } + + const optionalSecretCommands = includeCheckoutTokenCommands + ? [ + `gh secret set B1ADMIN_REPO_CHECKOUT_TOKEN --repo ${repo} --env aws-staging --body ''`, + `gh secret set B1ADMIN_REPO_CHECKOUT_TOKEN --repo ${repo} --env aws-prod --body ''`, + `gh secret set API_REPO_CHECKOUT_TOKEN --repo ${repo} --env aws-staging --body ''`, + `gh secret set API_REPO_CHECKOUT_TOKEN --repo ${repo} --env aws-prod --body ''`, + ] + : []; + + const publicSecretPlans = secretPlans.map(({ value, ...secret }) => secret); + const result = { + ok: environmentResults.every((entry) => entry.ok) && secretResults.every((entry) => entry.ok !== false), + write, + writeSecrets, + repo, + accountId, + deployEnvDir: deployEnvDir || null, + environments: environmentResults, + secretPlans: publicSecretPlans, + secretResults, + secretCommands: publicSecretPlans.map((secret) => secret.command), + optionalSecretCommands, + nextSteps: [ + writeSecrets ? "Review the stored secret results above." : "Run with `--write-secrets=true` after role ARNs and app-config files are ready, or run the printed secret commands manually.", + "Use different app-config JSON values for staging and prod.", + "Run installer:preflight after the environments and secrets are in place.", + ], + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Installer GitHub setup: ${write ? "write" : "preview"}`); + console.log(`Repository: ${repo}`); + environmentResults.forEach((environment) => { + console.log(`[${environment.ok ? "OK" : "FAIL"}] ${environment.name}: ${environment.ok ? environment.action : environment.error}`); + }); + console.log(""); + [...result.secretCommands, ...optionalSecretCommands].forEach((command) => console.log(command)); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-init.mjs b/scripts/installer-init.mjs new file mode 100644 index 000000000..2d90e2938 --- /dev/null +++ b/scripts/installer-init.mjs @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function fileExists(filePath) { + return fs.existsSync(filePath); +} + +function renderMarkdown(result) { + const lines = [ + "# B1Admin Installer Init", + "", + `- Status: ${result.ok ? "ready" : "needs attention"}`, + `- Private deploy repo dir: \`${result.deployRepoDir}\``, + `- Customer file: \`${result.customerFile}\``, + "", + "## Actions", + "", + ]; + + result.actions.forEach((action) => { + lines.push(`- ${action.ok ? "OK" : "TODO"}: ${action.label} - ${action.detail}`); + }); + + lines.push( + "", + "## Next Command", + "", + `\`\`\`bash\n${result.nextCommand}\n\`\`\``, + ); + + if (result.notes.length > 0) { + lines.push("", "## Notes", ""); + result.notes.forEach((note) => lines.push(`- ${note}`)); + } + + if (result.safeCommitCommands.length > 0) { + lines.push("", "## Safe Commit Commands", "", "After reviewing the private deployment repo, run:", ""); + lines.push("```bash"); + result.safeCommitCommands.forEach((line) => lines.push(line)); + lines.push("```"); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const deployRepoDirArg = getArg("deploy-repo-dir", "../b1admin-deploy"); + const deployRepoDir = path.resolve(rootDir, deployRepoDirArg); + const deployEnvDirArg = getArg("deploy-env-dir", path.join(deployRepoDirArg, "environments")); + const deploymentRootArg = getArg("deployment-root", path.join(deployRepoDirArg, "deployment")); + const customerFileArg = getArg("customer-file", path.join(deployRepoDirArg, "customer-values.json")); + const customerFile = path.resolve(rootDir, customerFileArg); + const sampleCustomerFile = path.join(deployRepoDir, "customer-values.sample.json"); + const write = boolArg("write", true); + const force = boolArg("force", false); + const actions = []; + const notes = []; + + const setupArgs = [ + `--deploy-repo-dir=${deployRepoDirArg}`, + `--write=${write ? "true" : "false"}`, + `--force=${force ? "true" : "false"}`, + "--output=json", + ]; + const setup = runNodeJson("scripts/setup-private-deployment-repo.mjs", setupArgs); + const scaffoldReady = fileExists(path.join(deployRepoDir, ".github", "workflows", "deploy-aws-self-hosted.yml")) + && fileExists(path.join(deployRepoDir, "environments", "staging", "backend-parameters.json")) + && fileExists(path.join(deployRepoDir, "environments", "prod", "backend-parameters.json")); + + actions.push({ + ok: setup.ok || scaffoldReady, + label: "Private deployment scaffold", + detail: setup.ok + ? write ? "scaffold created or refreshed" : "scaffold preview completed" + : scaffoldReady ? "existing scaffold found" : setup.stderr.trim() || "scaffold could not be created", + }); + + if (!setup.ok && scaffoldReady) { + notes.push("Existing scaffold files were kept. Use --force=true only when you intentionally want to replace scaffold files."); + } + + let customerFileCreated = false; + if (write && !fileExists(customerFile) && fileExists(sampleCustomerFile)) { + fs.mkdirSync(path.dirname(customerFile), { recursive: true }); + fs.copyFileSync(sampleCustomerFile, customerFile); + customerFileCreated = true; + } + + actions.push({ + ok: fileExists(customerFile), + label: "Customer values file", + detail: fileExists(customerFile) + ? customerFileCreated ? "created from sample" : "already exists and was not overwritten" + : "copy customer-values.sample.json to customer-values.json", + }); + + const nextCommand = fileExists(customerFile) + ? `# Answer the setup questions first. This writes ${relativeToRoot(customerFile)} for you. +yarn installer:customer-values -- --customer-file=${relativeToRoot(customerFile)} --write=true --output=markdown + +# Then choose one path. +# Smallest AWS footprint: deploy prod first and skip staging. +yarn installer:run -- --deploy-repo-dir=${deployRepoDirArg} --deploy-env-dir=${deployEnvDirArg} --deployment-root=${deploymentRootArg} --customer-file=${relativeToRoot(customerFile)} --environment=prod --output=markdown + +# Optional practice deployment: run staging first, then prod after staging is verified. +yarn installer:run -- --deploy-repo-dir=${deployRepoDirArg} --deploy-env-dir=${deployEnvDirArg} --deployment-root=${deploymentRootArg} --customer-file=${relativeToRoot(customerFile)} --environment=staging --output=markdown` + : `cp ${relativeToRoot(sampleCustomerFile)} ${relativeToRoot(customerFile)}`; + + const result = { + ok: actions.every((action) => action.ok), + write, + force, + deployRepoDir: relativeToRoot(deployRepoDir), + deployEnvDir: relativeToRoot(path.resolve(rootDir, deployEnvDirArg)), + deploymentRoot: relativeToRoot(path.resolve(rootDir, deploymentRootArg)), + customerFile: relativeToRoot(customerFile), + actions, + nextCommand, + safeCommitCommands: setup.parsed?.safeCommitCommands || [], + notes: [ + ...notes, + "Do not commit customer-values.json, app-config-secret.json, bootstrap-admin-secret.json, or deployment/.", + "Run installer:run to continue the guided deployment.", + ], + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`B1Admin installer init: ${result.ok ? "ready" : "needs attention"}`); + console.log(nextCommand); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-observe.mjs b/scripts/installer-observe.mjs new file mode 100644 index 000000000..d58b18fe5 --- /dev/null +++ b/scripts/installer-observe.mjs @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function runGh(args) { + return spawnSync("gh", args, { + cwd: rootDir, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); +} + +function parseJsonOutput(result, fallbackLabel) { + try { + return JSON.parse(result.stdout || "{}"); + } catch { + throw new Error(result.stderr.trim() || `Could not parse ${fallbackLabel} output.`); + } +} + +function latestRun(repo) { + const args = [ + "run", + "list", + "--workflow", + "deploy-aws-self-hosted.yml", + "--limit", + "1", + "--json", + "databaseId,status,conclusion,url,headSha,createdAt,updatedAt,displayTitle,workflowName", + ]; + if (repo) args.push("--repo", repo); + const result = runGh(args); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Could not list GitHub workflow runs."); + } + const runs = parseJsonOutput(result, "gh run list"); + return Array.isArray(runs) ? runs[0] || null : null; +} + +function viewRun(runId, repo) { + const args = [ + "run", + "view", + String(runId), + "--json", + "databaseId,status,conclusion,url,headSha,createdAt,updatedAt,displayTitle,workflowName,event", + ]; + if (repo) args.push("--repo", repo); + const result = runGh(args); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `Could not view GitHub workflow run ${runId}.`); + } + return parseJsonOutput(result, "gh run view"); +} + +function watchRun(runId, repo) { + const args = ["run", "watch", String(runId), "--compact", "--exit-status"]; + if (repo) args.push("--repo", repo); + const result = runGh(args); + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function downloadArtifact(runId, repo, artifactName, evidenceDir) { + fs.mkdirSync(evidenceDir, { recursive: true }); + const downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), `b1admin-${artifactName}-`)); + const args = [ + "run", + "download", + String(runId), + "--name", + artifactName, + "--dir", + downloadDir, + ]; + if (repo) args.push("--repo", repo); + const result = runGh(args); + if (result.status === 0) { + fs.cpSync(downloadDir, evidenceDir, { recursive: true, force: true }); + } + fs.rmSync(downloadDir, { recursive: true, force: true }); + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function readSummary(summaryFile) { + if (!fs.existsSync(summaryFile)) return null; + return JSON.parse(fs.readFileSync(summaryFile, "utf8")); +} + +function verifyFromEvidence(environment, evidenceDir, checkHttp) { + const backendOutputsFile = path.join(evidenceDir, "backend-outputs.json"); + const frontendOutputsFile = path.join(evidenceDir, "frontend-outputs.json"); + if (!fs.existsSync(backendOutputsFile) || !fs.existsSync(frontendOutputsFile)) { + return null; + } + + return runNodeJson("scripts/verify-split-stack.mjs", [ + `--backend-outputs-file=${backendOutputsFile}`, + `--frontend-outputs-file=${frontendOutputsFile}`, + `--check-http=${checkHttp ? "true" : "false"}`, + "--check-aws=false", + "--output=json", + ]).parsed; +} + +function renderMarkdown(result) { + const run = result.run || {}; + const lines = [ + `# Deployment Observe: ${result.environment}`, + "", + `- Status: ${result.ok ? "ok" : "needs attention"}`, + `- Repository: \`${result.repo || ""}\``, + `- Run id: \`${result.runId || ""}\``, + `- Run status: \`${run.status || ""}\``, + `- Conclusion: \`${run.conclusion || ""}\``, + `- URL: ${run.url ? `[open run](${run.url})` : ""}`, + `- Evidence dir: \`${result.evidenceDir}\``, + `- Downloaded artifact: \`${result.downloadedArtifact || ""}\``, + ]; + + if (result.summary?.resolved) { + lines.push( + "", + "## URLs", + "", + `- API base URL: \`${result.summary.resolved.apiBaseUrl || ""}\``, + `- Frontend app URL: \`${result.summary.resolved.frontendAppUrl || ""}\``, + `- Frontend bucket: \`${result.summary.resolved.frontendBucketName || ""}\``, + `- CloudFront distribution: \`${result.summary.resolved.frontendDistributionId || ""}\``, + ); + } + + if (result.verification?.checks) { + lines.push("", "## Verification", ""); + result.verification.checks.forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL"; + lines.push(`- ${status}: ${check.name} - ${check.detail}`); + }); + } + + if (result.followUp.length > 0) { + lines.push("", "## Follow Up", ""); + result.followUp.forEach((step) => lines.push(`- ${step}`)); + } + + if (result.warnings.length > 0) { + lines.push("", "## Warnings", ""); + result.warnings.forEach((warning) => lines.push(`- ${warning}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const repo = inferDeployRepo(getArg("repo")); + const runIdArg = getArg("run-id"); + const watch = boolArg("watch", false); + const download = boolArg("download-evidence", true); + const verify = boolArg("verify", true); + const checkHttp = boolArg("check-http", true); + const deploymentRoot = getArg("deployment-root", "deployment"); + const evidenceDir = path.resolve(rootDir, getArg("evidence-dir", path.join(deploymentRoot, environment))); + const warnings = []; + const followUp = []; + const attemptedArtifacts = []; + let downloadedArtifact = ""; + + let run = null; + try { + run = runIdArg ? viewRun(runIdArg, repo) : latestRun(repo); + } catch (error) { + failText(error instanceof Error ? error.message : String(error), outputMode); + } + + if (!run) { + failText("No deploy-aws-self-hosted.yml workflow runs were found.", outputMode); + } + + const runId = run.databaseId || runIdArg; + + if (watch) { + const watched = watchRun(runId, repo); + if (!watched.ok) { + failText(watched.stderr.trim() || `Workflow run ${runId} did not finish cleanly.`, outputMode, { run }); + } + try { + run = viewRun(runId, repo); + } catch (error) { + warnings.push(error instanceof Error ? error.message : String(error)); + } + } else if (run.status !== "completed") { + followUp.push(`Watch the run: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot} --run-id=${runId} --watch=true --output=markdown`); + } + + if (download) { + fs.mkdirSync(evidenceDir, { recursive: true }); + const artifactNames = [ + `aws-${environment}-deployment-evidence`, + `aws-${environment}-preflight-plan`, + ]; + let downloaded = null; + for (const artifactName of artifactNames) { + attemptedArtifacts.push(artifactName); + downloaded = downloadArtifact(runId, repo, artifactName, evidenceDir); + if (downloaded.ok) { + downloadedArtifact = artifactName; + break; + } + } + if (!downloaded.ok) { + warnings.push(downloaded.stderr.trim() || `Could not download deployment evidence for run ${runId}.`); + followUp.push(`Download evidence manually: gh run download ${runId}${repo ? ` --repo ${repo}` : ""} --name aws-${environment}-deployment-evidence --dir ${relativeToRoot(evidenceDir)}`); + } + } else { + followUp.push(`Download evidence: yarn installer:observe -- --environment=${environment}${repo ? ` --repo=${repo}` : ""} --deployment-root=${deploymentRoot} --run-id=${runId} --download-evidence=true --verify=false --output=markdown`); + } + + const summaryFile = path.join(evidenceDir, "deployment-summary.json"); + let summary = null; + try { + summary = readSummary(summaryFile); + } catch (error) { + warnings.push(error instanceof Error ? error.message : String(error)); + } + + if (!summary) { + followUp.push(`Show summary after evidence is available: yarn show:deployment-summary -- --summary-file=${relativeToRoot(summaryFile)} --output=markdown`); + if (downloadedArtifact.endsWith("-deployment-evidence") && run.status === "completed" && run.conclusion === "success") { + warnings.push(`Downloaded ${downloadedArtifact}, but it did not contain deployment-summary.json. Update the private deployment workflow so it runs save:split-stack-outputs after a real deploy.`); + } + } + + let verification = null; + if (verify) { + verification = verifyFromEvidence(environment, evidenceDir, checkHttp); + if (!verification) { + followUp.push(`Verify after evidence is available: yarn verify:split-stack -- --backend-outputs-file=${relativeToRoot(path.join(evidenceDir, "backend-outputs.json"))} --frontend-outputs-file=${relativeToRoot(path.join(evidenceDir, "frontend-outputs.json"))} --check-http=true --check-aws=false --output=markdown`); + } + } + + const result = { + ok: warnings.length === 0 && (!verification || verification.ok !== false), + environment, + repo, + runId, + run, + evidenceDir: relativeToRoot(evidenceDir), + downloadedArtifact, + attemptedArtifacts, + summaryFile: relativeToRoot(summaryFile), + summary, + verification, + warnings, + followUp, + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Deployment observe: ${result.ok ? "ok" : "needs attention"}`); + console.log(`Run id: ${runId}`); + followUp.forEach((step) => console.log(`- ${step}`)); + warnings.forEach((warning) => console.log(`Warning: ${warning}`)); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs new file mode 100644 index 000000000..5436070fe --- /dev/null +++ b/scripts/installer-preflight.mjs @@ -0,0 +1,198 @@ +import { + boolArg, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + requireEnvironmentDir, + resolveEnvironmentDir, + runCommandJson, + runNodeJson, + rootDir, +} from "./installer-common.mjs"; +import fs from "node:fs"; +import path from "node:path"; + +function planArgs(environment, environmentDirArg, repo) { + const args = [ + `--environment=${environment}`, + `--region=${getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1")}`, + `--deployment-source=${getArg("deployment-source", "api-repo")}`, + `--b1admin-repo=${getArg("b1admin-repo", "ChurchApps/B1Admin")}`, + `--b1admin-ref=${getArg("b1admin-ref", "main")}`, + `--api-repo=${getArg("api-repo", "ChurchApps/Api")}`, + `--api-ref=${getArg("api-ref", "main")}`, + `--run-api-migrations=${boolArg("run-api-migrations", true) ? "true" : "false"}`, + `--api-migration-runner=${getArg("api-migration-runner", "data-api")}`, + `--verify-http-after-deploy=${boolArg("verify-http-after-deploy", true) ? "true" : "false"}`, + `--sync-app-config-secret=${boolArg("sync-app-config-secret", true) ? "true" : "false"}`, + "--output=json", + ]; + + if (environmentDirArg) args.push(`--environment-dir=${environmentDirArg}`); + if (repo) args.push(`--repo=${repo}`); + return args; +} + +function ghRepoCheck(repo) { + if (boolArg("skip-github-repo-check", false)) { + return { ok: true, detail: "Skipped by --skip-github-repo-check=true." }; + } + + if (!repo) { + return { ok: false, detail: "DEPLOY_REPO or --repo is required so the installer knows which private deployment repository to use." }; + } + const result = runCommandJson("gh", ["repo", "view", repo, "--json", "nameWithOwner,isPrivate"]); + if (!result.ok) { + return { ok: false, detail: result.stderr.trim() || `Could not read GitHub repository ${repo}.` }; + } + return { + ok: true, + detail: `${result.parsed?.nameWithOwner || repo}${result.parsed?.isPrivate ? " is private" : " is visible to gh"}`, + }; +} + +function renderMarkdown(result) { + const lines = [ + `# Installer Preflight: ${result.environment}`, + "", + `- Status: ${result.ok ? "ready" : "blocked"}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Private deploy repo: \`${result.deployRepo || ""}\``, + `- Starter blockers: ${result.starterBlockers}`, + `- Deploy-plan blockers: ${result.deployPlanBlockers}`, + "", + "## Checks", + "", + ]; + + result.checks.forEach((check) => { + lines.push(`- ${check.ok ? "OK" : "BLOCKED"}: ${check.name} - ${check.detail}`); + }); + + if (result.nextCommand) { + lines.push("", "## Next Command", "", `\`${result.nextCommand}\``); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const includeDetails = boolArg("include-details", false); + const write = boolArg("write", false); + const outputFile = path.resolve(rootDir, getArg("output-file", path.join("deployment", environment, "preflight-readiness.json"))); + const repo = inferDeployRepo(getArg("repo")); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + try { + requireEnvironmentDir(environmentDir); + } catch (error) { + failText(error instanceof Error ? error.message : String(error), outputMode); + } + + const audit = runNodeJson("scripts/audit-environment-starter.mjs", [ + `--environment=${environment}`, + ...(environmentDirArg ? [`--environment-dir=${environmentDirArg}`] : []), + "--only-blockers=true", + "--output=json", + ]); + const plan = runNodeJson("scripts/plan-environment-deploy.mjs", planArgs(environment, environmentDirArg, repo)); + const repoCheck = ghRepoCheck(repo); + const awsPreflight = runNodeJson("scripts/installer-aws-preflight.mjs", [ + `--environment=${environment}`, + ...(environmentDirArg ? [`--environment-dir=${environmentDirArg}`] : []), + `--region=${getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1")}`, + ...(getArg("account-id") ? [`--account-id=${getArg("account-id")}`] : []), + ...(getArg("cloudformation-execution-role-arn") ? [`--cloudformation-execution-role-arn=${getArg("cloudformation-execution-role-arn")}`] : []), + ...(boolArg("skip-aws-check") ? ["--skip-aws-check=true"] : []), + ...(boolArg("skip-aws-identity-check") ? ["--skip-aws-identity-check=true"] : []), + ...(boolArg("skip-aws-resource-lookups") ? ["--skip-aws-resource-lookups=true"] : []), + "--output=json", + ]); + + const checks = [ + { + name: "environment starter audit", + ok: audit.ok, + detail: audit.ok ? "No unresolved placeholders, unsafe starter defaults, or required blanks." : "Environment starter still has blockers.", + }, + { + name: "deployment plan", + ok: plan.ok && Boolean(plan.parsed?.githubActionsExecution?.ok), + detail: plan.parsed?.recommendedExecution?.reason || plan.stderr.trim() || "Could not compute deploy plan.", + }, + { + name: "private deployment repository", + ...repoCheck, + }, + { + name: "AWS prerequisites", + ok: awsPreflight.ok && Boolean(awsPreflight.parsed?.ok), + detail: awsPreflight.parsed?.skipped + ? "Skipped by --skip-aws-check=true." + : awsPreflight.parsed?.ok + ? "AWS CLI, configured role/certificate/DNS prerequisites are readable." + : awsPreflight.stderr.trim() || "AWS prerequisite checks are blocked.", + }, + ]; + + const result = { + ok: checks.every((check) => check.ok), + write, + outputFile: relativeToRoot(outputFile), + environment, + environmentDir: relativeToRoot(environmentDir), + deployRepo: repo, + starterBlockers: audit.parsed?.blockerSummary?.blockerCount ?? 0, + deployPlanBlockers: plan.parsed?.githubActionsExecution?.blockerCount ?? 0, + checks, + summaries: { + audit: { + ok: audit.parsed?.ok, + blockerSummary: audit.parsed?.blockerSummary, + }, + plan: { + ok: plan.parsed?.ok, + recommendedExecution: plan.parsed?.recommendedExecution, + requiredGithubSecrets: plan.parsed?.requiredGithubSecrets, + optionalGithubSecrets: plan.parsed?.optionalGithubSecrets, + warnings: plan.parsed?.warnings, + }, + awsPreflight: { + ok: awsPreflight.parsed?.ok, + skipped: awsPreflight.parsed?.skipped, + checks: awsPreflight.parsed?.checks, + }, + }, + nextCommand: `yarn installer:deploy -- --environment=${environment}${environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""} --confirm=true`, + }; + + if (includeDetails) { + result.audit = audit.parsed; + result.plan = plan.parsed; + result.awsPreflight = awsPreflight.parsed; + } + + if (write) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, `${JSON.stringify(result, null, 2)}\n`); + } + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Installer preflight: ${environment}`); + checks.forEach((check) => console.log(`[${check.ok ? "OK" : "BLOCKED"}] ${check.name}: ${check.detail}`)); + console.log(`Next: ${result.nextCommand}`); + } + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/installer-report.mjs b/scripts/installer-report.mjs new file mode 100644 index 000000000..53efeb5f9 --- /dev/null +++ b/scripts/installer-report.mjs @@ -0,0 +1,214 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + getArg, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function environmentsFromArg() { + const value = getArg("environment", "all"); + if (value === "all") return ["staging", "prod"]; + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function readTextIfExists(filePath) { + if (!fs.existsSync(filePath)) return ""; + return fs.readFileSync(filePath, "utf8"); +} + +function verifyEvidence(environmentDir, checkHttp) { + const backendOutputsFile = path.join(environmentDir, "backend-outputs.json"); + const frontendOutputsFile = path.join(environmentDir, "frontend-outputs.json"); + if (!fs.existsSync(backendOutputsFile) || !fs.existsSync(frontendOutputsFile)) return null; + + const verify = runNodeJson("scripts/verify-split-stack.mjs", [ + `--backend-outputs-file=${backendOutputsFile}`, + `--frontend-outputs-file=${frontendOutputsFile}`, + "--check-aws=false", + `--check-http=${checkHttp ? "true" : "false"}`, + "--output=json", + ]); + return verify.parsed || { + ok: false, + errors: [verify.stderr.trim() || verify.stdout.trim() || "verify-split-stack did not return JSON"], + }; +} + +function collectEnvironment(environment, deploymentRoot, checkHttp) { + const environmentDir = path.resolve(rootDir, deploymentRoot, environment); + const summaryFile = path.join(environmentDir, "deployment-summary.json"); + const preflightPlanFile = path.join(environmentDir, "preflight-plan.md"); + const browserSmokeFile = path.join(environmentDir, "browser-smoke.json"); + const sourceMetadataFile = path.join(environmentDir, "source-metadata.json"); + const deployDispatchFile = path.join(environmentDir, "last-deploy-dispatch.json"); + const bootstrapAdminFile = path.join(environmentDir, "bootstrap-admin.json"); + const summary = readJsonIfExists(summaryFile); + const browserSmoke = readJsonIfExists(browserSmokeFile); + const sourceMetadata = readJsonIfExists(sourceMetadataFile); + const deployDispatch = readJsonIfExists(deployDispatchFile); + const bootstrapAdmin = readJsonIfExists(bootstrapAdminFile); + const verification = verifyEvidence(environmentDir, checkHttp); + const preflightPlan = readTextIfExists(preflightPlanFile); + const inferredBrowserResult = browserSmoke + ? browserSmoke.ok + ? `passed: ${browserSmoke.method || "browser"} login${browserSmoke.selectedChurch ? `, ${browserSmoke.selectedChurch} selection` : ""}${browserSmoke.dashboardLoaded ? ", dashboard load" : ""}` + : "browser smoke failed" + : "not recorded"; + const inferredBootstrapMethod = bootstrapAdmin?.ok === true && bootstrapAdmin?.dryRun !== true + ? "operator machine: installer:bootstrap-admin" + : "not recorded"; + const runId = getArg(`${environment}-run-id`, getArg("run-id", deployDispatch?.runId || sourceMetadata?.githubActions?.runId || "")); + const b1adminSha = getArg(`${environment}-b1admin-sha`, getArg("b1admin-sha", sourceMetadata?.b1admin?.sha || sourceMetadata?.b1adminSha || "")); + const apiSha = getArg(`${environment}-api-sha`, getArg("api-sha", sourceMetadata?.api?.sha || sourceMetadata?.apiSha || "")); + const browserResult = getArg(`${environment}-browser-result`, getArg("browser-result", inferredBrowserResult)); + const bootstrapMethod = getArg(`${environment}-bootstrap-method`, getArg("bootstrap-method", inferredBootstrapMethod)); + const evidenceArtifact = getArg(`${environment}-evidence-artifact`, `aws-${environment}-deployment-evidence`); + + const missing = []; + if (!summary) missing.push(relativeToRoot(summaryFile)); + if (!verification) missing.push(relativeToRoot(path.join(environmentDir, "backend-outputs.json")), relativeToRoot(path.join(environmentDir, "frontend-outputs.json"))); + if (!runId) missing.push(`${environment} GitHub Actions run id`); + if (!b1adminSha) missing.push(`${environment} B1Admin commit SHA`); + if (!apiSha) missing.push(`${environment} Api commit SHA`); + if (browserResult === "not recorded") missing.push(`${environment} browser login result`); + if (bootstrapMethod === "not recorded") missing.push(`${environment} first-admin bootstrap method`); + + return { + environment, + environmentDir: relativeToRoot(environmentDir), + summaryFile: relativeToRoot(summaryFile), + preflightPlanFile: fs.existsSync(preflightPlanFile) ? relativeToRoot(preflightPlanFile) : "", + browserSmokeFile: fs.existsSync(browserSmokeFile) ? relativeToRoot(browserSmokeFile) : "", + hasPreflightPlan: Boolean(preflightPlan), + browserSmoke, + sourceMetadata, + summary, + verification, + runId, + b1adminSha, + apiSha, + browserResult, + bootstrapMethod, + evidenceArtifact, + missing, + }; +} + +function renderCheck(status, label, detail = "") { + return `- ${status}: ${label}${detail ? ` - ${detail}` : ""}`; +} + +function renderEnvironment(environment) { + const summary = environment.summary || {}; + const verification = environment.verification || {}; + const lines = [ + `## ${environment.environment}`, + "", + renderCheck(environment.missing.length === 0 ? "OK" : "TODO", "Rollout record", environment.missing.length === 0 ? "complete" : `${environment.missing.length} item(s) missing`), + renderCheck(environment.summary ? "OK" : "TODO", "Deployment summary", environment.summaryFile), + renderCheck(verification.ok === true ? "OK" : verification ? "FAIL" : "TODO", "Installer verify result", verification ? `ok=${verification.ok === true ? "true" : "false"}` : "not available"), + "", + "### Recorded Values", + "", + `- GitHub Actions run id: \`${environment.runId || ""}\``, + `- B1Admin commit SHA: \`${environment.b1adminSha || ""}\``, + `- Api commit SHA: \`${environment.apiSha || ""}\``, + `- API base URL: \`${summary.resolved?.apiBaseUrl || verification.resolved?.apiBaseUrl || ""}\``, + `- Frontend URL: \`${summary.resolved?.frontendAppUrl || verification.resolved?.frontendAppUrl || ""}\``, + `- Deployment evidence artifact: \`${environment.evidenceArtifact}\``, + `- Browser login result: ${environment.browserResult}`, + `- Browser smoke file: \`${environment.browserSmokeFile || ""}\``, + `- First-admin bootstrap method: ${environment.bootstrapMethod}`, + ]; + + if (summary.stackNames) { + lines.push( + `- Backend stack: \`${summary.stackNames.backend || ""}\``, + `- Frontend stack: \`${summary.stackNames.frontend || ""}\``, + ); + } + + if (verification.checks?.length > 0) { + lines.push("", "### Verification Checks", ""); + verification.checks.forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL"; + lines.push(renderCheck(status, check.name, check.detail)); + }); + } + + if (environment.missing.length > 0) { + lines.push("", "### Missing Before Sign-Off", ""); + environment.missing.forEach((item) => lines.push(`- ${item}`)); + } + + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(result) { + const completeCount = result.environments.filter((environment) => environment.missing.length === 0 && environment.verification?.ok === true).length; + const lines = [ + "# B1Admin Deployment Report", + "", + `- Status: ${result.ok ? "complete" : "needs attention"}`, + `- Generated at: ${result.generatedAt}`, + `- Deployment evidence root: \`${result.deploymentRoot}\``, + `- Complete environments: ${completeCount}/${result.environments.length}`, + "", + ]; + + result.environments.forEach((environment) => { + lines.push(renderEnvironment(environment).trimEnd(), ""); + }); + + lines.push("## Sign-Off", ""); + result.environments.forEach((environment) => { + const label = environment.environment === "prod" ? "Prod" : "Staging"; + lines.push(`- ${label} browser workflow tested by: `); + }); + lines.push( + "- Approved by: ", + "- Notes: ", + "", + ); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const deploymentRoot = getArg("deployment-root", "deployment"); + const checkHttp = getArg("check-http", "false").toLowerCase() === "true"; + const outputFile = path.resolve(rootDir, getArg("output-file", path.join(deploymentRoot, "deployment-report.md"))); + const write = getArg("write", "false").toLowerCase() === "true"; + const environments = environmentsFromArg().map((environment) => collectEnvironment(environment, deploymentRoot, checkHttp)); + + const result = { + ok: environments.every((environment) => environment.missing.length === 0 && environment.verification?.ok === true), + generatedAt: new Date().toISOString(), + deploymentRoot, + outputFile: relativeToRoot(outputFile), + environments, + }; + + const markdown = renderMarkdown(result); + if (write) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, markdown); + } + + if (outputMode === "json") { + printJson({ ...result, markdown: write ? undefined : markdown }); + } else { + process.stdout.write(markdown); + } +} + +main(); diff --git a/scripts/installer-run.mjs b/scripts/installer-run.mjs new file mode 100644 index 000000000..1e1a1a0be --- /dev/null +++ b/scripts/installer-run.mjs @@ -0,0 +1,222 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import readline from "node:readline/promises"; +import { + boolArg, + getArg, + printJson, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +const runnerArgs = new Set([ + "approve-gates", + "dry-run", + "max-steps", + "output", + "yes", +]); + +function argName(arg) { + if (!arg.startsWith("--")) return ""; + return arg.replace(/^--/, "").split("=")[0]; +} + +function startArgs() { + const args = []; + const raw = process.argv.slice(2); + for (let index = 0; index < raw.length; index += 1) { + const arg = raw[index]; + const name = argName(arg); + if (name && runnerArgs.has(name)) { + if (!arg.includes("=") && raw[index + 1] && !raw[index + 1].startsWith("--")) index += 1; + continue; + } + args.push(arg); + } + return [...args, "--output=json"]; +} + +function splitWords(command) { + const words = []; + let current = ""; + let quote = ""; + let escaping = false; + + for (const char of command.trim()) { + if (escaping) { + current += char; + escaping = false; + continue; + } + if (char === "\\") { + escaping = true; + continue; + } + if (quote) { + if (char === quote) { + quote = ""; + } else { + current += char; + } + continue; + } + if (char === "\"" || char === "'") { + quote = char; + continue; + } + if (/\s/.test(char)) { + if (current) { + words.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current) words.push(current); + return words; +} + +function invocationFromCommand(command) { + const trimmed = command.trim(); + if (!trimmed || trimmed.startsWith("#")) return null; + + const words = splitWords(trimmed); + if (words[0] === "npm" && words[1] === "install" && words.length === 2) { + return { command: "npm", args: ["install"] }; + } + if (words[0] === "npm" && words[1] === "run" && words[2]) { + return { command: "npm", args: ["run", words[2], ...words.slice(3)] }; + } + if (words[0] === "cp" && words.length === 3) { + return { command: "cp", args: words.slice(1) }; + } + return null; +} + +function isApprovalGate(command) { + return command.includes("installer:deploy") + || command.includes("--write-secrets=true") + || command.includes("installer:bootstrap-admin") + || command.includes("installer:browser-smoke") + || command.includes("installer:adopt-frontend-origin") + || command.includes("installer:report"); +} + +function renderStep(step, check, gated) { + const lines = [ + "", + `Step ${step}: ${check?.label || "Next command"}`, + check?.detail ? `Status: ${check.detail}` : "", + gated ? "Approval: this step can change AWS/GitHub state, create a login, launch browser testing, or write sign-off evidence." : "", + "Command:", + check?.command || "", + ].filter(Boolean); + return lines.join("\n"); +} + +async function shouldRun(rl, command, options) { + const gated = isApprovalGate(command); + if (options.dryRun) return false; + if (options.yes && (!gated || options.approveGates)) return true; + + const suffix = gated ? " [y/N]" : " [Y/n]"; + const raw = (await rl.question(`Run this command now?${suffix}: `)).trim().toLowerCase(); + if (!raw) return !gated; + return ["y", "yes"].includes(raw); +} + +function runStart() { + const result = runNodeJson("scripts/installer-start.mjs", startArgs()); + if (!result.ok || !result.parsed) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "installer:next did not return JSON."); + } + return result.parsed; +} + +async function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const options = { + approveGates: boolArg("approve-gates", false), + dryRun: boolArg("dry-run", false), + maxSteps: Number(getArg("max-steps", "30")), + yes: boolArg("yes", false), + }; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const history = []; + + try { + for (let step = 1; step <= options.maxSteps; step += 1) { + const state = runStart(); + const nextCommand = String(state.nextCommand || "").trim(); + const nextCheck = state.checks?.find((check) => !check.ok && check.command === nextCommand) + || state.checks?.find((check) => !check.ok) + || null; + + if (state.ok || !nextCommand || nextCommand.startsWith("#")) { + history.push({ step, action: "complete", command: nextCommand }); + if (outputMode === "json") printJson({ ok: true, complete: true, history, state }); + else console.log(nextCommand || "Installer run complete."); + return; + } + + const invocation = invocationFromCommand(nextCommand); + if (!invocation) { + history.push({ step, action: "manual", command: nextCommand }); + if (outputMode === "json") printJson({ ok: false, complete: false, manualCommand: nextCommand, history, state }); + else console.log(`Run this command manually, then start installer:run again:\n${nextCommand}`); + process.exit(1); + } + + if (outputMode !== "json") console.log(renderStep(step, nextCheck, isApprovalGate(nextCommand))); + const runIt = await shouldRun(rl, nextCommand, options); + if (!runIt) { + history.push({ step, action: "paused", command: nextCommand }); + if (outputMode === "json") printJson({ ok: true, complete: false, paused: true, command: nextCommand, history, state }); + else console.log("Paused. Run installer:run again when you are ready to continue."); + return; + } + + history.push({ step, action: "run", command: nextCommand }); + const result = spawnSync(invocation.command, invocation.args, { + cwd: rootDir, + encoding: "utf8", + stdio: outputMode === "json" ? "pipe" : "inherit", + maxBuffer: 20 * 1024 * 1024, + }); + + if ((result.status ?? 1) !== 0) { + if (outputMode === "json") { + printJson({ + ok: false, + complete: false, + failedCommand: nextCommand, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + history, + }); + } else { + console.error("That command did not finish successfully. Fix the issue shown above, then run installer:run again."); + } + process.exit(result.status ?? 1); + } + } + + const message = `Paused after ${options.maxSteps} step(s). Re-run installer:run to continue.`; + if (outputMode === "json") { + printJson({ ok: true, complete: false, paused: true, reason: "max-steps", history }); + } else { + console.log(message); + } + } finally { + rl.close(); + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/installer-start.mjs b/scripts/installer-start.mjs new file mode 100644 index 000000000..2b5e0c718 --- /dev/null +++ b/scripts/installer-start.mjs @@ -0,0 +1,254 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, + runNodeJson, +} from "./installer-common.mjs"; + +function exists(filePath) { + return fs.existsSync(filePath); +} + +function status(ok, label, detail, command = "") { + return { ok, label, detail, command }; +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function isPlaceholderValue(name, value) { + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized) return true; + if (normalized.includes("<") || normalized.includes(">")) return true; + if (normalized.includes("replace-me") || normalized.includes("replace-with")) return true; + if (normalized.includes("your-")) return true; + + const placeholders = { + "account-id": new Set(["123456789012", "000000000000"]), + repo: new Set(["your-org/b1admin-deploy", "owner/repo", "example/b1admin-deploy"]), + "root-domain": new Set(["example.com", "customer.test"]), + "support-email": new Set(["support@example.com", "admin@example.com"]), + "support-phone": new Set(["+1-555-555-0100", "555-555-0100", "111-222-3333"]), + "first-admin-email": new Set(["admin@example.com"]), + "first-admin-password": new Set(["password", "temporary-password"]), + "first-church-name": new Set(["example church"]), + }; + + return placeholders[name]?.has(normalized) || false; +} + +function hasCustomerValue(name, value) { + return !isPlaceholderValue(name, value); +} + +function defaultDeployRepoDir() { + return getArg("deploy-repo-dir", "../b1admin-deploy"); +} + +function defaultDeployEnvDir(deployRepoDirArg) { + return getArg("deploy-env-dir", path.join(deployRepoDirArg, "environments")); +} + +function customerFilePath(deployRepoDirArg) { + return path.resolve(rootDir, getArg("customer-file", path.join(deployRepoDirArg, "customer-values.json"))); +} + +function envCommandBase(environment, deployEnvDirArg, customerFile) { + return `--environment=${environment} --environment-dir=${path.join(deployEnvDirArg, environment)} --customer-file=${relativeToRoot(customerFile)}`; +} + +function auditEnvironment(environment, deployEnvDirArg, customerFile) { + const environmentDirArg = path.join(deployEnvDirArg, environment); + const audit = runNodeJson("scripts/audit-environment-starter.mjs", [ + `--environment=${environment}`, + `--environment-dir=${environmentDirArg}`, + "--only-blockers=true", + "--output=json", + ]); + if (!audit.ok || !audit.parsed) return null; + return audit.parsed; +} + +function renderMarkdown(result) { + const lines = [ + "# B1Admin Installer Start", + "", + `- Environment: \`${result.environment}\``, + `- Customer file: \`${result.customerFile}\``, + `- Private deploy repo dir: \`${result.deployRepoDir}\``, + `- Environment dir root: \`${result.deployEnvDir}\``, + "", + "## Next Command", + "", + `\`\`\`bash\n${result.nextCommand || "# No next command found."}\n\`\`\``, + "", + "## Checklist", + "", + ]; + + result.checks.forEach((check) => { + lines.push(`- ${check.ok ? "OK" : "TODO"}: ${check.label} - ${check.detail}`); + }); + + if (result.showAllCommands) { + lines.push("", "## Command Reference", ""); + result.commands.forEach((command) => lines.push(`- \`${command}\``)); + } + + if (result.notes.length > 0) { + lines.push("", "## Notes", ""); + result.notes.forEach((note) => lines.push(`- ${note}`)); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const environment = getArg("environment", "prod"); + const showAllCommands = boolArg("show-all-commands", false); + const deployRepoDirArg = defaultDeployRepoDir(); + const deployEnvDirArg = defaultDeployEnvDir(deployRepoDirArg); + const deploymentRootArg = getArg("deployment-root", path.join(deployRepoDirArg, "deployment")); + const deployRepoDir = path.resolve(rootDir, deployRepoDirArg); + const deployEnvDir = path.resolve(rootDir, deployEnvDirArg); + const deploymentRoot = path.resolve(rootDir, deploymentRootArg); + const customerFile = customerFilePath(deployRepoDirArg); + const repo = getArg("repo"); + const accountId = getArg("account-id"); + const rootDomain = getArg("root-domain"); + const supportPhone = getArg("support-phone"); + const supportEmail = getArg("support-email"); + const firstAdminEmail = getArg("bootstrap-admin-email") || getArg("first-admin-email"); + const firstAdminPassword = getArg("bootstrap-admin-password") || getArg("first-admin-password"); + const firstChurchName = getArg("bootstrap-church-name") || getArg("first-church-name"); + const writeHint = boolArg("write", false) ? " --write=true" : ""; + const envBase = envCommandBase(environment, deployEnvDirArg, customerFile); + const environmentDir = path.join(deployEnvDir, environment); + const appConfigSecret = path.join(environmentDir, "app-config-secret.json"); + const workflowFile = path.join(deployRepoDir, ".github", "workflows", "deploy-aws-self-hosted.yml"); + const sampleCustomerFile = path.join(deployRepoDir, "customer-values.sample.json"); + const roleDir = path.join(deployRepoDir, "iam", environment); + const handoffFile = path.join(deployRepoDir, "aws-admin-handoff.md"); + const evidenceDir = path.join(deploymentRoot, environment); + const githubReadiness = readJsonIfExists(path.join(evidenceDir, "github-readiness.json")); + const preflightReadiness = readJsonIfExists(path.join(evidenceDir, "preflight-readiness.json")); + const previewDispatch = readJsonIfExists(path.join(evidenceDir, "last-preview-dispatch.json")); + const deployDispatch = readJsonIfExists(path.join(evidenceDir, "last-deploy-dispatch.json")); + const deploymentSummary = readJsonIfExists(path.join(evidenceDir, "deployment-summary.json")); + const bootstrapAdmin = readJsonIfExists(path.join(evidenceDir, "bootstrap-admin.json")); + const browserSmoke = readJsonIfExists(path.join(evidenceDir, "browser-smoke.json")); + const backendParameters = readJsonIfExists(path.join(environmentDir, "backend-parameters.json")); + const preflightPlanFile = path.join(evidenceDir, "preflight-plan.md"); + const reportFile = path.join(deploymentRoot, "deployment-report.md"); + const roleFilesExist = exists(roleDir) && fs.readdirSync(roleDir).some((fileName) => fileName.endsWith(".json")); + const audit = exists(environmentDir) ? auditEnvironment(environment, deployEnvDirArg, customerFile) : null; + const auditBlockerCount = audit?.blockerSummary?.blockerCount ?? null; + + const commands = { + editCustomerValues: `yarn installer:customer-values -- --customer-file=${relativeToRoot(customerFile)} --write=true --output=markdown`, + createCustomerFile: `cp ${relativeToRoot(sampleCustomerFile)} ${relativeToRoot(customerFile)}`, + installDeps: "yarn install", + setup: `yarn installer:init -- --deploy-repo-dir=${deployRepoDirArg} --output=markdown`, + awsHandoff: `yarn installer:aws-handoff -- --customer-file=${relativeToRoot(customerFile)} --deploy-repo-dir=${deployRepoDirArg} --write=true --output=markdown`, + roles: `yarn installer:aws-roles -- --environment=${environment} --customer-file=${relativeToRoot(customerFile)} --output-dir=${path.join(deployRepoDirArg, "iam", environment)} --write=true --output=markdown`, + githubEnvironments: `yarn installer:github-setup -- --repo=${repo || "/"} --write=true --output=markdown`, + configurePreview: `yarn installer:configure -- ${envBase} --output=markdown`, + configureWrite: `yarn installer:configure -- ${envBase}${writeHint || " --write=true"}`, + appConfig: `yarn installer:app-config-secret -- ${envBase} --write=true --output=markdown`, + githubSecrets: `yarn installer:github-setup -- --environment=${environment} --customer-file=${relativeToRoot(customerFile)} --deploy-env-dir=${deployEnvDirArg} --write=true --write-secrets=true --output=markdown`, + githubReadiness: `yarn installer:github-readiness -- --environment=${environment} --repo=${repo || "/"} --write=true --output=markdown`, + preflight: `yarn installer:preflight -- ${envBase} --repo=${repo || "/"} --write=true --output=markdown`, + previewDeploy: `yarn installer:deploy -- ${envBase} --repo=${repo || "/"} --deployment-root=${deploymentRootArg} --preview-only=true`, + observePreview: `yarn installer:observe -- --environment=${environment} --repo=${repo || "/"} --deployment-root=${deploymentRootArg} --watch=true --download-evidence=true --verify=false --output=markdown`, + realDeploy: `yarn installer:deploy -- ${envBase} --repo=${repo || "/"} --deployment-root=${deploymentRootArg} --confirm=true`, + observeDeploy: `yarn installer:observe -- --environment=${environment} --repo=${repo || "/"} --deployment-root=${deploymentRootArg} --watch=true --download-evidence=true --verify=true --output=markdown`, + adoptFrontendOrigin: `yarn installer:adopt-frontend-origin -- --environment=${environment} --environment-dir=${path.join(deployEnvDirArg, environment)} --deployment-root=${deploymentRootArg} --write=true --output=markdown`, + bootstrapAdmin: `yarn installer:bootstrap-admin -- --environment=${environment} --deployment-root=${deploymentRootArg} --customer-file=${relativeToRoot(customerFile)} --dry-run=false --output=markdown`, + browserSmoke: `yarn installer:browser-smoke -- --environment=${environment} --deployment-root=${deploymentRootArg} --customer-file=${relativeToRoot(customerFile)} --output=markdown`, + nextEnvironment: `yarn installer:next -- --customer-file=${relativeToRoot(customerFile)} --deployment-root=${deploymentRootArg} --environment=prod --output=markdown`, + report: `yarn installer:report -- --environment=${environment} --deployment-root=${deploymentRootArg} --write=true --check-http=true --output=markdown`, + }; + + const githubReadinessCommand = githubReadiness?.ok === false ? commands.githubSecrets : commands.githubReadiness; + + const localDependenciesReady = exists(path.join(rootDir, "node_modules", "vite", "dist", "node", "cli.js")); + const firstAdminValuesReady = hasCustomerValue("first-admin-email", firstAdminEmail) && hasCustomerValue("first-admin-password", firstAdminPassword) && hasCustomerValue("first-church-name", firstChurchName); + const localDependenciesSatisfied = localDependenciesReady || browserSmoke?.ok === true; + const privateDeploymentScaffoldReady = exists(workflowFile) && exists(path.join(deployEnvDir, "staging")) && exists(path.join(deployEnvDir, "prod")); + const deployedFrontendAppUrl = String(deploymentSummary?.resolved?.frontendAppUrl || "").replace(/\/$/, ""); + const frontendOriginReady = !deploymentSummary + || !deployedFrontendAppUrl + || (String(backendParameters?.CorsOrigin || "").replace(/\/$/, "") === deployedFrontendAppUrl + && String(backendParameters?.B1AdminRootUrl || "").replace(/\/$/, "") === deployedFrontendAppUrl); + + const checks = [ + status(privateDeploymentScaffoldReady, "Private deployment scaffold", privateDeploymentScaffoldReady ? "workflow plus staging/prod folders" : "create the private deployment repo scaffold", commands.setup), + status(exists(customerFile), "Customer values file", exists(customerFile) ? "found" : "copy the sample and replace placeholders", exists(sampleCustomerFile) ? commands.createCustomerFile : commands.setup), + status(hasCustomerValue("repo", repo) && hasCustomerValue("account-id", accountId), "Core customer values", hasCustomerValue("repo", repo) && hasCustomerValue("account-id", accountId) ? "repo and AWS account id available" : "answer the customer setup questions", commands.editCustomerValues), + status(roleFilesExist, "AWS IAM admin handoff", roleFilesExist ? `prepared${exists(handoffFile) ? " with handoff document" : ""}` : "generate files and commands for an AWS admin", commands.awsHandoff), + status(hasCustomerValue("root-domain", rootDomain) && hasCustomerValue("support-phone", supportPhone), "Environment public values", hasCustomerValue("root-domain", rootDomain) && hasCustomerValue("support-phone", supportPhone) ? "root domain and support phone available" : "answer the customer setup questions", commands.editCustomerValues), + status(auditBlockerCount === 0, "Environment parameter files", auditBlockerCount === null ? "not checked yet" : `${auditBlockerCount} blocker(s) remaining`, commands.configureWrite), + status(exists(appConfigSecret), "App config secret", exists(appConfigSecret) ? "local secret file exists and should not be committed" : "generate random app secrets locally", commands.appConfig), + status(hasCustomerValue("support-email", supportEmail), "Support email", hasCustomerValue("support-email", supportEmail) ? "available for web push subject" : "set supportEmail before syncing app config", commands.editCustomerValues), + status(githubReadiness?.ok === true, "GitHub readiness", githubReadiness?.ok === true ? "environments and required secret names confirmed" : "confirm GitHub Environments and required secrets", githubReadinessCommand), + status(preflightReadiness?.ok === true, "Installer preflight", preflightReadiness?.ok === true ? "ready for workflow dispatch" : "run local preflight before dispatch", commands.preflight), + status(exists(preflightPlanFile), "Preview workflow observed", exists(preflightPlanFile) ? "preflight plan evidence downloaded" : previewDispatch ? "preview dispatch found; observe the run" : "dispatch a preview workflow", previewDispatch ? commands.observePreview : commands.previewDeploy), + status(Boolean(deploymentSummary), "Deployment observed", deploymentSummary ? "deployment summary evidence downloaded" : deployDispatch ? "deploy dispatch found; observe and verify the run" : "dispatch the real deploy workflow", deployDispatch ? commands.observeDeploy : commands.realDeploy), + status(frontendOriginReady, "Frontend origin accepted by backend", frontendOriginReady ? "backend CORS/root URL match the deployed frontend" : "adopt the deployed frontend URL into backend parameters, commit/push, then rerun the real deploy", commands.adoptFrontendOrigin), + status(firstAdminValuesReady, "First admin values", firstAdminValuesReady ? "first admin email, temporary password, and church name available" : "answer the first-admin setup questions", commands.editCustomerValues), + status(localDependenciesSatisfied, "Local dependencies", localDependenciesReady ? "B1Admin npm dependencies installed" : browserSmoke?.ok === true ? "not installed locally; browser smoke evidence already saved" : "install B1Admin npm dependencies before first-admin bootstrap and browser smoke", commands.installDeps), + status(bootstrapAdmin?.ok === true && bootstrapAdmin?.dryRun !== true, "First admin bootstrap", bootstrapAdmin?.ok === true && bootstrapAdmin?.dryRun !== true ? "first admin evidence saved" : "seed the first admin from the operator machine", commands.bootstrapAdmin), + status(browserSmoke?.ok === true, "Browser smoke", browserSmoke?.ok === true ? "login and authenticated page evidence saved" : "run browser smoke after first-admin bootstrap", commands.browserSmoke), + ]; + + if (environment === "prod") { + checks.push(status(exists(reportFile), "Deployment report", exists(reportFile) ? "final report written" : "write final rollout report", commands.report)); + } + + const nextCheck = checks.find((check) => !check.ok && check.command); + const nextCommand = nextCheck?.command + || (environment === "staging" ? commands.nextEnvironment : `# Complete. Review ${relativeToRoot(reportFile)} and sign off.`); + + const result = { + ok: checks.every((check) => check.ok), + environment, + customerFile: relativeToRoot(customerFile), + deployRepoDir: relativeToRoot(deployRepoDir), + deployEnvDir: relativeToRoot(deployEnvDir), + deploymentRoot: relativeToRoot(deploymentRoot), + nextCommand, + checks, + commands: Object.values(commands), + showAllCommands, + notes: [ + environment === "prod" + ? "Prod-first keeps the AWS footprint smaller. Staging is optional and can be run separately when you want a practice deployment." + : "Staging is optional. After staging is verified, the installer will point you to prod.", + "Run this command again after each completed step to get the next recommendation.", + "Only run the command shown under Next Command. Use --show-all-commands=true when you need the full command reference.", + "The normal path uses the generated API Gateway URL. Only configure a frontend custom domain when the DNS and us-east-1 certificate are ready.", + "Do not commit customer-values.json, app-config-secret.json, bootstrap-admin-secret.json, or deployment/.", + ], + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + } else { + console.log(`Next command:\n${nextCommand}`); + } +} + +main(); diff --git a/scripts/installer-update.mjs b/scripts/installer-update.mjs new file mode 100644 index 000000000..58aeeb598 --- /dev/null +++ b/scripts/installer-update.mjs @@ -0,0 +1,155 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import readline from "node:readline/promises"; +import { + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || rootDir, + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + maxBuffer: 20 * 1024 * 1024, + }); + + return { + ok: result.status === 0, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function statusLines(deployRepoDir) { + const result = runCommand("git", ["status", "--short"], { cwd: deployRepoDir, capture: true }); + if (!result.ok) return []; + return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function hasSafePrivateChanges(lines) { + return lines.some((line) => { + const filePath = line.replace(/^..?\s+/, ""); + return filePath === "README.md" + || filePath === ".gitignore" + || filePath === "customer-values.sample.json" + || filePath === ".github/workflows/deploy-aws-self-hosted.yml" + || filePath.startsWith("environments/"); + }); +} + +async function confirm(rl, question, defaultYes = false, options = {}) { + if (options.yes) return true; + const suffix = defaultYes ? " [Y/n]" : " [y/N]"; + const raw = (await rl.question(`${question}${suffix}: `)).trim().toLowerCase(); + if (!raw) return defaultYes; + return raw === "y" || raw === "yes"; +} + +function runOrThrow(command, args, options = {}) { + const result = runCommand(command, args, options); + if (!result.ok) { + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + } + return result; +} + +async function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const deployRepoDirArg = getArg("deploy-repo-dir", "../b1admin-deploy"); + const deployEnvDirArg = getArg("deploy-env-dir", path.join(deployRepoDirArg, "environments")); + const deploymentRootArg = getArg("deployment-root", path.join(deployRepoDirArg, "deployment")); + const customerFileArg = getArg("customer-file", path.join(deployRepoDirArg, "customer-values.json")); + const environment = getArg("environment", "prod"); + const skipPull = boolArg("skip-pull", false); + const skipPrivateCommit = boolArg("skip-private-commit", false); + const yes = boolArg("yes", false); + const approveGates = boolArg("approve-gates", false); + const dryRun = boolArg("dry-run", false); + const deployRepoDir = path.resolve(rootDir, deployRepoDirArg); + const history = []; + + if (outputMode !== "json") { + console.log("B1Admin update will refresh source code, refresh the private deployment workspace, then start the guided deploy runner."); + console.log(`Environment: ${environment}`); + console.log(`User's private repository: ${relativeToRoot(deployRepoDir)}`); + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + try { + if (!skipPull) { + const shouldPull = await confirm(rl, "Pull latest B1Admin source repository code now?", true, { yes }); + if (shouldPull) { + history.push({ action: "git-pull" }); + if (!dryRun) runOrThrow("git", ["pull"]); + } + } + + history.push({ action: "installer-init" }); + if (!dryRun) { + runOrThrow("npm", [ + "run", + "installer:init", + "--", + `--deploy-repo-dir=${deployRepoDirArg}`, + "--output=markdown", + ]); + } + + const privateStatus = dryRun ? [] : statusLines(deployRepoDir); + if (privateStatus.length > 0 && outputMode !== "json") { + console.log("\nUser's private repository changes:"); + privateStatus.forEach((line) => console.log(line)); + } + + if (!skipPrivateCommit && privateStatus.length > 0 && hasSafePrivateChanges(privateStatus)) { + const shouldCommit = await confirm(rl, "Commit and push safe private repository scaffold changes now?", false, { yes }); + if (shouldCommit) { + history.push({ action: "private-commit-push" }); + if (!dryRun) { + runOrThrow("git", ["add", "README.md", ".gitignore", ".github/workflows/deploy-aws-self-hosted.yml", "customer-values.sample.json", "environments"], { cwd: deployRepoDir }); + const commit = runCommand("git", ["commit", "-m", "Update B1Admin deployment scaffold"], { cwd: deployRepoDir }); + if (!commit.ok && outputMode !== "json") { + console.log("No private scaffold commit was created. Continuing."); + } + runOrThrow("git", ["push"], { cwd: deployRepoDir }); + } + } else if (outputMode !== "json") { + console.log("Paused before deploy. Commit and push safe private repository changes, then run installer:update again."); + return; + } + } + + const runnerArgs = [ + "run", + "installer:run", + "--", + `--deploy-repo-dir=${deployRepoDirArg}`, + `--deploy-env-dir=${deployEnvDirArg}`, + `--deployment-root=${deploymentRootArg}`, + `--customer-file=${customerFileArg}`, + `--environment=${environment}`, + "--output=markdown", + ]; + if (yes) runnerArgs.push("--yes=true"); + if (approveGates) runnerArgs.push("--approve-gates=true"); + if (dryRun) runnerArgs.push("--dry-run=true"); + + history.push({ action: "installer-run" }); + if (!dryRun) runOrThrow("npm", runnerArgs); + + if (outputMode === "json") printJson({ ok: true, history }); + } finally { + rl.close(); + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/installer-verify.mjs b/scripts/installer-verify.mjs new file mode 100644 index 000000000..56591223a --- /dev/null +++ b/scripts/installer-verify.mjs @@ -0,0 +1,57 @@ +import { + defaultStackNames, + failText, + getArg, + printJson, + runNodeJson, +} from "./installer-common.mjs"; + +function main() { + const environment = getArg("environment", "staging"); + const outputMode = getArg("output", "text").toLowerCase(); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackNames = defaultStackNames(environment); + + const verify = runNodeJson("scripts/verify-split-stack.mjs", [ + `--region=${region}`, + `--backend-stack-name=${getArg("backend-stack-name", stackNames.backend)}`, + `--frontend-stack-name=${getArg("frontend-stack-name", stackNames.frontend)}`, + "--check-http=true", + "--output=json", + ]); + + if (!verify.parsed) { + failText(verify.stderr.trim() || "Could not parse split-stack verification output.", outputMode); + } + + if (outputMode === "json") { + printJson(verify.parsed); + } else if (outputMode === "markdown" || outputMode === "md") { + const lines = [ + `# Installer Verify: ${environment}`, + "", + `- Status: ${verify.parsed.ok ? "ok" : "failed"}`, + `- Region: \`${region}\``, + `- Backend stack: \`${verify.parsed.backendStackName}\``, + `- Frontend stack: \`${verify.parsed.frontendStackName}\``, + "", + "## Checks", + "", + ]; + (verify.parsed.checks || []).forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL"; + lines.push(`- ${status}: ${check.name} - ${check.detail}`); + }); + process.stdout.write(`${lines.join("\n")}\n`); + } else { + console.log(`Installer verify: ${verify.parsed.ok ? "ok" : "failed"}`); + (verify.parsed.checks || []).forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL"; + console.log(`[${status}] ${check.name}: ${check.detail}`); + }); + } + + process.exit(verify.ok ? 0 : 1); +} + +main(); diff --git a/scripts/launch-staging.mjs b/scripts/launch-staging.mjs new file mode 100644 index 000000000..1682fd621 --- /dev/null +++ b/scripts/launch-staging.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { getArg, getBooleanArg } from "./lib/arg-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const deployScriptPath = path.join(rootDir, "infrastructure", "environments", "staging", "deploy-split-stack.sh"); + +function runNodeScript(scriptPath, args) { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd: rootDir, + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function runShellScript(scriptPath, envOverrides = {}) { + const result = spawnSync("bash", [scriptPath], { + cwd: rootDir, + stdio: "inherit", + env: { ...process.env, ...envOverrides }, + }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function buildPlanArgs(options) { + const args = [ + path.join(rootDir, "scripts", "plan-environment-deploy.mjs"), + "--environment=staging", + `--region=${options.region}`, + `--deployment-source=${options.deploymentSource}`, + `--api-repo-path=${options.apiRepoPath}`, + `--sync-app-config-secret=${options.syncAppConfigSecret}`, + `--sync-bootstrap-admin-secret=${options.syncBootstrapAdminSecret}`, + `--run-api-migrations=${options.runApiMigrations}`, + `--run-bootstrap-admin=${options.runBootstrapAdmin}`, + `--api-migration-action=${options.apiMigrationAction}`, + `--api-migration-module=${options.apiMigrationModule}`, + `--verify-http-after-deploy=${options.verifyHttpAfterDeploy}`, + "--output=json", + ]; + + if (options.packageManifestFile) args.push(`--package-manifest-file=${options.packageManifestFile}`); + if (options.backendArtifactSourceFile) args.push(`--backend-artifact-source-file=${options.backendArtifactSourceFile}`); + if (options.migrationArtifactSourceFile) args.push(`--migration-artifact-source-file=${options.migrationArtifactSourceFile}`); + if (options.dependenciesLayerSourceFile) args.push(`--dependencies-layer-source-file=${options.dependenciesLayerSourceFile}`); + + return args; +} + +function getPlan(options) { + const result = spawnSync(process.execPath, buildPlanArgs(options), { + cwd: rootDir, + encoding: "utf8", + env: process.env, + }); + + let parsed; + try { + parsed = JSON.parse(result.stdout || "{}"); + } catch { + console.error("Could not parse staging deploy plan output."); + console.error(result.stdout); + console.error(result.stderr); + process.exit(result.status ?? 1); + } + + if (result.status !== 0) { + console.error("Staging deploy plan found blockers."); + if (parsed?.recommendedCommands?.primary) { + console.error(`Primary next command: ${parsed.recommendedCommands.primary}`); + } + process.exit(result.status); + } + + return parsed; +} + +function buildDispatchArgs(options) { + const args = [ + path.join(rootDir, "scripts", "dispatch-github-aws-deploy.mjs"), + "--environment=staging", + `--deployment-source=${options.deploymentSource}`, + `--region=${options.region}`, + `--sync-app-config-secret=${options.syncAppConfigSecret}`, + `--sync-bootstrap-admin-secret=${options.syncBootstrapAdminSecret}`, + `--run-api-migrations=${options.runApiMigrations}`, + `--run-bootstrap-admin=${options.runBootstrapAdmin}`, + `--api-migration-action=${options.apiMigrationAction}`, + `--api-migration-module=${options.apiMigrationModule}`, + `--verify-http-after-deploy=${options.verifyHttpAfterDeploy}`, + `--dry-run=${options.dryRun}`, + `--preview-only=${options.previewOnly}`, + ]; + + if (options.githubAuthMode !== "oidc") args.push(`--github-auth-mode=${options.githubAuthMode}`); + if (options.repo) args.push(`--repo=${options.repo}`); + if (options.apiRepo !== "ChurchApps/Api") args.push(`--api-repo=${options.apiRepo}`); + if (options.apiRef !== "main") args.push(`--api-ref=${options.apiRef}`); + if (options.packageManifestFile) args.push(`--package-manifest-file=${options.packageManifestFile}`); + if (options.backendArtifactSourceFile) args.push(`--backend-artifact-source-file=${options.backendArtifactSourceFile}`); + if (options.migrationArtifactSourceFile) args.push(`--migration-artifact-source-file=${options.migrationArtifactSourceFile}`); + if (options.dependenciesLayerSourceFile) args.push(`--dependencies-layer-source-file=${options.dependenciesLayerSourceFile}`); + + return args; +} + +function getLocalEnvOverrides(options) { + return { + AWS_REGION: options.region, + API_REPO_PATH: options.apiRepoPath, + PACKAGE_MODE: "layered", + PACKAGE_BUILD_LAYER: "true", + PACKAGE_MANIFEST_FILE: options.packageManifestFile, + BACKEND_ARTIFACT_SOURCE_FILE: options.backendArtifactSourceFile, + MIGRATION_ARTIFACT_SOURCE_FILE: options.migrationArtifactSourceFile, + DEPENDENCIES_LAYER_SOURCE_FILE: options.dependenciesLayerSourceFile, + SYNC_APP_CONFIG_SECRET: String(options.syncAppConfigSecret), + SYNC_BOOTSTRAP_ADMIN_SECRET: String(options.syncBootstrapAdminSecret), + RUN_API_MIGRATIONS: String(options.runApiMigrations), + RUN_BOOTSTRAP_ADMIN: String(options.runBootstrapAdmin), + API_MIGRATION_ACTION: options.apiMigrationAction, + API_MIGRATION_MODULE: options.apiMigrationModule, + VERIFY_HTTP_AFTER_DEPLOY: String(options.verifyHttpAfterDeploy), + PREVIEW_ONLY: String(options.previewOnly), + }; +} + +function main() { + const options = { + mode: getArg("mode", "auto"), + region: getArg("region", "us-east-1"), + deploymentSource: getArg("deployment-source", "api-repo"), + apiRepoPath: getArg("api-repo-path", "../Api"), + apiRepo: getArg("api-repo", "ChurchApps/Api"), + apiRef: getArg("api-ref", "main"), + packageManifestFile: getArg("package-manifest-file", ""), + backendArtifactSourceFile: getArg("backend-artifact-source-file", ""), + migrationArtifactSourceFile: getArg("migration-artifact-source-file", ""), + dependenciesLayerSourceFile: getArg("dependencies-layer-source-file", ""), + repo: getArg("repo", ""), + githubAuthMode: getArg("github-auth-mode", "oidc"), + syncAppConfigSecret: getBooleanArg("sync-app-config-secret", true), + syncBootstrapAdminSecret: getBooleanArg("sync-bootstrap-admin-secret", false), + runApiMigrations: getBooleanArg("run-api-migrations", false), + runBootstrapAdmin: getBooleanArg("run-bootstrap-admin", false), + apiMigrationAction: getArg("api-migration-action", "up"), + apiMigrationModule: getArg("api-migration-module", "all"), + verifyHttpAfterDeploy: getBooleanArg("verify-http-after-deploy", false), + previewOnly: getBooleanArg("preview-only", false), + dryRun: getBooleanArg("dry-run", false), + }; + + if (!options.previewOnly && !options.dryRun && options.deploymentSource === "api-repo") { + console.log("Staging launch note: the Api TypeScript compile step can take 10+ minutes on a full local build."); + console.log("During that step, repeated [WAIT] messages are expected while the deploy continues."); + } + + const plan = getPlan(options); + const recommendedPath = plan?.recommendedExecution?.path ?? "none"; + + let selectedMode = options.mode; + if (selectedMode === "auto") { + selectedMode = recommendedPath === "github-actions" ? "github" : "local"; + } + + if (selectedMode === "github") { + runNodeScript(buildDispatchArgs(options)[0], buildDispatchArgs(options).slice(1)); + return; + } + + if (selectedMode === "local") { + if (options.dryRun) { + console.log("Staging launch dry-run complete."); + console.log(`Recommended path: ${recommendedPath}`); + console.log(`Using local deploy wrapper: ${deployScriptPath}`); + return; + } + runShellScript(deployScriptPath, getLocalEnvOverrides(options)); + return; + } + + console.error(`Unsupported launch mode: ${options.mode}`); + process.exit(1); +} + +main(); diff --git a/scripts/lib/api-migration-data-api-shim.mjs b/scripts/lib/api-migration-data-api-shim.mjs new file mode 100644 index 000000000..9423e4389 --- /dev/null +++ b/scripts/lib/api-migration-data-api-shim.mjs @@ -0,0 +1,407 @@ +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, "``")}\``; +} + +function escapeColumnReference(value) { + return String(value) + .split(".") + .map((part) => escapeIdentifier(part)) + .join("."); +} + +function escapeSqlString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''"); +} + +function renderSqlValue(value) { + if (value instanceof RawSql) return value.text; + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL"; + if (typeof value === "boolean") return value ? "1" : "0"; + return `'${escapeSqlString(value)}'`; +} + +class RawSql { + constructor(text) { + this.text = String(text); + } + + async execute(db) { + return db.executeSql(this.text); + } + + toString() { + return this.text; + } +} + +function renderWhereClause(column, operator, value) { + const normalized = String(operator || "").trim().toLowerCase(); + const lhs = escapeColumnReference(column); + if (normalized === "is" || normalized === "is not") { + if (value === null || value === undefined) return `${lhs} ${normalized.toUpperCase()} NULL`; + return `${lhs} ${normalized.toUpperCase()} ${renderSqlValue(value)}`; + } + return `${lhs} ${operator} ${renderSqlValue(value)}`; +} + +class SelectQueryBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.selected = []; + this.conditions = []; + this.limitCount = null; + } + + select(columns) { + const values = Array.isArray(columns) ? columns : [columns]; + this.selected.push(...values); + return this; + } + + where(column, operator, value) { + this.conditions.push(renderWhereClause(column, operator, value)); + return this; + } + + limit(count) { + this.limitCount = count; + return this; + } + + async execute() { + const columns = this.selected.length > 0 + ? this.selected.map((value) => escapeColumnReference(value)).join(", ") + : "*"; + const where = this.conditions.length > 0 ? ` WHERE ${this.conditions.join(" AND ")}` : ""; + const limit = Number.isInteger(this.limitCount) ? ` LIMIT ${this.limitCount}` : ""; + const result = await this.executeSql(`SELECT ${columns} FROM ${escapeIdentifier(this.tableName)}${where}${limit}`); + return result.rows || []; + } + + async executeTakeFirst() { + if (!Number.isInteger(this.limitCount)) this.limitCount = 1; + const rows = await this.execute(); + return rows[0]; + } +} + +class InsertQueryBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.rows = []; + } + + values(values) { + this.rows = Array.isArray(values) ? [...values] : [values]; + return this; + } + + async execute() { + if (this.rows.length === 0) return { rows: [] }; + const columns = Object.keys(this.rows[0]); + const columnSql = columns.map((column) => escapeIdentifier(column)).join(", "); + const valueSql = this.rows.map((row) => `(${columns.map((column) => renderSqlValue(row[column])).join(", ")})`).join(", "); + return this.executeSql(`INSERT INTO ${escapeIdentifier(this.tableName)} (${columnSql}) VALUES ${valueSql}`); + } +} + +class UpdateQueryBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.assignments = {}; + this.conditions = []; + } + + set(values) { + this.assignments = { ...this.assignments, ...values }; + return this; + } + + where(column, operator, value) { + this.conditions.push(renderWhereClause(column, operator, value)); + return this; + } + + async execute() { + const assignmentSql = Object.entries(this.assignments) + .map(([column, value]) => `${escapeIdentifier(column)} = ${renderSqlValue(value)}`) + .join(", "); + const where = this.conditions.length > 0 ? ` WHERE ${this.conditions.join(" AND ")}` : ""; + return this.executeSql(`UPDATE ${escapeIdentifier(this.tableName)} SET ${assignmentSql}${where}`); + } +} + +class DeleteQueryBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.conditions = []; + } + + where(column, operator, value) { + this.conditions.push(renderWhereClause(column, operator, value)); + return this; + } + + async execute() { + const where = this.conditions.length > 0 ? ` WHERE ${this.conditions.join(" AND ")}` : ""; + return this.executeSql(`DELETE FROM ${escapeIdentifier(this.tableName)}${where}`); + } +} + +class ColumnBuilder { + constructor(name, type) { + this.name = name; + this.type = type instanceof RawSql ? type : new RawSql(type); + this.isNotNull = false; + this.isPrimaryKey = false; + this.defaultValue = undefined; + } + + notNull() { + this.isNotNull = true; + return this; + } + + primaryKey() { + this.isPrimaryKey = true; + return this; + } + + defaultTo(value) { + this.defaultValue = value; + return this; + } + + build() { + const parts = [ + escapeIdentifier(this.name), + this.type.text, + ]; + + if (this.isNotNull) parts.push("NOT NULL"); + if (this.isPrimaryKey) parts.push("PRIMARY KEY"); + if (this.defaultValue !== undefined) parts.push(`DEFAULT ${renderSqlValue(this.defaultValue)}`); + + return parts.join(" "); + } +} + +class CreateTableBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.columns = []; + this.ifNotExistsEnabled = false; + this.tableSuffix = ""; + } + + ifNotExists() { + this.ifNotExistsEnabled = true; + return this; + } + + addColumn(name, type, callback) { + const column = new ColumnBuilder(name, type); + if (typeof callback === "function") callback(column); + this.columns.push(column); + return this; + } + + modifyEnd(value) { + this.tableSuffix = value instanceof RawSql ? value.text : String(value || ""); + return this; + } + + async execute() { + const ifNotExists = this.ifNotExistsEnabled ? " IF NOT EXISTS" : ""; + const columnSql = this.columns.map((column) => column.build()).join(", "); + const suffix = this.tableSuffix ? ` ${this.tableSuffix}` : ""; + await this.executeSql(`CREATE TABLE${ifNotExists} ${escapeIdentifier(this.tableName)} (${columnSql})${suffix}`); + } +} + +class AlterTableBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.actions = []; + } + + addColumn(name, type, callback) { + const column = new ColumnBuilder(name, type); + if (typeof callback === "function") callback(column); + this.actions.push(`ADD COLUMN ${column.build()}`); + return this; + } + + dropColumn(name) { + this.actions.push(`DROP COLUMN ${escapeIdentifier(name)}`); + return this; + } + + async execute() { + if (this.actions.length === 0) return; + await this.executeSql(`ALTER TABLE ${escapeIdentifier(this.tableName)} ${this.actions.join(", ")}`); + } +} + +class DropTableBuilder { + constructor(tableName, executeSql) { + this.tableName = tableName; + this.executeSql = executeSql; + this.ifExistsEnabled = false; + } + + ifExists() { + this.ifExistsEnabled = true; + return this; + } + + async execute() { + const ifExists = this.ifExistsEnabled ? " IF EXISTS" : ""; + await this.executeSql(`DROP TABLE${ifExists} ${escapeIdentifier(this.tableName)}`); + } +} + +class CreateIndexBuilder { + constructor(indexName, executeSql) { + this.indexName = indexName; + this.executeSql = executeSql; + this.tableName = ""; + this.columnNames = []; + this.isUnique = false; + } + + on(tableName) { + this.tableName = tableName; + return this; + } + + column(name) { + this.columnNames = [name]; + return this; + } + + columns(names) { + this.columnNames = [...names]; + return this; + } + + unique() { + this.isUnique = true; + return this; + } + + async execute() { + const unique = this.isUnique ? "UNIQUE " : ""; + const columns = this.columnNames.map((name) => escapeIdentifier(name)).join(", "); + await this.executeSql(`CREATE ${unique}INDEX ${escapeIdentifier(this.indexName)} ON ${escapeIdentifier(this.tableName)} (${columns})`); + } +} + +class DropIndexBuilder { + constructor(indexName, executeSql) { + this.indexName = indexName; + this.executeSql = executeSql; + this.tableName = ""; + this.ifExistsEnabled = false; + } + + on(tableName) { + this.tableName = tableName; + return this; + } + + ifExists() { + this.ifExistsEnabled = true; + return this; + } + + async execute() { + const ifExists = this.ifExistsEnabled ? " IF EXISTS" : ""; + await this.executeSql(`DROP INDEX${ifExists} ${escapeIdentifier(this.indexName)} ON ${escapeIdentifier(this.tableName)}`); + } +} + +class SchemaBuilder { + constructor(executeSql) { + this.executeSql = executeSql; + } + + createTable(tableName) { + return new CreateTableBuilder(tableName, this.executeSql); + } + + alterTable(tableName) { + return new AlterTableBuilder(tableName, this.executeSql); + } + + dropTable(tableName) { + return new DropTableBuilder(tableName, this.executeSql); + } + + createIndex(indexName) { + return new CreateIndexBuilder(indexName, this.executeSql); + } + + dropIndex(indexName) { + return new DropIndexBuilder(indexName, this.executeSql); + } +} + +let dataApiDbFactory = null; + +export function createMigrationDbContext(executeSql) { + return { + executeSql, + selectFrom(tableName) { + return new SelectQueryBuilder(tableName, executeSql); + }, + insertInto(tableName) { + return new InsertQueryBuilder(tableName, executeSql); + }, + updateTable(tableName) { + return new UpdateQueryBuilder(tableName, executeSql); + }, + deleteFrom(tableName) { + return new DeleteQueryBuilder(tableName, executeSql); + }, + async destroy() { + // Kysely destroy() closes pooled DB resources. The Data API runner is stateless. + }, + schema: new SchemaBuilder(executeSql), + }; +} + +export function setDataApiDbFactory(factory) { + dataApiDbFactory = typeof factory === "function" ? factory : null; +} + +export function createKysely(moduleName) { + if (typeof dataApiDbFactory !== "function") { + throw new Error("Data API Kysely factory is not configured."); + } + return dataApiDbFactory(moduleName); +} + +export function sql(strings, ...values) { + if (!Array.isArray(strings) || !Object.prototype.hasOwnProperty.call(strings, "raw")) { + return new RawSql(strings); + } + + let text = ""; + for (let index = 0; index < strings.length; index += 1) { + text += strings[index]; + if (index < values.length) text += renderSqlValue(values[index]); + } + return new RawSql(text); +} + +sql.raw = function raw(text) { + return new RawSql(text); +}; diff --git a/scripts/lib/arg-utils.mjs b/scripts/lib/arg-utils.mjs new file mode 100644 index 000000000..2065ecb78 --- /dev/null +++ b/scripts/lib/arg-utils.mjs @@ -0,0 +1,22 @@ +export function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + return "true"; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +export function getBooleanArg(name, fallback = false) { + const value = getArg(name, fallback ? "true" : "false"); + if (typeof value === "boolean") return value; + return String(value).toLowerCase() === "true"; +} + diff --git a/scripts/lib/environment-setup-metadata.mjs b/scripts/lib/environment-setup-metadata.mjs new file mode 100644 index 000000000..633eddda6 --- /dev/null +++ b/scripts/lib/environment-setup-metadata.mjs @@ -0,0 +1,347 @@ +export const requiredFiles = [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", +]; + +export const phaseOrder = ["first-deploy", "custom-domains", "integrations"]; + +export const phaseMetadata = { + "first-deploy": { + title: "Required For First AWS Deploy", + description: "These values should be resolved before the first real stack launch.", + }, + "custom-domains": { + title: "Optional For Domain Cutover Later", + description: "Leave these blank until you are ready to wire ACM certificates and Route53 records.", + }, + integrations: { + title: "Optional Integrations And Enhancements", + description: "These settings can wait until the base stack is healthy.", + }, +}; + +export const optionalBlankKeys = { + "backend-parameters.json": new Set([ + "DependenciesLayerArn", + "ObservabilityLayerArn", + "LambdaNodeOptions", + "MigrationCodeS3Bucket", + "MigrationCodeS3Key", + "MigrationHandler", + "MigrationRuntime", + "MigrationTrigger", + "ApiCustomDomainName", + "ApiCertificateArn", + "ApiHostedZoneId", + "AssetBucketName", + "AppConfigSecretArn", + "CaddyHost", + "CaddyPort", + "MobileAppUrl", + "DomainCnameTarget", + "DomainATarget", + "DefaultStockPhoto", + "GoogleAnalyticsTag", + "SentryDsn", + ]), + "frontend-parameters.json": new Set([ + "BucketName", + "AlternateDomainName", + "AcmCertificateArn", + "HostedZoneId", + ]), + "app-config-secret.template.json": new Set([ + "hubspotKey", + "mauticUrl", + "mauticUser", + "mauticPassword", + "youTubeApiKey", + "pexelsKey", + "vimeoToken", + "apiBibleKey", + "youVersionApiKey", + "praiseChartsConsumerKey", + "praiseChartsConsumerSecret", + "googleRecaptchaSecretKey", + "openRouterApiKey", + "openAiApiKey", + "webPushPublicKey", + "webPushPrivateKey", + ]), +}; + +export const unsafeDefaultMatchers = { + "backend-parameters.json": { + LambdaCodeS3Bucket: (value) => typeof value === "string" && value.includes("replace-me"), + DependenciesLayerArn: (value) => typeof value === "string" && value.includes("replace-me"), + AppConfigSecretArn: (value) => typeof value === "string" && value.includes("replace-me"), + WebsiteBaseUrl: (value) => isKnownStarterHostname(value), + ContentRootUrl: (value) => isKnownStarterHostname(value), + B1AdminRootUrl: (value) => isKnownStarterHostname(value), + CorsOrigin: (value) => isKnownStarterHostname(value), + StoreApiUrl: (value) => isKnownStarterHostname(value), + TransferUrl: (value) => isKnownStarterHostname(value), + SupportEmail: (value) => value === "support@example.com" || value === "support@b1.church", + SupportPhone: (value) => value === "555-555-5555", + SupportSiteUrl: (value) => isKnownStarterHostname(value), + }, + "frontend-parameters.json": { + AlternateDomainName: (value) => typeof value === "string" && value.includes("example.com"), + AcmCertificateArn: (value) => typeof value === "string" && value.includes("replace-me"), + HostedZoneId: (value) => value === "Z0404841L5A2UX2RE4US", + }, + "app-config-secret.template.json": { + webPushSubject: (value) => value === "mailto:support@example.com" || value === "mailto:support@b1.church", + }, +}; + +export function isKnownStarterHostname(value) { + if (typeof value !== "string") return false; + return [ + "example.com", + "b1.church", + "churchapps.org", + ].some((hostname) => value.includes(hostname)); +} + +export const setupFieldMetadata = { + "bootstrap-parameters.json": { + TemplateBucketName: { + phase: "first-deploy", + label: "Bootstrap template bucket", + rationale: "CloudFormation needs a unique S3 bucket for packaged templates.", + }, + ArtifactBucketName: { + phase: "first-deploy", + label: "Bootstrap artifact bucket", + rationale: "Backend packages and deployment artifacts are uploaded here.", + }, + }, + "backend-parameters.json": { + LambdaCodeS3Bucket: { + phase: "first-deploy", + label: "Backend artifact bucket reference", + rationale: "Should point at the artifact bucket chosen during bootstrap.", + }, + WebsiteBaseUrl: { + phase: "first-deploy", + label: "Public website URL pattern", + rationale: "The backend uses this when generating site-facing links.", + }, + ContentRootUrl: { + phase: "first-deploy", + label: "Content site root URL", + rationale: "Needed for links back into your public content experience.", + }, + B1AdminRootUrl: { + phase: "first-deploy", + label: "Admin app URL", + rationale: "Used in callbacks, links, and CORS-related behavior.", + }, + CorsOrigin: { + phase: "first-deploy", + label: "Allowed admin origin", + rationale: "Must match the frontend URL the browser will call from.", + }, + StoreApiUrl: { + phase: "first-deploy", + label: "Store API URL", + rationale: "Needed if the app links into your store API surface.", + }, + TransferUrl: { + phase: "first-deploy", + label: "Transfer URL", + rationale: "Used anywhere the app expects the transfer service endpoint.", + }, + SupportEmail: { + phase: "first-deploy", + label: "Support email", + rationale: "Shows up in app messaging and support-related flows.", + }, + SupportPhone: { + phase: "first-deploy", + label: "Support phone", + rationale: "Shown in support and contact surfaces.", + }, + SupportSiteUrl: { + phase: "first-deploy", + label: "Support site URL", + rationale: "Used for support/help links.", + }, + ApiCustomDomainName: { + phase: "custom-domains", + label: "API custom domain", + rationale: "Optional until you want the API on your own hostname.", + }, + ApiCertificateArn: { + phase: "custom-domains", + label: "API ACM certificate", + rationale: "Only required when enabling a custom API domain.", + }, + ApiHostedZoneId: { + phase: "custom-domains", + label: "API Route53 hosted zone", + rationale: "Only required when creating DNS records for the API domain.", + }, + MobileAppUrl: { + phase: "integrations", + label: "Mobile app URL", + rationale: "Helpful metadata, but not needed to stand up the base stack.", + }, + DomainCnameTarget: { + phase: "integrations", + label: "Legacy CNAME target", + rationale: "Can be added later if your setup still uses this routing pattern.", + }, + DomainATarget: { + phase: "integrations", + label: "Legacy A-record target", + rationale: "Can be added later if your setup still uses this routing pattern.", + }, + DefaultStockPhoto: { + phase: "integrations", + label: "Default stock photo", + rationale: "Content polish only; not a first-launch blocker.", + }, + GoogleAnalyticsTag: { + phase: "integrations", + label: "Google Analytics tag", + rationale: "Optional analytics hookup.", + }, + SentryDsn: { + phase: "integrations", + label: "Sentry DSN", + rationale: "Optional observability hookup.", + }, + }, + "frontend-parameters.json": { + AlternateDomainName: { + phase: "custom-domains", + label: "Frontend custom domain", + rationale: "Blank is fine for the first deploy; CloudFront can use its generated hostname.", + }, + AcmCertificateArn: { + phase: "custom-domains", + label: "Frontend ACM certificate", + rationale: "Only needed once you attach a custom frontend hostname.", + }, + HostedZoneId: { + phase: "custom-domains", + label: "Frontend Route53 hosted zone", + rationale: "Only needed for DNS record creation during domain cutover.", + }, + }, + "app-config-secret.template.json": { + jwtSecret: { + phase: "first-deploy", + label: "JWT secret", + rationale: "Required for authentication and session signing.", + }, + encryptionKey: { + phase: "first-deploy", + label: "Encryption key", + rationale: "Required for encrypted app configuration and data handling.", + }, + webPushSubject: { + phase: "first-deploy", + label: "Web push contact subject", + rationale: "Should point at a real support mailbox before first live use.", + }, + hubspotKey: { + phase: "integrations", + label: "HubSpot key", + rationale: "Optional marketing integration.", + }, + mauticUrl: { + phase: "integrations", + label: "Mautic URL", + rationale: "Optional marketing automation integration.", + }, + mauticUser: { + phase: "integrations", + label: "Mautic user", + rationale: "Optional marketing automation integration.", + }, + mauticPassword: { + phase: "integrations", + label: "Mautic password", + rationale: "Optional marketing automation integration.", + }, + youTubeApiKey: { + phase: "integrations", + label: "YouTube API key", + rationale: "Optional video integration.", + }, + pexelsKey: { + phase: "integrations", + label: "Pexels key", + rationale: "Optional stock photo integration.", + }, + vimeoToken: { + phase: "integrations", + label: "Vimeo token", + rationale: "Optional video integration.", + }, + apiBibleKey: { + phase: "integrations", + label: "API Bible key", + rationale: "Optional scripture integration.", + }, + youVersionApiKey: { + phase: "integrations", + label: "YouVersion API key", + rationale: "Optional scripture integration.", + }, + praiseChartsConsumerKey: { + phase: "integrations", + label: "PraiseCharts consumer key", + rationale: "Optional worship-planning integration.", + }, + praiseChartsConsumerSecret: { + phase: "integrations", + label: "PraiseCharts consumer secret", + rationale: "Optional worship-planning integration.", + }, + googleRecaptchaSecretKey: { + phase: "integrations", + label: "reCAPTCHA secret", + rationale: "Optional anti-spam protection.", + }, + openRouterApiKey: { + phase: "integrations", + label: "OpenRouter API key", + rationale: "Optional AI integration.", + }, + openAiApiKey: { + phase: "integrations", + label: "OpenAI API key", + rationale: "Optional AI integration.", + }, + webPushPublicKey: { + phase: "integrations", + label: "Web push public key", + rationale: "Optional until you enable browser push notifications.", + }, + webPushPrivateKey: { + phase: "integrations", + label: "Web push private key", + rationale: "Optional until you enable browser push notifications.", + }, + }, +}; + +export function getFieldMetadata(fileName, key) { + const explicit = setupFieldMetadata[fileName]?.[key]; + if (explicit) return explicit; + + const optional = optionalBlankKeys[fileName]?.has(key) ?? false; + return { + phase: optional ? "integrations" : "first-deploy", + label: key, + rationale: optional + ? "This field is optional and can usually be filled later if your rollout needs it." + : "This field should be reviewed before the first deploy.", + }; +} diff --git a/scripts/lib/github-cli-readiness.mjs b/scripts/lib/github-cli-readiness.mjs new file mode 100644 index 000000000..20c7525d6 --- /dev/null +++ b/scripts/lib/github-cli-readiness.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; + +export function getGithubCliReadiness({ + cwd, + connectivityAction = "dispatching the workflow from here.", +} = {}) { + const result = spawnSync("gh", ["auth", "status", "-h", "github.com"], { + cwd, + encoding: "utf8", + }); + const combinedOutput = `${result.stdout || ""}\n${result.stderr || ""}`.toLowerCase(); + + if (result.error && result.error.code === "ENOENT") { + return { + ok: false, + blockerCount: 1, + blockers: [ + "GitHub CLI (`gh`) is not installed or not available on PATH from this workspace. Install it or dispatch the workflow from another machine.", + ], + }; + } + + if (result.status !== 0) { + if (combinedOutput.includes("error connecting to github.com") + || combinedOutput.includes("check your internet connection") + || combinedOutput.includes("could not resolve host") + || combinedOutput.includes("dial tcp") + || combinedOutput.includes("i/o timeout") + || combinedOutput.includes("timeout")) { + return { + ok: false, + blockerCount: 1, + blockers: [ + `GitHub CLI could not reach github.com from this machine. Check network access and GitHub availability before ${connectivityAction}`, + ], + }; + } + + if (combinedOutput.includes("the token in") && combinedOutput.includes("is invalid")) { + return { + ok: false, + blockerCount: 1, + blockers: [ + "GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again.", + ], + }; + } + + return { + ok: false, + blockerCount: 1, + blockers: [ + "GitHub CLI auth check failed for an unknown reason. Re-run `gh auth status -h github.com` locally for details.", + ], + }; + } + + return { + ok: true, + blockerCount: 0, + blockers: [], + }; +} diff --git a/scripts/lib/progress-utils.mjs b/scripts/lib/progress-utils.mjs new file mode 100644 index 000000000..fc73ddc67 --- /dev/null +++ b/scripts/lib/progress-utils.mjs @@ -0,0 +1,43 @@ +export function formatDuration(ms) { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds}s`; + return `${minutes}m ${seconds}s`; +} + +export function createProgressLogger({ quiet = false } = {}) { + return function withProgress(label, fn, details = "") { + const startedAt = Date.now(); + if (!quiet) { + console.log(`\n[START] ${label}${details ? `: ${details}` : ""}`); + } + + try { + const result = fn(); + if (result && typeof result.then === "function") { + return result.then((value) => { + if (!quiet) { + console.log(`[DONE] ${label} (${formatDuration(Date.now() - startedAt)})`); + } + return value; + }).catch((error) => { + if (!quiet) { + console.error(`[FAIL] ${label} after ${formatDuration(Date.now() - startedAt)}`); + } + throw error; + }); + } + + if (!quiet) { + console.log(`[DONE] ${label} (${formatDuration(Date.now() - startedAt)})`); + } + return result; + } catch (error) { + if (!quiet) { + console.error(`[FAIL] ${label} after ${formatDuration(Date.now() - startedAt)}`); + } + throw error; + } + }; +} diff --git a/scripts/package-api-backend.mjs b/scripts/package-api-backend.mjs new file mode 100644 index 000000000..def65e956 --- /dev/null +++ b/scripts/package-api-backend.mjs @@ -0,0 +1,251 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function exitForCommandError(error) { + if (error && typeof error === "object") { + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, cwd, options = {}) { + const { quiet = false } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + try { + execFileSync(command, args, { cwd, stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit" }); + } catch (error) { + exitForCommandError(error); + } +} + +function resolveYarnCommand() { + try { + execFileSync("corepack", ["--version"], { stdio: "ignore" }); + return { command: "corepack", argsPrefix: ["yarn"] }; + } catch (_error) { + return { command: "yarn", argsPrefix: [] }; + } +} + +function requirePathExists(label, targetPath) { + if (!fs.existsSync(targetPath)) { + console.error(`${label} not found: ${targetPath}`); + process.exit(1); + } +} + +function requirePathReadable(label, targetPath) { + try { + fs.accessSync(targetPath, fs.constants.R_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`${label} is not readable: ${targetPath} (${message})`); + process.exit(1); + } +} + +function hasPath(targetPath) { + return fs.existsSync(targetPath); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function removeIfExists(filePath) { + if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true }); +} + +function parseBoolean(value, fallback) { + if (value === "") return fallback; + return value.toLowerCase() === "true"; +} + +function deriveArtifactKey(projectName, environment, fileName) { + return `${projectName}/${environment}/backend/${fileName}`; +} + +function relativeToManifest(manifestPath, targetPath) { + return path.relative(path.dirname(manifestPath), targetPath) || "."; +} + +function main() { + const apiRepoPath = path.resolve(rootDir, getArg("api-repo-path", "../Api")); + const outputDir = path.resolve(rootDir, getArg("output-dir", "infrastructure/artifacts/api")); + const packageMode = getArg("package-mode", "self-contained"); + const environment = getArg("environment", "prod"); + const projectName = getArg("project-name", "b1admin"); + const build = parseBoolean(getArg("build", "true"), true); + const buildLayer = parseBoolean(getArg("build-layer", packageMode === "layered" ? "true" : "false"), packageMode === "layered"); + const cleanOutput = parseBoolean(getArg("clean-output", "true"), true); + const backendZipName = getArg("backend-zip-name", `api-${environment}-${packageMode}.zip`); + const layerZipName = getArg("layer-zip-name", `api-${environment}-dependencies-layer.zip`); + const manifestName = getArg("manifest-name", `api-${environment}-${packageMode}.manifest.json`); + const migrationArtifactPathArg = getArg("migration-artifact-path"); + const buildCommand = getArg("build-command", "build:prod"); + const buildLayerCommand = getArg("build-layer-command", "build-layer"); + const outputMode = getArg("output", "text"); + const jsonOutput = outputMode === "json"; + const yarnCommand = resolveYarnCommand(); + const migrationArtifactPath = migrationArtifactPathArg ? path.resolve(rootDir, migrationArtifactPathArg) : ""; + + if (!["self-contained", "layered"].includes(packageMode)) { + console.error(`Unsupported package mode: ${packageMode}`); + process.exit(1); + } + + requirePathExists("API repo", apiRepoPath); + requirePathReadable("API repo", apiRepoPath); + requirePathExists("API package.json", path.join(apiRepoPath, "package.json")); + requirePathReadable("API package.json", path.join(apiRepoPath, "package.json")); + if (build && !hasPath(path.join(apiRepoPath, "node_modules"))) { + console.error(`API repo dependencies are not installed: ${path.join(apiRepoPath, "node_modules")}`); + console.error("Run 'corepack yarn install' in the Api repo before packaging."); + process.exit(1); + } + if (build) { + requirePathReadable("API node_modules", path.join(apiRepoPath, "node_modules")); + } + + if (build) { + run(yarnCommand.command, [...yarnCommand.argsPrefix, buildCommand], apiRepoPath, { quiet: jsonOutput }); + } + + if (packageMode === "layered" && buildLayer) { + run(yarnCommand.command, [...yarnCommand.argsPrefix, buildLayerCommand], apiRepoPath, { quiet: jsonOutput }); + } + + const requiredEntries = [ + "config", + "dist", + "lambda.js", + "package.json", + ]; + + if (packageMode === "self-contained") { + requiredEntries.push("node_modules"); + } else if (buildLayer) { + requiredEntries.push("layer"); + } + + requiredEntries.forEach((entry) => { + const entryPath = path.join(apiRepoPath, entry); + requirePathExists(`Required package entry "${entry}"`, entryPath); + requirePathReadable(`Required package entry "${entry}"`, entryPath); + }); + + if (migrationArtifactPath) { + requirePathExists("Migration artifact", migrationArtifactPath); + requirePathReadable("Migration artifact", migrationArtifactPath); + } + + fs.mkdirSync(outputDir, { recursive: true }); + + const backendZipPath = path.join(outputDir, backendZipName); + const layerZipPath = path.join(outputDir, layerZipName); + const manifestPath = path.join(outputDir, manifestName); + + if (cleanOutput) { + removeIfExists(backendZipPath); + removeIfExists(layerZipPath); + removeIfExists(manifestPath); + } + + ensureParentDir(backendZipPath); + ensureParentDir(manifestPath); + + const backendEntries = ["config", "dist", "lambda.js", "package.json"]; + if (packageMode === "self-contained") backendEntries.push("node_modules"); + + run("zip", ["-rq", backendZipPath, ...backendEntries], apiRepoPath, { quiet: jsonOutput }); + + let createdLayerZip = ""; + if (packageMode === "layered") { + requirePathExists("Layer directory", path.join(apiRepoPath, "layer")); + ensureParentDir(layerZipPath); + run("zip", ["-rq", layerZipPath, "layer"], apiRepoPath, { quiet: jsonOutput }); + createdLayerZip = layerZipPath; + } + + const relativeBackendZipPath = path.relative(rootDir, backendZipPath); + const relativeMigrationArtifactPath = migrationArtifactPath ? path.relative(rootDir, migrationArtifactPath) : ""; + const relativeLayerZipPath = createdLayerZip ? path.relative(rootDir, createdLayerZip) : ""; + const relativeManifestPath = path.relative(rootDir, manifestPath); + const recommendedBackendArtifactKey = deriveArtifactKey(projectName, environment, "api.zip"); + const recommendedMigrationArtifactKey = deriveArtifactKey(projectName, environment, "migrations.zip"); + + const result = { + apiRepoPath, + projectName, + packageMode, + environment, + build, + buildCommand, + buildLayer, + buildLayerCommand: packageMode === "layered" ? buildLayerCommand : "", + backendArtifactPath: relativeToManifest(manifestPath, backendZipPath), + migrationArtifactPath: migrationArtifactPath ? relativeToManifest(manifestPath, migrationArtifactPath) : "", + dependenciesLayerArtifactPath: createdLayerZip ? relativeToManifest(manifestPath, createdLayerZip) : "", + manifestPath: relativeManifestPath, + recommendedBackendArtifactKey, + recommendedMigrationArtifactKey, + recommendedNextSteps: { + uploadBackendArtifact: `yarn upload:backend-artifact -- --source-file=${relativeBackendZipPath} --artifact-key=${recommendedBackendArtifactKey}`, + uploadMigrationArtifact: migrationArtifactPath + ? `yarn upload:backend-artifact -- --source-file=${relativeMigrationArtifactPath} --artifact-key=${recommendedMigrationArtifactKey} --artifact-label="Migration artifact"` + : "", + publishDependenciesLayer: createdLayerZip + ? `yarn publish:lambda-layer -- --source-file=${relativeLayerZipPath} --layer-name=${projectName}-${environment}-dependencies` + : "", + deployBackend: `yarn deploy:backend -- --package-manifest-file=${relativeManifestPath}`, + deployAws: `yarn deploy:aws -- --package-manifest-file=${relativeManifestPath}`, + deployFullStack: `yarn deploy:full-stack -- --package-manifest-file=${relativeManifestPath}`, + deployMode: + packageMode === "self-contained" + ? "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + : "Upload the backend zip and dependencies layer zip separately. Then publish the layer and pass its ARN through DependenciesLayerArn.", + }, + includedBackendEntries: backendEntries, + }; + + fs.writeFileSync(manifestPath, `${JSON.stringify(result, null, 2)}\n`); + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nBackend packaging complete."); + console.log(`API repo: ${apiRepoPath}`); + console.log(`Mode: ${packageMode}`); + console.log(`Backend artifact: ${backendZipPath}`); + if (createdLayerZip) console.log(`Dependencies layer artifact: ${createdLayerZip}`); + console.log(`Manifest: ${manifestPath}`); +} + +main(); diff --git a/scripts/plan-environment-deploy.mjs b/scripts/plan-environment-deploy.mjs new file mode 100644 index 000000000..2eceaea47 --- /dev/null +++ b/scripts/plan-environment-deploy.mjs @@ -0,0 +1,1122 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { getGithubCliReadiness } from "./lib/github-cli-readiness.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function canReadPath(targetPath) { + try { + fs.accessSync(targetPath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function buildEnvCommand(envVars, scriptPath) { + const assignments = Object.entries(envVars) + .filter(([, value]) => value !== "") + .map(([key, value]) => `${key}=${shellQuote(value)}`); + return [...assignments, scriptPath].join(" "); +} + +function buildGhWorkflowCommand(inputs, extraInputs = {}) { + const args = Object.entries({ ...inputs, ...extraInputs }) + .map(([key, value]) => `-f ${key}=${shellQuote(value)}`); + return `gh workflow run deploy-aws-self-hosted.yml ${args.join(" ")}`; +} + +function buildGithubDispatchWrapperCommand(options, extraArgs = []) { + const args = [ + "yarn dispatch:github-aws-deploy --", + `--environment=${options.environment}`, + `--deployment-source=${options.deploymentSource}`, + `--region=${options.region}`, + ]; + + if (options.environmentDirArg) { + args.push(`--environment-dir=${options.environmentDirArg}`); + } + + if (options.githubAuthMode !== "oidc") { + args.push(`--github-auth-mode=${options.githubAuthMode}`); + } + + if (options.b1adminRepo !== "ChurchApps/B1Admin") { + args.push(`--b1admin-repo=${options.b1adminRepo}`); + } + if (options.b1adminRef !== "main") { + args.push(`--b1admin-ref=${options.b1adminRef}`); + } + + if (options.deploymentSource === "api-repo") { + if (options.apiRepo !== "ChurchApps/Api") { + args.push(`--api-repo=${options.apiRepo}`); + } + if (options.apiRef !== "main") { + args.push(`--api-ref=${options.apiRef}`); + } + } + + if (options.deploymentSource === "package-manifest" && options.packageManifestFile) { + args.push(`--package-manifest-file=${options.packageManifestFile}`); + } + + if (options.deploymentSource === "backend-artifact" && options.backendArtifactSourceFile) { + args.push(`--backend-artifact-source-file=${options.backendArtifactSourceFile}`); + if (options.migrationArtifactSourceFile) { + args.push(`--migration-artifact-source-file=${options.migrationArtifactSourceFile}`); + } + if (options.dependenciesLayerSourceFile) { + args.push(`--dependencies-layer-source-file=${options.dependenciesLayerSourceFile}`); + } + } + + if (options.syncAppConfigSecret) { + args.push("--sync-app-config-secret=true"); + } + + if (options.syncBootstrapAdminSecret) { + args.push("--sync-bootstrap-admin-secret=true"); + } + + if (options.runApiMigrations) { + args.push("--run-api-migrations=true"); + args.push(`--api-migration-action=${options.apiMigrationAction}`); + args.push(`--api-migration-module=${options.apiMigrationModule}`); + if (options.apiMigrationRunner && options.apiMigrationRunner !== "direct") { + args.push(`--api-migration-runner=${options.apiMigrationRunner}`); + } + } + + if (options.runBootstrapAdmin) { + args.push("--run-bootstrap-admin=true"); + } + + if (options.verifyHttpAfterDeploy) { + args.push("--verify-http-after-deploy=true"); + } + + args.push(...extraArgs); + return args.join(" "); +} + +function getLocalGithubDispatchReadiness() { + return getGithubCliReadiness({ + cwd: rootDir, + connectivityAction: "dispatching the workflow from here.", + }); +} + +function buildStarterPrepCommands(environment, accountId = "", environmentDirArg = "") { + const resolvedAccountId = accountId || ""; + const accountArg = resolvedAccountId ? ` --account-id=${resolvedAccountId}` : ""; + const environmentDirCliArg = environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""; + + return { + dryRun: `yarn prepare:environment-starter -- --environment=${environment}${environmentDirCliArg}${accountArg} --output=json`, + commands: `yarn prepare:environment-starter -- --environment=${environment}${environmentDirCliArg}${accountArg} --output=commands`, + markdown: `yarn prepare:environment-starter -- --environment=${environment}${environmentDirCliArg}${accountArg} --output=markdown`, + write: `yarn prepare:environment-starter -- --environment=${environment}${environmentDirCliArg}${accountArg} --write=true`, + }; +} + +function readStarterAudit(environment, environmentDirArg = "") { + const args = [ + path.join(rootDir, "scripts", "audit-environment-starter.mjs"), + `--environment=${environment}`, + "--output=json", + ]; + + if (environmentDirArg) { + args.push(`--environment-dir=${environmentDirArg}`); + } + + const result = spawnSync(process.execPath, args, { + cwd: rootDir, + encoding: "utf8", + }); + + let parsed = null; + try { + parsed = JSON.parse(result.stdout || "{}"); + } catch { + parsed = null; + } + + if (!parsed || typeof parsed !== "object") { + throw new Error(`Could not parse starter audit output.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + return { + status: result.status ?? 1, + parsed, + }; +} + +function buildInputBlockers(deploymentSource, options) { + const blockers = []; + + if (deploymentSource === "package-manifest" && !options.packageManifestFile) { + blockers.push("package-manifest-file is required when deployment-source=package-manifest."); + } + + if (deploymentSource === "backend-artifact" && !options.backendArtifactSourceFile) { + blockers.push("backend-artifact-source-file is required when deployment-source=backend-artifact."); + } + + return blockers; +} + +function buildLocalExecutionBlockers(deploymentSource, environmentDir, options) { + const blockers = []; + const secretPath = path.join(environmentDir, "app-config-secret.json"); + const bootstrapAdminSecretPath = path.join(environmentDir, "bootstrap-admin-secret.json"); + + if (deploymentSource === "api-repo") { + const resolvedApiRepoPath = path.resolve(rootDir, options.apiRepoPath); + const apiRepoPackagePath = path.join(resolvedApiRepoPath, "package.json"); + + if (!fs.existsSync(resolvedApiRepoPath)) { + blockers.push(`Local api-repo path does not exist yet: ${options.apiRepoPath}`); + } else if (!canReadPath(resolvedApiRepoPath)) { + blockers.push(`Local api-repo path is not readable from this workspace: ${options.apiRepoPath}`); + blockers.push("Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact."); + } else if (!fs.existsSync(apiRepoPackagePath)) { + blockers.push(`Local api-repo package.json does not exist yet: ${path.join(options.apiRepoPath, "package.json")}`); + } else if (!canReadPath(apiRepoPackagePath)) { + blockers.push(`Local api-repo package.json is not readable from this workspace: ${path.join(options.apiRepoPath, "package.json")}`); + blockers.push("Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact."); + } + } + + if (deploymentSource === "package-manifest" && options.packageManifestFile && !fs.existsSync(path.resolve(rootDir, options.packageManifestFile))) { + blockers.push(`Local package manifest file does not exist yet: ${options.packageManifestFile}`); + } + + if (deploymentSource === "backend-artifact" && options.backendArtifactSourceFile && !fs.existsSync(path.resolve(rootDir, options.backendArtifactSourceFile))) { + blockers.push(`Local backend artifact file does not exist yet: ${options.backendArtifactSourceFile}`); + } + + if (options.migrationArtifactSourceFile && !fs.existsSync(path.resolve(rootDir, options.migrationArtifactSourceFile))) { + blockers.push(`Local migration artifact file does not exist yet: ${options.migrationArtifactSourceFile}`); + } + + if (options.dependenciesLayerSourceFile && !fs.existsSync(path.resolve(rootDir, options.dependenciesLayerSourceFile))) { + blockers.push(`Local dependencies layer file does not exist yet: ${options.dependenciesLayerSourceFile}`); + } + + if (options.syncAppConfigSecret && !fs.existsSync(secretPath)) { + blockers.push(`sync-app-config-secret is enabled, but ${path.relative(rootDir, secretPath)} does not exist yet. Create it locally before using the local deploy command.`); + } + + if (options.syncBootstrapAdminSecret && !fs.existsSync(bootstrapAdminSecretPath)) { + blockers.push(`sync-bootstrap-admin-secret is enabled, but ${path.relative(rootDir, bootstrapAdminSecretPath)} does not exist yet. Create it locally before using the local deploy command.`); + } + + return blockers; +} + +function buildWarnings(options) { + const warnings = []; + + if (options.githubAuthMode === "static") { + warnings.push("Static AWS access keys are selected for GitHub Actions. Prefer OIDC role assumption when the target AWS account can support it."); + } + + if (!options.verifyHttpAfterDeploy) { + warnings.push("HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically."); + } + + if (!options.runApiMigrations) { + warnings.push("API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy."); + } else if (options.apiMigrationRunner === "data-api") { + warnings.push("API migrations will use the Aurora Data API runner. This avoids private DB network access requirements on the workflow host."); + } + + if (!options.runBootstrapAdmin) { + warnings.push("Initial admin bootstrap is disabled for this plan. A fresh environment will not have a sign-in until you run the bootstrap-admin helper separately."); + } + + return warnings; +} + +function appConfigSecretRequiresRunnerMaterialization(auditParsed) { + const appConfigAudit = (auditParsed?.files || []).find((fileAudit) => fileAudit.fileName === "app-config-secret.template.json"); + if (!appConfigAudit) return false; + + return Array.isArray(appConfigAudit.resolvedBySecretFile) && appConfigAudit.resolvedBySecretFile.length > 0; +} + +function buildGithubExecutionBlockers(auditParsed, options) { + const blockers = []; + const requiresRunnerSecret = appConfigSecretRequiresRunnerMaterialization(auditParsed); + + if (requiresRunnerSecret && !options.syncAppConfigSecret) { + blockers.push("GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead."); + } + + if (options.runBootstrapAdmin && !options.syncBootstrapAdminSecret) { + blockers.push("GitHub Actions cannot materialize the bootstrap admin secret yet. Enable sync-bootstrap-admin-secret and provide AWS_BOOTSTRAP_ADMIN_SECRET_JSON in the GitHub Environment, or use a local deploy path for the first-login bootstrap."); + } + + return blockers; +} + +function buildLocalEnv(options) { + const localEnv = { + AWS_REGION: options.region, + SYNC_APP_CONFIG_SECRET: options.syncAppConfigSecret ? "true" : "false", + RUN_API_MIGRATIONS: options.runApiMigrations ? "true" : "false", + RUN_BOOTSTRAP_ADMIN: options.runBootstrapAdmin ? "true" : "false", + VERIFY_HTTP_AFTER_DEPLOY: options.verifyHttpAfterDeploy ? "true" : "false", + }; + + if (options.environmentDirArg) { + localEnv.ENV_DIR = options.environmentDirArg; + } + + if (options.runApiMigrations) { + localEnv.API_MIGRATION_ACTION = options.apiMigrationAction; + localEnv.API_MIGRATION_MODULE = options.apiMigrationModule; + localEnv.API_MIGRATION_RUNNER = options.apiMigrationRunner || "direct"; + } + + if (options.runBootstrapAdmin && options.syncBootstrapAdminSecret) { + localEnv.BOOTSTRAP_ADMIN_SECRET_FILE = path.join(options.environmentDir, "bootstrap-admin-secret.json"); + } + + if (options.deploymentSource === "api-repo") { + localEnv.API_REPO_PATH = options.apiRepoPath; + } else if (options.deploymentSource === "package-manifest") { + localEnv.PACKAGE_MANIFEST_FILE = options.packageManifestFile; + } else if (options.deploymentSource === "backend-artifact") { + localEnv.BACKEND_ARTIFACT_SOURCE_FILE = options.backendArtifactSourceFile; + if (options.migrationArtifactSourceFile) { + localEnv.MIGRATION_ARTIFACT_SOURCE_FILE = options.migrationArtifactSourceFile; + } + if (options.dependenciesLayerSourceFile) { + localEnv.DEPENDENCIES_LAYER_SOURCE_FILE = options.dependenciesLayerSourceFile; + } + } + + return localEnv; +} + +function buildWorkflowInputs(options) { + const workflowInputs = { + environment: options.environment, + aws_region: options.region, + deployment_source: options.deploymentSource, + b1admin_repo: options.b1adminRepo, + b1admin_ref: options.b1adminRef, + api_repo: options.deploymentSource === "api-repo" ? options.apiRepo : "", + api_ref: options.deploymentSource === "api-repo" ? options.apiRef : "", + package_manifest_file: options.deploymentSource === "package-manifest" ? options.packageManifestFile : "", + backend_artifact_source_file: options.deploymentSource === "backend-artifact" ? options.backendArtifactSourceFile : "", + migration_artifact_source_file: options.deploymentSource === "backend-artifact" ? options.migrationArtifactSourceFile : "", + dependencies_layer_source_file: options.deploymentSource === "backend-artifact" ? options.dependenciesLayerSourceFile : "", + sync_app_config_secret: options.syncAppConfigSecret ? "true" : "false", + run_api_migrations: options.runApiMigrations ? "true" : "false", + sync_bootstrap_admin_secret: options.syncBootstrapAdminSecret ? "true" : "false", + run_bootstrap_admin: options.runBootstrapAdmin ? "true" : "false", + api_migration_action: options.apiMigrationAction, + api_migration_module: options.apiMigrationModule, + verify_http_after_deploy: options.verifyHttpAfterDeploy ? "true" : "false", + }; + + return workflowInputs; +} + +function buildRequiredGithubSecrets(options) { + const required = options.githubAuthMode === "static" + ? ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] + : ["AWS_ROLE_TO_ASSUME"]; + + if (options.syncAppConfigSecret) { + required.push("AWS_APP_CONFIG_SECRET_JSON"); + } + + if (options.syncBootstrapAdminSecret) { + required.push("AWS_BOOTSTRAP_ADMIN_SECRET_JSON"); + } + + return required; +} + +function buildOptionalGithubSecrets(options) { + const optional = []; + + if (options.deploymentSource === "api-repo") { + optional.push("B1ADMIN_REPO_CHECKOUT_TOKEN"); + optional.push("API_REPO_CHECKOUT_TOKEN"); + } + + return optional; +} + +function buildRecommendedExecutionPath(blockers, localExecution, githubActionsExecution) { + if (blockers.length > 0) { + return { + path: "none", + reason: "Shared starter or input blockers still exist, so neither the local path nor the GitHub Actions path is ready yet.", + }; + } + + if (localExecution.ok && githubActionsExecution.ok) { + return { + path: "either", + reason: "Both the local path and the GitHub Actions path are ready based on the current checked-in environment files and provided inputs.", + }; + } + + if (githubActionsExecution.ok && !localExecution.ok) { + return { + path: "github-actions", + reason: "GitHub Actions is ready, but the local path still has execution-specific blockers in the current workspace.", + }; + } + + if (localExecution.ok && !githubActionsExecution.ok) { + return { + path: "local", + reason: "The local path is ready, but GitHub Actions still has execution-specific blockers.", + }; + } + + return { + path: "none", + reason: "Execution-specific blockers still need to be cleared before running a deploy.", + }; +} + +function buildRecommendedCommands(result) { + const { recommendedExecution, commands, starterPrepCommands } = result; + + if (recommendedExecution.path === "none" && result.starterSummary?.blockerCount > 0) { + return { + primary: starterPrepCommands.dryRun, + alternates: [ + commands.audit, + starterPrepCommands.commands, + starterPrepCommands.write, + commands.localPreview, + commands.githubActionsWrapperPreview, + commands.githubActionsPreview, + commands.local, + commands.githubActionsWrapper, + commands.githubActions, + ], + }; + } + + if (recommendedExecution.path === "none") { + const alternates = []; + + if (!result.localGithubDispatch?.ok) { + if (result.githubSecretSyncCommand) alternates.push(result.githubSecretSyncCommand); + alternates.push(commands.githubActionsWrapperPreview); + alternates.push(commands.githubActionsPreview); + alternates.push(commands.githubActionsWrapper); + alternates.push(commands.githubActions); + if (result.localFallbackCommands) { + alternates.push(result.localFallbackCommands.packageManifestPlan); + alternates.push(result.localFallbackCommands.backendArtifactPlan); + } + alternates.push(commands.localPreview); + alternates.push(commands.local); + + return { + primary: "gh auth login -h github.com", + alternates: [...new Set(alternates)], + }; + } + + const githubBlockers = result.githubActionsExecution?.blockers || []; + const requiresGithubSecretMaterialization = githubBlockers.some((entry) => ( + String(entry).includes("Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON") + )); + + if (requiresGithubSecretMaterialization && result.githubSecretSyncCommand) { + alternates.push(commands.githubActionsWrapper, commands.githubActions, commands.local); + alternates.push(commands.githubActionsWrapperPreview, commands.githubActionsPreview, commands.localPreview); + if (result.localFallbackCommands) { + alternates.push(result.localFallbackCommands.packageManifestPlan); + alternates.push(result.localFallbackCommands.backendArtifactPlan); + } + + return { + primary: result.githubSecretSyncCommand, + alternates: [...new Set(alternates)], + }; + } + } + + if (recommendedExecution.path === "github-actions") { + if (!result.localGithubDispatch?.ok) { + return { + primary: "gh auth login -h github.com", + alternates: [commands.githubActionsWrapperPreview, commands.githubActionsPreview, commands.githubActionsWrapper, commands.githubActions, commands.localPreview, commands.local], + }; + } + + return { + primary: commands.githubActionsWrapper, + alternates: [commands.githubActionsWrapperPreview, commands.githubActionsPreview, commands.localPreview, commands.local, commands.githubActions], + }; + } + + if (recommendedExecution.path === "local" || recommendedExecution.path === "either") { + return { + primary: commands.local, + alternates: [commands.localPreview, commands.githubActionsWrapperPreview, commands.githubActionsPreview, commands.githubActionsWrapper, commands.githubActions], + }; + } + + return { + primary: commands.audit, + alternates: [commands.localPreview, commands.githubActionsWrapperPreview, commands.githubActionsPreview, commands.local, commands.githubActionsWrapper, commands.githubActions], + }; +} + +function dedupeStrings(values) { + const seen = new Set(); + return values.filter((value) => { + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +function normalizeNextSteps(values) { + const deduped = dedupeStrings(values); + const firstStep = String(deduped[0] || ""); + + if (firstStep.includes("Run `gh auth login -h github.com` first")) { + return deduped.filter((value, index) => ( + index === 0 || !String(value).includes("fix the local `gh` login first with `gh auth login -h github.com`") + )); + } + + return deduped; +} + +function buildPostDeployCommands(result) { + const outputsDir = `deployment/${result.environment}`; + const backendOutputsFile = `${outputsDir}/backend-outputs.json`; + const frontendOutputsFile = `${outputsDir}/frontend-outputs.json`; + const summaryFile = `${outputsDir}/deployment-summary.json`; + const ensureOutputsDir = `mkdir -p ${outputsDir}`; + const verifyBase = [ + "yarn verify:split-stack --", + `--region=${result.region}`, + `--backend-stack-name=${result.stackNames.backend}`, + `--frontend-stack-name=${result.stackNames.frontend}`, + ]; + + const commands = { + verify: verifyBase.join(" "), + verifyWithHttp: [...verifyBase, "--check-http=true"].join(" "), + saveOutputsWithHelper: `yarn save:split-stack-outputs -- --environment=${result.environment} --region=${result.region}`, + showSavedSummary: `yarn show:deployment-summary -- --summary-file=${summaryFile} --output=markdown`, + ensureOutputsDir, + saveBackendOutputs: [ + ensureOutputsDir, + "&& aws cloudformation describe-stacks", + `--stack-name ${result.stackNames.backend}`, + `--region ${result.region}`, + "--output json", + `> ${backendOutputsFile}`, + ].join(" "), + saveFrontendOutputs: [ + ensureOutputsDir, + "&& aws cloudformation describe-stacks", + `--stack-name ${result.stackNames.frontend}`, + `--region ${result.region}`, + "--output json", + `> ${frontendOutputsFile}`, + ].join(" "), + verifyFromSavedOutputs: [ + "yarn verify:split-stack --", + `--region=${result.region}`, + `--backend-outputs-file=${backendOutputsFile}`, + `--frontend-outputs-file=${frontendOutputsFile}`, + ].join(" "), + verifyFromSavedOutputsWithHttp: [ + "yarn verify:split-stack --", + `--region=${result.region}`, + `--backend-outputs-file=${backendOutputsFile}`, + `--frontend-outputs-file=${frontendOutputsFile}`, + "--check-http=true", + ].join(" "), + publishFromSavedOutputs: [ + "yarn deploy:aws --", + `--region=${result.region}`, + `--project-name=${result.projectName}`, + `--environment=${result.environment}`, + `--frontend-parameters-file=${result.environmentDir}/frontend-parameters.json`, + `--backend-parameters-file=${result.environmentDir}/backend-parameters.json`, + `--frontend-outputs-file=${frontendOutputsFile}`, + `--backend-outputs-file=${backendOutputsFile}`, + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + ].join(" "), + publishFrontendAssetsFromSavedOutputs: [ + "yarn publish:frontend-assets --", + `--frontend-outputs-file=${frontendOutputsFile}`, + `--backend-outputs-file=${backendOutputsFile}`, + ].join(" "), + checklist: "Open infrastructure/environments/first-rollout-checklist.md and work through the post-deploy checks.", + }; + + return commands; +} + +function buildPreflightCommands(result, options) { + const environmentDirCliArg = options.environmentDirArg ? ` --environment-dir=${options.environmentDirArg}` : ""; + const commands = { + auditStarter: `yarn audit:environment-starter -- --environment=${result.environment}${environmentDirCliArg} --only-blockers=true --output=markdown`, + }; + + if (options.deploymentSource === "api-repo") { + commands.auditApiRepoContract = `yarn audit:api-repo-contract -- --api-repo-path=${options.apiRepoPath} --output=markdown`; + } + + return commands; +} + +function buildLocalFallbackCommands(result, options) { + if (options.deploymentSource !== "api-repo") return null; + + const localBlockers = result.localExecution?.blockers || []; + const hasUnreadableApiRepoBlocker = localBlockers.some((entry) => String(entry).includes("Local api-repo path is not readable from this workspace:")) + || localBlockers.some((entry) => String(entry).includes("Local api-repo package.json is not readable from this workspace:")); + + if (!hasUnreadableApiRepoBlocker) return null; + + return { + packageManifestPlan: `yarn plan:environment-deploy -- --environment=${result.environment} --deployment-source=package-manifest --package-manifest-file= --region=${result.region} --output=markdown`, + packageManifestLocal: buildEnvCommand({ + AWS_REGION: result.region, + PACKAGE_MANIFEST_FILE: "", + SYNC_APP_CONFIG_SECRET: options.syncAppConfigSecret ? "true" : "false", + RUN_API_MIGRATIONS: options.runApiMigrations ? "true" : "false", + ...(options.runApiMigrations ? { + API_MIGRATION_ACTION: options.apiMigrationAction, + API_MIGRATION_MODULE: options.apiMigrationModule, + API_MIGRATION_RUNNER: options.apiMigrationRunner || "direct", + } : {}), + VERIFY_HTTP_AFTER_DEPLOY: options.verifyHttpAfterDeploy ? "true" : "false", + }, `./infrastructure/environments/${result.environment}/deploy-split-stack.sh`), + backendArtifactPlan: `yarn plan:environment-deploy -- --environment=${result.environment} --deployment-source=backend-artifact --backend-artifact-source-file= --region=${result.region} --output=markdown`, + backendArtifactLocal: buildEnvCommand({ + AWS_REGION: result.region, + BACKEND_ARTIFACT_SOURCE_FILE: "", + SYNC_APP_CONFIG_SECRET: options.syncAppConfigSecret ? "true" : "false", + RUN_API_MIGRATIONS: options.runApiMigrations ? "true" : "false", + ...(options.runApiMigrations ? { + API_MIGRATION_ACTION: options.apiMigrationAction, + API_MIGRATION_MODULE: options.apiMigrationModule, + API_MIGRATION_RUNNER: options.apiMigrationRunner || "direct", + } : {}), + VERIFY_HTTP_AFTER_DEPLOY: options.verifyHttpAfterDeploy ? "true" : "false", + }, `./infrastructure/environments/${result.environment}/deploy-split-stack.sh`), + }; +} + +function buildGithubPostDeploy(result) { + return { + artifactName: `aws-${result.environment}-deployment-evidence`, + artifactPath: `deployment/${result.environment}/`, + failureArtifactName: `aws-${result.environment}-preflight-plan`, + failureArtifactPath: `deployment/${result.environment}/preflight-plan.md`, + summaryIncludes: [ + "preflight deploy plan", + "resolved stack names", + "API base URL", + "frontend app URL", + "saved-output follow-up commands", + ], + note: "After a successful GitHub Actions run, review the job summary for the preflight plan plus resolved values and download the deployment-evidence artifact if you need the saved output files outside the runner. If the deploy step fails earlier, GitHub still uploads the preflight-plan artifact so the computed blocker list is recoverable.", + }; +} + +function buildGithubSecretSyncCommand(result) { + if (!result.appConfigSecretFilePresent) return ""; + + return [ + "yarn sync:github-app-config-secret --", + `--environment=${result.environment}`, + ...(result.environmentDirArg ? [`--environment-dir=${result.environmentDirArg}`] : []), + `--secret-file=${result.environmentDir}/app-config-secret.json`, + ].join(" "); +} + +function renderMarkdown(result) { + const lines = [ + `# Environment Deploy Plan: ${result.environment}`, + "", + `- Status: ${result.ok ? "ready" : "blocked"}`, + `- Environment dir: \`${result.environmentDir}\``, + `- Deployment source: \`${result.deploymentSource}\``, + `- GitHub auth mode: \`${result.githubAuthMode}\``, + `- Starter blockers: ${result.starterSummary.blockerCount}`, + `- Input blockers: ${result.inputBlockers.length}`, + `- Local execution blockers: ${result.localExecution.blockerCount}`, + `- GitHub execution blockers: ${result.githubActionsExecution.blockerCount}`, + `- Local GitHub dispatch blockers: ${result.localGithubDispatch.blockerCount}`, + `- Warnings: ${result.warnings.length}`, + `- App config secret file present: ${result.appConfigSecretFilePresent ? "yes" : "no"}`, + ]; + + lines.push(`- Recommended path: \`${result.recommendedExecution.path}\``); + lines.push(`- Recommendation reason: ${result.recommendedExecution.reason}`); + + lines.push("", "## Stack Names", ""); + lines.push(`- Bootstrap: \`${result.stackNames.bootstrap}\``); + lines.push(`- Backend: \`${result.stackNames.backend}\``); + lines.push(`- Frontend: \`${result.stackNames.frontend}\``); + + if (result.blockers.length > 0) { + lines.push("", "## Blockers", ""); + result.blockers.forEach((blocker) => { + lines.push(`- ${blocker.summary}`); + if (blocker.file) { + lines.push(` File: \`${blocker.file}\``); + } + }); + } + + if (result.warnings.length > 0) { + lines.push("", "## Warnings", ""); + result.warnings.forEach((warning) => lines.push(`- ${warning}`)); + } + + lines.push("", "## Execution Readiness", ""); + lines.push(`- Local: ${result.localExecution.ok ? "ready" : "blocked"}`); + result.localExecution.blockers.forEach((blocker) => lines.push(` Local blocker: ${blocker}`)); + lines.push(`- GitHub Actions: ${result.githubActionsExecution.ok ? "ready" : "blocked"}`); + result.githubActionsExecution.blockers.forEach((blocker) => lines.push(` GitHub blocker: ${blocker}`)); + lines.push(`- Local GitHub dispatch: ${result.localGithubDispatch.ok ? "ready" : "blocked"}`); + result.localGithubDispatch.blockers.forEach((blocker) => lines.push(` Local GitHub dispatch blocker: ${blocker}`)); + + lines.push("", "## Recommendation", ""); + lines.push(`- Path: \`${result.recommendedExecution.path}\``); + lines.push(`- Why: ${result.recommendedExecution.reason}`); + lines.push(`- Primary command: \`${result.recommendedCommands.primary}\``); + result.recommendedCommands.alternates.forEach((command) => lines.push(`- Alternate command: \`${command}\``)); + + if (result.localFallbackCommands) { + lines.push("", "## Local Fallbacks", ""); + lines.push(`- Manifest plan: \`${result.localFallbackCommands.packageManifestPlan}\``); + lines.push(`- Manifest local run: \`${result.localFallbackCommands.packageManifestLocal}\``); + lines.push(`- Backend-artifact plan: \`${result.localFallbackCommands.backendArtifactPlan}\``); + lines.push(`- Backend-artifact local run: \`${result.localFallbackCommands.backendArtifactLocal}\``); + } + + if (result.starterSummary.blockerCount > 0) { + lines.push("", "## Starter Prep", ""); + lines.push(`- Dry-run prep: \`${result.starterPrepCommands.dryRun}\``); + lines.push(`- Prep follow-up commands: \`${result.starterPrepCommands.commands}\``); + lines.push(`- Markdown prep runbook: \`${result.starterPrepCommands.markdown}\``); + lines.push(`- Apply starter changes: \`${result.starterPrepCommands.write}\``); + } + + lines.push("", "## Preflight", ""); + lines.push(`- Starter audit: \`${result.preflightCommands.auditStarter}\``); + if (result.preflightCommands.auditApiRepoContract) { + lines.push(`- Api repo contract audit: \`${result.preflightCommands.auditApiRepoContract}\``); + } + + lines.push("", "## Post-Deploy Follow-Up", ""); + lines.push(`- Verify stacks and outputs: \`${result.postDeployCommands.verify}\``); + lines.push(`- Verify with HTTP probe: \`${result.postDeployCommands.verifyWithHttp}\``); + lines.push(`- Save outputs with helper: \`${result.postDeployCommands.saveOutputsWithHelper}\``); + lines.push(`- Show saved deployment summary: \`${result.postDeployCommands.showSavedSummary}\``); + lines.push(`- Create outputs folder: \`${result.postDeployCommands.ensureOutputsDir}\``); + lines.push(`- Save backend outputs: \`${result.postDeployCommands.saveBackendOutputs}\``); + lines.push(`- Save frontend outputs: \`${result.postDeployCommands.saveFrontendOutputs}\``); + lines.push(`- Verify from saved outputs: \`${result.postDeployCommands.verifyFromSavedOutputs}\``); + lines.push(`- Verify from saved outputs with HTTP probe: \`${result.postDeployCommands.verifyFromSavedOutputsWithHttp}\``); + lines.push(`- Publish later from saved outputs: \`${result.postDeployCommands.publishFromSavedOutputs}\``); + lines.push(`- Publish assets directly from saved outputs: \`${result.postDeployCommands.publishFrontendAssetsFromSavedOutputs}\``); + lines.push(`- Checklist: ${result.postDeployCommands.checklist}`); + + lines.push("", "## Local Run", ""); + lines.push(`- Audit: \`${result.commands.audit}\``); + lines.push(`- Preview-only: \`${result.commands.localPreview}\``); + lines.push(`- Deploy: \`${result.commands.local}\``); + + lines.push("", "## GitHub Actions Run", ""); + lines.push(`- Environment: \`${result.workflowEnvironmentName}\``); + lines.push(`- Local dispatch from this machine: ${result.localGithubDispatch.ok ? "ready" : "blocked"}`); + result.localGithubDispatch.blockers.forEach((blocker) => lines.push(`- Local dispatch blocker: ${blocker}`)); + lines.push(`- Dispatch preview wrapper: \`${result.commands.githubActionsWrapperPreview}\``); + lines.push(`- Raw preview dispatch: \`${result.commands.githubActionsPreview}\``); + lines.push(`- Dispatch wrapper: \`${result.commands.githubActionsWrapper}\``); + lines.push(`- Raw dispatch: \`${result.commands.githubActions}\``); + if (result.githubSecretSyncCommand) { + lines.push(`- Sync app-config JSON into GitHub secret: \`${result.githubSecretSyncCommand}\``); + } + lines.push(`- Post-deploy artifact: \`${result.githubPostDeploy.artifactName}\``); + lines.push(`- Artifact path inside run: \`${result.githubPostDeploy.artifactPath}\``); + lines.push(`- Failure artifact: \`${result.githubPostDeploy.failureArtifactName}\``); + lines.push(`- Failure artifact path inside run: \`${result.githubPostDeploy.failureArtifactPath}\``); + lines.push(`- Job summary includes: ${result.githubPostDeploy.summaryIncludes.join(", ")}`); + lines.push(`- Post-deploy note: ${result.githubPostDeploy.note}`); + + if (result.requiredGithubSecrets.length > 0) { + lines.push("", "## GitHub Secrets", ""); + result.requiredGithubSecrets.forEach((secretName) => lines.push(`- Required: \`${secretName}\``)); + result.optionalGithubSecrets.forEach((secretName) => lines.push(`- Optional: \`${secretName}\``)); + if (result.githubSecretSyncCommand) { + lines.push(`- Sync helper: \`${result.githubSecretSyncCommand}\``); + } + } + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const commandsOutput = outputMode === "commands" || outputMode === "shell"; + const deploymentSource = getArg("deployment-source", "api-repo"); + const githubAuthMode = getArg("github-auth-mode", "oidc"); + const region = getArg("region", "us-east-1"); + const apiRepoPath = getArg("api-repo-path", "../Api"); + const b1adminRepo = getArg("b1admin-repo", "ChurchApps/B1Admin"); + const b1adminRef = getArg("b1admin-ref", "main"); + const apiRepo = getArg("api-repo", "ChurchApps/Api"); + const apiRef = getArg("api-ref", "main"); + const packageManifestFile = getArg("package-manifest-file"); + const backendArtifactSourceFile = getArg("backend-artifact-source-file"); + const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); + const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); + const syncAppConfigSecret = getArg("sync-app-config-secret", "false").toLowerCase() === "true"; + const syncBootstrapAdminSecret = getArg("sync-bootstrap-admin-secret", "false").toLowerCase() === "true"; + const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; + const runBootstrapAdmin = getArg("run-bootstrap-admin", "false").toLowerCase() === "true"; + const apiMigrationAction = getArg("api-migration-action", "up"); + const apiMigrationModule = getArg("api-migration-module", "all"); + const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const verifyHttpAfterDeploy = getArg("verify-http-after-deploy", "false").toLowerCase() === "true"; + const accountId = getArg("account-id"); + + if (!fs.existsSync(environmentDir)) { + const message = `Unknown environment starter "${environment}".`; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, environment, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const bootstrap = readJson(path.join(environmentDir, "bootstrap-parameters.json")); + const projectName = bootstrap.ProjectName || "b1admin"; + const environmentName = bootstrap.EnvironmentName || environment; + const stackPrefix = `${projectName}-${environmentName}`; + const secretPath = path.join(environmentDir, "app-config-secret.json"); + const audit = readStarterAudit(environment, environmentDirArg); + + const options = { + environment, + region, + deploymentSource, + environmentDir, + environmentDirArg, + githubAuthMode, + apiRepoPath, + b1adminRepo, + b1adminRef, + apiRepo, + apiRef, + packageManifestFile, + backendArtifactSourceFile, + migrationArtifactSourceFile, + dependenciesLayerSourceFile, + syncAppConfigSecret, + syncBootstrapAdminSecret, + runApiMigrations, + runBootstrapAdmin, + apiMigrationAction, + apiMigrationModule, + apiMigrationRunner, + verifyHttpAfterDeploy, + accountId, + }; + + const inputBlockers = buildInputBlockers(deploymentSource, options); + const localExecutionBlockers = buildLocalExecutionBlockers(deploymentSource, environmentDir, options); + const githubExecutionBlockers = buildGithubExecutionBlockers(audit.parsed, options); + const warnings = buildWarnings(options); + const localEnv = buildLocalEnv(options); + const workflowInputs = buildWorkflowInputs(options); + const requiredGithubSecrets = buildRequiredGithubSecrets(options); + const optionalGithubSecrets = buildOptionalGithubSecrets(options); + const localGithubDispatch = getLocalGithubDispatchReadiness(); + + const starterBlockers = (audit.parsed.files || []).flatMap((fileAudit) => { + const keys = [ + ...(fileAudit.placeholders || []).map((entry) => entry.key), + ...(fileAudit.unsafeDefaults || []).map((entry) => entry.key), + ...(fileAudit.requiredBlankValues || []).map((entry) => entry.key), + ]; + + if (keys.length === 0) return []; + + return [{ + type: "starter-file", + file: fileAudit.relativePath, + keys, + summary: `Resolve ${keys.length} blocker value${keys.length === 1 ? "" : "s"} in ${fileAudit.relativePath}: ${keys.join(", ")}`, + }]; + }); + + const blockers = [ + ...starterBlockers, + ...inputBlockers.map((message) => ({ + type: "input", + file: "", + keys: [], + summary: message, + })), + ]; + const sharedExecutionBlockers = blockers.map((entry) => entry.summary); + + const result = { + ok: blockers.length === 0, + region, + environment, + projectName, + environmentDir: path.relative(rootDir, environmentDir), + environmentDirArg, + deploymentSource, + githubAuthMode, + workflowEnvironmentName: `aws-${environment}`, + appConfigSecretFilePresent: fs.existsSync(secretPath), + stackNames: { + bootstrap: `${stackPrefix}-bootstrap`, + backend: `${stackPrefix}-backend`, + frontend: `${stackPrefix}-frontend`, + }, + starterSummary: audit.parsed.blockerSummary || { + placeholderCount: 0, + requiredBlankCount: 0, + blockerCount: 0, + }, + inputBlockers, + warnings, + blockers, + localExecution: { + ok: blockers.length === 0 && localExecutionBlockers.length === 0, + blockerCount: sharedExecutionBlockers.length + localExecutionBlockers.length, + blockers: [...sharedExecutionBlockers, ...localExecutionBlockers], + }, + githubActionsExecution: { + ok: blockers.length === 0 && githubExecutionBlockers.length === 0, + blockerCount: sharedExecutionBlockers.length + githubExecutionBlockers.length, + blockers: [...sharedExecutionBlockers, ...githubExecutionBlockers], + }, + localGithubDispatch, + localEnv, + workflowInputs, + requiredGithubSecrets, + optionalGithubSecrets, + commands: { + audit: `yarn audit:environment-starter -- --environment=${environment}${environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""} --only-blockers=true --output=markdown`, + local: buildEnvCommand(localEnv, `./infrastructure/environments/${environment}/deploy-split-stack.sh`), + localPreview: buildEnvCommand({ ...localEnv, PREVIEW_ONLY: "true" }, `./infrastructure/environments/${environment}/deploy-split-stack.sh`), + githubActionsWrapper: buildGithubDispatchWrapperCommand(options), + githubActionsWrapperPreview: buildGithubDispatchWrapperCommand(options, ["--preview-only=true"]), + githubActions: buildGhWorkflowCommand(workflowInputs), + githubActionsPreview: buildGhWorkflowCommand(workflowInputs, { preview_only: "true" }), + }, + starterPrepCommands: buildStarterPrepCommands(environment, accountId, environmentDirArg), + nextSteps: [ + blockers.length > 0 + ? "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment." + : localExecutionBlockers.length > 0 + ? "Starter files are ready, but fix the local execution blockers before relying on the local deploy command." + : "Run either the local deploy script or the GitHub Actions workflow with the command below.", + "Save the backend and frontend outputs after staging so prod can reuse the proven values.", + syncAppConfigSecret + ? "Make sure the same app-config-secret JSON is available locally or in the GitHub Environment secret before deploy." + : appConfigSecretRequiresRunnerMaterialization(audit.parsed) + ? "If you want the GitHub Actions path, enable sync-app-config-secret and set AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment so the runner can recreate app-config-secret.json." + : "Decide whether app-config-secret should be synced on the first live run or introduced later.", + ], + }; + result.recommendedExecution = buildRecommendedExecutionPath(blockers, result.localExecution, result.githubActionsExecution); + result.preflightCommands = buildPreflightCommands(result, options); + result.postDeployCommands = buildPostDeployCommands(result); + result.githubPostDeploy = buildGithubPostDeploy(result); + result.githubSecretSyncCommand = buildGithubSecretSyncCommand(result); + result.localFallbackCommands = buildLocalFallbackCommands(result, options); + if (result.localFallbackCommands) { + result.nextSteps.splice(1, 0, + "If the local Api repo is unreadable here, switch the local run to package-manifest or backend-artifact mode with the fallback commands below.", + ); + } + if (appConfigSecretRequiresRunnerMaterialization(audit.parsed) && result.githubSecretSyncCommand) { + result.nextSteps.splice(2, 0, + `If you want the GitHub Actions path, run \`${result.githubSecretSyncCommand}\` to populate \`AWS_APP_CONFIG_SECRET_JSON\` from this checkout.`, + ); + } + if (result.githubActionsExecution.ok && !result.localGithubDispatch.ok) { + result.nextSteps.splice(1, 0, + "If you plan to dispatch the GitHub Actions workflow from this machine, fix the local `gh` login first with `gh auth login -h github.com`.", + ); + } + result.recommendedCommands = buildRecommendedCommands(result); + if (blockers.length === 0 && result.recommendedCommands.primary === "gh auth login -h github.com") { + result.nextSteps[0] = "Run `gh auth login -h github.com` first so this machine can dispatch the GitHub Actions workflow before you rely on the GitHub path."; + } else if ( + blockers.length === 0 + && result.recommendedExecution.path === "none" + && appConfigSecretRequiresRunnerMaterialization(audit.parsed) + && result.githubSecretSyncCommand + ) { + result.nextSteps[0] = `Run \`${result.githubSecretSyncCommand}\` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper.`; + } + result.nextSteps = normalizeNextSteps(result.nextSteps); + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (markdownOutput) { + process.stdout.write(renderMarkdown(result)); + } else if (commandsOutput) { + process.stdout.write(`${result.recommendedCommands.primary}\n`); + result.recommendedCommands.alternates.forEach((command) => process.stdout.write(`${command}\n`)); + if (result.localFallbackCommands) { + process.stdout.write(`${result.localFallbackCommands.packageManifestPlan}\n`); + process.stdout.write(`${result.localFallbackCommands.packageManifestLocal}\n`); + process.stdout.write(`${result.localFallbackCommands.backendArtifactPlan}\n`); + process.stdout.write(`${result.localFallbackCommands.backendArtifactLocal}\n`); + } + if (result.starterSummary.blockerCount > 0) { + process.stdout.write(`${result.starterPrepCommands.markdown}\n`); + } + process.stdout.write(`${result.preflightCommands.auditStarter}\n`); + if (result.preflightCommands.auditApiRepoContract) { + process.stdout.write(`${result.preflightCommands.auditApiRepoContract}\n`); + } + if (result.githubSecretSyncCommand) { + process.stdout.write(`${result.githubSecretSyncCommand}\n`); + } + process.stdout.write(`${result.commands.localPreview}\n`); + process.stdout.write(`${result.commands.githubActionsWrapperPreview}\n`); + process.stdout.write(`${result.commands.githubActionsPreview}\n`); + process.stdout.write(`${result.commands.githubActions}\n`); + process.stdout.write(`${result.postDeployCommands.verify}\n`); + process.stdout.write(`${result.postDeployCommands.verifyWithHttp}\n`); + process.stdout.write(`${result.postDeployCommands.saveOutputsWithHelper}\n`); + process.stdout.write(`${result.postDeployCommands.showSavedSummary}\n`); + process.stdout.write(`${result.postDeployCommands.ensureOutputsDir}\n`); + process.stdout.write(`${result.postDeployCommands.saveBackendOutputs}\n`); + process.stdout.write(`${result.postDeployCommands.saveFrontendOutputs}\n`); + process.stdout.write(`${result.postDeployCommands.verifyFromSavedOutputs}\n`); + process.stdout.write(`${result.postDeployCommands.verifyFromSavedOutputsWithHttp}\n`); + process.stdout.write(`${result.postDeployCommands.publishFromSavedOutputs}\n`); + process.stdout.write(`${result.postDeployCommands.publishFrontendAssetsFromSavedOutputs}\n`); + } else { + console.log(`Planned environment deploy: ${environment}`); + console.log(`Status: ${result.ok ? "ready" : "blocked"}`); + console.log(`Deployment source: ${deploymentSource}`); + console.log(`GitHub auth mode: ${githubAuthMode}`); + console.log(`Starter blockers: ${result.starterSummary.blockerCount}`); + console.log(`Input blockers: ${inputBlockers.length}`); + console.log(`Local execution blockers: ${result.localExecution.blockerCount}`); + console.log(`GitHub execution blockers: ${result.githubActionsExecution.blockerCount}`); + console.log(`Local GitHub dispatch blockers: ${result.localGithubDispatch.blockerCount}`); + console.log(`Recommended path: ${result.recommendedExecution.path}`); + console.log(`Recommendation reason: ${result.recommendedExecution.reason}`); + console.log(`Primary command: ${result.recommendedCommands.primary}`); + if (result.localFallbackCommands) { + console.log(`Manifest fallback plan: ${result.localFallbackCommands.packageManifestPlan}`); + console.log(`Manifest fallback local run: ${result.localFallbackCommands.packageManifestLocal}`); + console.log(`Backend-artifact fallback plan: ${result.localFallbackCommands.backendArtifactPlan}`); + console.log(`Backend-artifact fallback local run: ${result.localFallbackCommands.backendArtifactLocal}`); + } + if (result.starterSummary.blockerCount > 0) { + console.log(`Starter prep dry run: ${result.starterPrepCommands.dryRun}`); + console.log(`Starter prep apply: ${result.starterPrepCommands.write}`); + } + console.log(`Starter audit: ${result.preflightCommands.auditStarter}`); + if (result.preflightCommands.auditApiRepoContract) { + console.log(`Api repo contract audit: ${result.preflightCommands.auditApiRepoContract}`); + } + console.log(`Local GitHub dispatch: ${result.localGithubDispatch.ok ? "ready" : "blocked"}`); + console.log(`Post-deploy verify: ${result.postDeployCommands.verify}`); + console.log(`GitHub post-deploy artifact: ${result.githubPostDeploy.artifactName}`); + console.log(`GitHub failure artifact: ${result.githubPostDeploy.failureArtifactName}`); + console.log(`Save outputs with helper: ${result.postDeployCommands.saveOutputsWithHelper}`); + console.log(`Show saved deployment summary: ${result.postDeployCommands.showSavedSummary}`); + console.log(`Create outputs folder: ${result.postDeployCommands.ensureOutputsDir}`); + console.log(`Save backend outputs: ${result.postDeployCommands.saveBackendOutputs}`); + console.log(`Save frontend outputs: ${result.postDeployCommands.saveFrontendOutputs}`); + console.log(`Verify from saved outputs: ${result.postDeployCommands.verifyFromSavedOutputs}`); + console.log(`Publish later from saved outputs: ${result.postDeployCommands.publishFromSavedOutputs}`); + if (warnings.length > 0) { + console.log("Warnings:"); + warnings.forEach((warning) => console.log(`- ${warning}`)); + } + console.log("Commands:"); + console.log(`- Audit: ${result.commands.audit}`); + console.log(`- Local preview-only: ${result.commands.localPreview}`); + console.log(`- Local: ${result.commands.local}`); + console.log(`- GitHub Actions preview wrapper: ${result.commands.githubActionsWrapperPreview}`); + console.log(`- GitHub Actions preview raw: ${result.commands.githubActionsPreview}`); + console.log(`- GitHub Actions wrapper: ${result.commands.githubActionsWrapper}`); + console.log(`- GitHub Actions raw: ${result.commands.githubActions}`); + } + + if (!result.ok) { + process.exit(1); + } +} + +main(); diff --git a/scripts/prepare-environment-starter.mjs b/scripts/prepare-environment-starter.mjs new file mode 100644 index 000000000..25edc9aae --- /dev/null +++ b/scripts/prepare-environment-starter.mjs @@ -0,0 +1,418 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { isKnownStarterHostname } from "./lib/environment-setup-metadata.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function randomSecret(bytes = 32) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function replaceIfStarterDefault(currentValue, nextValue, predicate) { + if (!nextValue || typeof currentValue !== "string") return currentValue; + return predicate(currentValue) ? nextValue : currentValue; +} + +function isGeneratedBucketStarterDefault(value) { + return typeof value === "string" && ( + value.includes("replace-me") + || /-\d{12}$/.test(value) + ); +} + +function deriveEnvironmentHost(service, environment, rootDomain) { + if (!rootDomain) return ""; + const trimmed = rootDomain.replace(/^\.+|\.+$/g, ""); + const suffix = environment === "prod" ? "" : `-${environment}`; + return `${service}${suffix}.${trimmed}`; +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function buildRecommendedCommands(environment, accountId, writeChanges, environmentDirArg = "") { + const accountArg = accountId ? ` --account-id=${accountId}` : ""; + const environmentDirCliArg = environmentDirArg ? ` --environment-dir=${environmentDirArg}` : ""; + const environmentDirPath = environmentDirArg || `infrastructure/environments/${environment}`; + return [ + `yarn prepare:environment-starter -- --environment=${environment}${environmentDirCliArg}${accountArg} --write=true`, + `yarn audit:environment-starter -- --environment=${environment}${environmentDirCliArg} --only-blockers=true`, + `yarn validate:aws-deploy -- --mode=bootstrap --region=us-east-1 --stack-name=b1admin-${environment}-bootstrap --parameters-file=${environmentDirPath}/bootstrap-parameters.json`, + `yarn validate:aws-deploy -- --mode=split-stack --region=us-east-1 --backend-parameters-file=${environmentDirPath}/backend-parameters.json --frontend-parameters-file=${environmentDirPath}/frontend-parameters.json`, + `ENV_DIR=${environmentDirPath} ./infrastructure/environments/${environment}/deploy-split-stack.sh`, + ]; +} + +function renderMarkdown(result) { + const lines = [ + `# Prepare Environment Starter: ${result.environment}`, + "", + `- Status: ${result.ok ? "ok" : "error"}`, + `- Write mode: ${result.write ? "enabled" : "dry-run"}`, + `- Account ID: ${result.accountId || ""}`, + `- Generated secrets: ${result.generatedSecrets ? "yes" : "no"}`, + `- Existing app-config-secret.json: ${result.usedExistingSecretFile ? "yes" : "no"}`, + ]; + + if (result.changes.length > 0) { + lines.push("", "## Proposed Changes", ""); + result.changes.forEach((change) => { + lines.push(`- \`${change.file}\` :: \`${change.key}\``); + lines.push(` Current: \`${change.currentValue}\``); + lines.push(` Next: \`${change.nextValue}\``); + }); + } else { + lines.push("", "## Proposed Changes", "", "- No changes proposed."); + } + + if (result.nextSteps.length > 0) { + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((line) => lines.push(`- ${line}`)); + } + + if (result.recommendedCommands.length > 0) { + lines.push("", "## Recommended Commands", ""); + result.recommendedCommands.forEach((line) => lines.push(`- \`${line}\``)); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const projectName = getArg("project-name", "b1admin"); + const accountId = getArg("account-id"); + const writeChanges = getArg("write", "false").toLowerCase() === "true"; + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const commandsOutput = outputMode === "commands" || outputMode === "shell"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const generateSecrets = getArg("generate-secrets", "true").toLowerCase() === "true"; + const force = getArg("force", "false").toLowerCase() === "true"; + const rootDomain = getArg("root-domain"); + const websiteBaseUrl = getArg("website-base-url"); + const contentRootUrl = getArg("content-root-url"); + const adminRootUrl = getArg("admin-root-url"); + const corsOrigin = getArg("cors-origin"); + const frontendDomain = getArg("frontend-domain"); + const frontendCertificateArn = getArg("frontend-certificate-arn"); + const frontendHostedZoneId = getArg("frontend-hosted-zone-id"); + const apiDomain = getArg("api-domain"); + const apiCertificateArn = getArg("api-certificate-arn"); + const apiHostedZoneId = getArg("api-hosted-zone-id"); + const storeApiUrl = getArg("store-api-url"); + const transferUrl = getArg("transfer-url"); + const supportEmail = getArg("support-email"); + const supportPhone = getArg("support-phone"); + const supportSiteUrl = getArg("support-site-url"); + const mobileAppUrl = getArg("mobile-app-url"); + const domainCnameTarget = getArg("domain-cname-target"); + const domainATarget = getArg("domain-a-target"); + const defaultStockPhoto = getArg("default-stock-photo"); + const googleAnalyticsTag = getArg("google-analytics-tag"); + const writeSecretFile = getArg("write-secret-file", "true").toLowerCase() === "true"; + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + if (!fs.existsSync(environmentDir)) { + const message = `Unknown environment starter "${environment}".`; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, environment, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const bootstrapPath = path.join(environmentDir, "bootstrap-parameters.json"); + const backendPath = path.join(environmentDir, "backend-parameters.json"); + const frontendPath = path.join(environmentDir, "frontend-parameters.json"); + const templateSecretPath = path.join(environmentDir, "app-config-secret.template.json"); + const secretPath = path.join(environmentDir, "app-config-secret.json"); + + const bootstrap = readJson(bootstrapPath); + const backend = readJson(backendPath); + const frontend = readJson(frontendPath); + const templateSecret = readJson(templateSecretPath); + const existingSecret = fs.existsSync(secretPath) ? readJson(secretPath) : null; + const derivedWebsiteBaseUrl = rootDomain ? `https://{subdomain}.${rootDomain.replace(/^\.+|\.+$/g, "")}` : ""; + const derivedAdminRootUrl = rootDomain ? `https://${deriveEnvironmentHost("admin", environment, rootDomain)}` : ""; + const derivedContentRootUrl = rootDomain ? `https://${deriveEnvironmentHost("content", environment, rootDomain)}` : ""; + const derivedStoreApiUrl = rootDomain ? `https://${deriveEnvironmentHost("store", environment, rootDomain)}` : ""; + const derivedTransferUrl = rootDomain ? `https://${deriveEnvironmentHost("transfer", environment, rootDomain)}` : ""; + const derivedSupportEmail = rootDomain ? `support@${rootDomain.replace(/^\.+|\.+$/g, "")}` : ""; + const derivedSupportSiteUrl = rootDomain ? `https://${deriveEnvironmentHost("support", environment, rootDomain)}` : ""; + const resolvedSupportEmail = supportEmail || derivedSupportEmail; + + const proposedTemplateBucketName = accountId ? `${projectName}-${environment}-templates-${accountId}` : ""; + const proposedArtifactBucketName = accountId ? `${projectName}-${environment}-artifacts-${accountId}` : ""; + const proposedBootstrap = { + ...bootstrap, + TemplateBucketName: accountId ? replaceIfStarterDefault(bootstrap.TemplateBucketName, proposedTemplateBucketName, isGeneratedBucketStarterDefault) : bootstrap.TemplateBucketName, + ArtifactBucketName: accountId ? replaceIfStarterDefault(bootstrap.ArtifactBucketName, proposedArtifactBucketName, isGeneratedBucketStarterDefault) : bootstrap.ArtifactBucketName, + }; + const proposedBackend = { + ...backend, + LambdaCodeS3Bucket: accountId ? replaceIfStarterDefault(backend.LambdaCodeS3Bucket, proposedArtifactBucketName, isGeneratedBucketStarterDefault) : backend.LambdaCodeS3Bucket, + WebsiteBaseUrl: replaceIfStarterDefault(backend.WebsiteBaseUrl, websiteBaseUrl || derivedWebsiteBaseUrl, isKnownStarterHostname), + ContentRootUrl: replaceIfStarterDefault(backend.ContentRootUrl, contentRootUrl || derivedContentRootUrl, isKnownStarterHostname), + B1AdminRootUrl: replaceIfStarterDefault(backend.B1AdminRootUrl, adminRootUrl || (frontendDomain ? `https://${frontendDomain}` : "") || derivedAdminRootUrl, isKnownStarterHostname), + CorsOrigin: replaceIfStarterDefault(backend.CorsOrigin, corsOrigin || adminRootUrl || (frontendDomain ? `https://${frontendDomain}` : "") || derivedAdminRootUrl, isKnownStarterHostname), + StoreApiUrl: replaceIfStarterDefault(backend.StoreApiUrl, storeApiUrl || derivedStoreApiUrl, isKnownStarterHostname), + TransferUrl: replaceIfStarterDefault(backend.TransferUrl, transferUrl || derivedTransferUrl, isKnownStarterHostname), + SupportEmail: replaceIfStarterDefault(backend.SupportEmail, resolvedSupportEmail, (value) => value === "support@example.com" || value === "support@b1.church"), + SupportPhone: replaceIfStarterDefault(backend.SupportPhone, supportPhone, (value) => value === "555-555-5555"), + SupportSiteUrl: replaceIfStarterDefault(backend.SupportSiteUrl, supportSiteUrl || derivedSupportSiteUrl, isKnownStarterHostname), + MobileAppUrl: mobileAppUrl || backend.MobileAppUrl, + DomainCnameTarget: domainCnameTarget || backend.DomainCnameTarget, + DomainATarget: domainATarget || backend.DomainATarget, + DefaultStockPhoto: defaultStockPhoto || backend.DefaultStockPhoto, + GoogleAnalyticsTag: googleAnalyticsTag || backend.GoogleAnalyticsTag, + ApiCustomDomainName: apiDomain || backend.ApiCustomDomainName, + ApiCertificateArn: apiCertificateArn || backend.ApiCertificateArn, + ApiHostedZoneId: apiHostedZoneId || backend.ApiHostedZoneId, + }; + const proposedFrontend = { + ...frontend, + AlternateDomainName: frontendDomain || frontend.AlternateDomainName, + AcmCertificateArn: frontendCertificateArn || frontend.AcmCertificateArn, + HostedZoneId: frontendHostedZoneId || frontend.HostedZoneId, + }; + + const secretSource = existingSecret || templateSecret; + const proposedTemplateSecret = { + ...templateSecret, + webPushSubject: replaceIfStarterDefault( + templateSecret.webPushSubject, + resolvedSupportEmail ? `mailto:${resolvedSupportEmail}` : "", + (value) => value === "mailto:support@example.com" || value === "mailto:support@b1.church", + ), + }; + const proposedSecret = { + ...secretSource, + jwtSecret: generateSecrets && (force || !existingSecret) ? randomSecret(32) : secretSource.jwtSecret, + encryptionKey: generateSecrets && (force || !existingSecret) ? randomSecret(32) : secretSource.encryptionKey, + webPushSubject: replaceIfStarterDefault( + secretSource.webPushSubject, + resolvedSupportEmail ? `mailto:${resolvedSupportEmail}` : "", + (value) => value === "mailto:support@example.com" || value === "mailto:support@b1.church", + ), + }; + + const changes = []; + const secretChanges = []; + + if (bootstrap.TemplateBucketName !== proposedBootstrap.TemplateBucketName) { + changes.push({ + file: path.relative(rootDir, bootstrapPath), + key: "TemplateBucketName", + currentValue: bootstrap.TemplateBucketName, + nextValue: proposedBootstrap.TemplateBucketName, + }); + } + if (bootstrap.ArtifactBucketName !== proposedBootstrap.ArtifactBucketName) { + changes.push({ + file: path.relative(rootDir, bootstrapPath), + key: "ArtifactBucketName", + currentValue: bootstrap.ArtifactBucketName, + nextValue: proposedBootstrap.ArtifactBucketName, + }); + } + if (backend.LambdaCodeS3Bucket !== proposedBackend.LambdaCodeS3Bucket) { + changes.push({ + file: path.relative(rootDir, backendPath), + key: "LambdaCodeS3Bucket", + currentValue: backend.LambdaCodeS3Bucket, + nextValue: proposedBackend.LambdaCodeS3Bucket, + }); + } + for (const key of [ + "WebsiteBaseUrl", + "ContentRootUrl", + "B1AdminRootUrl", + "CorsOrigin", + "StoreApiUrl", + "TransferUrl", + "SupportEmail", + "SupportPhone", + "SupportSiteUrl", + "MobileAppUrl", + "DomainCnameTarget", + "DomainATarget", + "DefaultStockPhoto", + "GoogleAnalyticsTag", + ]) { + if (backend[key] !== proposedBackend[key]) { + changes.push({ + file: path.relative(rootDir, backendPath), + key, + currentValue: backend[key], + nextValue: proposedBackend[key], + }); + } + } + for (const key of ["ApiCustomDomainName", "ApiCertificateArn", "ApiHostedZoneId"]) { + if (backend[key] !== proposedBackend[key]) { + changes.push({ + file: path.relative(rootDir, backendPath), + key, + currentValue: backend[key], + nextValue: proposedBackend[key], + }); + } + } + for (const key of ["AlternateDomainName", "AcmCertificateArn", "HostedZoneId"]) { + if (frontend[key] !== proposedFrontend[key]) { + changes.push({ + file: path.relative(rootDir, frontendPath), + key, + currentValue: frontend[key], + nextValue: proposedFrontend[key], + }); + } + } + if (!existingSecret) { + secretChanges.push({ + file: path.relative(rootDir, secretPath), + key: "app-config-secret.json", + currentValue: "", + nextValue: "will be created from template", + }); + } + if (secretSource.jwtSecret !== proposedSecret.jwtSecret) { + secretChanges.push({ + file: path.relative(rootDir, secretPath), + key: "jwtSecret", + currentValue: existingSecret ? "" : templateSecret.jwtSecret, + nextValue: "", + }); + } + if (secretSource.encryptionKey !== proposedSecret.encryptionKey) { + secretChanges.push({ + file: path.relative(rootDir, secretPath), + key: "encryptionKey", + currentValue: existingSecret ? "" : templateSecret.encryptionKey, + nextValue: "", + }); + } + if (secretSource.webPushSubject !== proposedSecret.webPushSubject) { + secretChanges.push({ + file: path.relative(rootDir, secretPath), + key: "webPushSubject", + currentValue: secretSource.webPushSubject, + nextValue: proposedSecret.webPushSubject, + }); + } + + if (!existingSecret && !writeSecretFile && templateSecret.webPushSubject !== proposedTemplateSecret.webPushSubject) { + changes.push({ + file: path.relative(rootDir, templateSecretPath), + key: "webPushSubject", + currentValue: templateSecret.webPushSubject, + nextValue: proposedTemplateSecret.webPushSubject, + }); + } + + if (!writeChanges || writeSecretFile) { + changes.push(...secretChanges); + } + + if (writeChanges) { + writeJson(bootstrapPath, proposedBootstrap); + writeJson(backendPath, proposedBackend); + writeJson(frontendPath, proposedFrontend); + if (writeSecretFile) { + writeJson(secretPath, proposedSecret); + } else if (!existingSecret && templateSecret.webPushSubject !== proposedTemplateSecret.webPushSubject) { + writeJson(templateSecretPath, proposedTemplateSecret); + } + } + + const result = { + ok: true, + environment, + write: writeChanges, + writeSecretFile, + accountId, + generatedSecrets: generateSecrets, + usedExistingSecretFile: Boolean(existingSecret), + changes, + recommendedCommands: buildRecommendedCommands(environment, accountId, writeChanges, environmentDirArg), + nextSteps: [ + accountId + ? `Review the proposed bucket values written to ${path.relative(rootDir, bootstrapPath)} and ${path.relative(rootDir, backendPath)}.` + : "Re-run with --account-id= if you want bucket-name replacements generated automatically.", + rootDomain + ? `Review the domain-derived backend URLs in ${path.relative(rootDir, backendPath)} and override any that should not follow the standard ${environment === "prod" ? "prod" : `${environment}`} host pattern.` + : "If your backend URLs mostly follow a shared DNS pattern, re-run with --root-domain= to derive the common admin/content/store/transfer values automatically.", + frontendDomain || apiDomain + ? `Review the proposed frontend/API custom-domain values written to ${path.relative(rootDir, frontendPath)} and ${path.relative(rootDir, backendPath)} before enabling cert and DNS-backed deploys.` + : "If you want to prep optional custom-domain fields too, re-run with --frontend-domain/--frontend-certificate-arn/--frontend-hosted-zone-id and --api-domain/--api-certificate-arn/--api-hosted-zone-id.", + websiteBaseUrl || contentRootUrl || adminRootUrl || corsOrigin || storeApiUrl || transferUrl || supportEmail || supportPhone || supportSiteUrl || mobileAppUrl || domainCnameTarget || domainATarget || defaultStockPhoto || googleAnalyticsTag + ? `Review the proposed backend runtime URLs/contact values written to ${path.relative(rootDir, backendPath)}.` + : "If you already know the real staging/prod URLs, public app metadata, and support contact values, re-run with --admin-root-url, --cors-origin, --content-root-url, --store-api-url, --transfer-url, --support-email, --support-phone, --support-site-url, --mobile-app-url, --domain-cname-target, --domain-a-target, --default-stock-photo, and --google-analytics-tag to prep them now.", + writeChanges && writeSecretFile + ? `Review ${path.relative(rootDir, secretPath)} before syncing it to Secrets Manager or GitHub Actions secrets.` + : writeChanges + ? `Create ${path.relative(rootDir, secretPath)} separately when you are ready to materialize runtime secrets, or re-run with --write-secret-file=true.` + : `Re-run with --write=true to apply these starter-file changes and create ${path.relative(rootDir, secretPath)}.`, + ], + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (markdownOutput) { + process.stdout.write(renderMarkdown(result)); + } else if (commandsOutput) { + process.stdout.write(`${result.recommendedCommands.join("\n")}\n`); + } else { + console.log(`Prepared environment starter: ${environment}`); + console.log(`Write mode: ${writeChanges ? "enabled" : "dry-run"}`); + if (changes.length === 0) { + console.log("No changes proposed."); + } else { + console.log("Proposed changes:"); + changes.forEach((change) => { + console.log(`- ${change.file} :: ${change.key}`); + console.log(` current: ${change.currentValue}`); + console.log(` next: ${change.nextValue}`); + }); + } + console.log("\nNext steps:"); + result.nextSteps.forEach((line) => console.log(`- ${line}`)); + console.log("\nRecommended commands:"); + result.recommendedCommands.forEach((line) => console.log(`- ${line}`)); + } +} + +main(); diff --git a/scripts/publish-frontend-assets.mjs b/scripts/publish-frontend-assets.mjs new file mode 100644 index 000000000..04dd0c5bc --- /dev/null +++ b/scripts/publish-frontend-assets.mjs @@ -0,0 +1,302 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function readJsonFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return normalizeOutputs(JSON.parse(fs.readFileSync(resolved, "utf8"))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function getFrontendOutputs(region) { + const stackName = getArg("stack-name"); + const outputsFile = getArg("frontend-outputs-file"); + + if (stackName) return getStackOutputsSafe(stackName, region, "frontend stack"); + if (outputsFile) return readJsonFile(outputsFile, "frontend outputs file"); + + return {}; +} + +function getFrontendOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function getOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function compactObject(obj) { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== "")); +} + +function ensureFrontendPublishPrerequisites(skipBuild) { + const distDir = path.join(rootDir, "dist"); + const serviceWorkerPath = path.join(distDir, "sw.js"); + + if (skipBuild) { + if (!fs.existsSync(distDir)) { + fail(`Build output not found: ${distDir}`); + } + if (!fs.existsSync(serviceWorkerPath)) { + fail(`Expected service worker not found: ${serviceWorkerPath}`); + } + return; + } + + const nodeModulesPath = path.join(rootDir, "node_modules"); + const viteCliPath = path.join(nodeModulesPath, "vite", "dist", "node", "cli.js"); + if (!fs.existsSync(nodeModulesPath) || !fs.existsSync(viteCliPath)) { + fail(`Frontend dependencies are not installed: ${nodeModulesPath}`); + } +} + +function getBackendBuildEnv(region) { + const backendStackName = getArg("backend-stack-name"); + const backendOutputsFile = getArg("backend-outputs-file"); + + let outputs = {}; + if (backendStackName) outputs = getStackOutputsSafe(backendStackName, region, "backend stack"); + else if (backendOutputsFile) outputs = readJsonFile(backendOutputsFile, "backend outputs file"); + + return compactObject({ + REACT_APP_API_BASE: getOutputValue(outputs, ["ReactAppApiBase", "ApiBaseUrl", "ApiBase", "PublicApiBaseUrl"]), + REACT_APP_CONTENT_ROOT: getOutputValue(outputs, ["ReactAppContentRoot", "ContentRootUrl", "ContentRoot", "PublicContentRootUrl"]), + REACT_APP_B1_WEBSITE_URL: getOutputValue(outputs, ["ReactAppB1WebsiteUrl", "WebsiteBaseUrl", "WebsiteUrlPattern", "PublicWebsiteUrlPattern"]), + REACT_APP_LESSONS_API: getOutputValue(outputs, ["ReactAppLessonsApi", "LessonsApiUrl", "LessonsApi"]), + REACT_APP_GOOGLE_ANALYTICS: getOutputValue(outputs, ["ReactAppGoogleAnalytics", "GoogleAnalyticsTag"]), + REACT_APP_SENTRY_DSN: getOutputValue(outputs, ["ReactAppSentryDsn", "SentryDsn"]), + REACT_APP_TRANSFER_URL: getOutputValue(outputs, ["ReactAppTransferUrl", "TransferUrl"]), + REACT_APP_SUPPORT_EMAIL: getOutputValue(outputs, ["ReactAppSupportEmail", "SupportEmail"]), + REACT_APP_SUPPORT_PHONE: getOutputValue(outputs, ["ReactAppSupportPhone", "SupportPhone"]), + REACT_APP_SUPPORT_SITE_URL: getOutputValue(outputs, ["ReactAppSupportSiteUrl", "SupportSiteUrl"]), + REACT_APP_MOBILE_APP_URL: getOutputValue(outputs, ["ReactAppMobileAppUrl", "MobileAppUrl"]), + REACT_APP_DOMAIN_CNAME_TARGET: getOutputValue(outputs, ["ReactAppDomainCnameTarget", "DomainCnameTarget"]), + REACT_APP_DOMAIN_A_TARGET: getOutputValue(outputs, ["ReactAppDomainATarget", "DomainATarget"]), + REACT_APP_DEFAULT_STOCK_PHOTO: getOutputValue(outputs, ["ReactAppDefaultStockPhoto", "DefaultStockPhoto"]), + }); +} + +function main() { + const stackName = getArg("stack-name"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const environmentName = getArg("environment", process.env.REACT_APP_STAGE || "prod"); + const skipBuild = hasFlag("skip-build"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const backendBuildEnv = skipBuild ? {} : getBackendBuildEnv(region); + const frontendOutputs = getFrontendOutputs(region); + const bucket = getArg("bucket", getFrontendOutputValue(frontendOutputs, ["SiteBucketName", "FrontendBucketName"])); + const distributionId = getArg("distribution-id", getFrontendOutputValue(frontendOutputs, ["CloudFrontDistributionId", "FrontendDistributionId"])); + const appUrl = getArg("app-url", getFrontendOutputValue(frontendOutputs, ["AppUrl", "FrontendAppUrl"])); + + if (!stackName && !getArg("frontend-outputs-file") && (!bucket || !distributionId)) { + console.error("Provide --stack-name, --frontend-outputs-file, or both --bucket and --distribution-id."); + process.exit(1); + } + + ensureFrontendPublishPrerequisites(skipBuild); + + requireValue("SiteBucketName output", bucket); + requireValue("CloudFrontDistributionId output", distributionId); + + if (!skipBuild) { + if (!jsonOutput && Object.keys(backendBuildEnv).length > 0) { + console.log("\nUsing backend-provided frontend env:"); + Object.entries(backendBuildEnv).forEach(([key, value]) => console.log(`- ${key}=${value}`)); + } + + run("npm", ["run", "build"], { + env: { + ...process.env, + ...backendBuildEnv, + REACT_APP_STAGE: environmentName, + }, + shell: true, + quiet: jsonOutput, + }); + } + + const distDir = path.join(rootDir, "dist"); + const serviceWorkerPath = path.join(distDir, "sw.js"); + if (!fs.existsSync(distDir)) { + console.error(`Build output not found: ${distDir}`); + process.exit(1); + } + if (!fs.existsSync(serviceWorkerPath)) { + console.error(`Expected service worker not found: ${serviceWorkerPath}`); + process.exit(1); + } + + run("aws", [ + "s3", + "sync", + "dist/", + `s3://${bucket}`, + "--delete", + "--exclude", + "sw.js", + "--region", + region, + ], { quiet: jsonOutput }); + + run("aws", [ + "s3", + "cp", + "dist/sw.js", + `s3://${bucket}/sw.js`, + "--cache-control", + "no-cache", + "--region", + region, + ], { quiet: jsonOutput }); + + run("aws", [ + "cloudfront", + "create-invalidation", + "--distribution-id", + distributionId, + "--paths", + "/*", + ], { quiet: jsonOutput }); + + const result = { + stackName, + region, + environmentName, + bucket, + distributionId, + appUrl, + outputs: frontendOutputs, + backendBuildEnv, + skipBuild, + frontendPublished: true, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log("\nFrontend asset publish complete."); + console.log(`Bucket: ${bucket}`); + console.log(`Distribution: ${distributionId}`); + if (appUrl) console.log(`URL: ${appUrl}`); +} + +main(); diff --git a/scripts/publish-lambda-layer.mjs b/scripts/publish-lambda-layer.mjs new file mode 100644 index 000000000..624114925 --- /dev/null +++ b/scripts/publish-lambda-layer.mjs @@ -0,0 +1,117 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function parseCsv(value, fallback = []) { + const source = value || fallback.join(","); + return source + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function runAwsJson(args, failureLabel) { + try { + return JSON.parse(execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`${failureLabel}: ${message}`); + } +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const layerName = getArg("layer-name"); + const sourceFile = getArg("source-file"); + const description = getArg("description", "Published by B1Admin AWS deployment tooling"); + const licenseInfo = getArg("license-info"); + const compatibleRuntimes = parseCsv(getArg("compatible-runtimes", "nodejs22.x")); + const compatibleArchitectures = parseCsv(getArg("compatible-architectures", "arm64")); + const outputMode = getArg("output", "text"); + + requireValue("layer-name", layerName); + requireValue("source-file", sourceFile); + + const resolvedSource = path.resolve(rootDir, sourceFile); + if (!fs.existsSync(resolvedSource)) { + console.error(`Source file not found: ${resolvedSource}`); + process.exit(1); + } + if (path.extname(resolvedSource).toLowerCase() !== ".zip") { + fail(`Source file must be a .zip archive: ${resolvedSource}`); + } + + const args = [ + "lambda", + "publish-layer-version", + "--layer-name", + layerName, + "--zip-file", + `fileb://${resolvedSource}`, + "--description", + description, + "--region", + region, + "--output", + "json", + ]; + + if (compatibleRuntimes.length > 0) args.push("--compatible-runtimes", ...compatibleRuntimes); + if (compatibleArchitectures.length > 0) args.push("--compatible-architectures", ...compatibleArchitectures); + if (licenseInfo) args.push("--license-info", licenseInfo); + + const commandPreview = `aws ${args.join(" ")}`; + if (outputMode === "json") { + console.error(`\n> ${commandPreview}`); + } else { + console.log(`\n> ${commandPreview}`); + } + const response = runAwsJson(args, `Could not publish Lambda layer "${layerName}"`); + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + return; + } + + console.log("\nLayer publication complete."); + console.log(`Layer name: ${response.LayerArn?.split(":").slice(-1)[0] || layerName}`); + console.log(`Layer version ARN: ${response.LayerVersionArn}`); + console.log(`Version: ${response.Version}`); +} + +main(); diff --git a/scripts/reset-prod.mjs b/scripts/reset-prod.mjs new file mode 100644 index 000000000..603b8d0f3 --- /dev/null +++ b/scripts/reset-prod.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const resetStagingScript = path.join(scriptDir, "reset-staging.mjs"); + +const userArgs = process.argv.slice(2); + +function readEnvironmentArg(args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--environment") return args[index + 1] || ""; + if (arg.startsWith("--environment=")) return arg.split("=", 2)[1] || ""; + } + + return ""; +} + +const requestedEnvironment = readEnvironmentArg(userArgs); +if (requestedEnvironment && requestedEnvironment !== "prod") { + console.error(`reset:prod only supports --environment=prod, received: ${requestedEnvironment}`); + process.exit(1); +} + +const forwardedArgs = requestedEnvironment ? userArgs : ["--environment=prod", ...userArgs]; + +const result = spawnSync(process.execPath, [resetStagingScript, ...forwardedArgs], { + cwd: path.resolve(scriptDir, ".."), + stdio: "inherit", + env: process.env, +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/scripts/reset-staging.mjs b/scripts/reset-staging.mjs new file mode 100644 index 000000000..be0995abc --- /dev/null +++ b/scripts/reset-staging.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { getArg, getBooleanArg } from "./lib/arg-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function runAws(args, options = {}) { + const result = spawnSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + env: process.env, + maxBuffer: 1024 * 1024 * 64, + ...options, + }); + + if (result.status !== 0) { + throw new Error(`aws ${args.join(" ")} failed.\nSTDOUT:\n${result.stdout || ""}\nSTDERR:\n${result.stderr || result.error?.message || ""}`); + } + + return (result.stdout || "").trim(); +} + +function runAwsJson(args) { + const output = runAws(args); + return output ? JSON.parse(output) : {}; +} + +function stackExists(stackName, region) { + try { + runAws(["cloudformation", "describe-stacks", "--stack-name", stackName, "--region", region, "--output", "json"]); + return true; + } catch { + return false; + } +} + +function secretExists(secretId, region) { + try { + runAws(["secretsmanager", "describe-secret", "--secret-id", secretId, "--region", region, "--output", "json"]); + return true; + } catch { + return false; + } +} + +function uniq(values) { + return values.filter((value, index) => value && values.indexOf(value) === index); +} + +function getStackResource(stackName, logicalId, region) { + const output = runAwsJson([ + "cloudformation", "describe-stack-resources", + "--stack-name", stackName, + "--region", region, + "--output", "json", + ]); + + const resources = Array.isArray(output.StackResources) ? output.StackResources : []; + const match = resources.find((resource) => resource.LogicalResourceId === logicalId); + return match?.PhysicalResourceId || ""; +} + +function listBucketNames() { + const output = runAwsJson(["s3api", "list-buckets", "--output", "json"]); + return Array.isArray(output.Buckets) ? output.Buckets.map((bucket) => bucket.Name).filter(Boolean) : []; +} + +function findBucketsByPrefix(prefixes) { + const bucketNames = listBucketNames(); + return uniq(bucketNames.filter((name) => prefixes.some((prefix) => name.startsWith(prefix)))); +} + +function listBucketObjectVersions(bucketName, region) { + const args = [ + "s3api", + "list-object-versions", + "--bucket", bucketName, + "--region", region, + "--max-items", "500", + "--output", "json", + ]; + + return runAwsJson(args); +} + +function deleteBucketBatch(bucketName, region, objects) { + if (objects.length === 0) return; + + const payload = JSON.stringify({ + Objects: objects.map((entry) => ({ + Key: entry.Key, + VersionId: entry.VersionId, + })), + Quiet: true, + }); + + runAws([ + "s3api", + "delete-objects", + "--bucket", bucketName, + "--region", region, + "--delete", payload, + ]); +} + +function emptyBucket(bucketName, region) { + if (!bucketName) return; + if (!bucketExists(bucketName, region)) return; + + while (true) { + let page; + try { + page = listBucketObjectVersions(bucketName, region); + } catch (error) { + if (String(error.message || "").includes("NoSuchBucket")) return; + throw error; + } + const objects = [ + ...(page.Versions || []), + ...(page.DeleteMarkers || []), + ]; + + if (objects.length === 0) break; + + for (let index = 0; index < objects.length; index += 1000) { + deleteBucketBatch(bucketName, region, objects.slice(index, index + 1000)); + } + } + + try { + runAws(["s3", "rm", `s3://${bucketName}`, "--recursive", "--region", region]); + } catch { + // `s3 rm` is only a final sweep for any non-versioned remnants. + } +} + +function bucketExists(bucketName, region) { + try { + runAws(["s3api", "head-bucket", "--bucket", bucketName, "--region", region]); + return true; + } catch { + return false; + } +} + +function deleteBucket(bucketName, region) { + if (!bucketName) return; + if (!bucketExists(bucketName, region)) return; + runAws(["s3api", "delete-bucket", "--bucket", bucketName, "--region", region]); +} + +function listLogGroupsByPrefix(prefix, region) { + const names = []; + let nextToken = ""; + + while (true) { + const args = [ + "logs", + "describe-log-groups", + "--region", region, + "--log-group-name-prefix", prefix, + "--output", "json", + ]; + if (nextToken) args.push("--next-token", nextToken); + + const output = runAwsJson(args); + const groups = Array.isArray(output.logGroups) ? output.logGroups : []; + names.push(...groups.map((group) => group.logGroupName).filter(Boolean)); + + nextToken = output.nextToken || ""; + if (!nextToken) break; + } + + return uniq(names); +} + +function deleteLogGroup(logGroupName, region) { + if (!logGroupName) return; + try { + runAws(["logs", "delete-log-group", "--log-group-name", logGroupName, "--region", region]); + } catch (error) { + if (String(error.message || "").includes("ResourceNotFoundException")) return; + throw error; + } +} + +function deleteStack(stackName, region) { + runAws(["cloudformation", "delete-stack", "--stack-name", stackName, "--region", region]); + runAws(["cloudformation", "wait", "stack-delete-complete", "--stack-name", stackName, "--region", region], { stdio: "inherit" }); +} + +function deleteSecret(secretId, region, forceDelete) { + const args = ["secretsmanager", "delete-secret", "--secret-id", secretId, "--region", region]; + if (forceDelete) args.push("--force-delete-without-recovery"); + else args.push("--recovery-window-in-days", "7"); + runAws(args, { stdio: "inherit" }); +} + +function buildResetPlan(options) { + const { region, stackPrefix, appConfigSecretName, forceDeleteSecret, removeLocalEvidence } = options; + const frontendStack = `${stackPrefix}-frontend`; + const backendStack = `${stackPrefix}-backend`; + const bootstrapStack = `${stackPrefix}-bootstrap`; + const deploymentDir = path.join(rootDir, "deployment", options.environment); + const lambdaLogGroups = listLogGroupsByPrefix(`/aws/lambda/${stackPrefix}`, region); + + const frontendExists = stackExists(frontendStack, region); + const backendExists = stackExists(backendStack, region); + const bootstrapExists = stackExists(bootstrapStack, region); + + const frontendBucket = frontendExists ? getStackResource(frontendStack, "SiteBucket", region) : ""; + const backendBucket = backendExists ? getStackResource(backendStack, "ManagedAssetBucket", region) : ""; + const databaseMasterSecret = backendExists ? getStackResource(backendStack, "DatabaseMasterSecret", region) : ""; + const bootstrapArtifactBucket = bootstrapExists ? getStackResource(bootstrapStack, "ArtifactBucket", region) : ""; + const bootstrapTemplateBucket = bootstrapExists ? getStackResource(bootstrapStack, "TemplateBucket", region) : ""; + const frontendBuckets = uniq([ + frontendBucket, + ...findBucketsByPrefix([`${frontendStack.toLowerCase()}-sitebucket-`]), + ]); + const backendBuckets = uniq([ + backendBucket, + ...findBucketsByPrefix([`${backendStack.toLowerCase()}-managedassetbucket-`]), + ]); + const bootstrapArtifactBuckets = uniq([ + bootstrapArtifactBucket, + ...findBucketsByPrefix([`${stackPrefix.toLowerCase()}-artifacts-`]), + ]); + const bootstrapTemplateBuckets = uniq([ + bootstrapTemplateBucket, + ...findBucketsByPrefix([`${stackPrefix.toLowerCase()}-templates-`]), + ]); + + const actions = []; + + frontendBuckets.forEach((bucket) => actions.push(`Empty frontend site bucket ${bucket}`)); + if (frontendExists) actions.push(`Delete stack ${frontendStack}`); + backendBuckets.forEach((bucket) => actions.push(`Empty backend managed asset bucket ${bucket}`)); + if (backendExists) actions.push(`Delete stack ${backendStack}`); + lambdaLogGroups.forEach((logGroupName) => actions.push(`Delete Lambda log group ${logGroupName}`)); + if (databaseMasterSecret) { + actions.push(`Delete database master secret ${databaseMasterSecret}${forceDeleteSecret ? " immediately" : " with recovery window"}`); + } + if (secretExists(appConfigSecretName, region)) { + actions.push(`Delete app config secret ${appConfigSecretName}${forceDeleteSecret ? " immediately" : " with recovery window"}`); + } + bootstrapArtifactBuckets.forEach((bucket) => actions.push(`Empty bootstrap artifact bucket ${bucket}`)); + bootstrapTemplateBuckets.forEach((bucket) => actions.push(`Empty bootstrap template bucket ${bucket}`)); + if (bootstrapExists) actions.push(`Delete stack ${bootstrapStack}`); + if (removeLocalEvidence && fs.existsSync(deploymentDir)) { + actions.push(`Remove local deployment evidence in ${path.relative(rootDir, deploymentDir)}`); + } + + return { + frontendStack, + backendStack, + bootstrapStack, + frontendExists, + backendExists, + bootstrapExists, + frontendBuckets, + backendBuckets, + lambdaLogGroups, + databaseMasterSecret, + bootstrapArtifactBuckets, + bootstrapTemplateBuckets, + deploymentDir, + actions, + }; +} + +function main() { + const region = getArg("region", "us-east-1"); + const projectName = getArg("project-name", "b1admin"); + const environment = getArg("environment", "staging"); + const stackPrefix = `${projectName}-${environment}`; + const appConfigSecretName = `${projectName}/${environment}/app-config`; + const forceDeleteSecret = getBooleanArg("force-delete-secret", true); + const removeLocalEvidence = getBooleanArg("remove-local-evidence", true); + const dryRun = getBooleanArg("dry-run", false); + + const plan = buildResetPlan({ + region, + environment, + stackPrefix, + appConfigSecretName, + forceDeleteSecret, + removeLocalEvidence, + }); + + if (dryRun) { + console.log(JSON.stringify({ + ok: true, + region, + environment, + actions: plan.actions, + }, null, 2)); + return; + } + + for (const bucket of plan.frontendBuckets) emptyBucket(bucket, region); + if (plan.frontendExists) deleteStack(plan.frontendStack, region); + for (const bucket of plan.frontendBuckets) { + if (bucketExists(bucket, region)) deleteBucket(bucket, region); + } + + for (const bucket of plan.backendBuckets) emptyBucket(bucket, region); + if (plan.backendExists) deleteStack(plan.backendStack, region); + for (const bucket of plan.backendBuckets) { + if (bucketExists(bucket, region)) deleteBucket(bucket, region); + } + for (const logGroupName of plan.lambdaLogGroups) deleteLogGroup(logGroupName, region); + + if (plan.databaseMasterSecret && secretExists(plan.databaseMasterSecret, region)) { + deleteSecret(plan.databaseMasterSecret, region, forceDeleteSecret); + } + + if (secretExists(appConfigSecretName, region)) { + deleteSecret(appConfigSecretName, region, forceDeleteSecret); + } + + for (const bucket of plan.bootstrapArtifactBuckets) emptyBucket(bucket, region); + for (const bucket of plan.bootstrapTemplateBuckets) emptyBucket(bucket, region); + if (plan.bootstrapExists) deleteStack(plan.bootstrapStack, region); + for (const bucket of plan.bootstrapArtifactBuckets) { + if (bucketExists(bucket, region)) deleteBucket(bucket, region); + } + for (const bucket of plan.bootstrapTemplateBuckets) { + if (bucketExists(bucket, region)) deleteBucket(bucket, region); + } + + if (removeLocalEvidence && fs.existsSync(plan.deploymentDir)) { + fs.rmSync(plan.deploymentDir, { recursive: true, force: true }); + } + + console.log(`Reset complete for ${environment} in ${region}.`); +} + +main(); diff --git a/scripts/run-api-migrations-data-api.mjs b/scripts/run-api-migrations-data-api.mjs new file mode 100644 index 000000000..6f1c1b289 --- /dev/null +++ b/scripts/run-api-migrations-data-api.mjs @@ -0,0 +1,868 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as ts from "typescript"; +import { createMigrationDbContext, setDataApiDbFactory } from "./lib/api-migration-data-api-shim.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const dataApiShimUrl = pathToFileURL(path.join(scriptDir, "lib", "api-migration-data-api-shim.mjs")).href; + +const moduleOutputKeys = { + membership: "MembershipDatabaseName", + attendance: "AttendanceDatabaseName", + content: "ContentDatabaseName", + giving: "GivingDatabaseName", + messaging: "MessagingDatabaseName", + doing: "DoingDatabaseName", + reporting: "ReportingDatabaseName", +}; + +const preferredModuleOrder = Object.keys(moduleOutputKeys); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function parseBoolean(value, fallback = false) { + if (value === "") return fallback; + return String(value).toLowerCase() === "true"; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function loadJsonFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return JSON.parse(fs.readFileSync(resolved, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function runAwsJson(args, failureLabel) { + try { + return JSON.parse(execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : ""; + const message = error instanceof Error ? error.message : String(error); + fail(`${failureLabel}: ${stderr.trim() || message}`); + } +} + +function getStackOutputs(stackName, region) { + const response = runAwsJson([ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], `Could not read stack "${stackName}"`); + + return normalizeOutputs(response); +} + +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, "``")}\``; +} + +function escapeSqlString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''"); +} + +function sqlString(value) { + return `'${escapeSqlString(value)}'`; +} + +function decodeField(field) { + if (!field || typeof field !== "object") return null; + if (field.isNull) return null; + if ("stringValue" in field) return field.stringValue; + if ("longValue" in field) return field.longValue; + if ("doubleValue" in field) return field.doubleValue; + if ("booleanValue" in field) return field.booleanValue; + return null; +} + +function decodeRows(result) { + const columns = (result.columnMetadata || []).map((column) => column.name || ""); + const records = result.records || []; + return records.map((record) => { + const row = {}; + record.forEach((field, index) => { + row[columns[index] || `column${index + 1}`] = decodeField(field); + }); + return row; + }); +} + +function buildExecuteArgs({ region, resourceArn, secretArn, database, sql, transactionId, includeResultMetadata = false }) { + const args = [ + "rds-data", + "execute-statement", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--sql", + sql, + "--output", + "json", + ]; + + if (database) { + args.push("--database", database); + } + + if (transactionId) { + args.push("--transaction-id", transactionId); + } + + if (includeResultMetadata) { + args.push("--include-result-metadata"); + } + + return args; +} + +function beginTransaction({ region, resourceArn, secretArn, database }) { + const result = runAwsJson([ + "rds-data", + "begin-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--database", + database, + "--output", + "json", + ], `Could not start transaction for database "${database}"`); + + return result.transactionId; +} + +function commitTransaction({ region, resourceArn, secretArn, transactionId }) { + runAwsJson([ + "rds-data", + "commit-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--transaction-id", + transactionId, + "--output", + "json", + ], "Could not commit migration transaction"); +} + +function rollbackTransaction({ region, resourceArn, secretArn, transactionId }) { + try { + runAwsJson([ + "rds-data", + "rollback-transaction", + "--region", + region, + "--resource-arn", + resourceArn, + "--secret-arn", + secretArn, + "--transaction-id", + transactionId, + "--output", + "json", + ], "Could not roll back migration transaction"); + } catch (_error) { + // Best-effort rollback only. + } +} + +function loadMigrationFiles(moduleDir) { + return fs.readdirSync(moduleDir) + .filter((fileName) => /\.(ts|js|mjs|cjs)$/.test(fileName)) + .sort() + .map((fileName) => ({ + name: fileName.replace(/\.(ts|js|mjs|cjs)$/, ""), + fileName, + filePath: path.join(moduleDir, fileName), + })); +} + +function loadMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + const directories = fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const rank = new Map(preferredModuleOrder.map((name, index) => [name, index])); + return directories.sort((left, right) => { + const leftRank = rank.has(left) ? rank.get(left) : Number.MAX_SAFE_INTEGER; + const rightRank = rank.has(right) ? rank.get(right) : Number.MAX_SAFE_INTEGER; + if (leftRank !== rightRank) return leftRank - rightRank; + return left.localeCompare(right); + }); +} + +function replaceKyselyImport(source) { + return source + .replace(/from\s+["']kysely["']/g, `from "${dataApiShimUrl}"`) + .replace(/from\s+["'][^"']*kysely-config\.js["']/g, `from "${dataApiShimUrl}"`); +} + +function getFallbackCompilerOptions() { + return { + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2022, + experimentalDecorators: true, + emitDecoratorMetadata: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + resolveJsonModule: true, + allowJs: true, + jsx: ts.JsxEmit.Preserve, + }; +} + +function normalizeTranspileCompilerOptions(options = {}) { + return { + ...getFallbackCompilerOptions(), + ...options, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + sourceMap: false, + inlineSourceMap: false, + inlineSources: false, + declaration: false, + declarationMap: false, + emitDeclarationOnly: false, + noEmit: false, + noEmitOnError: false, + incremental: false, + composite: false, + tsBuildInfoFile: undefined, + outDir: undefined, + rootDir: undefined, + }; +} + +function loadApiCompilerOptions(apiRepoPath) { + const tsconfigPath = path.join(apiRepoPath, "tsconfig.json"); + if (!fs.existsSync(tsconfigPath)) { + return normalizeTranspileCompilerOptions(); + } + + try { + const tsconfig = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (tsconfig.error) { + return normalizeTranspileCompilerOptions(); + } + + const parsed = ts.parseJsonConfigFileContent( + tsconfig.config, + ts.sys, + apiRepoPath, + undefined, + tsconfigPath, + ); + + return normalizeTranspileCompilerOptions(parsed.options); + } catch (_error) { + return normalizeTranspileCompilerOptions(); + } +} + +function transpileText(source, fileName, compilerOptions) { + const primaryOptions = normalizeTranspileCompilerOptions(compilerOptions); + const fallbackOptions = normalizeTranspileCompilerOptions(); + + try { + const transpiled = ts.transpileModule(source, { + fileName, + compilerOptions: primaryOptions, + reportDiagnostics: false, + }); + + if (transpiled.outputText === undefined) { + throw new Error("Output generation failed"); + } + + return transpiled.outputText; + } catch (primaryError) { + try { + const transpiled = ts.transpileModule(source, { + fileName, + compilerOptions: fallbackOptions, + reportDiagnostics: false, + }); + + if (transpiled.outputText === undefined) { + throw new Error("Output generation failed"); + } + + return transpiled.outputText; + } catch { + throw primaryError; + } + } +} + +function symlinkIntoCache(sourcePath, targetPath) { + try { + fs.symlinkSync(sourcePath, targetPath, fs.lstatSync(sourcePath).isDirectory() ? "dir" : "file"); + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error; + } +} + +function transpileCachePath(filePath) { + if (/\.(ts|tsx)$/.test(filePath)) return filePath.replace(/\.(ts|tsx)$/, ".js"); + if (/\.mts$/.test(filePath)) return filePath.replace(/\.mts$/, ".mjs"); + if (/\.cts$/.test(filePath)) return filePath.replace(/\.cts$/, ".cjs"); + return filePath; +} + +function transpileSourceFile(sourcePath, targetPath, compilerOptions) { + const source = fs.readFileSync(sourcePath, "utf8"); + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, transpileText(source, sourcePath, compilerOptions), "utf8"); +} + +function mirrorApiTree(sourceDir, targetDir, compilerOptions, skip = () => false) { + if (!fs.existsSync(sourceDir)) return; + + fs.mkdirSync(targetDir, { recursive: true }); + const queue = [{ sourceDir, targetDir }]; + + while (queue.length > 0) { + const current = queue.pop(); + for (const entry of fs.readdirSync(current.sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(current.sourceDir, entry.name); + if (skip(sourcePath)) continue; + + const targetPath = path.join(current.targetDir, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(targetPath, { recursive: true }); + queue.push({ sourceDir: sourcePath, targetDir: targetPath }); + continue; + } + + if (/\.(ts|tsx|mts|cts)$/.test(entry.name)) { + transpileSourceFile(sourcePath, transpileCachePath(targetPath), compilerOptions); + continue; + } + + symlinkIntoCache(sourcePath, targetPath); + } + } +} + +function createMigrationCacheContext(apiRepoPath) { + const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "b1admin-data-api-migrations-")); + const cacheRepoRoot = path.join(cacheRoot, path.basename(apiRepoPath)); + const compilerOptions = loadApiCompilerOptions(apiRepoPath); + fs.mkdirSync(cacheRepoRoot, { recursive: true }); + + for (const entryName of ["node_modules", ".pnp.cjs", ".pnp.loader.mjs", ".yarn", "config", "dist", "reports", "package.json"]) { + const sourcePath = path.join(apiRepoPath, entryName); + if (!fs.existsSync(sourcePath)) continue; + + const targetPath = path.join(cacheRepoRoot, entryName); + symlinkIntoCache(sourcePath, targetPath); + } + + mirrorApiTree( + path.join(apiRepoPath, "src"), + path.join(cacheRepoRoot, "src"), + compilerOptions, + ); + + mirrorApiTree( + path.join(apiRepoPath, "tools"), + path.join(cacheRepoRoot, "tools"), + compilerOptions, + (sourcePath) => sourcePath === path.join(apiRepoPath, "tools", "migrations"), + ); + + return { cacheRoot, cacheRepoRoot, compilerOptions }; +} + +function buildCachedMigrationPath({ apiRepoPath, filePath, cacheRepoRoot }) { + const relativeFilePath = path.relative(apiRepoPath, filePath); + if (!relativeFilePath || relativeFilePath.startsWith("..") || path.isAbsolute(relativeFilePath)) { + fail(`Migration file is outside the API repo path: ${filePath}`); + } + + return path.join( + cacheRepoRoot, + relativeFilePath.replace(/\.(ts|js|mjs|cjs)$/, ".mjs"), + ); +} + +async function importMigrationModule(filePath, { apiRepoPath, cacheRepoRoot }) { + const source = fs.readFileSync(filePath, "utf8"); + const tempFilePath = buildCachedMigrationPath({ apiRepoPath, filePath, cacheRepoRoot }); + fs.mkdirSync(path.dirname(tempFilePath), { recursive: true }); + fs.writeFileSync(tempFilePath, transpileText(replaceKyselyImport(source), filePath, loadApiCompilerOptions(apiRepoPath)), "utf8"); + return import(`${pathToFileURL(tempFilePath).href}?v=${Date.now()}-${Math.random().toString(36).slice(2)}`); +} + +function ensureValue(name, value) { + if (!value) fail(`Missing required value: ${name}`); + return value; +} + +function getDatabaseName(outputs, moduleName) { + return outputs[moduleOutputKeys[moduleName]] || ""; +} + +function formatExecutedAt(timestampValue) { + if (timestampValue === null || timestampValue === undefined || timestampValue === "") return ""; + const numeric = Number(timestampValue); + if (Number.isFinite(numeric) && numeric > 0) { + return new Date(numeric).toISOString(); + } + return String(timestampValue); +} + +async function runDryMigration(moduleExport, action) { + const statements = []; + const db = createMigrationDbContext(async (sql) => { + statements.push(sql); + return { rows: [] }; + }); + + if (action === "up") { + if (typeof moduleExport.up !== "function") fail("Dry-run requested for a migration that does not export up()."); + await moduleExport.up(db); + } else if (action === "down") { + if (typeof moduleExport.down !== "function") fail("Dry-run requested for a migration that does not export down()."); + await moduleExport.down(db); + } else { + fail("Dry-run is only supported for action=up or action=down."); + } + + return statements; +} + +function executeSqlFactory(baseContext, dryRun) { + return async (sql, transactionId = "") => { + if (dryRun) return { rows: [] }; + + const result = runAwsJson( + buildExecuteArgs({ + ...baseContext, + sql, + transactionId, + includeResultMetadata: true, + }), + `Could not execute SQL in database "${baseContext.database || ""}"`, + ); + + return { rows: decodeRows(result) }; + }; +} + +async function ensureDatabaseExists(context, executeAdminSql) { + await executeAdminSql(`CREATE DATABASE IF NOT EXISTS ${escapeIdentifier(context.database)}`); +} + +async function ensureMigrationTables(context, executeSql) { + await executeSql(` + CREATE TABLE IF NOT EXISTS kysely_migration ( + name varchar(255) NOT NULL PRIMARY KEY, + timestamp varchar(255) NOT NULL + ) ENGINE=InnoDB + `); + + await executeSql(` + CREATE TABLE IF NOT EXISTS kysely_migration_lock ( + id varchar(255) NOT NULL PRIMARY KEY, + is_locked integer NOT NULL DEFAULT 0 + ) ENGINE=InnoDB + `); + + await executeSql(` + INSERT INTO kysely_migration_lock (id, is_locked) + VALUES ('migration_lock', 0) + ON DUPLICATE KEY UPDATE id = id + `); +} + +async function readAppliedMigrations(executeSql) { + const result = await executeSql("SELECT name, timestamp FROM kysely_migration ORDER BY timestamp ASC, name ASC"); + return new Map(result.rows.map((row) => [String(row.name), String(row.timestamp)])); +} + +async function applyMigration({ + moduleName, + migrationFile, + migrationExport, + databaseContext, + executeSql, + baseContext, +}) { + const transactionId = beginTransaction(baseContext); + + try { + await executeSql("SELECT id FROM kysely_migration_lock WHERE id = 'migration_lock' FOR UPDATE", transactionId); + const existing = await executeSql(`SELECT name FROM kysely_migration WHERE name = ${sqlString(migrationFile.name)} LIMIT 1`, transactionId); + if (existing.rows.length > 0) { + commitTransaction({ ...baseContext, transactionId }); + return { + name: migrationFile.name, + status: "already-applied", + }; + } + + if (typeof migrationExport.up !== "function") { + throw new Error(`[${moduleName}] ${migrationFile.fileName} does not export up().`); + } + + const db = createMigrationDbContext((sql) => executeSql(sql, transactionId)); + await migrationExport.up(db); + await executeSql( + `INSERT INTO kysely_migration (name, timestamp) VALUES (${sqlString(migrationFile.name)}, ${sqlString(String(Date.now()))})`, + transactionId, + ); + commitTransaction({ ...baseContext, transactionId }); + + return { + name: migrationFile.name, + status: "applied", + }; + } catch (error) { + rollbackTransaction({ ...baseContext, transactionId }); + throw error; + } +} + +async function revertMigration({ + moduleName, + migrationFile, + migrationExport, + executeSql, + baseContext, +}) { + const transactionId = beginTransaction(baseContext); + + try { + await executeSql("SELECT id FROM kysely_migration_lock WHERE id = 'migration_lock' FOR UPDATE", transactionId); + + if (typeof migrationExport.down !== "function") { + throw new Error(`[${moduleName}] ${migrationFile.fileName} does not export down().`); + } + + const db = createMigrationDbContext((sql) => executeSql(sql, transactionId)); + await migrationExport.down(db); + await executeSql(`DELETE FROM kysely_migration WHERE name = ${sqlString(migrationFile.name)}`, transactionId); + commitTransaction({ ...baseContext, transactionId }); + + return { + name: migrationFile.name, + status: "reverted", + }; + } catch (error) { + rollbackTransaction({ ...baseContext, transactionId }); + throw error; + } +} + +async function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name"); + const outputsFile = getArg("outputs-file"); + const apiRepoPathArg = getArg("api-repo-path", "../Api"); + const action = getArg("action", "up"); + const moduleArg = getArg("module"); + const dbClusterArnArg = getArg("db-cluster-arn"); + const dbSecretArnArg = getArg("db-secret-arn"); + const dbSecretFile = getArg("db-secret-file"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const dryRun = parseBoolean(getArg("dry-run", "false"), false); + + if (!["up", "down", "status"].includes(action)) { + fail(`Invalid action "${action}". Use up, down, or status.`); + } + + if (!moduleArg) { + fail("--module is required (e.g. --module=membership or --module=all)"); + } + + if (dryRun && action === "status") { + fail("--dry-run is not supported with --action=status."); + } + + const apiRepoPath = path.resolve(rootDir, apiRepoPathArg); + if (!fs.existsSync(apiRepoPath)) { + fail(`API repo path not found: ${apiRepoPath}`); + } + + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) { + fail(`API repo is missing tools/migrations: ${migrationsRoot}`); + } + + const allModules = loadMigrationDirectories(apiRepoPath); + const targetModules = moduleArg === "all" ? allModules : [moduleArg]; + if (targetModules.length === 0) { + fail(`No migration modules found in ${migrationsRoot}`); + } + + targetModules.forEach((moduleName) => { + if (!allModules.includes(moduleName)) { + fail(`API repo has no tools/migrations/${moduleName} directory.`); + } + }); + + const outputs = stackName + ? getStackOutputs(stackName, region) + : outputsFile + ? normalizeOutputs(loadJsonFile(outputsFile, "stack outputs file")) + : {}; + + const resolvedDbClusterArn = dbClusterArnArg || outputs.DatabaseClusterArn || ""; + const resolvedDbSecretArn = dbSecretArnArg || outputs.DatabaseSecretArn || ""; + + if (!dryRun) { + ensureValue("stack-name, outputs-file, or db-cluster-arn", stackName || outputsFile || dbClusterArnArg); + ensureValue("db-cluster-arn or DatabaseClusterArn output", resolvedDbClusterArn); + ensureValue("db-secret-arn or DatabaseSecretArn output", resolvedDbSecretArn); + } else if (!resolvedDbSecretArn && dbSecretFile) { + // Dry runs do not call AWS, but the file path is still worth validating for operator clarity. + if (!fs.existsSync(path.resolve(rootDir, dbSecretFile))) { + fail(`API migration DB secret file not found: ${path.resolve(rootDir, dbSecretFile)}`); + } + } + + if (!dryRun && dbSecretFile && !resolvedDbSecretArn) { + fail("Data API migrations need a Secrets Manager ARN. Provide --db-secret-arn or use stack outputs that include DatabaseSecretArn."); + } + + const adminContext = { + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + database: "", + }; + const executeAdminSql = executeSqlFactory(adminContext, dryRun); + const cacheContext = createMigrationCacheContext(apiRepoPath); + const moduleDbContextCache = new Map(); + const getModuleDbContext = (moduleName) => { + if (!moduleDbContextCache.has(moduleName)) { + const moduleBaseContext = { + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + database: getDatabaseName(outputs, moduleName) || moduleName, + }; + moduleDbContextCache.set( + moduleName, + createMigrationDbContext(executeSqlFactory(moduleBaseContext, dryRun)), + ); + } + return moduleDbContextCache.get(moduleName); + }; + setDataApiDbFactory((moduleName) => getModuleDbContext(moduleName)); + const result = { + ok: true, + action, + module: moduleArg, + dryRun, + region, + stackName: stackName || "", + outputsFile: outputsFile ? path.resolve(rootDir, outputsFile) : "", + apiRepoPath, + runner: "data-api", + modules: [], + }; + + try { + for (const moduleName of targetModules) { + const moduleDir = path.join(migrationsRoot, moduleName); + const migrationFiles = loadMigrationFiles(moduleDir); + const databaseName = getDatabaseName(outputs, moduleName) || moduleName; + const moduleBaseContext = { + region, + resourceArn: resolvedDbClusterArn, + secretArn: resolvedDbSecretArn, + database: databaseName, + }; + const executeSql = executeSqlFactory(moduleBaseContext, dryRun); + const moduleResult = { + moduleName, + database: databaseName, + migrations: [], + }; + + if (!dryRun) { + await ensureDatabaseExists(moduleBaseContext, executeAdminSql); + await ensureMigrationTables(moduleBaseContext, executeSql); + } + + if (action === "status") { + const applied = await readAppliedMigrations(executeSql); + moduleResult.migrations = migrationFiles.map((migrationFile) => ({ + name: migrationFile.name, + status: applied.has(migrationFile.name) ? "applied" : "pending", + executedAt: applied.has(migrationFile.name) ? formatExecutedAt(applied.get(migrationFile.name)) : "", + })); + result.modules.push(moduleResult); + continue; + } + + if (action === "down") { + if (dryRun) { + const newestMigration = migrationFiles[migrationFiles.length - 1]; + if (newestMigration) { + const migrationExport = await importMigrationModule(newestMigration.filePath, { apiRepoPath, cacheRepoRoot: cacheContext.cacheRepoRoot }); + const statements = await runDryMigration(migrationExport, "down"); + moduleResult.migrations.push({ + name: newestMigration.name, + status: "dry-run", + statements, + }); + } + result.modules.push(moduleResult); + continue; + } + + const applied = await readAppliedMigrations(executeSql); + const lastApplied = [...migrationFiles].reverse().find((migrationFile) => applied.has(migrationFile.name)); + if (!lastApplied) { + moduleResult.migrations.push({ + name: "", + status: "no-op", + }); + result.modules.push(moduleResult); + continue; + } + + const migrationExport = await importMigrationModule(lastApplied.filePath, { apiRepoPath, cacheRepoRoot: cacheContext.cacheRepoRoot }); + const reverted = await revertMigration({ + moduleName, + migrationFile: lastApplied, + migrationExport, + executeSql, + baseContext: moduleBaseContext, + }); + moduleResult.migrations.push(reverted); + result.modules.push(moduleResult); + continue; + } + + const applied = dryRun ? new Map() : await readAppliedMigrations(executeSql); + for (const migrationFile of migrationFiles) { + const migrationExport = await importMigrationModule(migrationFile.filePath, { apiRepoPath, cacheRepoRoot: cacheContext.cacheRepoRoot }); + + if (dryRun) { + const statements = await runDryMigration(migrationExport, "up"); + moduleResult.migrations.push({ + name: migrationFile.name, + status: "dry-run", + statements, + }); + continue; + } + + if (applied.has(migrationFile.name)) { + moduleResult.migrations.push({ + name: migrationFile.name, + status: "already-applied", + executedAt: formatExecutedAt(applied.get(migrationFile.name)), + }); + continue; + } + + const appliedResult = await applyMigration({ + moduleName, + migrationFile, + migrationExport, + databaseContext: moduleBaseContext, + executeSql, + baseContext: moduleBaseContext, + }); + moduleResult.migrations.push(appliedResult); + } + + result.modules.push(moduleResult); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.ok = false; + result.error = message; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(1); + } + fail(message); + } finally { + setDataApiDbFactory(null); + fs.rmSync(cacheContext.cacheRoot, { recursive: true, force: true }); + } + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + for (const moduleResult of result.modules) { + console.log(`\n[${moduleResult.moduleName}] Database: ${moduleResult.database}`); + for (const migration of moduleResult.migrations) { + console.log(`- ${migration.status}: ${migration.name || "(none)"}`); + } + } + + if (dryRun) { + console.log("\nDry-run complete."); + } else { + console.log("\nData API migration run complete."); + } +} + +main(); diff --git a/scripts/run-api-migrations.mjs b/scripts/run-api-migrations.mjs new file mode 100644 index 000000000..3ecd695c4 --- /dev/null +++ b/scripts/run-api-migrations.mjs @@ -0,0 +1,378 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const modules = ["membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function parseBoolean(value, fallback) { + if (value === "") return fallback; + return value.toLowerCase() === "true"; +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function loadJsonFile(filePath, label) { + try { + const resolved = path.resolve(rootDir, filePath); + return JSON.parse(fs.readFileSync(resolved, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function runAwsJson(args, failureLabel) { + try { + return JSON.parse(execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`${failureLabel}: ${message}`); + } +} + +function getStackOutputs(stackName, region) { + const response = runAwsJson([ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], `Could not read stack "${stackName}"`); + + return normalizeOutputs(response); +} + +function getSecretJson(secretId, region) { + const result = runAwsJson([ + "secretsmanager", + "get-secret-value", + "--secret-id", + secretId, + "--region", + region, + "--output", + "json", + ], `Could not read Secrets Manager secret "${secretId}"`); + + if (!result.SecretString) fail(`Secret does not contain SecretString: ${secretId}`); + + try { + return JSON.parse(result.SecretString); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`SecretString for "${secretId}" is not valid JSON: ${message}`); + } +} + +function resolveYarnCommand() { + try { + execFileSync("corepack", ["--version"], { stdio: "ignore" }); + return { command: "corepack", argsPrefix: ["yarn"] }; + } catch (_error) { + return { command: "yarn", argsPrefix: [] }; + } +} + +function runChild(command, args, cwd, env, captureOutput) { + try { + if (captureOutput) { + return { + stdout: execFileSync(command, args, { + cwd, + env, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }), + stderr: "", + }; + } + + execFileSync(command, args, { + cwd, + stdio: "inherit", + env, + }); + return { stdout: "", stderr: "" }; + } catch (error) { + if (captureOutput && error && typeof error === "object") { + if (error.stdout) process.stderr.write(String(error.stdout)); + if (error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + if (error && typeof error === "object") { + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); + } +} + +function ensurePathExists(label, targetPath) { + if (!fs.existsSync(targetPath)) { + fail(`${label} not found: ${targetPath}`); + } +} + +function loadApiRepoMigrationModules(apiRepoPath) { + const kyselyConfigPath = path.join(apiRepoPath, "tools", "kysely-config.ts"); + if (!fs.existsSync(kyselyConfigPath)) return modules; + + try { + const source = fs.readFileSync(kyselyConfigPath, "utf8"); + const match = source.match(/const\s+MODULES\s*=\s*\[(.*?)\]\s+as const/s); + if (!match) return modules; + + const values = Array.from(match[1].matchAll(/"([^"]+)"/g)).map((item) => item[1]); + return values.length > 0 ? values : modules; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read API migration module config from "${kyselyConfigPath}": ${message}`); + } +} + +function loadApiRepoMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); + } +} + +function buildMysqlConnectionString({ username, password, host, port, database }) { + const encodedUsername = encodeURIComponent(String(username)); + const encodedPassword = encodeURIComponent(String(password)); + const encodedDatabase = encodeURIComponent(String(database)); + return `mysql://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`; +} + +function getDatabaseNameForModule(outputs, moduleName) { + const outputKeys = { + membership: "MembershipDatabaseName", + attendance: "AttendanceDatabaseName", + content: "ContentDatabaseName", + giving: "GivingDatabaseName", + messaging: "MessagingDatabaseName", + doing: "DoingDatabaseName", + reporting: "ReportingDatabaseName", + }; + + return outputs[outputKeys[moduleName]]; +} + +function getTargetModules(moduleName) { + return moduleName === "all" ? modules : [moduleName]; +} + +function requireOutput(outputs, key) { + if (!outputs[key]) fail(`Missing required stack output: ${key}`); + return outputs[key]; +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name"); + const outputsFile = getArg("outputs-file"); + const dbSecretArn = getArg("db-secret-arn"); + const dbSecretFile = getArg("db-secret-file"); + const apiRepoPath = path.resolve(rootDir, getArg("api-repo-path", "../Api")); + const action = getArg("action", "up"); + const moduleName = getArg("module", "all"); + const dryRun = parseBoolean(getArg("dry-run", "false"), false); + const outputMode = getArg("output", "text").toLowerCase(); + + if (!["up", "down", "status"].includes(action)) { + fail(`Invalid action "${action}". Use up, down, or status.`); + } + + if (moduleName !== "all" && !modules.includes(moduleName)) { + fail(`Invalid module "${moduleName}". Use one of ${modules.join(", ")} or all.`); + } + + if (!stackName && !outputsFile) { + fail("Provide --stack-name or --outputs-file."); + } + + ensurePathExists("API repo", apiRepoPath); + ensurePathExists("API package.json", path.join(apiRepoPath, "package.json")); + ensurePathExists("API migrate tool", path.join(apiRepoPath, "tools", "migrate.ts")); + const apiRepoMigrationModules = loadApiRepoMigrationModules(apiRepoPath); + const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(apiRepoPath); + + const outputs = stackName + ? getStackOutputs(stackName, region) + : normalizeOutputs(loadJsonFile(outputsFile, "stack outputs file")); + + const databaseEndpoint = requireOutput(outputs, "DatabaseEndpoint"); + const databasePort = requireOutput(outputs, "DatabasePort"); + const resolvedDbSecretArn = dbSecretArn || outputs.DatabaseSecretArn || ""; + if (!dbSecretFile && !resolvedDbSecretArn) { + fail("Provide --db-secret-file, --db-secret-arn, or stack outputs with DatabaseSecretArn."); + } + + const dbSecret = dbSecretFile + ? loadJsonFile(dbSecretFile, "database secret file") + : getSecretJson(resolvedDbSecretArn, region); + + if (!dbSecret.username) fail("Database secret is missing username."); + if (!dbSecret.password) fail("Database secret is missing password."); + + const targetModules = moduleName === "all" ? apiRepoMigrationModules : getTargetModules(moduleName); + const connectionStrings = {}; + targetModules.forEach((name) => { + const databaseName = getDatabaseNameForModule(outputs, name); + if (!databaseName) fail(`Could not resolve ${name} database name from stack outputs.`); + + connectionStrings[`${name.toUpperCase()}_CONNECTION_STRING`] = buildMysqlConnectionString({ + username: dbSecret.username, + password: dbSecret.password, + host: databaseEndpoint, + port: databasePort, + database: databaseName, + }); + }); + + if (connectionStrings.MEMBERSHIP_CONNECTION_STRING) { + connectionStrings.DOING_MEMBERSHIP_CONNECTION_STRING = connectionStrings.MEMBERSHIP_CONNECTION_STRING; + } + + const yarnCommand = resolveYarnCommand(); + const command = yarnCommand.command; + const args = [...yarnCommand.argsPrefix, "migrate", `--action=${action}`, `--module=${moduleName}`]; + + const redactedConnectionStrings = Object.fromEntries(Object.entries(connectionStrings).map(([key, value]) => { + const redacted = String(value).replace(/:\/\/([^:]+):([^@]+)@/, "://$1:***@"); + return [key, redacted]; + })); + + const result = { + apiRepoPath, + region, + stackName, + outputsFile, + action, + module: moduleName, + dryRun, + command: `${command} ${args.join(" ")}`, + databaseEndpoint, + databasePort, + resolvedDbSecretSource: dbSecretFile ? path.resolve(rootDir, dbSecretFile) : resolvedDbSecretArn, + apiRepoMigrationModules, + apiRepoMigrationDirectories, + effectiveModules: targetModules, + skippedConfiguredModules: moduleName === "all" + ? modules.filter((name) => !apiRepoMigrationModules.includes(name)) + : [], + warnings: [], + connectionStrings: redactedConnectionStrings, + executed: false, + }; + + if (moduleName !== "all" && !apiRepoMigrationModules.includes(moduleName)) { + result.warnings.push(`The current Api repo's --module=all migration set does not include ${moduleName}.`); + } + + const modulesWithoutMigrationDirectories = targetModules.filter((name) => !apiRepoMigrationDirectories.includes(name)); + if (modulesWithoutMigrationDirectories.length > 0) { + result.warnings.push(`No migration directory exists in the Api repo for: ${modulesWithoutMigrationDirectories.join(", ")}.`); + } + + if (!dryRun && moduleName !== "all" && modulesWithoutMigrationDirectories.length > 0) { + fail(`The current Api repo has no tools/migrations/${moduleName} directory. Refusing to run a direct ${moduleName} migration outside dry-run mode.`); + } + + if (outputMode === "json") { + if (dryRun) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + } else { + console.log("\nAPI migration helper ready."); + console.log(`API repo: ${apiRepoPath}`); + console.log(`Action: ${action}`); + console.log(`Module: ${moduleName}`); + if (moduleName === "all") { + console.log(`Effective modules: ${targetModules.join(", ")}`); + if (result.skippedConfiguredModules.length > 0) { + console.warn(`Warning: --module=all in the current Api repo does not include: ${result.skippedConfiguredModules.join(", ")}`); + } + } + result.warnings.forEach((warning) => console.warn(`Warning: ${warning}`)); + console.log(`Database host: ${databaseEndpoint}:${databasePort}`); + if (dryRun) { + console.log("Dry run only. No migrations executed."); + Object.keys(redactedConnectionStrings).forEach((key) => console.log(`- ${key}=${redactedConnectionStrings[key]}`)); + return; + } + } + + if (!fs.existsSync(path.join(apiRepoPath, "node_modules"))) { + fail(`API repo dependencies are not installed: ${path.join(apiRepoPath, "node_modules")}`); + } + + const childResult = runChild( + command, + args, + apiRepoPath, + { + ...process.env, + ...connectionStrings, + }, + outputMode === "json", + ); + + result.executed = true; + if (outputMode === "json") { + result.stdout = childResult.stdout; + result.stderr = childResult.stderr; + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } +} + +main(); diff --git a/scripts/save-split-stack-outputs.mjs b/scripts/save-split-stack-outputs.mjs new file mode 100644 index 000000000..1a182a6f1 --- /dev/null +++ b/scripts/save-split-stack-outputs.mjs @@ -0,0 +1,233 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function toRepoRelative(resolvedPath) { + const relative = path.relative(rootDir, resolvedPath); + return relative.startsWith("..") ? resolvedPath : relative; +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function readEnvironmentConfig(environment) { + const environmentDir = path.join(rootDir, "infrastructure", "environments", environment); + const bootstrapPath = path.join(environmentDir, "bootstrap-parameters.json"); + + if (!fs.existsSync(bootstrapPath)) { + throw new Error(`Could not find bootstrap-parameters.json for environment "${environment}".`); + } + + const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, "utf8")); + const projectName = bootstrap.ProjectName || "b1admin"; + const environmentName = bootstrap.EnvironmentName || environment; + const stackPrefix = `${projectName}-${environmentName}`; + + return { + environmentDir, + projectName, + environmentName, + backendStackName: `${stackPrefix}-backend`, + frontendStackName: `${stackPrefix}-frontend`, + }; +} + +function describeStack(stackName, region, label) { + try { + const response = JSON.parse(execFileSync("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + + return { + raw: response, + outputs: normalizeOutputs(response), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function writeJson(filePath, data) { + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +function main() { + const environment = getArg("environment"); + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + + let environmentConfig = null; + try { + environmentConfig = environment ? readEnvironmentConfig(environment) : null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, environment, region, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const backendStackName = getArg("backend-stack-name", environmentConfig?.backendStackName || ""); + const frontendStackName = getArg("frontend-stack-name", environmentConfig?.frontendStackName || ""); + const projectName = environmentConfig?.projectName || getArg("project-name", "b1admin"); + const environmentName = environmentConfig?.environmentName || environment || ""; + const defaultOutputDir = environment ? `deployment/${environment}` : "deployment/manual"; + const outputDirArg = getArg("output-dir", defaultOutputDir); + const outputDir = path.resolve(rootDir, outputDirArg); + const backendOutputsFile = path.resolve(outputDir, getArg("backend-outputs-file", "backend-outputs.json")); + const frontendOutputsFile = path.resolve(outputDir, getArg("frontend-outputs-file", "frontend-outputs.json")); + const summaryFile = path.resolve(outputDir, getArg("summary-file", "deployment-summary.json")); + const preflightPlanFile = path.resolve(outputDir, getArg("preflight-plan-file", "preflight-plan.md")); + + const errors = []; + if (!backendStackName) errors.push("Provide --backend-stack-name or --environment."); + if (!frontendStackName) errors.push("Provide --frontend-stack-name or --environment."); + + if (errors.length > 0) { + const result = { + ok: false, + environment, + region, + errors, + }; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + errors.forEach((message) => console.error(message)); + } + process.exit(1); + } + + let backend = null; + let frontend = null; + + try { + backend = describeStack(backendStackName, region, "backend stack"); + frontend = describeStack(frontendStackName, region, "frontend stack"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ + ok: false, + environment, + region, + stackNames: { + backend: backendStackName, + frontend: frontendStackName, + }, + errors: [message], + }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + fs.mkdirSync(outputDir, { recursive: true }); + writeJson(backendOutputsFile, backend.raw); + writeJson(frontendOutputsFile, frontend.raw); + + const backendOutputsPath = toRepoRelative(backendOutputsFile); + const frontendOutputsPath = toRepoRelative(frontendOutputsFile); + const summaryPath = toRepoRelative(summaryFile); + const preflightPlanPath = fs.existsSync(preflightPlanFile) ? toRepoRelative(preflightPlanFile) : ""; + const environmentDirRelative = environmentConfig ? toRepoRelative(environmentConfig.environmentDir) : ""; + + const summary = { + ok: true, + environment, + projectName, + environmentName, + region, + stackNames: { + backend: backendStackName, + frontend: frontendStackName, + }, + outputDir: toRepoRelative(outputDir), + files: { + backendOutputsFile: backendOutputsPath, + frontendOutputsFile: frontendOutputsPath, + summaryFile: summaryPath, + preflightPlanFile: preflightPlanPath, + }, + resolved: { + apiBaseUrl: getOutputValue(backend.outputs, ["ApiBaseUrl", "PublicApiBaseUrl", "ReactAppApiBase"]), + frontendAppUrl: getOutputValue(frontend.outputs, ["AppUrl", "FrontendAppUrl"]), + frontendBucketName: getOutputValue(frontend.outputs, ["SiteBucketName", "FrontendBucketName"]), + frontendDistributionId: getOutputValue(frontend.outputs, ["CloudFrontDistributionId", "FrontendDistributionId"]), + appConfigSecretArn: getOutputValue(backend.outputs, ["AppConfigSecretArn"]), + }, + followUpCommands: { + showDeploymentSummary: `yarn show:deployment-summary -- --summary-file=${summaryPath} --output=markdown`, + verifyFromSavedOutputs: `yarn verify:split-stack -- --region=${region} --backend-outputs-file=${backendOutputsPath} --frontend-outputs-file=${frontendOutputsPath}`, + verifyFromSavedOutputsWithHttp: `yarn verify:split-stack -- --region=${region} --backend-outputs-file=${backendOutputsPath} --frontend-outputs-file=${frontendOutputsPath} --check-http=true`, + publishFrontendAssetsFromSavedOutputs: `yarn publish:frontend-assets -- --frontend-outputs-file=${frontendOutputsPath} --backend-outputs-file=${backendOutputsPath}`, + publishFromSavedOutputs: environmentDirRelative + ? `yarn deploy:aws -- --region=${region} --project-name=${projectName} --environment=${environment || environmentName} --frontend-parameters-file=${environmentDirRelative}/frontend-parameters.json --backend-parameters-file=${environmentDirRelative}/backend-parameters.json --frontend-outputs-file=${frontendOutputsPath} --backend-outputs-file=${backendOutputsPath} --skip-backend --skip-frontend --publish-frontend-assets` + : "", + }, + }; + + writeJson(summaryFile, summary); + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + + console.log("Saved split-stack outputs."); + console.log(`Backend outputs: ${backendOutputsPath}`); + console.log(`Frontend outputs: ${frontendOutputsPath}`); + console.log(`Summary: ${summaryPath}`); + if (preflightPlanPath) console.log(`Preflight plan: ${preflightPlanPath}`); + if (summary.resolved.apiBaseUrl) console.log(`API base URL: ${summary.resolved.apiBaseUrl}`); + if (summary.resolved.frontendAppUrl) console.log(`Frontend app URL: ${summary.resolved.frontendAppUrl}`); +} + +main(); diff --git a/scripts/setup-private-deployment-repo.mjs b/scripts/setup-private-deployment-repo.mjs new file mode 100644 index 000000000..ac85f3b60 --- /dev/null +++ b/scripts/setup-private-deployment-repo.mjs @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const environmentFiles = [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + "deploy-split-stack.sh", +]; + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function copyPlanEntry(sourcePath, targetPath) { + return { + source: path.relative(rootDir, sourcePath), + target: targetPath, + exists: fs.existsSync(targetPath), + }; +} + +function writeFileFromSource(entry, options) { + if (entry.exists && !options.force) return false; + fs.mkdirSync(path.dirname(entry.target), { recursive: true }); + fs.copyFileSync(path.join(rootDir, entry.source), entry.target); + return true; +} + +function buildReadme({ deployRepoName, deployRepoDirArg, b1adminRepo, apiRepo }) { + const deployEnvDirArg = path.join(deployRepoDirArg, "environments"); + const customerFileArg = path.join(deployRepoDirArg, "customer-values.json"); + const safeAddCommand = "git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments"; + + return `# ${deployRepoName} + +Private deployment workspace for B1Admin. + +This repository holds the workflow and environment parameter files for this AWS install. + +It should not contain application source changes, live app-config secret files, bootstrap-admin secret files, or committed deployment evidence. + +## Start + +Run setup and deploy commands from a sibling B1Admin checkout: + +\`\`\`bash +export DEPLOY_REPO=/ +export DEPLOY_ENV_DIR=${deployEnvDirArg} + +yarn installer:init -- --deploy-repo-dir=${deployRepoDirArg} --output=markdown +yarn installer:customer-values -- --customer-file=${customerFileArg} --write=true --output=markdown + +# Smallest AWS footprint: deploy prod first and skip staging. +yarn installer:run -- --deploy-repo-dir=${deployRepoDirArg} --deploy-env-dir=${deployEnvDirArg} --deployment-root=${path.join(deployRepoDirArg, "deployment")} --customer-file=${customerFileArg} --environment=prod --output=markdown + +# Optional practice deployment: run staging first, then prod after staging is verified. +yarn installer:run -- --deploy-repo-dir=${deployRepoDirArg} --deploy-env-dir=${deployEnvDirArg} --deployment-root=${path.join(deployRepoDirArg, "deployment")} --customer-file=${customerFileArg} --environment=staging --output=markdown +\`\`\` + +Run \`installer:customer-values\` when the installer asks for customer setup information. It asks plain questions and writes \`customer-values.json\` for you. Then run \`installer:run\`; it keeps moving through the installer and pauses before approval steps. + +Do not commit \`customer-values.json\`, \`app-config-secret.json\`, \`bootstrap-admin-secret.json\`, or \`deployment/\`. +\`deployment/\` is an ignored local folder where the installer stores downloaded workflow evidence, browser-smoke results, and the final report on the operator machine. + +Safe first commit from this private repo: + +\`\`\`bash +${safeAddCommand} +git commit -m "Add B1Admin deployment scaffold" +git push +\`\`\` + +The workflow defaults to: + +- B1Admin source: \`${b1adminRepo}\` +- Api source: \`${apiRepo}\` + +Do not copy the Api source into this private deployment repository. The workflow checks out the Api source repository during deployment using read-only access. Use a private Api fork or mirror only if your organization intentionally maintains customized backend code. + +Use the B1Admin \`infrastructure/environments/start-here.md\` guide for the full rollout. Staging is optional because it creates a second AWS stack and costs money while it is running. Use \`yarn installer:doctor -- --repo="$DEPLOY_REPO" --deploy-env-dir="$DEPLOY_ENV_DIR" --output=markdown\` only when the guided next step does not explain the problem. + +## Update Later + +When B1Admin source code changes and you want to update this AWS install, run this from the sibling B1Admin checkout: + +\`\`\`bash +yarn installer:update -- --deploy-repo-dir=${deployRepoDirArg} --deploy-env-dir=${deployEnvDirArg} --deployment-root=${path.join(deployRepoDirArg, "deployment")} --customer-file=${customerFileArg} --environment=prod --output=markdown +\`\`\` + +This is a guided update command, not a zero-downtime guarantee. For production with active users, verify staging first when available and run prod updates during an approved low-traffic or maintenance window. +`; +} + +function renderMarkdown(result) { + const lines = [ + "# Private Deployment Repo Setup", + "", + `- Status: ${result.ok ? "ready" : "needs attention"}`, + `- Mode: ${result.write ? "write" : "preview"}`, + `- Target: \`${result.deployRepoDir}\``, + `- Files planned: ${result.plannedCount}`, + `- Files ${result.write ? "written" : "that would be written"}: ${result.write ? result.writtenCount : result.writableCount}`, + `- Existing files skipped: ${result.skippedCount}`, + ]; + + if (result.skipped.length > 0) { + lines.push("", "## Existing Files Skipped", ""); + result.skipped.forEach((fileName) => lines.push(`- \`${fileName}\``)); + } + + lines.push("", "## Next Steps", ""); + result.nextSteps.forEach((line) => lines.push(`- ${line}`)); + + if (result.safeCommitCommands.length > 0) { + lines.push("", "## Safe Commit Commands", "", "Run these after reviewing the private deployment repo:", ""); + lines.push("```bash"); + result.safeCommitCommands.forEach((line) => lines.push(line)); + lines.push("```"); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const deployRepoDirArg = getArg("deploy-repo-dir", "../b1admin-deploy"); + const deployRepoDir = path.resolve(rootDir, deployRepoDirArg); + const deployRepoName = path.basename(deployRepoDir); + const write = getArg("write", "false").toLowerCase() === "true"; + const force = getArg("force", "false").toLowerCase() === "true"; + const outputMode = getArg("output", "text").toLowerCase(); + const b1adminRepo = getArg("b1admin-repo", "ChurchApps/B1Admin"); + const apiRepo = getArg("api-repo", "ChurchApps/Api"); + + const entries = [ + copyPlanEntry( + path.join(rootDir, "infrastructure", "environments", "private-deployment-workflow.sample.yml"), + path.join(deployRepoDir, ".github", "workflows", "deploy-aws-self-hosted.yml"), + ), + copyPlanEntry( + path.join(rootDir, "infrastructure", "environments", "private-deployment-gitignore.sample"), + path.join(deployRepoDir, ".gitignore"), + ), + copyPlanEntry( + path.join(rootDir, "infrastructure", "environments", "customer-values.sample.json"), + path.join(deployRepoDir, "customer-values.sample.json"), + ), + ...["staging", "prod"].flatMap((environment) => environmentFiles.map((fileName) => copyPlanEntry( + path.join(rootDir, "infrastructure", "environments", environment, fileName), + path.join(deployRepoDir, "environments", environment, fileName), + ))), + ]; + + const readmePath = path.join(deployRepoDir, "README.md"); + const readmeEntry = { + source: "", + target: readmePath, + exists: fs.existsSync(readmePath), + }; + + const planned = [...entries, readmeEntry]; + const skipped = planned.filter((entry) => entry.exists && !force); + const writable = planned.filter((entry) => !entry.exists || force); + const written = []; + + if (write) { + entries.forEach((entry) => { + if (writeFileFromSource(entry, { force })) written.push(entry.target); + }); + + if (!readmeEntry.exists || force) { + fs.mkdirSync(path.dirname(readmePath), { recursive: true }); + fs.writeFileSync(readmePath, buildReadme({ deployRepoName, deployRepoDirArg, b1adminRepo, apiRepo })); + written.push(readmePath); + } + } + + const result = { + ok: skipped.length === 0 || force || !write, + write, + force, + deployRepoDir, + plannedCount: planned.length, + writableCount: writable.length, + skippedCount: skipped.length, + writtenCount: written.length, + planned: planned.map((entry) => ({ + source: entry.source, + target: path.relative(rootDir, entry.target), + exists: entry.exists, + action: entry.exists && !force ? "skip-existing" : write ? "write" : "would-write", + })), + skipped: skipped.map((entry) => path.relative(rootDir, entry.target)), + written: written.map((filePath) => path.relative(rootDir, filePath)), + safeCommitCommands: [ + `cd ${path.relative(rootDir, deployRepoDir) || "."}`, + "git status --short --ignored", + "git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments", + 'git commit -m "Add B1Admin deployment scaffold"', + "git push", + ], + nextSteps: [ + write + ? `Review ${path.relative(rootDir, deployRepoDir)} and commit only the scaffolded private deployment files shown below.` + : skipped.length > 0 && writable.length === 0 + ? "All planned files already exist. Re-run with --force=true only if you intentionally want to replace them." + : "Re-run with --write=true to create the private deployment repo scaffold.", + `Choose the first environment: prod to keep the AWS footprint smaller, or staging for an optional practice deployment.`, + `Run yarn installer:configure -- --environment=prod --environment-dir=${path.relative(rootDir, path.join(deployRepoDir, "environments", "prod"))} --account-id= --root-domain= --support-phone= --output=markdown`, + "Create the aws-prod GitHub Environment and add the deployment secrets described in start-here.md. Create aws-staging only if you choose the optional staging deployment.", + ], + }; + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(result.ok ? 0 : 1); + } + + if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result)); + process.exit(result.ok ? 0 : 1); + } + + console.log(`Private deployment repo setup: ${write ? "write" : "dry-run"}`); + console.log(`Target: ${deployRepoDir}`); + console.log(`Files planned: ${result.plannedCount}`); + console.log(`Files ${write ? "written" : "that would be written"}: ${write ? result.writtenCount : result.writableCount}`); + if (skipped.length > 0) { + console.log(`Existing files skipped: ${skipped.length}`); + skipped.forEach((filePath) => console.log(`- ${path.relative(rootDir, filePath.target)}`)); + } + console.log("\nNext steps:"); + result.nextSteps.forEach((line) => console.log(`- ${line}`)); + + process.exit(result.ok ? 0 : 1); +} + +main(); diff --git a/scripts/show-deployment-summary.mjs b/scripts/show-deployment-summary.mjs new file mode 100644 index 000000000..994734e73 --- /dev/null +++ b/scripts/show-deployment-summary.mjs @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function readSummary(summaryFileArg) { + const summaryPath = path.resolve(rootDir, summaryFileArg); + try { + return { + path: summaryPath, + data: JSON.parse(fs.readFileSync(summaryPath, "utf8")), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not load deployment summary "${summaryFileArg}": ${message}`); + } +} + +function renderMarkdown(summary) { + const lines = [ + `## ${summary.environment || "deployment"} summary`, + "", + `- Region: \`${summary.region || ""}\``, + `- Backend stack: \`${summary.stackNames?.backend || ""}\``, + `- Frontend stack: \`${summary.stackNames?.frontend || ""}\``, + `- API base URL: \`${summary.resolved?.apiBaseUrl || ""}\``, + `- Frontend app URL: \`${summary.resolved?.frontendAppUrl || ""}\``, + `- Frontend bucket: \`${summary.resolved?.frontendBucketName || ""}\``, + `- CloudFront distribution: \`${summary.resolved?.frontendDistributionId || ""}\``, + `- App config secret ARN: \`${summary.resolved?.appConfigSecretArn || ""}\``, + "", + "### Saved files", + "", + `- Backend outputs: \`${summary.files?.backendOutputsFile || ""}\``, + `- Frontend outputs: \`${summary.files?.frontendOutputsFile || ""}\``, + `- Summary file: \`${summary.files?.summaryFile || ""}\``, + "", + "### Follow-up commands", + "", + `- Verify from saved outputs: \`${summary.followUpCommands?.verifyFromSavedOutputs || ""}\``, + `- Verify with HTTP probe: \`${summary.followUpCommands?.verifyFromSavedOutputsWithHttp || ""}\``, + `- Publish frontend assets later: \`${summary.followUpCommands?.publishFrontendAssetsFromSavedOutputs || ""}\``, + ]; + + if (summary.files?.preflightPlanFile) { + lines.splice(lines.indexOf("### Follow-up commands") - 1, 0, `- Preflight plan: \`${summary.files.preflightPlanFile}\``); + } + + if (summary.followUpCommands?.publishFromSavedOutputs) { + lines.push(`- Re-run staged publish flow: \`${summary.followUpCommands.publishFromSavedOutputs}\``); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderCommands(summary) { + const lines = []; + const commands = [ + summary.followUpCommands?.verifyFromSavedOutputs || "", + summary.followUpCommands?.verifyFromSavedOutputsWithHttp || "", + summary.followUpCommands?.publishFrontendAssetsFromSavedOutputs || "", + summary.followUpCommands?.publishFromSavedOutputs || "", + summary.followUpCommands?.showDeploymentSummary || "", + ].filter((command, index, list) => command && list.indexOf(command) === index); + + commands.forEach((command) => lines.push(command)); + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderText(summary, summaryFileArg) { + const lines = [ + `Deployment summary: ${summary.environment || "deployment"}`, + `Region: ${summary.region || ""}`, + `Backend stack: ${summary.stackNames?.backend || ""}`, + `Frontend stack: ${summary.stackNames?.frontend || ""}`, + `API base URL: ${summary.resolved?.apiBaseUrl || ""}`, + `Frontend app URL: ${summary.resolved?.frontendAppUrl || ""}`, + `Frontend bucket: ${summary.resolved?.frontendBucketName || ""}`, + `CloudFront distribution: ${summary.resolved?.frontendDistributionId || ""}`, + `App config secret ARN: ${summary.resolved?.appConfigSecretArn || ""}`, + `Backend outputs file: ${summary.files?.backendOutputsFile || ""}`, + `Frontend outputs file: ${summary.files?.frontendOutputsFile || ""}`, + `Summary file: ${summary.files?.summaryFile || summaryFileArg}`, + ...(summary.files?.preflightPlanFile ? [`Preflight plan file: ${summary.files.preflightPlanFile}`] : []), + `Verify from saved outputs: ${summary.followUpCommands?.verifyFromSavedOutputs || ""}`, + `Verify with HTTP probe: ${summary.followUpCommands?.verifyFromSavedOutputsWithHttp || ""}`, + `Publish frontend assets later: ${summary.followUpCommands?.publishFrontendAssetsFromSavedOutputs || ""}`, + ]; + + if (summary.followUpCommands?.publishFromSavedOutputs) { + lines.push(`Re-run staged publish flow: ${summary.followUpCommands.publishFromSavedOutputs}`); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const summaryFile = getArg("summary-file", "deployment/staging/deployment-summary.json"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const commandsOutput = outputMode === "commands" || outputMode === "shell"; + + let summary = null; + try { + summary = readSummary(summaryFile).data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, summaryFile, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + + const rendered = commandsOutput + ? renderCommands(summary) + : markdownOutput + ? renderMarkdown(summary) + : renderText(summary, summaryFile); + process.stdout.write(rendered); +} + +main(); diff --git a/scripts/show-environment-setup-guide.mjs b/scripts/show-environment-setup-guide.mjs new file mode 100644 index 000000000..3946ce2cf --- /dev/null +++ b/scripts/show-environment-setup-guide.mjs @@ -0,0 +1,314 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + getFieldMetadata, + optionalBlankKeys, + phaseMetadata, + phaseOrder, + requiredFiles, + unsafeDefaultMatchers, +} from "./lib/environment-setup-metadata.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function resolveEnvironmentDir(environment, explicitDir = "") { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + return path.join(rootDir, "infrastructure", "environments", environment); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function isPlaceholder(value) { + return typeof value === "string" && value.includes("replace-me"); +} + +function isBlankString(value) { + return typeof value === "string" && value.trim() === ""; +} + +function isResolvedSecretValue(value) { + return typeof value === "string" && value.trim() !== "" && !value.includes("replace-me"); +} + +function isUnsafeDefault(fileName, key, value) { + const matcher = unsafeDefaultMatchers[fileName]?.[key]; + return typeof matcher === "function" ? matcher(value) : false; +} + +function getFieldState(fileName, key, value, secretOverride) { + const overrideValue = secretOverride?.[key]; + const resolvedByOverride = isResolvedSecretValue(overrideValue); + + if (isPlaceholder(value)) { + if (resolvedByOverride) return { state: "ready", source: "app-config-secret.json", value: overrideValue }; + return { state: "needs-value", reason: "placeholder" }; + } + + if (isUnsafeDefault(fileName, key, value)) { + if (resolvedByOverride) return { state: "ready", source: "app-config-secret.json", value: overrideValue }; + return { state: "needs-value", reason: "starter-default" }; + } + + if (isBlankString(value)) { + if (resolvedByOverride) return { state: "ready", source: "app-config-secret.json", value: overrideValue }; + const isOptional = optionalBlankKeys[fileName]?.has(key) ?? false; + return { state: isOptional ? "optional-empty" : "needs-value", reason: isOptional ? "optional-blank" : "required-blank" }; + } + + return { state: "ready", value }; +} + +function buildPrepCommands(environment) { + return [ + `yarn prepare:environment-starter -- --environment=${environment} --account-id= --output=markdown`, + `yarn prepare:environment-starter -- --environment=${environment} --account-id= --root-domain= --output=markdown`, + `yarn discover:github-aws-roles -- --environment=${environment} --output=markdown`, + `yarn audit:environment-starter -- --environment=${environment} --only-blockers=true`, + ]; +} + +function buildPhaseSummary(entries) { + return { + total: entries.length, + ready: entries.filter((entry) => entry.state === "ready").length, + needsValue: entries.filter((entry) => entry.state === "needs-value").length, + optionalEmpty: entries.filter((entry) => entry.state === "optional-empty").length, + }; +} + +function buildGuide(environment, environmentDir) { + const missingFiles = requiredFiles.filter((fileName) => !fs.existsSync(path.join(environmentDir, fileName))); + if (missingFiles.length > 0) { + return { + ok: false, + environment, + environmentDir: path.relative(rootDir, environmentDir), + errors: [`Missing required starter files: ${missingFiles.join(", ")}`], + }; + } + + const secretOverridePath = path.join(environmentDir, "app-config-secret.json"); + const secretOverride = fs.existsSync(secretOverridePath) ? readJson(secretOverridePath) : null; + const groups = Object.fromEntries(phaseOrder.map((phase) => [phase, []])); + + requiredFiles.forEach((fileName) => { + const filePath = path.join(environmentDir, fileName); + const parsed = readJson(filePath); + + Object.entries(parsed).forEach(([key, value]) => { + const metadata = getFieldMetadata(fileName, key); + const state = getFieldState( + fileName, + key, + value, + fileName === "app-config-secret.template.json" ? secretOverride : null, + ); + + groups[metadata.phase].push({ + fileName, + file: path.relative(rootDir, filePath), + key, + label: metadata.label, + rationale: metadata.rationale, + state: state.state, + reason: state.reason || "", + source: state.source || "", + currentValue: state.value ?? value, + }); + }); + }); + + const summaries = Object.fromEntries( + phaseOrder.map((phase) => [phase, buildPhaseSummary(groups[phase])]), + ); + const firstDeployBlockers = groups["first-deploy"].filter((entry) => entry.state === "needs-value"); + + return { + ok: firstDeployBlockers.length === 0, + environment, + environmentDir: path.relative(rootDir, environmentDir), + firstDeployReady: firstDeployBlockers.length === 0, + usedSecretOverride: Boolean(secretOverride), + summaries, + groups, + firstDeployBlockers, + recommendedCommands: buildPrepCommands(environment), + }; +} + +function formatValue(value) { + if (typeof value === "string" && value.length > 90) return `${value.slice(0, 87)}...`; + if (value === "") return ""; + return String(value); +} + +function renderText(result, selectedPhase) { + const showMode = getArg("show", "actionable").toLowerCase(); + const lines = [ + `Environment setup guide: ${result.environment}`, + `Path: ${result.environmentDir}`, + `First deploy status: ${result.firstDeployReady ? "ready" : "blocked"}`, + `Secret override file present: ${result.usedSecretOverride ? "yes" : "no"}`, + `View: ${showMode}`, + ]; + + if (result.firstDeployBlockers.length > 0) { + lines.push("", "First deploy blockers:"); + result.firstDeployBlockers.forEach((entry) => { + lines.push(`- ${entry.key} (${entry.file})`); + }); + } + + const phasesToShow = selectedPhase === "all" ? phaseOrder : [selectedPhase]; + phasesToShow.forEach((phase) => { + const phaseInfo = phaseMetadata[phase]; + const summary = result.summaries[phase]; + const entries = showMode === "all" + ? result.groups[phase] + : result.groups[phase].filter((entry) => entry.state !== "ready"); + lines.push( + "", + `${phaseInfo.title}: ${summary.ready} ready, ${summary.needsValue} needs value, ${summary.optionalEmpty} optional blank`, + phaseInfo.description, + ); + + if (entries.length === 0) { + lines.push("- No actionable items in this phase."); + return; + } + + entries.forEach((entry) => { + const suffix = entry.source ? ` via ${entry.source}` : ""; + lines.push(`- [${entry.state}] ${entry.key} :: ${entry.file}${suffix}`); + lines.push(` ${entry.rationale}`); + lines.push(` Current: ${formatValue(entry.currentValue)}`); + }); + }); + + lines.push("", "Recommended commands:"); + result.recommendedCommands.forEach((command) => lines.push(`- ${command}`)); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderMarkdown(result, selectedPhase) { + const showMode = getArg("show", "actionable").toLowerCase(); + const lines = [ + `# Environment Setup Guide: ${result.environment}`, + "", + `- Path: \`${result.environmentDir}\``, + `- First deploy status: ${result.firstDeployReady ? "ready" : "blocked"}`, + `- Secret override file present: ${result.usedSecretOverride ? "yes" : "no"}`, + `- View: \`${showMode}\``, + ]; + + if (result.firstDeployBlockers.length > 0) { + lines.push("", "## First Deploy Blockers", ""); + result.firstDeployBlockers.forEach((entry) => { + lines.push(`- \`${entry.key}\` in \`${entry.file}\``); + }); + } + + const phasesToShow = selectedPhase === "all" ? phaseOrder : [selectedPhase]; + phasesToShow.forEach((phase) => { + const phaseInfo = phaseMetadata[phase]; + const summary = result.summaries[phase]; + const entries = showMode === "all" + ? result.groups[phase] + : result.groups[phase].filter((entry) => entry.state !== "ready"); + lines.push("", `## ${phaseInfo.title}`, ""); + lines.push(phaseInfo.description, ""); + lines.push(`- Ready: ${summary.ready}`); + lines.push(`- Needs value: ${summary.needsValue}`); + lines.push(`- Optional blank: ${summary.optionalEmpty}`, ""); + + if (entries.length === 0) { + lines.push("- No actionable items in this phase."); + return; + } + + entries.forEach((entry) => { + const suffix = entry.source ? ` via \`${entry.source}\`` : ""; + lines.push(`- [${entry.state}] \`${entry.key}\` in \`${entry.file}\`${suffix}`); + lines.push(` ${entry.rationale}`); + lines.push(` Current: \`${formatValue(entry.currentValue)}\``); + }); + }); + + lines.push("", "## Recommended Commands", ""); + result.recommendedCommands.forEach((command) => lines.push(`- \`${command}\``)); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderCommands(result) { + return `${result.recommendedCommands.join("\n")}\n`; +} + +function main() { + const environment = getArg("environment", "staging"); + const environmentDirArg = getArg("environment-dir"); + const outputMode = getArg("output", "text").toLowerCase(); + const selectedPhase = getArg("phase", "all").toLowerCase(); + const environmentDir = resolveEnvironmentDir(environment, environmentDirArg); + + if (!fs.existsSync(environmentDir)) { + const message = `Unknown environment starter "${environment}". Expected a directory under infrastructure/environments/.`; + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify({ ok: false, environment, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + if (selectedPhase !== "all" && !phaseOrder.includes(selectedPhase)) { + console.error(`Unsupported phase "${selectedPhase}". Use one of: all, ${phaseOrder.join(", ")}`); + process.exit(1); + } + + const result = buildGuide(environment, environmentDir); + if (!result.ok && result.errors) { + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + console.error(result.errors.join("\n")); + } + process.exit(1); + } + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (outputMode === "markdown" || outputMode === "md") { + process.stdout.write(renderMarkdown(result, selectedPhase)); + } else if (outputMode === "commands" || outputMode === "shell") { + process.stdout.write(renderCommands(result)); + } else { + process.stdout.write(renderText(result, selectedPhase)); + } + + process.exit(result.firstDeployReady ? 0 : 1); +} + +main(); diff --git a/scripts/show-rollout-status.mjs b/scripts/show-rollout-status.mjs new file mode 100644 index 000000000..202d3e0cf --- /dev/null +++ b/scripts/show-rollout-status.mjs @@ -0,0 +1,454 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function parseEnvironmentNames() { + const singleEnvironment = getArg("environment"); + if (singleEnvironment) return [singleEnvironment]; + + const environmentsArg = getArg("environments", "staging,prod"); + const names = environmentsArg + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + return names.length > 0 ? names : ["staging", "prod"]; +} + +function normalizeDeploymentIntent(value) { + const normalized = String(value || "all").trim().toLowerCase(); + if (["github", "github-actions", "github_actions", "gha"].includes(normalized)) { + return "github-actions"; + } + if (normalized === "local") { + return "local"; + } + return "all"; +} + +function buildPlanArgs(environment, options) { + const args = [ + path.join(rootDir, "scripts", "plan-environment-deploy.mjs"), + `--environment=${environment}`, + "--output=json", + ]; + + if (options.environmentRootDir) { + args.push(`--environment-dir=${path.join(options.environmentRootDir, environment)}`); + } + + for (const [flagName, value] of Object.entries(options.forwardedArgs)) { + if (value === "") continue; + args.push(`--${flagName}=${value}`); + } + + return args; +} + +function isLocalOnlyNextStep(value) { + const text = String(value || ""); + return text.includes("local Api repo is unreadable") + || text.includes("switch the local run to package-manifest") + || text.includes("switch the local run to package-manifest or backend-artifact"); +} + +function isLocalOnlyCommand(value) { + const text = String(value || ""); + return text.includes("deploy-split-stack.sh") + || ( + text.startsWith("yarn plan:environment-deploy --") + && (text.includes("--deployment-source=package-manifest") || text.includes("--deployment-source=backend-artifact")) + ); +} + +function filterCommandsForIntent(commands, deploymentIntent) { + if (deploymentIntent !== "github-actions") return commands; + return commands.filter((command) => !isLocalOnlyCommand(command)); +} + +function runPlan(environment, options) { + const result = spawnSync(process.execPath, buildPlanArgs(environment, options), { + cwd: rootDir, + encoding: "utf8", + env: process.env, + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + let parsed = null; + + try { + parsed = JSON.parse(stdout); + } catch { + parsed = null; + } + + if (!parsed || typeof parsed !== "object") { + return { + ok: false, + environment, + status: "blocked", + errors: [`Could not parse plan output for ${environment}.`, stderr || stdout || "No output returned."], + }; + } + + const ready = parsed.recommendedExecution?.path && parsed.recommendedExecution.path !== "none"; + const starterAndInputBlockerCount = (parsed.starterSummary?.blockerCount || 0) + (parsed.inputBlockers?.length || 0); + const sharedBlockers = (parsed.blockers || []).map((entry) => entry.summary).filter(Boolean); + const localOnlyBlockers = (parsed.localExecution?.blockers || []).filter((entry) => !sharedBlockers.includes(entry)); + const githubOnlyBlockers = (parsed.githubActionsExecution?.blockers || []).filter((entry) => !sharedBlockers.includes(entry)); + const localDispatchBlockers = parsed.localGithubDispatch?.blockers || []; + + const highlightedBlockers = [ + ...sharedBlockers, + ...localOnlyBlockers, + ...githubOnlyBlockers, + ...localDispatchBlockers, + ].filter((value, index, list) => list.indexOf(value) === index).slice(0, 5); + + const githubFocusedHighlightedBlockers = [ + ...sharedBlockers, + ...githubOnlyBlockers, + ...localDispatchBlockers, + ].filter((value, index, list) => list.indexOf(value) === index).slice(0, 5); + + const githubFocusedNextSteps = (parsed.nextSteps || []).filter((step) => !isLocalOnlyNextStep(step)); + const githubFocusedReady = starterAndInputBlockerCount === 0 + && parsed.githubActionsExecution?.ok === true + && parsed.localGithubDispatch?.ok === true; + + return { + ok: true, + environment, + status: ready ? "ready" : "blocked", + githubFocusedStatus: githubFocusedReady ? "ready" : "blocked", + recommendedPath: parsed.recommendedExecution?.path || "none", + recommendedReason: parsed.recommendedExecution?.reason || "", + primaryCommand: parsed.recommendedCommands?.primary || "", + alternateCommands: parsed.recommendedCommands?.alternates || [], + starterBlockerCount: parsed.starterSummary?.blockerCount || 0, + inputBlockerCount: parsed.inputBlockers?.length || 0, + starterAndInputBlockerCount, + localExecutionOk: parsed.localExecution?.ok === true, + localExecutionBlockerCount: parsed.localExecution?.blockerCount || 0, + githubActionsExecutionOk: parsed.githubActionsExecution?.ok === true, + githubActionsExecutionBlockerCount: parsed.githubActionsExecution?.blockerCount || 0, + localGithubDispatchOk: parsed.localGithubDispatch?.ok === true, + localGithubDispatchBlockerCount: parsed.localGithubDispatch?.blockerCount || 0, + appConfigSecretFilePresent: parsed.appConfigSecretFilePresent === true, + warnings: parsed.warnings || [], + highlightedBlockers, + githubFocusedHighlightedBlockers, + nextSteps: parsed.nextSteps || [], + githubFocusedNextSteps, + }; +} + +function uniq(values) { + return values.filter((value, index) => value && values.indexOf(value) === index); +} + +function buildBlockerCategory(environments, predicate) { + const matching = environments + .filter(predicate) + .map((entry) => entry.environment); + + return { + environmentCount: matching.length, + environments: matching, + }; +} + +function buildCommandSummary(result) { + const global = result.recommendedNextCommand ? [result.recommendedNextCommand] : []; + const globalSet = new Set(global); + const byEnvironment = {}; + const all = [...global]; + + result.environments.forEach((environment) => { + const commands = filterCommandsForIntent([ + environment.primaryCommand, + ...environment.alternateCommands, + ], result.deploymentIntent).filter((command) => command && !globalSet.has(command)); + + byEnvironment[environment.environment] = commands; + commands.forEach((command) => { + if (!all.includes(command)) all.push(command); + }); + }); + + return { + global, + all, + byEnvironment, + }; +} + +function renderCommands(result) { + const lines = []; + const printedGlobalCommands = new Set(); + + if (result.recommendedNextCommand) { + lines.push(result.recommendedNextCommand); + printedGlobalCommands.add(result.recommendedNextCommand); + } + + result.environments.forEach((environment) => { + const environmentCommands = filterCommandsForIntent([ + environment.primaryCommand, + ...environment.alternateCommands, + ], result.deploymentIntent).filter((command) => command && !printedGlobalCommands.has(command)); + + if (environmentCommands.length === 0) return; + + if (lines.length > 0) lines.push(""); + lines.push(`# ${environment.environment}`); + for (const command of environmentCommands) { + lines.push(command); + } + }); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderMarkdown(result) { + const lines = [ + "# Rollout Status", + "", + `- Overall status: ${result.ok ? "ready" : "blocked"}`, + `- Deployment intent: \`${result.deploymentIntent}\``, + `- Ready environments: ${result.readyEnvironmentCount}/${result.environmentCount}`, + `- Blocked environments: ${result.blockedEnvironmentCount}`, + ]; + + if (result.recommendedNextCommand) { + lines.push(`- Recommended next command: \`${result.recommendedNextCommand}\``); + } + if (result.readyEnvironments.length > 0) { + lines.push(`- Ready environment names: ${result.readyEnvironments.map((name) => `\`${name}\``).join(", ")}`); + } + if (result.blockedEnvironments.length > 0) { + lines.push(`- Blocked environment names: ${result.blockedEnvironments.map((name) => `\`${name}\``).join(", ")}`); + } + lines.push(`- Starter/Input blocker environments: ${result.blockerCategories.starterOrInput.environmentCount}`); + lines.push(`- Local execution blocker environments: ${result.blockerCategories.localExecution.environmentCount}`); + lines.push(`- GitHub execution blocker environments: ${result.blockerCategories.githubActionsExecution.environmentCount}`); + lines.push(`- Local GitHub dispatch blocker environments: ${result.blockerCategories.localGithubDispatch.environmentCount}`); + if (result.ignoredBlockerCategories.length > 0) { + lines.push(`- Ignored blocker categories: ${result.ignoredBlockerCategories.map((value) => `\`${value}\``).join(", ")}`); + } + if (result.overallHighlightedBlockers.length > 0) { + lines.push("", "## Overall Blockers", ""); + result.overallHighlightedBlockers.forEach((blocker) => lines.push(`- ${blocker}`)); + } + if (result.recommendedNextSteps.length > 0) { + lines.push("", "## Overall Next Steps", ""); + result.recommendedNextSteps.forEach((step) => lines.push(`- ${step}`)); + } + + result.environments.forEach((environment) => { + lines.push("", `## ${environment.environment}`, ""); + lines.push(`- Status: ${environment.status}`); + lines.push(`- Recommended path: \`${environment.recommendedPath}\``); + lines.push(`- Reason: ${environment.recommendedReason}`); + lines.push(`- Starter/Input blockers: ${environment.starterAndInputBlockerCount}`); + lines.push(`- Local path: ${environment.localExecutionOk ? "ready" : `blocked (${environment.localExecutionBlockerCount})`}`); + lines.push(`- GitHub Actions path: ${environment.githubActionsExecutionOk ? "ready" : `blocked (${environment.githubActionsExecutionBlockerCount})`}`); + lines.push(`- Local GitHub dispatch: ${environment.localGithubDispatchOk ? "ready" : `blocked (${environment.localGithubDispatchBlockerCount})`}`); + lines.push(`- Primary command: \`${environment.primaryCommand}\``); + + if (environment.highlightedBlockers.length > 0) { + lines.push("- Highlighted blockers:"); + environment.highlightedBlockers.forEach((blocker) => lines.push(` - ${blocker}`)); + } + + if (environment.nextSteps.length > 0) { + lines.push("- Next step:"); + lines.push(` - ${environment.nextSteps[0]}`); + } + }); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderText(result) { + const lines = [ + `Rollout status: ${result.ok ? "ready" : "blocked"}`, + `Deployment intent: ${result.deploymentIntent}`, + `Ready environments: ${result.readyEnvironmentCount}/${result.environmentCount}`, + `Blocked environments: ${result.blockedEnvironmentCount}`, + ]; + + if (result.recommendedNextCommand) { + lines.push(`Recommended next command: ${result.recommendedNextCommand}`); + } + if (result.readyEnvironments.length > 0) { + lines.push(`Ready environment names: ${result.readyEnvironments.join(", ")}`); + } + if (result.blockedEnvironments.length > 0) { + lines.push(`Blocked environment names: ${result.blockedEnvironments.join(", ")}`); + } + lines.push(`Starter/Input blocker environments: ${result.blockerCategories.starterOrInput.environmentCount}`); + lines.push(`Local execution blocker environments: ${result.blockerCategories.localExecution.environmentCount}`); + lines.push(`GitHub execution blocker environments: ${result.blockerCategories.githubActionsExecution.environmentCount}`); + lines.push(`Local GitHub dispatch blocker environments: ${result.blockerCategories.localGithubDispatch.environmentCount}`); + if (result.ignoredBlockerCategories.length > 0) { + lines.push(`Ignored blocker categories: ${result.ignoredBlockerCategories.join(", ")}`); + } + result.overallHighlightedBlockers.forEach((blocker) => { + lines.push(`Overall blocker: ${blocker}`); + }); + result.recommendedNextSteps.forEach((step) => { + lines.push(`Overall next step: ${step}`); + }); + + result.environments.forEach((environment) => { + lines.push(""); + lines.push(`[${environment.environment}] ${environment.status}`); + lines.push(`Recommended path: ${environment.recommendedPath}`); + lines.push(`Reason: ${environment.recommendedReason}`); + lines.push(`Starter/Input blockers: ${environment.starterAndInputBlockerCount}`); + lines.push(`Local path: ${environment.localExecutionOk ? "ready" : `blocked (${environment.localExecutionBlockerCount})`}`); + lines.push(`GitHub Actions path: ${environment.githubActionsExecutionOk ? "ready" : `blocked (${environment.githubActionsExecutionBlockerCount})`}`); + lines.push(`Local GitHub dispatch: ${environment.localGithubDispatchOk ? "ready" : `blocked (${environment.localGithubDispatchBlockerCount})`}`); + lines.push(`Primary command: ${environment.primaryCommand}`); + + environment.highlightedBlockers.forEach((blocker) => { + lines.push(`Blocker: ${blocker}`); + }); + }); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function main() { + const environmentRootDir = getArg("environment-root-dir"); + const deploymentIntent = normalizeDeploymentIntent(getArg("deployment-intent", "all")); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const markdownOutput = outputMode === "markdown" || outputMode === "md"; + const commandsOutput = outputMode === "commands" || outputMode === "shell"; + const environmentNames = parseEnvironmentNames(); + + const forwardedArgs = { + "deployment-source": getArg("deployment-source"), + "github-auth-mode": getArg("github-auth-mode"), + "region": getArg("region"), + "api-repo-path": getArg("api-repo-path"), + "api-repo": getArg("api-repo"), + "api-ref": getArg("api-ref"), + "package-manifest-file": getArg("package-manifest-file"), + "backend-artifact-source-file": getArg("backend-artifact-source-file"), + "migration-artifact-source-file": getArg("migration-artifact-source-file"), + "dependencies-layer-source-file": getArg("dependencies-layer-source-file"), + "sync-app-config-secret": getArg("sync-app-config-secret"), + "run-api-migrations": getArg("run-api-migrations"), + "api-migration-action": getArg("api-migration-action"), + "api-migration-module": getArg("api-migration-module"), + "verify-http-after-deploy": getArg("verify-http-after-deploy"), + "account-id": getArg("account-id"), + }; + + if (environmentRootDir && !fs.existsSync(environmentRootDir)) { + const message = `Environment root directory does not exist: ${environmentRootDir}`; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify({ ok: false, errors: [message] }, null, 2)}\n`); + } else { + console.error(message); + } + process.exit(1); + } + + const environments = environmentNames.map((environment) => runPlan(environment, { + environmentRootDir: environmentRootDir ? path.resolve(rootDir, environmentRootDir) : "", + forwardedArgs, + })); + + const intentStatusField = deploymentIntent === "github-actions" ? "githubFocusedStatus" : "status"; + const ignoredBlockerCategories = deploymentIntent === "github-actions" ? ["localExecution"] : []; + + const readyEnvironmentCount = environments.filter((entry) => entry[intentStatusField] === "ready").length; + const blockedEnvironmentCount = environments.length - readyEnvironmentCount; + const firstBlockedEnvironment = environments.find((entry) => entry[intentStatusField] === "blocked"); + const readyEnvironments = environments.filter((entry) => entry[intentStatusField] === "ready").map((entry) => entry.environment); + const blockedEnvironments = environments.filter((entry) => entry[intentStatusField] === "blocked").map((entry) => entry.environment); + const overallHighlightedBlockers = uniq(environments.flatMap((entry) => ( + deploymentIntent === "github-actions" ? (entry.githubFocusedHighlightedBlockers || []) : (entry.highlightedBlockers || []) + ))).slice(0, 8); + const recommendedNextSteps = uniq(environments + .filter((entry) => entry[intentStatusField] === "blocked") + .map((entry) => ( + deploymentIntent === "github-actions" + ? (entry.githubFocusedNextSteps?.[0] || entry.nextSteps?.[0] || "") + : (entry.nextSteps?.[0] || "") + ))) + .slice(0, 5); + const blockerCategories = { + starterOrInput: buildBlockerCategory(environments, (entry) => entry.starterAndInputBlockerCount > 0), + localExecution: buildBlockerCategory(environments, (entry) => entry.localExecutionOk !== true), + githubActionsExecution: buildBlockerCategory(environments, (entry) => entry.githubActionsExecutionOk !== true), + localGithubDispatch: buildBlockerCategory(environments, (entry) => entry.localGithubDispatchOk !== true), + }; + const intentBlockerCategories = deploymentIntent === "github-actions" + ? { + starterOrInput: blockerCategories.starterOrInput, + githubActionsExecution: blockerCategories.githubActionsExecution, + localGithubDispatch: blockerCategories.localGithubDispatch, + } + : blockerCategories; + const commandSummary = buildCommandSummary({ + deploymentIntent, + recommendedNextCommand: firstBlockedEnvironment?.primaryCommand || "", + environments, + }); + + const result = { + ok: blockedEnvironmentCount === 0 && environments.every((entry) => entry.ok), + deploymentIntent, + ignoredBlockerCategories, + environmentCount: environments.length, + readyEnvironmentCount, + blockedEnvironmentCount, + readyEnvironments, + blockedEnvironments, + recommendedNextCommand: firstBlockedEnvironment?.primaryCommand || "", + commandSummary, + blockerCategories, + intentBlockerCategories, + overallHighlightedBlockers, + recommendedNextSteps, + environments, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (commandsOutput) { + process.stdout.write(renderCommands(result)); + } else { + process.stdout.write(markdownOutput ? renderMarkdown(result) : renderText(result)); + } + + if (!result.ok) { + process.exit(1); + } +} + +main(); diff --git a/scripts/smoke-aws-tooling.mjs b/scripts/smoke-aws-tooling.mjs new file mode 100644 index 000000000..7b4bdc00d --- /dev/null +++ b/scripts/smoke-aws-tooling.mjs @@ -0,0 +1,8777 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const jsonOutput = process.argv.includes("--output=json") || (process.argv.includes("--output") && process.argv[process.argv.indexOf("--output") + 1] === "json"); +const childProcessTimeoutMs = Number(process.env.SMOKE_CHILD_TIMEOUT_MS || 30000); + +function spawnNode(scriptPath, args, env = {}) { + const result = spawnSync("node", [scriptPath, ...args], { + cwd: rootDir, + encoding: "utf8", + timeout: childProcessTimeoutMs, + env: { + ...process.env, + ...env, + }, + }); + + return { + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: `${result.stderr || ""}${result.error ? `\n${result.error.message}` : ""}`, + }; +} + +function runCheck(scriptPath) { + execFileSync("node", ["--check", scriptPath], { + cwd: rootDir, + stdio: "pipe", + encoding: "utf8", + }); +} + +function runShellCheck(scriptPath) { + execFileSync("bash", ["-n", scriptPath], { + cwd: rootDir, + stdio: "pipe", + encoding: "utf8", + }); +} + +function runYamlParse(filePath) { + execFileSync("ruby", ["-e", 'require "psych"; Psych.parse_stream(File.read(ARGV[0]))', filePath], { + cwd: rootDir, + stdio: "pipe", + encoding: "utf8", + }); +} + +function sleepMs(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function parseJsonFileWithRetry(filePath, { attempts = 4, retryDelayMs = 50 } = {}) { + let lastError = null; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return JSON.parse(fs.readFileSync(path.join(rootDir, filePath), "utf8")); + } catch (error) { + lastError = error; + const isMissingFile = error && typeof error === "object" && "code" in error && error.code === "ENOENT"; + const isTransientParseFailure = error instanceof SyntaxError; + const shouldRetry = attempt < attempts && (isMissingFile || isTransientParseFailure); + if (!shouldRetry) break; + sleepMs(retryDelayMs); + } + } + + throw lastError; +} + +function runJsonParse(filePath) { + parseJsonFileWithRetry(filePath); +} + +function readJsonFile(filePath) { + return parseJsonFileWithRetry(filePath); +} + +function listContractCheckedJsonSamples() { + const smokeSource = fs.readFileSync(fileURLToPath(import.meta.url), "utf8"); + return [...new Set( + [...smokeSource.matchAll(/readJsonFile\("([^"]+\.sample\.json)"\)/g)].map((match) => match[1]), + )].sort(); +} + +function expectJsonExampleContractCoverage(jsonFilesToParse) { + const inputOnlySamples = [ + "infrastructure/examples/app-config-secret.sample.json", + "infrastructure/examples/backend-outputs.sample.json", + "infrastructure/examples/backend-parameters.sample.json", + "infrastructure/examples/backend-stack-outputs.sample.json", + "infrastructure/examples/bootstrap-parameters.sample.json", + "infrastructure/examples/database-secret.sample.json", + "infrastructure/examples/frontend-outputs.sample.json", + "infrastructure/examples/frontend-parameters.sample.json", + "infrastructure/examples/full-stack-parameters.sample.json", + ].sort(); + + const parsedExampleSamples = jsonFilesToParse + .filter((filePath) => filePath.startsWith("infrastructure/examples/") && filePath.endsWith(".sample.json")) + .sort(); + const contractCheckedSamples = listContractCheckedJsonSamples(); + + const missingContractCoverage = parsedExampleSamples.filter((filePath) => ( + !contractCheckedSamples.includes(filePath) && !inputOnlySamples.includes(filePath) + )); + const staleInputOnlyEntries = inputOnlySamples.filter((filePath) => !parsedExampleSamples.includes(filePath)); + const contractCheckedButUnparsed = contractCheckedSamples.filter((filePath) => !parsedExampleSamples.includes(filePath)); + + if (missingContractCoverage.length > 0 || staleInputOnlyEntries.length > 0 || contractCheckedButUnparsed.length > 0) { + const lines = []; + if (missingContractCoverage.length > 0) { + lines.push(`Samples missing contract coverage or input-only classification: ${missingContractCoverage.join(", ")}`); + } + if (staleInputOnlyEntries.length > 0) { + lines.push(`Input-only sample allowlist contains files that are no longer parsed: ${staleInputOnlyEntries.join(", ")}`); + } + if (contractCheckedButUnparsed.length > 0) { + lines.push(`Contract-checked samples are no longer parsed by the smoke suite: ${contractCheckedButUnparsed.join(", ")}`); + } + throw new Error(lines.join("\n")); + } +} + +function expectEnvironmentStarterParity() { + const environmentsRoot = path.join(rootDir, "infrastructure", "environments"); + const environmentNames = fs.readdirSync(environmentsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const requiredEnvironments = ["prod", "staging"]; + const missingEnvironments = requiredEnvironments.filter((name) => !environmentNames.includes(name)); + if (missingEnvironments.length > 0) { + throw new Error(`Missing expected environment starter directories: ${missingEnvironments.join(", ")}`); + } + + const fileSets = Object.fromEntries(environmentNames.map((name) => { + const envDir = path.join(environmentsRoot, name); + const files = fs.readdirSync(envDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .filter((entry) => [ + "app-config-secret.template.json", + "backend-parameters.json", + "bootstrap-parameters.json", + "deploy-split-stack.sh", + "frontend-parameters.json", + ].includes(entry.name)) + .map((entry) => entry.name) + .sort(); + return [name, files]; + })); + + const baseline = fileSets[requiredEnvironments[0]]; + for (const environmentName of requiredEnvironments.slice(1)) { + const current = fileSets[environmentName]; + const missingFromCurrent = baseline.filter((fileName) => !current.includes(fileName)); + const extraInCurrent = current.filter((fileName) => !baseline.includes(fileName)); + + if (missingFromCurrent.length > 0 || extraInCurrent.length > 0) { + const lines = [`Environment starter kits are out of sync between ${requiredEnvironments[0]} and ${environmentName}.`]; + if (missingFromCurrent.length > 0) { + lines.push(`${environmentName} is missing: ${missingFromCurrent.join(", ")}`); + } + if (extraInCurrent.length > 0) { + lines.push(`${environmentName} has extra files: ${extraInCurrent.join(", ")}`); + } + throw new Error(lines.join("\n")); + } + } +} + +function expectDeployAwsWorkflowUploadsEvidenceArtifact() { + const workflowPath = path.join(rootDir, ".github", "workflows", "deploy-aws-self-hosted.yml"); + const workflowText = fs.readFileSync(workflowPath, "utf8"); + + const expectedSnippets = [ + "name: Write preflight plan summary", + "## Preflight Plan", + "yarn plan:environment-deploy --", + "preflight-plan.md", + "preview_only:", + "PREVIEW_ONLY:", + "name: Upload deployment evidence", + "uses: actions/upload-artifact@v4", + "name: aws-${{ inputs.environment }}-deployment-evidence", + "path: deployment/${{ inputs.environment }}/", + "name: Save source metadata", + "source-metadata.json", + "name: Upload preflight plan for preview-only run", + "name: Upload preflight plan on failure", + "name: aws-${{ inputs.environment }}-preflight-plan", + "path: deployment/${{ inputs.environment }}/preflight-plan.md", + "name: Write deployment summary", + "name: Write preview-only summary", + "## Preview-Only Result", + "GITHUB_STEP_SUMMARY", + "deployment-summary.json", + "yarn show:deployment-summary -- --summary-file=\"${SUMMARY_FILE}\" --output=markdown", + ]; + + const missing = expectedSnippets.filter((snippet) => !workflowText.includes(snippet)); + if (missing.length > 0) { + throw new Error(`deploy-aws-self-hosted workflow is missing expected deployment-evidence upload content: ${missing.join(", ")}`); + } + + const privateWorkflowTemplatePath = path.join(rootDir, "infrastructure", "environments", "private-deployment-workflow.sample.yml"); + const privateWorkflowTemplateText = fs.readFileSync(privateWorkflowTemplatePath, "utf8"); + if (!privateWorkflowTemplateText.includes("ARGS+=(--run-api-migrations=true)")) { + throw new Error("private deployment workflow template must pass --run-api-migrations=true explicitly so deploy-aws forwards migrations to deploy-backend."); + } + if (!privateWorkflowTemplateText.includes("name: Save source metadata") + || !privateWorkflowTemplateText.includes("source-metadata.json")) { + throw new Error("private deployment workflow template must save source-metadata.json into deployment evidence."); + } +} + +function expectObjectContainsKeys(name, actual, sample, objectPath = "") { + if (!actual || typeof actual !== "object" || Array.isArray(actual)) { + throw new Error(`${name} expected an object at ${objectPath}.`); + } + if (!sample || typeof sample !== "object" || Array.isArray(sample)) { + throw new Error(`${name} sample did not contain an object at ${objectPath}.`); + } + + const missing = Object.keys(actual).filter((key) => !(key in sample)); + if (missing.length > 0) { + throw new Error(`${name} sample is missing keys at ${objectPath}: ${missing.join(", ")}`); + } +} + +function canReadFile(filePath) { + try { + fs.accessSync(filePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function canReadDirectory(filePath) { + try { + fs.readdirSync(filePath); + return true; + } catch { + return false; + } +} + +function addSkippedResults(results, names) { + names.forEach((name) => { + results.push({ + name, + ok: true, + skipped: true, + }); + }); +} + +function parseApiRepoServerlessEnvKeys(filePath) { + const ruby = ` + require "yaml" + require "json" + data = YAML.load_file(ARGV[0]) + provider_env = (data.dig("provider", "environment") || {}).keys + function_env = (data["functions"] || {}).values.flat_map { |fn| (fn["environment"] || {}).keys } + puts JSON.generate((provider_env + function_env).uniq.sort) + `; + + return JSON.parse(execFileSync("ruby", ["-e", ruby, filePath], { + cwd: rootDir, + stdio: "pipe", + encoding: "utf8", + })); +} + +function checkBackendTemplateContainsApiRepoEnvKeys(apiRepoPath) { + const serverlessPath = path.join(apiRepoPath, "serverless.yml"); + const templatePath = path.join(rootDir, "infrastructure", "cloudformation", "backend-api.yaml"); + const envKeys = parseApiRepoServerlessEnvKeys(serverlessPath); + const templateText = fs.readFileSync(templatePath, "utf8"); + const missing = envKeys.filter((key) => !templateText.includes(`${key}:`)); + + if (missing.length > 0) { + throw new Error(`backend-api.yaml is missing env keys required by Api/serverless.yml: ${missing.join(", ")}`); + } +} + +function runJsonScript(scriptPath, args) { + const result = spawnNode(scriptPath, args); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + let parsed = null; + + if (stdout.trim() !== "") { + parsed = JSON.parse(stdout); + } + + return { + status: result.status ?? 1, + stdout, + stderr, + parsed, + }; +} + +function runJsonScriptWithEnv(scriptPath, args, env) { + const result = spawnNode(scriptPath, args, env); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + let parsed = null; + + if (stdout.trim() !== "") { + parsed = JSON.parse(stdout); + } + + return { + status: result.status ?? 1, + stdout, + stderr, + parsed, + }; +} + +function runScript(scriptPath, args) { + return spawnNode(scriptPath, args); +} + +function runScriptWithEnv(scriptPath, args, env) { + return spawnNode(scriptPath, args, env); +} + +function expectOk(name, invocation) { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", invocation); + if (result.status !== 0) { + throw new Error(`${name} failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + if (!result.parsed?.ok) { + throw new Error(`${name} returned ok=false unexpectedly.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectBootstrapValidatorNextStep() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=bootstrap", + "--stack-name=example-bootstrap", + "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`bootstrap validator next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.stackName !== "example-bootstrap") { + throw new Error(`bootstrap validator did not preserve stack-name.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.parametersFile !== "infrastructure/examples/bootstrap-parameters.sample.json") { + throw new Error(`bootstrap validator did not expose parametersFile cleanly.\nSTDOUT:\n${result.stdout}`); + } + + const nextStep = result.parsed.nextSteps?.[0] || ""; + const expected = "yarn deploy:bootstrap -- --region=us-east-1 --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json --stack-name=example-bootstrap"; + if (nextStep !== expected) { + throw new Error(`bootstrap validator next step was not exact.\nExpected:\n${expected}\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); + } +} + +function expectPackageManifestValidatorNextStep() { + withFakePackageManifest((manifestPath) => { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`package manifest validator next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.resolved?.packageManifestFile !== manifestPath) { + throw new Error(`package manifest validator did not expose the resolved manifest path.\nSTDOUT:\n${result.stdout}`); + } + + const nextStep = result.parsed.nextSteps?.find((step) => String(step).includes("upload:backend-artifact")) || ""; + if (!nextStep.includes(`--source-file=${path.join(path.dirname(manifestPath), "api-test-self-contained.zip")}`)) { + throw new Error(`package manifest validator next step did not reuse the manifest backend artifact path.\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); + } + }); +} + +function expectPackageManifestValidatorMigrationNextStep() { + withFakePackageManifest((manifestPath) => { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--run-migrations=true", + "--migration-handler=index.migrate", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`package manifest validator migration next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.resolved?.migrationArtifactSource !== path.join(path.dirname(manifestPath), "api-test-migrations.zip")) { + throw new Error(`package manifest validator did not expose the resolved migration artifact path.\nSTDOUT:\n${result.stdout}`); + } + + const nextStep = result.parsed.nextSteps?.find((step) => String(step).includes('Migration artifact')) || ""; + if (!nextStep.includes(`--source-file=${path.join(path.dirname(manifestPath), "api-test-migrations.zip")}`)) { + throw new Error(`package manifest validator migration next step did not reuse the manifest migration artifact path.\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); + } + }, { includeMigrationArtifact: true }); +} + +function expectPackageApiBackendJsonIncludesManifestDeployHints() { + withFakePackagableApiRepo((fakeApiRepoPath) => { + const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-output-")); + const migrationArtifactPath = path.join(outputDir, "api-stage-migrations.zip"); + + try { + fs.writeFileSync(migrationArtifactPath, "fake migration artifact\n"); + + const result = runJsonScript("scripts/package-api-backend.mjs", [ + `--api-repo-path=${fakeApiRepoPath}`, + `--output-dir=${outputDir}`, + "--project-name=testproj", + "--environment=stage", + `--migration-artifact-path=${migrationArtifactPath}`, + "--build=false", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`package-api-backend json hints failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.backendArtifactPath !== "api-stage-self-contained.zip") { + throw new Error(`package-api-backend did not emit a manifest-relative backend artifact path.\nSTDOUT:\n${result.stdout}`); + } + + if (parsed.recommendedBackendArtifactKey !== "testproj/stage/backend/api.zip") { + throw new Error(`package-api-backend did not expose the derived backend artifact key.\nSTDOUT:\n${result.stdout}`); + } + + if (parsed.migrationArtifactPath !== "api-stage-migrations.zip") { + throw new Error(`package-api-backend did not emit a manifest-relative migration artifact path.\nSTDOUT:\n${result.stdout}`); + } + + const deployBackend = parsed.recommendedNextSteps?.deployBackend || ""; + if (!deployBackend.includes("--package-manifest-file=")) { + throw new Error(`package-api-backend did not emit a manifest-driven deploy:backend hint.\nSTDOUT:\n${result.stdout}`); + } + + const uploadHint = parsed.recommendedNextSteps?.uploadBackendArtifact || ""; + if (!uploadHint.includes("--artifact-key=testproj/stage/backend/api.zip")) { + throw new Error(`package-api-backend upload hint did not include the derived artifact key.\nSTDOUT:\n${result.stdout}`); + } + + const uploadMigrationHint = parsed.recommendedNextSteps?.uploadMigrationArtifact || ""; + if (!uploadMigrationHint.includes("--artifact-key=testproj/stage/backend/migrations.zip")) { + throw new Error(`package-api-backend migration upload hint did not include the derived migration artifact key.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); +} + +function expectPackageManifestSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/package-manifest.sample.json"); + + withFakePackagableApiRepo((fakeApiRepoPath) => { + const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-manifest-contract-")); + + try { + const result = runJsonScript("scripts/package-api-backend.mjs", [ + `--api-repo-path=${fakeApiRepoPath}`, + `--output-dir=${outputDir}`, + "--project-name=b1admin", + "--environment=prod", + "--build=false", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`package manifest sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("package manifest sample", actual, sample); + expectObjectContainsKeys("package manifest sample", actual.recommendedNextSteps || {}, sample.recommendedNextSteps || {}, "recommendedNextSteps"); + + if (sample.apiRepoPath !== "") { + throw new Error(`package manifest sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendArtifactPath !== "api-prod-self-contained.zip") { + throw new Error(`package manifest sample should document the manifest-relative backend artifact path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.manifestPath !== "package-manifest.sample.json") { + throw new Error(`package manifest sample should point at the checked sample manifest filename.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedBackendArtifactKey !== "b1admin/prod/backend/api.zip") { + throw new Error(`package manifest sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedMigrationArtifactKey !== "b1admin/prod/backend/migrations.zip") { + throw new Error(`package manifest sample should document the derived migration artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.recommendedNextSteps?.deployBackend || "").includes("--package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json")) { + throw new Error(`package manifest sample should document the manifest-driven deploy:backend hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.recommendedNextSteps?.uploadBackendArtifact || "").includes("--artifact-key=b1admin/prod/backend/api.zip")) { + throw new Error(`package manifest sample should document the upload helper artifact key hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); +} + +function expectPackageApiBackendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/package-api-backend-output.sample.json"); + + withFakePackagableApiRepo((fakeApiRepoPath) => { + const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-output-contract-")); + + try { + const result = runJsonScript("scripts/package-api-backend.mjs", [ + `--api-repo-path=${fakeApiRepoPath}`, + `--output-dir=${outputDir}`, + "--project-name=b1admin", + "--environment=prod", + "--build=false", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`package-api-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("package-api-backend output sample", actual, sample); + expectObjectContainsKeys( + "package-api-backend output sample", + actual.recommendedNextSteps || {}, + sample.recommendedNextSteps || {}, + "recommendedNextSteps", + ); + + if (sample.apiRepoPath !== "") { + throw new Error(`package-api-backend output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendArtifactPath !== "api-prod-self-contained.zip") { + throw new Error(`package-api-backend output sample should document the manifest-relative backend artifact path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.manifestPath !== "infrastructure/artifacts/api/api-prod-self-contained.manifest.json") { + throw new Error(`package-api-backend output sample should point at the generated manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedBackendArtifactKey !== "b1admin/prod/backend/api.zip") { + throw new Error(`package-api-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedMigrationArtifactKey !== "b1admin/prod/backend/migrations.zip") { + throw new Error(`package-api-backend output sample should document the derived migration artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.recommendedNextSteps?.deployBackend || "").includes("--package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json")) { + throw new Error(`package-api-backend output sample should document the manifest-driven deploy:backend hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.recommendedNextSteps?.uploadBackendArtifact || "").includes("--artifact-key=b1admin/prod/backend/api.zip")) { + throw new Error(`package-api-backend output sample should document the upload helper artifact key hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); +} + +function expectAuditEnvironmentStarterOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/audit-environment-starter-output.sample.json"); + let result; + withRawStarterEnvironment("staging", (tempDir) => { + result = runJsonScript("scripts/audit-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--output=json", + ]); + }); + + if (result.status !== 1) { + throw new Error(`audit-environment-starter sample contract run should fail while placeholders remain in staging.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("audit-environment-starter output sample", actual, sample); + + if (sample.ok !== false || sample.environment !== "staging") { + throw new Error(`audit-environment-starter output sample should document a non-ready staging starter.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.summary?.placeholderCount !== 5 + || sample.summary?.unsafeDefaultCount !== 10 + || sample.summary?.requiredBlankCount !== 0 + || sample.summary?.optionalBlankCount !== 41) { + throw new Error(`audit-environment-starter output sample should document the current staging placeholder, starter-default, and optional-blank counts.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectAuditEnvironmentStarterMarkdownOutputWorks() { + let result; + withRawStarterEnvironment("staging", (tempDir) => { + result = spawnSync("node", ["scripts/audit-environment-starter.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--only-blockers=true", "--output=markdown"], { + cwd: rootDir, + encoding: "utf8", + }); + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + + if (result.status !== 1) { + throw new Error(`audit-environment-starter markdown mode should fail while blockers remain in staging.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + if (!stdout.includes("# Environment Starter Audit: staging")) { + throw new Error(`audit-environment-starter markdown output is missing the expected title.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("## Next Steps") || !stdout.includes("## Suggestions") || !stdout.includes("## Findings")) { + throw new Error(`audit-environment-starter markdown output is missing one or more expected sections.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("jwtSecret") + || !stdout.includes("encryptionKey")) { + throw new Error(`audit-environment-starter markdown output should include the current staging blocker keys.\nSTDOUT:\n${stdout}`); + } +} + +function expectPrepareEnvironmentStarterOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/prepare-environment-starter-output.sample.json"); + const result = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--account-id=123456789012", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`prepare-environment-starter output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("prepare-environment-starter output sample", actual, sample); + + if (sample.ok !== true || sample.environment !== "staging" || sample.write !== false) { + throw new Error(`prepare-environment-starter output sample should document a staging dry-run result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.accountId !== "123456789012" || sample.generatedSecrets !== true || sample.usedExistingSecretFile !== false) { + throw new Error(`prepare-environment-starter output sample should document the expected input identity and secret-generation path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectPrepareEnvironmentStarterCommandsOutputWorks() { + const result = spawnSync("node", ["scripts/prepare-environment-starter.mjs", "--environment=staging", "--account-id=123456789012", "--output=commands"], { + cwd: rootDir, + encoding: "utf8", + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + + if (result.status !== 0) { + throw new Error(`prepare-environment-starter commands mode failed unexpectedly.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + const expectedSnippets = [ + "yarn prepare:environment-starter -- --environment=staging --account-id=123456789012 --write=true", + "yarn audit:environment-starter -- --environment=staging --only-blockers=true", + "./infrastructure/environments/staging/deploy-split-stack.sh", + ]; + + for (const snippet of expectedSnippets) { + if (!stdout.includes(snippet)) { + throw new Error(`prepare-environment-starter commands output is missing expected command: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectPrepareEnvironmentStarterMarkdownOutputWorks() { + let result; + withRawStarterEnvironment("staging", (tempDir) => { + result = spawnSync("node", ["scripts/prepare-environment-starter.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--account-id=123456789012", "--output=markdown"], { + cwd: rootDir, + encoding: "utf8", + }); + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + + if (result.status !== 0) { + throw new Error(`prepare-environment-starter markdown mode failed unexpectedly.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + const expectedSnippets = [ + "# Prepare Environment Starter: staging", + "## Proposed Changes", + "## Next Steps", + "## Recommended Commands", + "app-config-secret.json", + "jwtSecret", + ]; + + for (const snippet of expectedSnippets) { + if (!stdout.includes(snippet)) { + throw new Error(`prepare-environment-starter markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectPrepareEnvironmentStarterWriteModeClearsGeneratedBlockers() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-starter-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + if (!fs.existsSync(path.join(tempDir, "app-config-secret.json"))) { + throw new Error("prepare-environment-starter write mode did not create app-config-secret.json in the target environment directory."); + } + + const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--only-blockers=true", + "--output=json", + ]); + + if (auditResult.status !== 1) { + throw new Error(`audit-environment-starter should still report starter-default blockers after prepare write mode updates the copied environment.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); + } + + if (auditResult.parsed?.summary?.placeholderCount !== 0) { + throw new Error(`prepare-environment-starter write mode should clear placeholder blockers in the copied environment.\nSTDOUT:\n${auditResult.stdout}`); + } + + if (auditResult.parsed?.blockerSummary?.unsafeDefaultCount !== 9 || auditResult.parsed?.blockerSummary?.blockerCount !== 9) { + throw new Error(`audit-environment-starter should leave only the known starter-default blockers after prepare write mode.\nSTDOUT:\n${auditResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPrepareEnvironmentStarterWriteModeCanClearStarterDefaults() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-complete-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--admin-root-url=https://admin-staging.b1test.org", + "--cors-origin=https://admin-staging.b1test.org", + "--content-root-url=https://content-staging.b1test.org", + "--store-api-url=https://store-staging.b1test.org", + "--transfer-url=https://transfer-staging.b1test.org", + "--support-email=support@b1test.org", + "--support-phone=800-555-0199", + "--support-site-url=https://support.b1test.org", + "--website-base-url=https://{subdomain}.staging.b1test.org", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter full write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--only-blockers=true", + "--output=json", + ]); + + if (auditResult.status !== 0) { + throw new Error(`audit-environment-starter should report no blockers after prepare write mode receives explicit backend values.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); + } + + if (auditResult.parsed?.blockerSummary?.blockerCount !== 0) { + throw new Error(`prepare-environment-starter should be able to clear all starter blockers when explicit backend values are provided.\nSTDOUT:\n${auditResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPrepareEnvironmentStarterRootDomainShortcutWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-root-domain-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--root-domain=b1test.org", + "--support-phone=800-555-0199", + "--support-site-url=https://support.b1test.org", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter root-domain write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); + const expected = { + WebsiteBaseUrl: "https://{subdomain}.b1test.org", + ContentRootUrl: "https://content-staging.b1test.org", + B1AdminRootUrl: "https://admin-staging.b1test.org", + CorsOrigin: "https://admin-staging.b1test.org", + StoreApiUrl: "https://store-staging.b1test.org", + TransferUrl: "https://transfer-staging.b1test.org", + SupportEmail: "support@b1test.org", + }; + + for (const [key, value] of Object.entries(expected)) { + if (backend[key] !== value) { + throw new Error(`prepare-environment-starter root-domain shortcut did not derive ${key} correctly.\nBackend:\n${JSON.stringify(backend, null, 2)}`); + } + } + + const secret = JSON.parse(fs.readFileSync(path.join(tempDir, "app-config-secret.json"), "utf8")); + if (secret.webPushSubject !== "mailto:support@b1test.org") { + throw new Error(`prepare-environment-starter root-domain shortcut did not derive webPushSubject correctly.\nSecret:\n${JSON.stringify(secret, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPrepareEnvironmentStarterCustomDomainInputsWork() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-domains-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--frontend-domain=admin-staging.b1test.org", + "--frontend-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/frontend", + "--frontend-hosted-zone-id=ZFRONTEND123", + "--api-domain=api-staging.b1test.org", + "--api-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/api", + "--api-hosted-zone-id=ZAPI123", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter custom-domain write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); + const frontend = JSON.parse(fs.readFileSync(path.join(tempDir, "frontend-parameters.json"), "utf8")); + + if (frontend.AlternateDomainName !== "admin-staging.b1test.org" + || frontend.AcmCertificateArn !== "arn:aws:acm:us-east-1:123456789012:certificate/frontend" + || frontend.HostedZoneId !== "ZFRONTEND123") { + throw new Error(`prepare-environment-starter did not write the expected frontend custom-domain fields.\nFrontend:\n${JSON.stringify(frontend, null, 2)}`); + } + + if (backend.ApiCustomDomainName !== "api-staging.b1test.org" + || backend.ApiCertificateArn !== "arn:aws:acm:us-east-1:123456789012:certificate/api" + || backend.ApiHostedZoneId !== "ZAPI123" + || backend.B1AdminRootUrl !== "https://admin-staging.b1test.org" + || backend.CorsOrigin !== "https://admin-staging.b1test.org") { + throw new Error(`prepare-environment-starter did not write the expected backend custom-domain fields.\nBackend:\n${JSON.stringify(backend, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPrepareEnvironmentStarterWriteModeCanSkipSecretFile() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-no-secret-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--admin-root-url=https://admin-staging.customer.test", + "--cors-origin=https://admin-staging.customer.test", + "--content-root-url=https://content-staging.customer.test", + "--transfer-url=https://transfer-staging.customer.test", + "--support-email=support@customer.test", + "--support-phone=918-994-2638", + "--support-site-url=https://support-staging.customer.test", + "--website-base-url=https://{subdomain}.customer.test", + "--write=true", + "--write-secret-file=false", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter no-secret write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + if (prepareResult.parsed?.writeSecretFile !== false) { + throw new Error(`prepare-environment-starter should report writeSecretFile=false when asked to skip secret materialization.\nSTDOUT:\n${prepareResult.stdout}`); + } + + if (fs.existsSync(path.join(tempDir, "app-config-secret.json"))) { + throw new Error("prepare-environment-starter should not create app-config-secret.json when --write-secret-file=false is set."); + } + + const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--only-blockers=true", + "--output=json", + ]); + + if (auditResult.status !== 1) { + throw new Error(`audit-environment-starter should still report the unresolved secret-template blockers when no secret file is written.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); + } + + const secretTemplate = JSON.parse(fs.readFileSync(path.join(tempDir, "app-config-secret.template.json"), "utf8")); + if (secretTemplate.webPushSubject !== "mailto:support@customer.test") { + throw new Error(`prepare-environment-starter should update the template webPushSubject when secret materialization is skipped.\nTemplate:\n${JSON.stringify(secretTemplate, null, 2)}`); + } + + if (auditResult.parsed?.blockerSummary?.blockerCount !== 3) { + throw new Error(`prepare-environment-starter no-secret write mode should leave only the unresolved store URL plus the two secret placeholders.\nSTDOUT:\n${auditResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPrepareEnvironmentStarterOptionalPublicFieldsWork() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-public-fields-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "prod", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "prod"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=prod", + `--environment-dir=${tempDir}`, + "--mobile-app-url=https://customer.test/app", + "--domain-cname-target=proxy.customer.test", + "--domain-a-target=3.23.251.61", + "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", + "--google-analytics-tag=G-47N4XQJQJ5", + "--write=true", + "--write-secret-file=false", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter optional public fields write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); + const expected = { + MobileAppUrl: "https://customer.test/app", + DomainCnameTarget: "proxy.customer.test", + DomainATarget: "3.23.251.61", + DefaultStockPhoto: "https://content.customer.test/stockPhotos/default.png", + GoogleAnalyticsTag: "G-47N4XQJQJ5", + }; + + for (const [key, value] of Object.entries(expected)) { + if (backend[key] !== value) { + throw new Error(`prepare-environment-starter did not write ${key} as expected.\nBackend:\n${JSON.stringify(backend, null, 2)}`); + } + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/plan-environment-deploy-output.sample.json"); + let result; + withFailingGhForDispatchGithubAwsDeploy((env) => withRawStarterEnvironment("staging", (tempDir) => { + result = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--api-repo-path=.", + "--output=json", + ], env); + })); + + if (sample.localGithubDispatch?.ok !== false + || sample.localGithubDispatch?.blockerCount !== 1 + || !sample.localGithubDispatch?.blockers?.some((entry) => String(entry).includes("gh auth login -h github.com"))) { + throw new Error(`plan-environment-deploy output sample should document the local gh auth blocker cleanly.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + + if (result.status !== 1) { + throw new Error(`plan-environment-deploy output sample contract run should be blocked while placeholders remain in staging.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("plan-environment-deploy output sample", actual, sample); + + if (sample.ok !== false || sample.environment !== "staging" || sample.deploymentSource !== "api-repo") { + throw new Error(`plan-environment-deploy output sample should document a blocked staging api-repo plan.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.requiredGithubSecrets) || sample.requiredGithubSecrets[0] !== "AWS_ROLE_TO_ASSUME") { + throw new Error(`plan-environment-deploy output sample should document the default OIDC secret requirement.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.localExecution?.blockerCount !== 3 || sample.githubActionsExecution?.blockerCount !== 3) { + throw new Error(`plan-environment-deploy output sample should document the expected shared execution blocker counts.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.starterSummary?.unsafeDefaultCount !== 10 || sample.starterSummary?.blockerCount !== 15) { + throw new Error(`plan-environment-deploy output sample should document the expected starter blocker totals.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedExecution?.path !== "none") { + throw new Error(`plan-environment-deploy output sample should recommend no execution path while shared blockers remain.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedCommands?.primary !== sample.starterPrepCommands?.dryRun) { + throw new Error(`plan-environment-deploy output sample should recommend the starter prep dry-run first while shared starter blockers remain.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.commands?.localPreview || "").includes("PREVIEW_ONLY='true'") + || !String(sample.commands?.githubActionsWrapperPreview || "").includes("--preview-only=true") + || !String(sample.commands?.githubActionsPreview || "").includes("preview_only='true'")) { + throw new Error(`plan-environment-deploy output sample should expose local and GitHub preview commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.recommendedCommands?.alternates) + || !sample.recommendedCommands.alternates.some((command) => String(command).includes("PREVIEW_ONLY='true'")) + || !sample.recommendedCommands.alternates.some((command) => String(command).includes("--preview-only=true")) + || !sample.recommendedCommands.alternates.some((command) => String(command).includes("preview_only='true'"))) { + throw new Error(`plan-environment-deploy output sample should include preview-mode alternates alongside the live commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.starterPrepCommands?.commands || "").includes("prepare:environment-starter") + || !String(sample.starterPrepCommands?.write || "").includes("--write=true")) { + throw new Error(`plan-environment-deploy output sample should include the starter prep follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.preflightCommands?.auditStarter || "").includes("audit:environment-starter") + || !String(sample.preflightCommands?.auditApiRepoContract || "").includes("audit:api-repo-contract")) { + throw new Error(`plan-environment-deploy output sample should document the expected preflight audit commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.postDeployCommands?.verify || "").includes("verify:split-stack") || !String(sample.postDeployCommands?.checklist || "").includes("first-rollout-checklist.md")) { + throw new Error(`plan-environment-deploy output sample should document the expected post-deploy follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.postDeployCommands?.ensureOutputsDir || "").includes("mkdir -p deployment/staging") + || !String(sample.postDeployCommands?.saveBackendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks") + || !String(sample.postDeployCommands?.saveFrontendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks")) { + throw new Error(`plan-environment-deploy output sample should document the expected output-capture commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.postDeployCommands?.saveOutputsWithHelper || "").includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { + throw new Error(`plan-environment-deploy output sample should document the helper-based output capture command.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.postDeployCommands?.showSavedSummary || "").includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { + throw new Error(`plan-environment-deploy output sample should document the saved-summary render command.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.postDeployCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=deployment/staging/backend-outputs.json") + || !String(sample.postDeployCommands?.verifyFromSavedOutputsWithHttp || "").includes("--check-http=true") + || !String(sample.postDeployCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets") + || !String(sample.postDeployCommands?.publishFrontendAssetsFromSavedOutputs || "").includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json")) { + throw new Error(`plan-environment-deploy output sample should document the expected saved-output reuse commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.githubPostDeploy?.artifactName !== "aws-staging-deployment-evidence" + || sample.githubPostDeploy?.artifactPath !== "deployment/staging/" + || sample.githubPostDeploy?.failureArtifactName !== "aws-staging-preflight-plan" + || sample.githubPostDeploy?.failureArtifactPath !== "deployment/staging/preflight-plan.md" + || !Array.isArray(sample.githubPostDeploy?.summaryIncludes) + || !sample.githubPostDeploy.summaryIncludes.includes("preflight deploy plan") + || !sample.githubPostDeploy.summaryIncludes.includes("saved-output follow-up commands")) { + throw new Error(`plan-environment-deploy output sample should document the expected GitHub post-deploy handoff.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectPlanEnvironmentDeployCommandsOutputWorks() { + let result; + withRawStarterEnvironment("staging", (tempDir) => { + result = spawnSync("node", ["scripts/plan-environment-deploy.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--api-repo-path=.", "--output=commands"], { + cwd: rootDir, + encoding: "utf8", + }); + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + + if (result.status !== 1) { + throw new Error(`plan-environment-deploy commands mode should be blocked while staging placeholders remain.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + const expectedSnippets = [ + "yarn prepare:environment-starter -- --environment=staging --environment-dir=", + "--account-id= --output=json", + "--account-id= --output=commands", + "--account-id= --write=true", + "PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", + "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --environment-dir=", + "--preview-only=true", + "preview_only='true'", + "./infrastructure/environments/staging/deploy-split-stack.sh", + "gh workflow run deploy-aws-self-hosted.yml", + ]; + + const lines = stdout.trim().split("\n"); + if (!lines[0]?.startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") + || !lines[0]?.endsWith("--account-id= --output=json")) { + throw new Error(`plan-environment-deploy commands output should recommend the starter prep dry-run first while shared starter blockers remain.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend")) { + throw new Error(`plan-environment-deploy commands output should include the post-deploy verification command.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { + throw new Error(`plan-environment-deploy commands output should include the helper-based output capture command.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("yarn audit:api-repo-contract -- --api-repo-path=. --output=markdown")) { + throw new Error(`plan-environment-deploy commands output should include the Api repo contract preflight command.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { + throw new Error(`plan-environment-deploy commands output should include the saved-summary render command.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("mkdir -p deployment/staging") + || !stdout.includes("mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-backend --region us-east-1 --output json > deployment/staging/backend-outputs.json") + || !stdout.includes("mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-frontend --region us-east-1 --output json > deployment/staging/frontend-outputs.json")) { + throw new Error(`plan-environment-deploy commands output should include the output-capture commands.\nSTDOUT:\n${stdout}`); + } + const publishFromSavedOutputsPattern = new RegExp( + String.raw`yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=.*frontend-parameters\.json --backend-parameters-file=.*backend-parameters\.json --frontend-outputs-file=deployment/staging/frontend-outputs\.json --backend-outputs-file=deployment/staging/backend-outputs\.json --skip-backend --skip-frontend --publish-frontend-assets`, + ); + + if (!stdout.includes("yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json") + || !publishFromSavedOutputsPattern.test(stdout) + || !stdout.includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json")) { + throw new Error(`plan-environment-deploy commands output should include the saved-output reuse commands.\nSTDOUT:\n${stdout}`); + } + + for (const snippet of expectedSnippets) { + if (!stdout.includes(snippet)) { + throw new Error(`plan-environment-deploy commands output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectInstallerSetupScaffoldsPrivateDeploymentRepo() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-setup-")); + + try { + const dryRun = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--output=json", + ]); + + if (dryRun.status !== 0 || dryRun.parsed?.write !== false || dryRun.parsed?.writtenCount !== 0 || dryRun.parsed?.plannedCount !== 14) { + throw new Error(`installer setup dry-run did not report the expected scaffold plan.\nSTDOUT:\n${dryRun.stdout}\nSTDERR:\n${dryRun.stderr}`); + } + + const writeRun = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + + if (writeRun.status !== 0 || writeRun.parsed?.write !== true || writeRun.parsed?.writtenCount !== 14) { + throw new Error(`installer setup write mode did not write the expected scaffold.\nSTDOUT:\n${writeRun.stdout}\nSTDERR:\n${writeRun.stderr}`); + } + const safeCommitCommands = writeRun.parsed?.safeCommitCommands || []; + if (!safeCommitCommands.some((command) => String(command).includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments")) + || safeCommitCommands.some((command) => String(command).includes("customer-values.json ")) + || safeCommitCommands.some((command) => String(command).includes("deployment/"))) { + throw new Error(`installer setup should output safe private repo commit commands that do not stage local secrets or evidence.\nSTDOUT:\n${writeRun.stdout}`); + } + + const expectedFiles = [ + ".github/workflows/deploy-aws-self-hosted.yml", + ".gitignore", + "README.md", + "customer-values.sample.json", + "environments/staging/bootstrap-parameters.json", + "environments/staging/backend-parameters.json", + "environments/staging/frontend-parameters.json", + "environments/staging/app-config-secret.template.json", + "environments/staging/deploy-split-stack.sh", + "environments/prod/bootstrap-parameters.json", + "environments/prod/backend-parameters.json", + "environments/prod/frontend-parameters.json", + "environments/prod/app-config-secret.template.json", + "environments/prod/deploy-split-stack.sh", + ]; + + const missing = expectedFiles.filter((fileName) => !fs.existsSync(path.join(tempDir, fileName))); + if (missing.length > 0) { + throw new Error(`installer setup scaffold is missing expected files: ${missing.join(", ")}`); + } + + for (const forbiddenFile of [ + "environments/staging/app-config-secret.json", + "environments/staging/bootstrap-admin-secret.json", + "environments/prod/app-config-secret.json", + "environments/prod/bootstrap-admin-secret.json", + ]) { + if (fs.existsSync(path.join(tempDir, forbiddenFile))) { + throw new Error(`installer setup should not copy local runtime secret files: ${forbiddenFile}`); + } + } + + const workflowText = fs.readFileSync(path.join(tempDir, ".github/workflows/deploy-aws-self-hosted.yml"), "utf8"); + if (!workflowText.includes("b1admin_repo:") || !workflowText.includes("b1admin_ref:")) { + throw new Error("installer setup should copy the private workflow with explicit B1Admin source inputs."); + } + if (!workflowText.includes("name: Save deployment evidence") + || !workflowText.includes("yarn save:split-stack-outputs --") + || !workflowText.includes("name: Write deployment summary") + || !workflowText.includes("deployment-summary.json")) { + throw new Error("installer setup should copy a private workflow that saves and summarizes deployment evidence before uploading artifacts."); + } + + const gitignoreText = fs.readFileSync(path.join(tempDir, ".gitignore"), "utf8"); + if (!gitignoreText.includes("environments/*/app-config-secret.json") + || !gitignoreText.includes("environments/*/bootstrap-admin-secret.json") + || !gitignoreText.includes("customer-values.json")) { + throw new Error("installer setup should create a private repo .gitignore that protects runtime secret files."); + } + + const readmeText = fs.readFileSync(path.join(tempDir, "README.md"), "utf8"); + if (!readmeText.includes("pauses before approval steps") + || !readmeText.includes(`yarn installer:init -- --deploy-repo-dir=${tempDir} --output=markdown`) + || !readmeText.includes("yarn installer:customer-values") + || !readmeText.includes("yarn installer:run") + || !readmeText.includes(`--deploy-env-dir=${path.join(tempDir, "environments")}`) + || !readmeText.includes(`--deployment-root=${path.join(tempDir, "deployment")}`) + || !readmeText.includes("Smallest AWS footprint: deploy prod first and skip staging") + || !readmeText.includes("--environment=prod") + || !readmeText.includes("Optional practice deployment") + || !readmeText.includes("Do not commit `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`") + || !readmeText.includes("installer stores downloaded workflow evidence, browser-smoke results, and the final report") + || !readmeText.includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments") + || !readmeText.includes("Use `yarn installer:doctor")) { + throw new Error(`installer setup private README should keep the operator on the guided path.\n${readmeText}`); + } + + const customerFilePath = path.join(tempDir, "customer-values.json"); + fs.copyFileSync(path.join(tempDir, "customer-values.sample.json"), customerFilePath); + const customerValues = JSON.parse(fs.readFileSync(customerFilePath, "utf8")); + fs.writeFileSync(customerFilePath, `${JSON.stringify({ + ...customerValues, + accountId: "123456789012", + repo: "example/b1admin-deploy", + rootDomain: "customer.test", + supportEmail: "support@customer.test", + supportPhone: "111-222-3333", + }, null, 2)}\n`); + + const audit = runJsonScript("scripts/audit-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--only-blockers=true", + "--output=json", + ]); + + if (audit.status !== 1 || audit.parsed?.blockerSummary?.blockerCount !== 15) { + throw new Error(`installer setup should scaffold raw starters with expected first-run blockers.\nSTDOUT:\n${audit.stdout}\nSTDERR:\n${audit.stderr}`); + } + + const configured = runJsonScript("scripts/installer-configure.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--write=true", + "--output=json", + ]); + + if (configured.status !== 0 || configured.parsed?.auditBlockerCount !== 0) { + throw new Error(`installer configure should clear generated staging blockers.\nSTDOUT:\n${configured.stdout}\nSTDERR:\n${configured.stderr}`); + } + + const appConfigPreview = runJsonScript("scripts/installer-app-config-secret.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--output=json", + ]); + + if (appConfigPreview.status !== 0 || !["preview", "reuse-existing"].includes(appConfigPreview.parsed?.fileAction)) { + throw new Error(`installer app-config secret preview should report whether it would create or reuse the local secret file.\nSTDOUT:\n${appConfigPreview.stdout}\nSTDERR:\n${appConfigPreview.stderr}`); + } + + const appConfigWrite = runJsonScript("scripts/installer-app-config-secret.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--write=true", + "--output=json", + ]); + + const appConfigSecretPath = path.join(tempDir, "environments", "staging", "app-config-secret.json"); + const appConfigSecret = JSON.parse(fs.readFileSync(appConfigSecretPath, "utf8")); + if (appConfigWrite.status !== 0 + || !["created", "kept-existing"].includes(appConfigWrite.parsed?.fileAction) + || String(appConfigSecret.jwtSecret).startsWith("replace-me") + || String(appConfigSecret.encryptionKey).startsWith("replace-me") + || appConfigSecret.webPushSubject !== "mailto:support@customer.test") { + throw new Error(`installer app-config secret write should create a usable gitignored secret JSON.\nSTDOUT:\n${appConfigWrite.stdout}\nSTDERR:\n${appConfigWrite.stderr}`); + } + + const appConfigGithubPreview = runJsonScript("scripts/installer-app-config-secret.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--repo=example/b1admin-deploy", + "--sync-github-secret=true", + "--skip-gh-auth-check=true", + "--output=json", + ]); + + if (appConfigGithubPreview.status !== 0 + || appConfigGithubPreview.parsed?.githubSync?.action !== "validated" + || appConfigGithubPreview.parsed?.githubSync?.secretName !== "AWS_APP_CONFIG_SECRET_JSON" + || !String(appConfigGithubPreview.parsed?.githubSync?.commandPreview || "").includes("gh secret set")) { + throw new Error(`installer app-config secret should dry-run GitHub secret sync without touching GitHub.\nSTDOUT:\n${appConfigGithubPreview.stdout}\nSTDERR:\n${appConfigGithubPreview.stderr}`); + } + + const awsPreflightSkipped = runJsonScript("scripts/installer-aws-preflight.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--skip-aws-check=true", + "--output=json", + ]); + + if (awsPreflightSkipped.status !== 0 || awsPreflightSkipped.parsed?.ok !== true || awsPreflightSkipped.parsed?.skipped !== true) { + throw new Error(`installer aws preflight should support an explicit offline skip mode.\nSTDOUT:\n${awsPreflightSkipped.stdout}\nSTDERR:\n${awsPreflightSkipped.stderr}`); + } + + const frontendParamsPath = path.join(tempDir, "environments", "staging", "frontend-parameters.json"); + const frontendParams = JSON.parse(fs.readFileSync(frontendParamsPath, "utf8")); + fs.writeFileSync(frontendParamsPath, `${JSON.stringify({ + ...frontendParams, + AlternateDomainName: "admin-staging.customer.test", + AcmCertificateArn: "arn:aws:acm:us-west-2:123456789012:certificate/example", + HostedZoneId: "Z1234567890", + }, null, 2)}\n`); + + const badCertificate = runJsonScript("scripts/installer-aws-preflight.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--skip-aws-identity-check=true", + "--skip-aws-resource-lookups=true", + "--output=json", + ]); + + if (badCertificate.status === 0 || badCertificate.parsed?.ok !== false || !String(JSON.stringify(badCertificate.parsed)).includes("CloudFront requires the frontend ACM certificate in us-east-1")) { + throw new Error(`installer aws preflight should block frontend certs outside us-east-1.\nSTDOUT:\n${badCertificate.stdout}\nSTDERR:\n${badCertificate.stderr}`); + } + + fs.writeFileSync(frontendParamsPath, `${JSON.stringify(frontendParams, null, 2)}\n`); + + const preflight = runJsonScript("scripts/installer-preflight.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--repo=example/b1admin-deploy", + "--skip-github-repo-check=true", + "--skip-aws-check=true", + "--output=json", + ]); + + if (preflight.status !== 0 || preflight.parsed?.ok !== true || preflight.parsed?.starterBlockers !== 0) { + throw new Error(`installer preflight should pass for a configured private staging starter when repo lookup is skipped.\nSTDOUT:\n${preflight.stdout}\nSTDERR:\n${preflight.stderr}`); + } + + const deployDryRun = runJsonScript("scripts/installer-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + "--repo=example/b1admin-deploy", + "--skip-github-repo-check=true", + "--skip-gh-auth-check=true", + "--skip-aws-check=true", + "--dry-run=true", + "--output=json", + ]); + + if (deployDryRun.status !== 0 || deployDryRun.parsed?.action !== "validated" || deployDryRun.parsed?.dispatch?.previewOnly !== true) { + throw new Error(`installer deploy dry-run should validate a preview workflow dispatch without touching GitHub.\nSTDOUT:\n${deployDryRun.stdout}\nSTDERR:\n${deployDryRun.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerInitCreatesGuidedStartingPoint() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-init-")); + + try { + const result = runJsonScript("scripts/installer-init.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--output=json", + ]); + + if (result.status !== 0 + || result.parsed?.ok !== true + || !fs.existsSync(path.join(tempDir, ".github", "workflows", "deploy-aws-self-hosted.yml")) + || !fs.existsSync(path.join(tempDir, "customer-values.json")) + || !String(result.parsed?.nextCommand || "").includes("installer:customer-values") + || !String(result.parsed?.nextCommand || "").includes("installer:run") + || !String(result.parsed?.nextCommand || "").includes(`--deploy-repo-dir=${tempDir}`) + || !String(result.parsed?.nextCommand || "").includes(`--deploy-env-dir=${path.join(tempDir, "environments")}`) + || !String(result.parsed?.nextCommand || "").includes(`--deployment-root=${path.join(tempDir, "deployment")}`) + || !String(result.parsed?.nextCommand || "").includes("--environment=prod") + || !String(result.parsed?.nextCommand || "").includes("Optional practice deployment") + || !result.parsed?.safeCommitCommands?.some((command) => String(command).includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments")) + || result.parsed?.safeCommitCommands?.some((command) => String(command).includes("customer-values.json ")) + || result.parsed?.safeCommitCommands?.some((command) => String(command).includes("deployment/"))) { + throw new Error(`installer init should scaffold the private repo, create customer-values.json, and recommend installer:run.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const customerFile = path.join(tempDir, "customer-values.json"); + fs.writeFileSync(customerFile, `${JSON.stringify({ sentinel: "keep-me" }, null, 2)}\n`); + + const rerun = runJsonScript("scripts/installer-init.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--output=json", + ]); + const customerValues = JSON.parse(fs.readFileSync(customerFile, "utf8")); + + if (rerun.status !== 0 + || customerValues.sentinel !== "keep-me" + || !String(rerun.parsed?.actions?.find((action) => action.label === "Customer values file")?.detail || "").includes("not overwritten")) { + throw new Error(`installer init should preserve an existing customer-values.json.\nSTDOUT:\n${rerun.stdout}\nSTDERR:\n${rerun.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerCustomerValuesWritesGuidedAnswers() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-customer-values-")); + const customerFile = path.join(tempDir, "customer-values.json"); + + try { + const result = runJsonScript("scripts/installer-customer-values.mjs", [ + `--customer-file=${customerFile}`, + "--interactive=false", + "--write=true", + "--aws-region=us-east-1", + "--account-id=123456789012", + "--repo=example/b1admin-deploy", + "--root-domain=customer.test", + "--support-email=support@customer.test", + "--support-phone=111-222-3333", + "--first-admin-email=admin@customer.test", + "--first-admin-password=Use-Once-2638!", + "--first-church-name=Customer Church", + "--prod-frontend-domain=admin.customer.test", + "--prod-frontend-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/example", + "--prod-frontend-hosted-zone-id=Z123EXAMPLE", + "--output=json", + ]); + + const values = JSON.parse(fs.readFileSync(customerFile, "utf8")); + if (result.status !== 0 + || result.parsed?.ok !== true + || values.accountId !== "123456789012" + || values.repo !== "example/b1admin-deploy" + || values.firstChurchName !== "Customer Church" + || values.environments?.prod?.frontendDomain !== "admin.customer.test" + || values.environments?.staging?.frontendDomain !== "") { + throw new Error(`installer customer-values should write answers into the local customer file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerRunExecutesGuidedStep() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-run-")); + + try { + const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + if (setup.status !== 0) { + throw new Error(`installer run fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); + } + + const result = runJsonScript("scripts/installer-run.mjs", [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--customer-file=${path.join(tempDir, "customer-values.json")}`, + "--yes=true", + "--max-steps=1", + "--output=json", + ]); + + if (result.status !== 0 + || result.parsed?.complete !== false + || result.parsed?.history?.[0]?.action !== "run" + || !fs.existsSync(path.join(tempDir, "customer-values.json"))) { + throw new Error(`installer run should execute the first safe guided step.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerUpdateDryRun() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-update-")); + + try { + const result = runJsonScript("scripts/installer-update.mjs", [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--deployment-root=${path.join(tempDir, "deployment")}`, + `--customer-file=${path.join(tempDir, "customer-values.json")}`, + "--environment=prod", + "--dry-run=true", + "--skip-pull=true", + "--skip-private-commit=true", + "--output=json", + ]); + + if (result.status !== 0 + || result.parsed?.ok !== true + || result.parsed?.history?.[0]?.action !== "installer-init" + || result.parsed?.history?.[1]?.action !== "installer-run" + || result.parsed?.history?.some((entry) => entry.action === "git-pull")) { + throw new Error(`installer update dry-run should plan scaffold refresh and guided deploy without pulling source.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerStartRecommendsNextStep() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-start-")); + const nodeModulesDir = path.join(rootDir, "node_modules"); + const viteCliPath = path.join(nodeModulesDir, "vite", "dist", "node", "cli.js"); + const hadNodeModules = fs.existsSync(nodeModulesDir); + const hadViteCli = fs.existsSync(viteCliPath); + + try { + const noScaffoldDir = path.join(tempDir, "empty-deploy-repo"); + const noScaffold = runJsonScript("scripts/installer-start.mjs", [ + `--deploy-repo-dir=${noScaffoldDir}`, + `--deploy-env-dir=${path.join(noScaffoldDir, "environments")}`, + `--customer-file=${path.join(noScaffoldDir, "customer-values.json")}`, + "--environment=staging", + "--output=json", + ]); + if (noScaffold.status !== 0 || !String(noScaffold.parsed?.nextCommand || "").includes("installer:init")) { + throw new Error(`installer start should recommend scaffolding before copying a missing customer sample.\nSTDOUT:\n${noScaffold.stdout}\nSTDERR:\n${noScaffold.stderr}`); + } + + const defaultEnvironment = runJsonScript("scripts/installer-start.mjs", [ + `--deploy-repo-dir=${noScaffoldDir}`, + `--deploy-env-dir=${path.join(noScaffoldDir, "environments")}`, + `--customer-file=${path.join(noScaffoldDir, "customer-values.json")}`, + "--output=json", + ]); + if (defaultEnvironment.status !== 0 || defaultEnvironment.parsed?.environment !== "prod") { + throw new Error(`installer start should default to prod for the smaller AWS footprint path.\nSTDOUT:\n${defaultEnvironment.stdout}\nSTDERR:\n${defaultEnvironment.stderr}`); + } + + const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + if (setup.status !== 0) { + throw new Error(`installer start fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); + } + + const missingCustomer = runJsonScript("scripts/installer-start.mjs", [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--customer-file=${path.join(tempDir, "customer-values.json")}`, + "--environment=staging", + "--output=json", + ]); + + if (missingCustomer.status !== 0 || !String(missingCustomer.parsed?.nextCommand || "").startsWith("cp ")) { + throw new Error(`installer start should recommend creating customer-values.json first.\nSTDOUT:\n${missingCustomer.stdout}\nSTDERR:\n${missingCustomer.stderr}`); + } + + const customerFilePath = path.join(tempDir, "customer-values.json"); + const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); + fs.writeFileSync(customerFilePath, `${JSON.stringify(sample, null, 2)}\n`); + + const blankCustomer = runJsonScript("scripts/installer-start.mjs", [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--customer-file=${customerFilePath}`, + "--environment=staging", + "--output=json", + ]); + + if (blankCustomer.status !== 0 + || !String(blankCustomer.parsed?.nextCommand || "").includes("installer:customer-values") + || blankCustomer.parsed?.checks?.find((check) => check.label === "Core customer values")?.ok !== false) { + throw new Error(`installer start should not treat blank/sample customer values as ready.\nSTDOUT:\n${blankCustomer.stdout}\nSTDERR:\n${blankCustomer.stderr}`); + } + + fs.writeFileSync(customerFilePath, `${JSON.stringify({ + ...sample, + accountId: "999888777666", + repo: "acme-church/b1admin-deploy", + rootDomain: "acmechurch.org", + supportEmail: "support@acmechurch.org", + supportPhone: "918-555-2638", + firstAdminEmail: "", + firstAdminPassword: "", + firstChurchName: "", + }, null, 2)}\n`); + + const withCustomer = runJsonScript("scripts/installer-start.mjs", [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--customer-file=${customerFilePath}`, + "--environment=staging", + "--output=json", + ]); + + if (withCustomer.status !== 0 + || !String(withCustomer.parsed?.nextCommand || "").includes("installer:aws-handoff") + || withCustomer.parsed?.deploymentRoot !== path.relative(rootDir, path.join(tempDir, "deployment")) + || !withCustomer.parsed?.checks?.some((check) => check.label === "Core customer values" && check.ok === true)) { + throw new Error(`installer start should use customer-values.json and recommend the IAM handoff next.\nSTDOUT:\n${withCustomer.stdout}\nSTDERR:\n${withCustomer.stderr}`); + } + + const deploymentRoot = path.join(tempDir, "deployment"); + const startArgs = [ + `--deploy-repo-dir=${tempDir}`, + `--deploy-env-dir=${path.join(tempDir, "environments")}`, + `--deployment-root=${deploymentRoot}`, + `--customer-file=${customerFilePath}`, + "--environment=staging", + "--output=json", + ]; + + const handoff = runJsonScript("scripts/installer-aws-handoff.mjs", [ + `--customer-file=${customerFilePath}`, + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + if (handoff.status !== 0) { + throw new Error(`installer start fixture handoff failed.\nSTDOUT:\n${handoff.stdout}\nSTDERR:\n${handoff.stderr}`); + } + + const configure = runJsonScript("scripts/installer-configure.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--write=true", + "--output=json", + ]); + if (configure.status !== 0) { + throw new Error(`installer start fixture configure failed.\nSTDOUT:\n${configure.stdout}\nSTDERR:\n${configure.stderr}`); + } + + const appConfig = runJsonScript("scripts/installer-app-config-secret.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--write=true", + "--output=json", + ]); + if (appConfig.status !== 0) { + throw new Error(`installer start fixture app-config failed.\nSTDOUT:\n${appConfig.stdout}\nSTDERR:\n${appConfig.stderr}`); + } + + const needsGithubReadiness = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsGithubReadiness.status !== 0 || !String(needsGithubReadiness.parsed?.nextCommand || "").includes("installer:github-readiness")) { + throw new Error(`installer start should recommend GitHub readiness after local setup is complete.\nSTDOUT:\n${needsGithubReadiness.stdout}\nSTDERR:\n${needsGithubReadiness.stderr}`); + } + + const stagingEvidenceDir = path.join(deploymentRoot, "staging"); + fs.mkdirSync(stagingEvidenceDir, { recursive: true }); + fs.writeFileSync(path.join(stagingEvidenceDir, "github-readiness.json"), JSON.stringify({ ok: false }, null, 2)); + const failedGithubReadiness = runJsonScript("scripts/installer-start.mjs", startArgs); + if (failedGithubReadiness.status !== 0 + || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("installer:github-setup") + || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("--write=true") + || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("--write-secrets=true")) { + throw new Error(`installer start should recommend GitHub setup when readiness evidence is not clean.\nSTDOUT:\n${failedGithubReadiness.stdout}\nSTDERR:\n${failedGithubReadiness.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "github-readiness.json"), JSON.stringify({ ok: true }, null, 2)); + + const needsPreflight = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsPreflight.status !== 0 || !String(needsPreflight.parsed?.nextCommand || "").includes("installer:preflight")) { + throw new Error(`installer start should recommend preflight after GitHub readiness evidence exists.\nSTDOUT:\n${needsPreflight.stdout}\nSTDERR:\n${needsPreflight.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "preflight-readiness.json"), JSON.stringify({ ok: true }, null, 2)); + const needsPreview = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsPreview.status !== 0 || !String(needsPreview.parsed?.nextCommand || "").includes("--preview-only=true")) { + throw new Error(`installer start should recommend preview dispatch after preflight evidence exists.\nSTDOUT:\n${needsPreview.stdout}\nSTDERR:\n${needsPreview.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "last-preview-dispatch.json"), JSON.stringify({ ok: true, runId: 123 }, null, 2)); + const needsObservePreview = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsObservePreview.status !== 0 || !String(needsObservePreview.parsed?.nextCommand || "").includes("installer:observe")) { + throw new Error(`installer start should recommend observing a dispatched preview.\nSTDOUT:\n${needsObservePreview.stdout}\nSTDERR:\n${needsObservePreview.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "preflight-plan.md"), "# Preview plan\n"); + const needsDeploy = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsDeploy.status !== 0 || !String(needsDeploy.parsed?.nextCommand || "").includes("--confirm=true")) { + throw new Error(`installer start should recommend real deploy after preview evidence exists.\nSTDOUT:\n${needsDeploy.stdout}\nSTDERR:\n${needsDeploy.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "last-deploy-dispatch.json"), JSON.stringify({ ok: true, runId: 456 }, null, 2)); + const needsObserveDeploy = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsObserveDeploy.status !== 0 || !String(needsObserveDeploy.parsed?.nextCommand || "").includes("--verify=true")) { + throw new Error(`installer start should recommend observing a dispatched deploy.\nSTDOUT:\n${needsObserveDeploy.stdout}\nSTDERR:\n${needsObserveDeploy.stderr}`); + } + + writeReportEvidenceFixture(deploymentRoot, "staging"); + const needsFrontendOrigin = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsFrontendOrigin.status !== 0 + || !String(needsFrontendOrigin.parsed?.nextCommand || "").includes("installer:adopt-frontend-origin") + || !needsFrontendOrigin.parsed?.checks?.some((check) => check.label === "Frontend origin accepted by backend" && check.ok === false)) { + throw new Error(`installer start should recommend adopting the deployed frontend origin before browser login steps.\nSTDOUT:\n${needsFrontendOrigin.stdout}\nSTDERR:\n${needsFrontendOrigin.stderr}`); + } + + const adoptFrontendOrigin = runJsonScript("scripts/installer-adopt-frontend-origin.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--deployment-root=${deploymentRoot}`, + "--write=true", + "--output=json", + ]); + if (adoptFrontendOrigin.status !== 0 || !adoptFrontendOrigin.parsed?.ok) { + throw new Error(`installer adopt frontend origin should update backend parameters from deployment evidence.\nSTDOUT:\n${adoptFrontendOrigin.stdout}\nSTDERR:\n${adoptFrontendOrigin.stderr}`); + } + + const needsFirstAdminValues = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsFirstAdminValues.status !== 0 + || !String(needsFirstAdminValues.parsed?.nextCommand || "").includes("installer:customer-values") + || !needsFirstAdminValues.parsed?.checks?.some((check) => check.label === "First admin values" && check.ok === false)) { + throw new Error(`installer start should ask for first-admin values after deployment evidence exists.\nSTDOUT:\n${needsFirstAdminValues.stdout}\nSTDERR:\n${needsFirstAdminValues.stderr}`); + } + + const customerValues = JSON.parse(fs.readFileSync(customerFilePath, "utf8")); + fs.writeFileSync(customerFilePath, `${JSON.stringify({ + ...customerValues, + firstAdminEmail: "admin@customer.test", + firstAdminPassword: "Use-Once-2638!", + firstChurchName: "Customer Church", + }, null, 2)}\n`); + + let needsBootstrapAdmin = runJsonScript("scripts/installer-start.mjs", startArgs); + if (!hadViteCli) { + if (needsBootstrapAdmin.status !== 0 || !String(needsBootstrapAdmin.parsed?.nextCommand || "").includes("yarn install")) { + throw new Error(`installer start should ask for yarn install only when local bootstrap/browser work is next.\nSTDOUT:\n${needsBootstrapAdmin.stdout}\nSTDERR:\n${needsBootstrapAdmin.stderr}`); + } + fs.mkdirSync(path.dirname(viteCliPath), { recursive: true }); + fs.writeFileSync(viteCliPath, "export {};\n"); + needsBootstrapAdmin = runJsonScript("scripts/installer-start.mjs", startArgs); + } + if (needsBootstrapAdmin.status !== 0 || !String(needsBootstrapAdmin.parsed?.nextCommand || "").includes("installer:bootstrap-admin")) { + throw new Error(`installer start should recommend first-admin bootstrap after deployment evidence exists.\nSTDOUT:\n${needsBootstrapAdmin.stdout}\nSTDERR:\n${needsBootstrapAdmin.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "bootstrap-admin.json"), JSON.stringify({ ok: true, dryRun: false }, null, 2)); + const needsBrowserSmoke = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsBrowserSmoke.status !== 0 || !String(needsBrowserSmoke.parsed?.nextCommand || "").includes("installer:browser-smoke")) { + throw new Error(`installer start should recommend browser smoke after first-admin evidence exists.\nSTDOUT:\n${needsBrowserSmoke.stdout}\nSTDERR:\n${needsBrowserSmoke.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "browser-smoke.json"), JSON.stringify({ ok: true }, null, 2)); + const stagingComplete = runJsonScript("scripts/installer-start.mjs", startArgs); + if (stagingComplete.status !== 0 || !String(stagingComplete.parsed?.nextCommand || "").includes("--environment=prod")) { + throw new Error(`installer start should send the operator to prod after staging is complete.\nSTDOUT:\n${stagingComplete.stdout}\nSTDERR:\n${stagingComplete.stderr}`); + } + + const markdownShort = spawnNode("scripts/installer-start.mjs", [ + ...startArgs.filter((arg) => arg !== "--output=json"), + "--output=markdown", + ]); + if (markdownShort.status !== 0 + || !markdownShort.stdout.includes("## Next Command") + || markdownShort.stdout.includes("## Command Reference") + || markdownShort.stdout.includes("## Useful Commands")) { + throw new Error(`installer start markdown should focus on one next command by default.\nSTDOUT:\n${markdownShort.stdout}\nSTDERR:\n${markdownShort.stderr}`); + } + + const markdownReference = spawnNode("scripts/installer-start.mjs", [ + ...startArgs.filter((arg) => arg !== "--output=json"), + "--output=markdown", + "--show-all-commands=true", + ]); + if (markdownReference.status !== 0 || !markdownReference.stdout.includes("## Command Reference")) { + throw new Error(`installer start markdown should expose the command reference when requested.\nSTDOUT:\n${markdownReference.stdout}\nSTDERR:\n${markdownReference.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (!hadNodeModules) { + fs.rmSync(nodeModulesDir, { recursive: true, force: true }); + } else if (!hadViteCli) { + fs.rmSync(path.join(nodeModulesDir, "vite"), { recursive: true, force: true }); + } + } +} + +function expectCustomerFileAwsRegionAliasWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-region-alias-")); + + try { + const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + if (setup.status !== 0) { + throw new Error(`region alias fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); + } + + const customerFilePath = path.join(tempDir, "customer-values.json"); + const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); + fs.writeFileSync(customerFilePath, `${JSON.stringify({ + ...sample, + awsRegion: "us-west-2", + accountId: "123456789012", + repo: "example/b1admin-deploy", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/installer-aws-preflight.mjs", [ + "--environment=staging", + `--environment-dir=${path.join(tempDir, "environments", "staging")}`, + `--customer-file=${customerFilePath}`, + "--skip-aws-check=true", + "--output=json", + ]); + + if (result.status !== 0 || result.parsed?.region !== "us-west-2") { + throw new Error(`customer-values awsRegion should be accepted as the installer region.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerAwsRolesGeneratesPolicyFiles() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-aws-roles-")); + + try { + const result = runJsonScript("scripts/installer-aws-roles.mjs", [ + "--environment=staging", + "--account-id=123456789012", + "--repo=example/b1admin-deploy", + `--output-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + + if (result.status !== 0 || result.parsed?.files?.length !== 4 || result.parsed?.roleArns?.deployRoleArn !== "arn:aws:iam::123456789012:role/b1admin-staging-github-deploy") { + throw new Error(`installer aws roles should render the expected IAM file set and role ARNs.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const expectedFiles = [ + "b1admin-staging-github-deploy-trust.json", + "b1admin-staging-github-deploy-policy.json", + "b1admin-staging-cfn-exec-trust.json", + "b1admin-staging-cfn-exec-policy.json", + ]; + expectedFiles.forEach((fileName) => { + const filePath = path.join(tempDir, fileName); + if (!fs.existsSync(filePath)) { + throw new Error(`installer aws roles did not write ${fileName}.`); + } + const text = fs.readFileSync(filePath, "utf8"); + JSON.parse(text); + if (text.includes("") || text.includes("") || text.includes("")) { + throw new Error(`installer aws roles left placeholders in ${fileName}.\n${text}`); + } + }); + + const trust = JSON.parse(fs.readFileSync(path.join(tempDir, "b1admin-staging-github-deploy-trust.json"), "utf8")); + const subject = trust.Statement?.[0]?.Condition?.StringLike?.["token.actions.githubusercontent.com:sub"]; + if (subject !== "repo:example/b1admin-deploy:environment:aws-staging") { + throw new Error(`installer aws roles rendered the wrong GitHub OIDC subject: ${subject}`); + } + + const deployPolicy = JSON.parse(fs.readFileSync(path.join(tempDir, "b1admin-staging-github-deploy-policy.json"), "utf8")); + const passRole = deployPolicy.Statement.find((statement) => statement.Sid === "PassCloudFormationExecutionRole"); + if (passRole?.Resource !== "arn:aws:iam::123456789012:role/b1admin-staging-cfn-exec") { + throw new Error(`installer aws roles rendered the wrong iam:PassRole resource.\n${JSON.stringify(passRole, null, 2)}`); + } + + if (!result.parsed.awsCommands.some((command) => command.includes("create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com")) + || result.parsed.awsCommands.some((command) => command.includes("--thumbprint-list")) + || !result.parsed.githubSecretCommands.some((command) => command.includes("AWS_ROLE_TO_ASSUME")) + || !result.parsed.githubSecretCommands.some((command) => command.includes("AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN"))) { + throw new Error(`installer aws roles should output OIDC setup and GitHub secret commands.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerAwsHandoffWritesAdminDocument() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-aws-handoff-")); + + try { + const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + if (setup.status !== 0) { + throw new Error(`installer aws handoff fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); + } + + const customerFilePath = path.join(tempDir, "customer-values.json"); + const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); + fs.writeFileSync(customerFilePath, `${JSON.stringify({ + ...sample, + accountId: "123456789012", + repo: "example/b1admin-deploy", + rootDomain: "customer.test", + supportEmail: "support@customer.test", + supportPhone: "111-222-3333", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/installer-aws-handoff.mjs", [ + `--customer-file=${customerFilePath}`, + `--deploy-repo-dir=${tempDir}`, + "--write=true", + "--output=json", + ]); + + const handoffPath = path.join(tempDir, "aws-admin-handoff.md"); + if (result.status !== 0 + || result.parsed?.environments?.length !== 2 + || !fs.existsSync(handoffPath) + || !fs.existsSync(path.join(tempDir, "iam", "staging", "b1admin-staging-github-deploy-trust.json")) + || !fs.existsSync(path.join(tempDir, "iam", "prod", "b1admin-prod-github-deploy-trust.json"))) { + throw new Error(`installer aws handoff should write a two-environment admin bundle.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const body = fs.readFileSync(handoffPath, "utf8"); + if (!body.includes("# B1Admin AWS Admin Handoff") + || !body.includes("aws iam create-role --role-name b1admin-staging-github-deploy") + || !body.includes("aws iam create-role --role-name b1admin-prod-github-deploy") + || !body.includes("arn:aws:iam::123456789012:role/b1admin-prod-cfn-exec") + || !body.includes("gh secret set AWS_ROLE_TO_ASSUME --repo example/b1admin-deploy --env aws-prod") + || !body.includes("Smallest AWS footprint: continue with prod first") + || !body.includes("--environment=prod")) { + throw new Error(`installer aws handoff document is missing expected admin/operator content.\n${body}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectInstallerGithubSetupPlansAndWritesSecrets() { + const deployRepoDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-setup-repo-")); + const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-setup-gh-")); + const ghPath = path.join(fakeGhDir, "gh"); + const capturePath = path.join(fakeGhDir, "capture.jsonl"); + const ghScript = `#!/usr/bin/env node +import fs from "node:fs"; +const args = process.argv.slice(2); +let stdin = ""; +process.stdin.on("data", (chunk) => { stdin += chunk; }); +process.stdin.on("end", () => { + if (args[0] === "api" && args[1] === "-X" && args[2] === "PUT") { + fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ kind: "environment", args }) + "\\n"); + process.exit(0); + } + if (args[0] === "secret" && args[1] === "set") { + const bodyIndex = args.indexOf("--body"); + fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ + kind: "secret", + args, + body: bodyIndex >= 0 ? args[bodyIndex + 1] : stdin, + }) + "\\n"); + process.exit(0); + } + process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); + process.exit(1); +}); +`; + + try { + const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ + `--deploy-repo-dir=${deployRepoDir}`, + "--write=true", + "--output=json", + ]); + if (setup.status !== 0) { + throw new Error(`installer github setup fixture scaffold failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); + } + + for (const environment of ["staging", "prod"]) { + const appConfig = runJsonScript("scripts/installer-app-config-secret.mjs", [ + `--environment=${environment}`, + `--environment-dir=${path.join(deployRepoDir, "environments", environment)}`, + "--support-email=support@customer.test", + "--write=true", + "--output=json", + ]); + if (appConfig.status !== 0) { + throw new Error(`installer github setup fixture app-config generation failed for ${environment}.\nSTDOUT:\n${appConfig.stdout}\nSTDERR:\n${appConfig.stderr}`); + } + } + + const preview = runJsonScript("scripts/installer-github-setup.mjs", [ + "--repo=example/b1admin-deploy", + "--account-id=123456789012", + `--deploy-env-dir=${path.join(deployRepoDir, "environments")}`, + "--include-checkout-token-commands=false", + "--output=json", + ]); + + if (preview.status !== 0 + || preview.parsed?.secretPlans?.length !== 6 + || !preview.parsed.secretPlans.every((secret) => secret.ready === true) + || !preview.parsed.secretCommands.some((command) => command.includes("arn:aws:iam::123456789012:role/b1admin-staging-github-deploy")) + || !preview.parsed.secretCommands.some((command) => command.includes("app-config-secret.json"))) { + throw new Error(`installer github setup should produce ready concrete secret commands from generated IAM/app-config values.\nSTDOUT:\n${preview.stdout}\nSTDERR:\n${preview.stderr}`); + } + + fs.writeFileSync(ghPath, ghScript); + fs.chmodSync(ghPath, 0o755); + const write = runJsonScriptWithEnv("scripts/installer-github-setup.mjs", [ + "--repo=example/b1admin-deploy", + "--account-id=123456789012", + `--deploy-env-dir=${path.join(deployRepoDir, "environments")}`, + "--include-checkout-token-commands=false", + "--write=true", + "--write-secrets=true", + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (write.status !== 0 || write.parsed?.secretResults?.length !== 6 || !write.parsed.secretResults.every((secret) => secret.ok === true)) { + throw new Error(`installer github setup should create environments and write all ready required secrets through gh.\nSTDOUT:\n${write.stdout}\nSTDERR:\n${write.stderr}`); + } + + const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + const environmentCreates = captures.filter((capture) => capture.kind === "environment"); + const secretWrites = captures.filter((capture) => capture.kind === "secret"); + if (environmentCreates.length !== 2 || secretWrites.length !== 6) { + throw new Error(`installer github setup did not call gh for both environments and all required secrets.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + if (!secretWrites.some((capture) => capture.args[2] === "AWS_APP_CONFIG_SECRET_JSON" && String(capture.body || "").includes("jwtSecret")) + || !secretWrites.some((capture) => capture.args[2] === "AWS_ROLE_TO_ASSUME" && String(capture.body || "").includes("b1admin-staging-github-deploy"))) { + throw new Error(`installer github setup did not send expected secret bodies.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + } finally { + fs.rmSync(deployRepoDir, { recursive: true, force: true }); + fs.rmSync(fakeGhDir, { recursive: true, force: true }); + } +} + +function expectInstallerGithubReadinessChecksEnvironmentSecrets() { + const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-readiness-gh-")); + const ghPath = path.join(fakeGhDir, "gh"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +const endpoint = args[1] === "api" ? args[2] : args[1]; +if (args[0] !== "api") { + process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); + process.exit(1); +} +if (endpoint.endsWith("/environments/aws-staging")) { + process.stdout.write(JSON.stringify({ name: "aws-staging" })); + process.exit(0); +} +if (endpoint.endsWith("/environments/aws-staging/secrets")) { + process.stdout.write(JSON.stringify({ secrets: [ + { name: "AWS_ROLE_TO_ASSUME" }, + { name: "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN" }, + { name: "AWS_APP_CONFIG_SECRET_JSON" } + ] })); + process.exit(0); +} +if (endpoint.endsWith("/environments/aws-prod")) { + process.stdout.write(JSON.stringify({ name: "aws-prod" })); + process.exit(0); +} +if (endpoint.endsWith("/environments/aws-prod/secrets")) { + process.stdout.write(JSON.stringify({ secrets: [ + { name: "AWS_ROLE_TO_ASSUME" }, + { name: "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN" } + ] })); + process.exit(0); +} +process.stderr.write("Unexpected gh endpoint: " + endpoint + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + + const staging = runJsonScriptWithEnv("scripts/installer-github-readiness.mjs", [ + "--environment=staging", + "--repo=example/b1admin-deploy", + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (staging.status !== 0 + || staging.parsed?.ok !== true + || staging.parsed?.environments?.[0]?.missingSecrets?.length !== 0) { + throw new Error(`installer github readiness should pass when all required environment secrets exist.\nSTDOUT:\n${staging.stdout}\nSTDERR:\n${staging.stderr}`); + } + + const all = runJsonScriptWithEnv("scripts/installer-github-readiness.mjs", [ + "--environment=all", + "--repo=example/b1admin-deploy", + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (all.status === 0 + || all.parsed?.ok !== false + || !all.parsed?.environments?.find((environment) => environment.githubEnvironment === "aws-prod")?.missingSecrets?.includes("AWS_APP_CONFIG_SECRET_JSON")) { + throw new Error(`installer github readiness should fail clearly when a required environment secret is missing.\nSTDOUT:\n${all.stdout}\nSTDERR:\n${all.stderr}`); + } + } finally { + fs.rmSync(fakeGhDir, { recursive: true, force: true }); + } +} + +function expectInstallerObserveSummarizesDownloadedEvidence() { + const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-gh-")); + const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-evidence-")); + const ghPath = path.join(fakeGhDir, "gh"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +if (args[0] === "run" && args[1] === "list") { + process.stdout.write(JSON.stringify([{ + databaseId: 12345, + status: "completed", + conclusion: "success", + url: "https://github.com/example/b1admin-deploy/actions/runs/12345", + headSha: "abc123", + createdAt: "2026-07-22T12:00:00Z", + updatedAt: "2026-07-22T12:05:00Z", + displayTitle: "Deploy AWS From Private Repo", + workflowName: "Deploy AWS From Private Repo" + }])); + process.exit(0); +} +if (args[0] === "run" && args[1] === "view") { + process.stdout.write(JSON.stringify({ + databaseId: Number(args[2]), + status: "completed", + conclusion: "success", + url: "https://github.com/example/b1admin-deploy/actions/runs/" + args[2], + headSha: "abc123", + createdAt: "2026-07-22T12:00:00Z", + updatedAt: "2026-07-22T12:05:00Z", + displayTitle: "Deploy AWS From Private Repo", + workflowName: "Deploy AWS From Private Repo", + event: "workflow_dispatch" + })); + process.exit(0); +} +if (args[0] === "run" && args[1] === "download") { + const dir = args[args.indexOf("--dir") + 1]; + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(path.join(${JSON.stringify(rootDir)}, "infrastructure", "examples", "backend-outputs.sample.json"), path.join(dir, "backend-outputs.json")); + fs.copyFileSync(path.join(${JSON.stringify(rootDir)}, "infrastructure", "examples", "frontend-outputs.sample.json"), path.join(dir, "frontend-outputs.json")); + fs.writeFileSync(path.join(dir, "deployment-summary.json"), JSON.stringify({ + environment: "staging", + region: "us-east-1", + stackNames: { + backend: "b1admin-staging-backend", + frontend: "b1admin-staging-frontend" + }, + resolved: { + apiBaseUrl: "https://api.example.com", + frontendAppUrl: "https://admin.example.com", + frontendBucketName: "example-frontend-bucket", + frontendDistributionId: "EXAMPLE123" + }, + files: { + backendOutputsFile: "deployment/staging/backend-outputs.json", + frontendOutputsFile: "deployment/staging/frontend-outputs.json", + summaryFile: "deployment/staging/deployment-summary.json" + }, + followUpCommands: {} + }, null, 2) + "\\n"); + process.exit(0); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + + const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ + "--environment=staging", + "--repo=example/b1admin-deploy", + `--evidence-dir=${evidenceDir}`, + "--check-http=false", + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (result.status !== 0 + || result.parsed?.runId !== 12345 + || result.parsed?.summary?.resolved?.frontendAppUrl !== "https://admin.example.com" + || result.parsed?.verification?.ok !== true + || result.parsed?.warnings?.length !== 0) { + throw new Error(`installer observe should summarize a completed run from downloaded evidence.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(fakeGhDir, { recursive: true, force: true }); + fs.rmSync(evidenceDir, { recursive: true, force: true }); + } +} + +function expectInstallerObserveDownloadsPreviewArtifactFallback() { + const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-preview-gh-")); + const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-preview-evidence-")); + const ghPath = path.join(fakeGhDir, "gh"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +if (args[0] === "run" && args[1] === "list") { + process.stdout.write(JSON.stringify([{ + databaseId: 67890, + status: "completed", + conclusion: "success", + url: "https://github.com/example/b1admin-deploy/actions/runs/67890", + headSha: "def456", + createdAt: "2026-07-22T12:00:00Z", + updatedAt: "2026-07-22T12:05:00Z", + displayTitle: "Deploy AWS From Private Repo", + workflowName: "Deploy AWS From Private Repo" + }])); + process.exit(0); +} +if (args[0] === "run" && args[1] === "download") { + const name = args[args.indexOf("--name") + 1]; + if (name.endsWith("deployment-evidence")) { + process.stderr.write("artifact not found\\n"); + process.exit(1); + } + const dir = args[args.indexOf("--dir") + 1]; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "preflight-plan.md"), "# Preview plan\\n"); + process.exit(0); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + + const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ + "--environment=staging", + "--repo=example/b1admin-deploy", + `--evidence-dir=${evidenceDir}`, + "--verify=false", + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (result.status !== 0 + || result.parsed?.downloadedArtifact !== "aws-staging-preflight-plan" + || !fs.existsSync(path.join(evidenceDir, "preflight-plan.md")) + || result.parsed?.warnings?.length !== 0) { + throw new Error(`installer observe should fall back to preview preflight artifacts.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(fakeGhDir, { recursive: true, force: true }); + fs.rmSync(evidenceDir, { recursive: true, force: true }); + } +} + +function expectInstallerObserveWarnsOnIncompleteDeploymentArtifact() { + const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-incomplete-gh-")); + const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-incomplete-evidence-")); + const ghPath = path.join(fakeGhDir, "gh"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +if (args[0] === "run" && args[1] === "list") { + process.stdout.write(JSON.stringify([{ + databaseId: 24680, + status: "completed", + conclusion: "success", + url: "https://github.com/example/b1admin-deploy/actions/runs/24680", + headSha: "abc123", + createdAt: "2026-07-22T12:00:00Z", + updatedAt: "2026-07-22T12:05:00Z", + displayTitle: "Deploy AWS From Private Repo", + workflowName: "Deploy AWS From Private Repo" + }])); + process.exit(0); +} +if (args[0] === "run" && args[1] === "download") { + const dir = args[args.indexOf("--dir") + 1]; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "preflight-plan.md"), "# Preview plan\\n"); + process.exit(0); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + + const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ + "--environment=staging", + "--repo=example/b1admin-deploy", + `--evidence-dir=${evidenceDir}`, + "--output=json", + ], { + PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, + }); + + if (result.status === 0 + || result.parsed?.ok !== false + || !String(result.parsed?.warnings?.[0] || "").includes("deployment-summary.json")) { + throw new Error(`installer observe should warn when a deployment artifact lacks saved deployment evidence.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(fakeGhDir, { recursive: true, force: true }); + fs.rmSync(evidenceDir, { recursive: true, force: true }); + } +} + +function writeReportEvidenceFixture(deploymentRoot, environment) { + const environmentDir = path.join(deploymentRoot, environment); + fs.mkdirSync(environmentDir, { recursive: true }); + fs.copyFileSync(path.join(rootDir, "infrastructure", "examples", "backend-outputs.sample.json"), path.join(environmentDir, "backend-outputs.json")); + fs.copyFileSync(path.join(rootDir, "infrastructure", "examples", "frontend-outputs.sample.json"), path.join(environmentDir, "frontend-outputs.json")); + fs.writeFileSync(path.join(environmentDir, "deployment-summary.json"), JSON.stringify({ + environment, + region: "us-east-1", + stackNames: { + backend: `b1admin-${environment}-backend`, + frontend: `b1admin-${environment}-frontend`, + }, + resolved: { + apiBaseUrl: "https://api.example.com", + frontendAppUrl: "https://d123example.cloudfront.net", + frontendBucketName: "example-frontend-bucket", + frontendDistributionId: "EXAMPLE123", + }, + files: { + backendOutputsFile: path.join(environmentDir, "backend-outputs.json"), + frontendOutputsFile: path.join(environmentDir, "frontend-outputs.json"), + summaryFile: path.join(environmentDir, "deployment-summary.json"), + }, + }, null, 2)); + fs.writeFileSync(path.join(environmentDir, "last-deploy-dispatch.json"), JSON.stringify({ + ok: true, + runId: environment === "staging" ? 111 : 222, + }, null, 2)); + fs.writeFileSync(path.join(environmentDir, "source-metadata.json"), JSON.stringify({ + ok: true, + environment, + githubActions: { + runId: environment === "staging" ? 111 : 222, + privateRepoSha: `${environment}-deploy-repo`, + }, + b1admin: { + repo: "ChurchApps/B1Admin", + ref: "main", + sha: `${environment}-b1`, + }, + api: { + repo: "ChurchApps/Api", + ref: "main", + sha: `${environment}-api`, + }, + }, null, 2)); +} + +function expectInstallerReportGeneratesRolloutRecord() { + const deploymentRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-report-")); + + try { + writeReportEvidenceFixture(deploymentRoot, "staging"); + writeReportEvidenceFixture(deploymentRoot, "prod"); + + const browserSmoke = runJsonScript("scripts/installer-browser-smoke.mjs", [ + `--deployment-root=${deploymentRoot}`, + "--environment=staging", + "--app-url=https://admin.example.com", + "--email=admin@example.com", + "--password=temporary-password", + "--church-name=Example Church", + "--dry-run=true", + "--output=json", + ]); + + if (browserSmoke.status !== 0 + || browserSmoke.parsed?.ok !== true + || !fs.existsSync(path.join(deploymentRoot, "staging", "browser-smoke.json"))) { + throw new Error(`installer browser smoke dry-run should write browser evidence without launching a browser.\nSTDOUT:\n${browserSmoke.stdout}\nSTDERR:\n${browserSmoke.stderr}`); + } + + const incomplete = runJsonScript("scripts/installer-report.mjs", [ + `--deployment-root=${deploymentRoot}`, + "--environment=all", + "--output=json", + ]); + + if (incomplete.status !== 0 + || incomplete.parsed?.ok !== false + || !String(incomplete.stdout).includes("browser login result")) { + throw new Error(`installer report should flag missing human rollout records.\nSTDOUT:\n${incomplete.stdout}\nSTDERR:\n${incomplete.stderr}`); + } + + fs.writeFileSync(path.join(deploymentRoot, "prod", "browser-smoke.json"), JSON.stringify({ + ok: true, + method: "dry-run", + selectedChurch: "Example Church", + dashboardLoaded: true, + }, null, 2)); + ["staging", "prod"].forEach((environmentName) => { + fs.writeFileSync(path.join(deploymentRoot, environmentName, "bootstrap-admin.json"), JSON.stringify({ + ok: true, + dryRun: false, + }, null, 2)); + }); + + const complete = runJsonScript("scripts/installer-report.mjs", [ + `--deployment-root=${deploymentRoot}`, + "--environment=all", + "--write=true", + "--output=json", + ]); + + const reportPath = path.join(deploymentRoot, "deployment-report.md"); + if (complete.status !== 0 + || complete.parsed?.ok !== true + || !fs.existsSync(reportPath)) { + throw new Error(`installer report should write a complete rollout report from evidence and supplied records.\nSTDOUT:\n${complete.stdout}\nSTDERR:\n${complete.stderr}`); + } + + const body = fs.readFileSync(reportPath, "utf8"); + if (!body.includes("# B1Admin Deployment Report") + || !body.includes("Complete environments: 2/2") + || !body.includes("GitHub Actions run id: `222`") + || !body.includes("B1Admin commit SHA: `prod-b1`") + || !body.includes("Api commit SHA: `prod-api`") + || !body.includes("API base URL: `https://api.example.com`") + || !body.includes("Browser login result: passed:")) { + throw new Error(`installer report markdown is missing expected rollout evidence.\n${body}`); + } + + const prodOnly = runJsonScript("scripts/installer-report.mjs", [ + `--deployment-root=${deploymentRoot}`, + "--environment=prod", + "--output=json", + ]); + if (prodOnly.status !== 0 + || prodOnly.parsed?.environments?.length !== 1 + || !String(prodOnly.parsed?.markdown || "").includes("- Prod browser workflow tested by: ") + || String(prodOnly.parsed?.markdown || "").includes("- Staging browser workflow tested by: ")) { + throw new Error(`installer report should support a clean prod-only sign-off.\nSTDOUT:\n${prodOnly.stdout}\nSTDERR:\n${prodOnly.stderr}`); + } + } finally { + fs.rmSync(deploymentRoot, { recursive: true, force: true }); + } +} + +function expectShowRolloutStatusSummarizesMultipleEnvironments() { + const tempRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-rollout-status-env-root-")); + + try { + for (const environmentName of ["staging", "prod"]) { + const targetDir = path.join(tempRoot, environmentName); + fs.mkdirSync(targetDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", environmentName, fileName), + path.join(targetDir, fileName), + ); + } + } + + restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); + restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); + + const prepareProdResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=prod", + `--environment-dir=${path.join(tempRoot, "prod")}`, + "--account-id=123456789012", + "--admin-root-url=https://admin.customer.test", + "--cors-origin=https://admin.customer.test", + "--content-root-url=https://content.customer.test", + "--store-api-url=https://store.customer.test", + "--transfer-url=https://transfer.customer.test", + "--support-email=support@customer.test", + "--support-phone=918-994-2638", + "--support-site-url=https://support.customer.test", + "--website-base-url=https://{subdomain}.customer.test", + "--mobile-app-url=https://customer.test/app", + "--domain-cname-target=proxy.customer.test", + "--domain-a-target=3.23.251.61", + "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", + "--write=true", + "--write-secret-file=true", + "--output=json", + ]); + + if (prepareProdResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before rollout-status verification.\nSTDOUT:\n${prepareProdResult.stdout}\nSTDERR:\n${prepareProdResult.stderr}`); + } + + withFakePackagableApiRepo((fakeApiRepoPath) => withFakeGhForDispatchGithubAwsDeploy(({ env }) => { + const result = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ + `--environment-root-dir=${tempRoot}`, + `--api-repo-path=${fakeApiRepoPath}`, + "--output=json", + ], env); + + if (result.status !== 1) { + throw new Error(`show-rollout-status should exit non-zero when at least one environment is still blocked.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + if (actual.ok !== false || actual.environmentCount !== 2 || actual.readyEnvironmentCount !== 1 || actual.blockedEnvironmentCount !== 1) { + throw new Error(`show-rollout-status did not report the expected ready/blocked counts.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.blockedEnvironments) || actual.blockedEnvironments.join(",") !== "staging" || !Array.isArray(actual.readyEnvironments) || actual.readyEnvironments.join(",") !== "prod") { + throw new Error(`show-rollout-status did not report the expected ready/blocked environment names.\nSTDOUT:\n${result.stdout}`); + } + if (actual.blockerCategories?.starterOrInput?.environmentCount !== 1 + || actual.blockerCategories?.localExecution?.environmentCount < 1 + || actual.blockerCategories?.githubActionsExecution?.environmentCount < 1 + || actual.blockerCategories?.localGithubDispatch?.environmentCount !== 0) { + throw new Error(`show-rollout-status did not report the expected blocker-category summary.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.overallHighlightedBlockers) || !actual.overallHighlightedBlockers.some((entry) => String(entry).includes("AWS_APP_CONFIG_SECRET_JSON"))) { + throw new Error(`show-rollout-status did not surface the expected cross-environment blocker summary.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.recommendedNextSteps) || actual.recommendedNextSteps.length !== 1) { + throw new Error(`show-rollout-status did not surface the expected cross-environment next-step summary.\nSTDOUT:\n${result.stdout}`); + } + + if (!String(actual.recommendedNextCommand || "").startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") + || !String(actual.recommendedNextCommand || "").endsWith("--account-id= --output=json")) { + throw new Error(`show-rollout-status should surface the first blocked environment's primary command.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.commandSummary?.global) || actual.commandSummary.global[0] !== actual.recommendedNextCommand) { + throw new Error(`show-rollout-status should expose the top recommended command in commandSummary.global.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.commandSummary?.all) || !actual.commandSummary.all.some((command) => String(command).includes("ENV_DIR=") + && String(command).includes("/prod") + && String(command).includes("./infrastructure/environments/prod/deploy-split-stack.sh"))) { + throw new Error(`show-rollout-status should expose the ordered cross-environment command list in commandSummary.all.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.commandSummary?.byEnvironment?.staging) || actual.commandSummary.byEnvironment.staging.some((command) => command === actual.recommendedNextCommand)) { + throw new Error(`show-rollout-status should omit the global top command from commandSummary.byEnvironment entries.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.commandSummary?.byEnvironment?.prod) || !actual.commandSummary.byEnvironment.prod.some((command) => String(command).includes("./infrastructure/environments/prod/deploy-split-stack.sh"))) { + throw new Error(`show-rollout-status should preserve per-environment fallback commands in commandSummary.byEnvironment.\nSTDOUT:\n${result.stdout}`); + } + + const staging = (actual.environments || []).find((entry) => entry.environment === "staging"); + const prod = (actual.environments || []).find((entry) => entry.environment === "prod"); + + if (!staging || !prod) { + throw new Error(`show-rollout-status did not return both staging and prod summaries.\nSTDOUT:\n${result.stdout}`); + } + if (staging.status !== "blocked" || staging.starterAndInputBlockerCount !== 15 || staging.recommendedPath !== "none") { + throw new Error(`show-rollout-status did not preserve the blocked staging summary.\nSTDOUT:\n${result.stdout}`); + } + if (prod.status !== "ready" || prod.starterAndInputBlockerCount !== 0 || prod.recommendedPath !== "local") { + throw new Error(`show-rollout-status did not preserve the locally ready prod summary.\nSTDOUT:\n${result.stdout}`); + } + if (prod.localExecutionOk !== true || prod.githubActionsExecutionOk !== false || prod.localGithubDispatchOk !== true) { + throw new Error(`show-rollout-status did not preserve the expected prod execution-path readiness details.\nSTDOUT:\n${result.stdout}`); + } + }), { includeLayer: true }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function expectShowRolloutStatusOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/show-rollout-status-output.sample.json"); + const tempRoot = path.join(rootDir, ".tmp-rollout-status-sample-env"); + + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.mkdirSync(path.join(tempRoot, "staging"), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, "prod"), { recursive: true }); + + try { + for (const environmentName of ["staging", "prod"]) { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", environmentName, fileName), + path.join(tempRoot, environmentName, fileName), + ); + } + } + + restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); + restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); + + const prepareProdResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=prod", + `--environment-dir=${path.join(tempRoot, "prod")}`, + "--account-id=123456789012", + "--admin-root-url=https://admin.customer.test", + "--cors-origin=https://admin.customer.test", + "--content-root-url=https://content.customer.test", + "--store-api-url=https://store.customer.test", + "--transfer-url=https://transfer.customer.test", + "--support-email=support@customer.test", + "--support-phone=918-994-2638", + "--support-site-url=https://support.customer.test", + "--website-base-url=https://{subdomain}.customer.test", + "--mobile-app-url=https://customer.test/app", + "--domain-cname-target=proxy.customer.test", + "--domain-a-target=3.23.251.61", + "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", + "--write=true", + "--write-secret-file=true", + "--output=json", + ]); + + if (prepareProdResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before rollout-status sample verification.\nSTDOUT:\n${prepareProdResult.stdout}\nSTDERR:\n${prepareProdResult.stderr}`); + } + + let result; + let githubFocusedResult; + withFakeGhForDispatchGithubAwsDeploy(({ env }) => { + result = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ + "--environment-root-dir=.tmp-rollout-status-sample-env", + "--output=json", + ], env); + githubFocusedResult = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ + "--environment-root-dir=.tmp-rollout-status-sample-env", + "--deployment-intent=github-actions", + "--output=json", + ], env); + }); + + if (result.status !== 1) { + throw new Error(`show-rollout-status output sample contract run should stay blocked while the local Api repo is unreadable and GitHub secret materialization is still missing.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + if (githubFocusedResult.status !== 1) { + throw new Error(`show-rollout-status github-focused sample contract run should stay blocked while starter or GitHub-specific blockers remain.\nSTDOUT:\n${githubFocusedResult.stdout}\nSTDERR:\n${githubFocusedResult.stderr}`); + } + + const actual = result.parsed || {}; + const githubFocusedActual = githubFocusedResult.parsed || {}; + expectObjectContainsKeys("show-rollout-status output sample", actual, sample); + + if (sample.ok !== false || sample.environmentCount !== 2 || sample.blockedEnvironmentCount !== 2) { + throw new Error(`show-rollout-status output sample should document a blocked two-environment rollout snapshot.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.blockedEnvironments) || sample.blockedEnvironments.join(",") !== "staging,prod" || !Array.isArray(sample.readyEnvironments) || sample.readyEnvironments.length !== 0) { + throw new Error(`show-rollout-status output sample should list the expected ready/blocked environment names.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.blockerCategories?.starterOrInput?.environmentCount !== 1 + || sample.blockerCategories?.localExecution?.environmentCount !== 2 + || sample.blockerCategories?.githubActionsExecution?.environmentCount !== 2 + || sample.blockerCategories?.localGithubDispatch?.environmentCount !== 0) { + throw new Error(`show-rollout-status output sample should include the expected blocker-category summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.recommendedNextCommand !== "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json") { + throw new Error(`show-rollout-status output sample should surface the staging starter prep command first.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.commandSummary?.global) || sample.commandSummary.global[0] !== sample.recommendedNextCommand) { + throw new Error(`show-rollout-status output sample should expose the global command list.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.commandSummary?.all) || !sample.commandSummary.all.includes("yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json")) { + throw new Error(`show-rollout-status output sample should include the ordered cross-environment command list.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.commandSummary?.byEnvironment?.staging) || sample.commandSummary.byEnvironment.staging.some((command) => command === sample.recommendedNextCommand)) { + throw new Error(`show-rollout-status output sample should omit the top-level command from staging-specific commandSummary entries.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.commandSummary?.byEnvironment?.prod) || !sample.commandSummary.byEnvironment.prod.some((command) => String(command).includes("deployment-source=backend-artifact"))) { + throw new Error(`show-rollout-status output sample should preserve prod fallback commands in commandSummary.byEnvironment.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.overallHighlightedBlockers) || !sample.overallHighlightedBlockers.some((entry) => String(entry).includes("AWS_APP_CONFIG_SECRET_JSON"))) { + throw new Error(`show-rollout-status output sample should include the cross-environment blocker summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.recommendedNextSteps) || sample.recommendedNextSteps.length !== 2) { + throw new Error(`show-rollout-status output sample should include the cross-environment next-step summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + + const environments = sample.environments || []; + const staging = environments.find((entry) => entry.environment === "staging"); + const prod = environments.find((entry) => entry.environment === "prod"); + + if (!staging || !prod) { + throw new Error(`show-rollout-status output sample should include both staging and prod summaries.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (staging.status !== "blocked" || staging.starterAndInputBlockerCount !== 15 || staging.localGithubDispatchOk !== true) { + throw new Error(`show-rollout-status output sample should preserve the blocked staging summary with local gh ready.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(staging.alternateCommands) || !staging.alternateCommands.some((command) => String(command).includes("dispatch:github-aws-deploy"))) { + throw new Error(`show-rollout-status output sample should include staging alternate deploy commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (prod.status !== "blocked" || prod.starterAndInputBlockerCount !== 0 || prod.primaryCommand !== "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json") { + throw new Error(`show-rollout-status output sample should preserve the execution-blocked prod summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(prod.highlightedBlockers) || !prod.highlightedBlockers.some((blocker) => String(blocker).includes("AWS_APP_CONFIG_SECRET_JSON"))) { + throw new Error(`show-rollout-status output sample should include the prod GitHub secret materialization blocker.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (githubFocusedActual.deploymentIntent !== "github-actions" || !Array.isArray(githubFocusedActual.ignoredBlockerCategories) || !githubFocusedActual.ignoredBlockerCategories.includes("localExecution")) { + throw new Error(`show-rollout-status github-focused mode should report that localExecution blockers are ignored in the rollout summary.\nSTDOUT:\n${githubFocusedResult.stdout}`); + } + if (!Array.isArray(githubFocusedActual.overallHighlightedBlockers) || githubFocusedActual.overallHighlightedBlockers.some((entry) => String(entry).includes("Local api-repo path is not readable from this workspace"))) { + throw new Error(`show-rollout-status github-focused mode should suppress local Api readability blockers from the overall summary.\nSTDOUT:\n${githubFocusedResult.stdout}`); + } + if (!Array.isArray(githubFocusedActual.commandSummary?.all) || githubFocusedActual.commandSummary.all.some((command) => String(command).includes("deploy-split-stack.sh"))) { + throw new Error(`show-rollout-status github-focused mode should keep local deploy commands out of the recommended command list.\nSTDOUT:\n${githubFocusedResult.stdout}`); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function expectShowRolloutStatusCommandsOutputWorks() { + const tempRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-rollout-status-commands-root-")); + + try { + for (const environmentName of ["staging", "prod"]) { + const targetDir = path.join(tempRoot, environmentName); + fs.mkdirSync(targetDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", environmentName, fileName), + path.join(targetDir, fileName), + ); + } + } + + restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); + restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); + + const result = spawnSync("node", ["scripts/show-rollout-status.mjs", `--environment-root-dir=${tempRoot}`, "--output=commands"], { + cwd: rootDir, + encoding: "utf8", + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + const lines = stdout.trim().split("\n"); + + if (result.status !== 1) { + throw new Error(`show-rollout-status commands mode should exit non-zero when any environment is blocked.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + if (!lines[0].startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") + || !lines[0].endsWith(" --account-id= --output=json")) { + throw new Error(`show-rollout-status commands mode should print the top recommended command first.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("# staging") || !stdout.includes("# prod")) { + throw new Error(`show-rollout-status commands mode should split command lists by environment.\nSTDOUT:\n${stdout}`); + } + if ((stdout.match(new RegExp(`^${lines[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm")) || []).length !== 1) { + throw new Error(`show-rollout-status commands mode should not repeat the same top-level remediation command inside each environment block.\nSTDOUT:\n${stdout}`); + } + if (!stdout.includes("gh workflow run deploy-aws-self-hosted.yml")) { + throw new Error(`show-rollout-status commands mode should include alternate deploy commands from the underlying plan.\nSTDOUT:\n${stdout}`); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployMarkdownOutputWorks() { + let result; + withFailingGhForDispatchGithubAwsDeploy((env) => withRawStarterEnvironment("staging", (tempDir) => { + result = spawnSync("node", ["scripts/plan-environment-deploy.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--api-repo-path=.", "--output=markdown"], { + cwd: rootDir, + encoding: "utf8", + env: { ...process.env, ...env }, + }); + })); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + + if (result.status !== 1) { + throw new Error(`plan-environment-deploy markdown mode should be blocked while staging placeholders remain.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + const expectedSnippets = [ + "# Environment Deploy Plan: staging", + "## Blockers", + "## Recommendation", + "## Starter Prep", + "## Preflight", + "## Local Run", + "## GitHub Actions Run", + "## GitHub Secrets", + "aws-staging-deployment-evidence", + "aws-staging-preflight-plan", + "saved-output follow-up commands", + "jwtSecret", + "encryptionKey", + "audit:api-repo-contract", + "prepare:environment-starter", + "Local GitHub dispatch: blocked", + "gh auth login -h github.com", + ]; + + for (const snippet of expectedSnippets) { + if (!stdout.includes(snippet)) { + throw new Error(`plan-environment-deploy markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectPlanEnvironmentDeployReadyPackageManifestModeWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-deploy-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before ready-mode plan verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + const manifestPath = path.join(tempDir, "package-manifest.json"); + fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + let planResult; + withFakeGhForDispatchGithubAwsDeploy(({ env }) => { + planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${manifestPath}`, + "--github-auth-mode=static", + "--sync-app-config-secret=true", + "--run-api-migrations=true", + "--api-migration-action=status", + "--api-migration-module=membership", + "--output=json", + ], env); + }); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should report ready after prepare write mode and valid package-manifest inputs.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true || actual.starterSummary?.blockerCount !== 0) { + throw new Error(`plan-environment-deploy ready-mode result should be ok with zero starter blockers.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.localExecution?.ok !== true || actual.githubActionsExecution?.ok !== true) { + throw new Error(`plan-environment-deploy ready-mode result should mark both local and GitHub execution as ready.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.localGithubDispatch?.ok !== true || actual.localGithubDispatch?.blockerCount !== 0) { + throw new Error(`plan-environment-deploy ready-mode result should mark local gh dispatch as ready when gh auth succeeds.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedExecution?.path !== "either") { + throw new Error(`plan-environment-deploy ready-mode result should recommend either path when both are ready.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedCommands?.primary !== actual.commands?.local) { + throw new Error(`plan-environment-deploy ready-mode result should recommend the local command first when both paths are ready.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.postDeployCommands?.verifyWithHttp || "").includes("--check-http=true")) { + throw new Error(`plan-environment-deploy ready-mode result should include the HTTP verification follow-up command.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.githubSecretSyncCommand || "").includes("yarn sync:github-app-config-secret -- --environment=staging") + || !String(actual.githubSecretSyncCommand || "").includes("--secret-file=")) { + throw new Error(`plan-environment-deploy ready-mode result should include the GitHub app-config secret sync helper command when app-config-secret.json exists.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.postDeployCommands?.ensureOutputsDir || "").includes("mkdir -p deployment/staging") + || !String(actual.postDeployCommands?.saveBackendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks") + || !String(actual.postDeployCommands?.saveFrontendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks")) { + throw new Error(`plan-environment-deploy ready-mode result should include output-capture follow-up commands.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.postDeployCommands?.saveOutputsWithHelper || "").includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { + throw new Error(`plan-environment-deploy ready-mode result should include the helper-based output capture command.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.postDeployCommands?.showSavedSummary || "").includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { + throw new Error(`plan-environment-deploy ready-mode result should include the saved-summary render command.\nSTDOUT:\n${planResult.stdout}`); + } + if (!String(actual.postDeployCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=deployment/staging/backend-outputs.json") + || !String(actual.postDeployCommands?.verifyFromSavedOutputsWithHttp || "").includes("--check-http=true") + || !String(actual.postDeployCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets") + || !String(actual.postDeployCommands?.publishFrontendAssetsFromSavedOutputs || "").includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json")) { + throw new Error(`plan-environment-deploy ready-mode result should include saved-output reuse follow-up commands.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.githubPostDeploy?.artifactName !== "aws-staging-deployment-evidence" + || actual.githubPostDeploy?.artifactPath !== "deployment/staging/" + || actual.githubPostDeploy?.failureArtifactName !== "aws-staging-preflight-plan" + || actual.githubPostDeploy?.failureArtifactPath !== "deployment/staging/preflight-plan.md" + || !Array.isArray(actual.githubPostDeploy?.summaryIncludes) + || !actual.githubPostDeploy.summaryIncludes.includes("preflight deploy plan") + || !actual.githubPostDeploy.summaryIncludes.includes("saved-output follow-up commands")) { + throw new Error(`plan-environment-deploy ready-mode result should include the GitHub post-deploy handoff.\nSTDOUT:\n${planResult.stdout}`); + } + + const requiredSecrets = actual.requiredGithubSecrets || []; + for (const secretName of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_APP_CONFIG_SECRET_JSON"]) { + if (!requiredSecrets.includes(secretName)) { + throw new Error(`plan-environment-deploy ready-mode result is missing required GitHub secret: ${secretName}\nSTDOUT:\n${planResult.stdout}`); + } + } + + const localCommand = actual.commands?.local || ""; + const localPreviewCommand = actual.commands?.localPreview || ""; + const githubWrapperCommand = actual.commands?.githubActionsWrapper || ""; + const githubWrapperPreviewCommand = actual.commands?.githubActionsWrapperPreview || ""; + const githubCommand = actual.commands?.githubActions || ""; + const githubPreviewCommand = actual.commands?.githubActionsPreview || ""; + + for (const snippet of [ + "PACKAGE_MANIFEST_FILE=", + "SYNC_APP_CONFIG_SECRET='true'", + "RUN_API_MIGRATIONS='true'", + "API_MIGRATION_ACTION='status'", + "API_MIGRATION_MODULE='membership'", + ]) { + if (!localCommand.includes(snippet)) { + throw new Error(`plan-environment-deploy ready-mode local command is missing expected content: ${snippet}\nSTDOUT:\n${planResult.stdout}`); + } + } + if (!localPreviewCommand.includes("PREVIEW_ONLY='true'")) { + throw new Error(`plan-environment-deploy ready-mode local preview command should include PREVIEW_ONLY='true'.\nSTDOUT:\n${planResult.stdout}`); + } + + if (!githubWrapperCommand.includes("yarn dispatch:github-aws-deploy --") + || !githubWrapperCommand.includes("--deployment-source=package-manifest") + || !githubWrapperCommand.includes("--sync-app-config-secret=true")) { + throw new Error(`plan-environment-deploy ready-mode GitHub wrapper command is missing expected content.\nSTDOUT:\n${planResult.stdout}`); + } + if (!githubWrapperPreviewCommand.includes("--preview-only=true")) { + throw new Error(`plan-environment-deploy ready-mode GitHub preview wrapper command should include --preview-only=true.\nSTDOUT:\n${planResult.stdout}`); + } + + for (const snippet of [ + "deployment_source='package-manifest'", + "sync_app_config_secret='true'", + "run_api_migrations='true'", + "api_migration_action='status'", + "api_migration_module='membership'", + ]) { + if (!githubCommand.includes(snippet)) { + throw new Error(`plan-environment-deploy ready-mode GitHub command is missing expected content: ${snippet}\nSTDOUT:\n${planResult.stdout}`); + } + } + if (!githubPreviewCommand.includes("preview_only='true'")) { + throw new Error(`plan-environment-deploy ready-mode GitHub preview command should include preview_only='true'.\nSTDOUT:\n${planResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployGithubNeedsSecretMaterializationWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-github-secret-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, "staging"); + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--admin-root-url=https://admin-staging.customer.test", + "--cors-origin=https://admin-staging.customer.test", + "--content-root-url=https://content-staging.customer.test", + "--store-api-url=https://store-staging.customer.test", + "--transfer-url=https://transfer-staging.customer.test", + "--support-email=support@customer.test", + "--support-phone=918-994-2638", + "--support-site-url=https://support-staging.customer.test", + "--website-base-url=https://{subdomain}.customer.test", + "--mobile-app-url=https://customer.test/app", + "--domain-cname-target=proxy.customer.test", + "--domain-a-target=3.23.251.61", + "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", + "--write=true", + "--write-secret-file=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before GitHub secret-materialization plan verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + const manifestPath = path.join(tempDir, "package-manifest.json"); + fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const planResult = runJsonScript("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${manifestPath}`, + "--output=json", + ]); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when only the GitHub secret-materialization blocker remains.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true || actual.localExecution?.ok !== true || actual.githubActionsExecution?.ok !== false) { + throw new Error(`plan-environment-deploy should keep the local path ready while blocking GitHub until app-config-secret is materialized there.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedExecution?.path !== "local") { + throw new Error(`plan-environment-deploy should recommend the local path when GitHub is missing app-config-secret materialization.\nSTDOUT:\n${planResult.stdout}`); + } + if (!actual.githubActionsExecution?.blockers?.some((entry) => String(entry).includes("Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON"))) { + throw new Error(`plan-environment-deploy should explain how to materialize app-config-secret on the GitHub runner.\nSTDOUT:\n${planResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployLocalOnlyExecutionBlockerWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-local-only-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before local-only execution blocker verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); + let planResult; + withFakeGhForDispatchGithubAwsDeploy(({ env }) => { + planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${missingManifestPath}`, + "--sync-app-config-secret=true", + "--output=json", + ], env); + }); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when starter files are prepared and package-manifest input is present, even if the local file path is missing.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true || actual.githubActionsExecution?.ok !== true || actual.localExecution?.ok !== false) { + throw new Error(`plan-environment-deploy should distinguish local-only execution blockers from shared readiness blockers.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedExecution?.path !== "github-actions") { + throw new Error(`plan-environment-deploy should recommend GitHub Actions when it is ready and the local path is not.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedCommands?.primary !== actual.commands?.githubActionsWrapper) { + throw new Error(`plan-environment-deploy should recommend the GitHub wrapper command first when only GitHub is ready.\nSTDOUT:\n${planResult.stdout}`); + } + + const localBlockers = actual.localExecution?.blockers || []; + if (!localBlockers.some((entry) => String(entry).includes("Local package manifest file does not exist yet:"))) { + throw new Error(`plan-environment-deploy did not surface the missing local package manifest as a local-only execution blocker.\nSTDOUT:\n${planResult.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployUnreadableApiRepoLocalOnlyBlockerWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-unreadable-api-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before unreadable api-repo verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + withFakeGhForDispatchGithubAwsDeploy(({ env }) => withUnreadableFakeApiRepoDirectory((fakeApiRepoPath) => { + const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=api-repo", + `--api-repo-path=${fakeApiRepoPath}`, + "--sync-app-config-secret=true", + "--output=json", + ], env); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when starter files are prepared and only the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true || actual.githubActionsExecution?.ok !== true || actual.localExecution?.ok !== false) { + throw new Error(`plan-environment-deploy should classify an unreadable local api-repo path as a local-only execution blocker.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedExecution?.path !== "github-actions") { + throw new Error(`plan-environment-deploy should recommend GitHub Actions when the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedCommands?.primary !== actual.commands?.githubActionsWrapper) { + throw new Error(`plan-environment-deploy should recommend the GitHub wrapper command first when only the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}`); + } + + const localBlockers = actual.localExecution?.blockers || []; + if (!localBlockers.some((entry) => String(entry).includes("Local api-repo path is not readable from this workspace:"))) { + throw new Error(`plan-environment-deploy did not surface the unreadable local api-repo path.\nSTDOUT:\n${planResult.stdout}`); + } + if (!localBlockers.some((entry) => String(entry).includes("deployment-source=package-manifest")) + || !localBlockers.some((entry) => String(entry).includes("deployment-source=backend-artifact"))) { + throw new Error(`plan-environment-deploy did not include the expected manifest/artifact fallback guidance.\nSTDOUT:\n${planResult.stdout}`); + } + const packageManifestPlan = String(actual.localFallbackCommands?.packageManifestPlan || ""); + const packageManifestLocal = String(actual.localFallbackCommands?.packageManifestLocal || ""); + const backendArtifactPlan = String(actual.localFallbackCommands?.backendArtifactPlan || ""); + const backendArtifactLocal = String(actual.localFallbackCommands?.backendArtifactLocal || ""); + + if (!packageManifestPlan.includes("--deployment-source=package-manifest") + || !packageManifestLocal.includes("PACKAGE_MANIFEST_FILE=") + || !backendArtifactPlan.includes("--deployment-source=backend-artifact") + || !backendArtifactLocal.includes("BACKEND_ARTIFACT_SOURCE_FILE=")) { + throw new Error(`plan-environment-deploy did not include the expected concrete fallback commands.\nSTDOUT:\n${planResult.stdout}`); + } + if (backendArtifactLocal.includes("MIGRATION_ARTIFACT_SOURCE_FILE=") + || backendArtifactLocal.includes("DEPENDENCIES_LAYER_SOURCE_FILE=")) { + throw new Error(`plan-environment-deploy should keep the local backend-artifact fallback focused on the backend zip unless the user supplies extra artifacts separately.\nSTDOUT:\n${planResult.stdout}`); + } + if (!Array.isArray(actual.nextSteps) + || !actual.nextSteps.some((entry) => String(entry).includes("switch the local run to package-manifest or backend-artifact mode"))) { + throw new Error(`plan-environment-deploy did not add the expected fallback next step.\nSTDOUT:\n${planResult.stdout}`); + } + })); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployGithubOnlyNeedsGhAuthWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-github-only-auth-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before github-only gh-auth verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); + withFailingGhForDispatchGithubAwsDeploy((env) => { + const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${missingManifestPath}`, + "--sync-app-config-secret=true", + "--output=json", + ], env); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when GitHub is the only deploy path and gh auth is the remaining machine blocker.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true + || actual.recommendedExecution?.path !== "github-actions" + || actual.githubActionsExecution?.ok !== true + || actual.localExecution?.ok !== false + || actual.localGithubDispatch?.ok !== false) { + throw new Error(`plan-environment-deploy should classify this as a GitHub-only path blocked locally by gh auth.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedCommands?.primary !== "gh auth login -h github.com") { + throw new Error(`plan-environment-deploy should recommend fixing gh auth before a GitHub-only deploy from this machine.\nSTDOUT:\n${planResult.stdout}`); + } + if (!Array.isArray(actual.nextSteps) + || !String(actual.nextSteps[0] || "").includes("Run `gh auth login -h github.com` first")) { + throw new Error(`plan-environment-deploy should prioritize the gh auth remediation step in nextSteps.\nSTDOUT:\n${planResult.stdout}`); + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployGhNetworkFailureWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-gh-network-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before gh network failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); + withNetworkFailingGhForPlanEnvironmentDeploy((env) => { + const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${missingManifestPath}`, + "--sync-app-config-secret=true", + "--output=json", + ], env); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when GitHub is the only deploy path and the remaining machine blocker is connectivity.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.localGithubDispatch?.ok !== false + || !actual.localGithubDispatch?.blockers?.some((entry) => String(entry).includes("could not reach github.com"))) { + throw new Error(`plan-environment-deploy should classify gh network failures separately from invalid-token auth failures.\nSTDOUT:\n${planResult.stdout}`); + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployExecutionRemediationCommandWorks() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-remediation-")); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--account-id=123456789012", + "--write=true", + "--write-secret-file=true", + "--output=json", + ]); + + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before execution-remediation verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + + withFailingGhForDispatchGithubAwsDeploy((env) => withUnreadableFakeApiRepoDirectory((fakeApiRepoPath) => { + const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${tempDir}`, + "--deployment-source=api-repo", + `--api-repo-path=${fakeApiRepoPath}`, + "--output=json", + ], env); + + if (planResult.status !== 0) { + throw new Error(`plan-environment-deploy should stay overall-ready when only execution-specific blockers remain.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); + } + + const actual = planResult.parsed || {}; + if (actual.ok !== true + || actual.recommendedExecution?.path !== "none" + || actual.localExecution?.ok !== false + || actual.githubActionsExecution?.ok !== false + || actual.localGithubDispatch?.ok !== false) { + throw new Error(`plan-environment-deploy should classify this as execution-only blockers with no runnable path yet.\nSTDOUT:\n${planResult.stdout}`); + } + if (actual.recommendedCommands?.primary !== "gh auth login -h github.com") { + throw new Error(`plan-environment-deploy should recommend fixing local gh auth first when GitHub dispatch remediation depends on it.\nSTDOUT:\n${planResult.stdout}`); + } + if (!Array.isArray(actual.nextSteps) + || !String(actual.nextSteps[0] || "").includes("Run `gh auth login -h github.com` first")) { + throw new Error(`plan-environment-deploy should prioritize the gh auth remediation step in nextSteps when execution-only blockers remain.\nSTDOUT:\n${planResult.stdout}`); + } + const alternates = actual.recommendedCommands?.alternates || []; + if (!alternates.some((entry) => String(entry).includes("sync:github-app-config-secret")) + || !alternates.some((entry) => String(entry).includes("--deployment-source=package-manifest")) + || !alternates.some((entry) => String(entry).includes("--deployment-source=backend-artifact"))) { + throw new Error(`plan-environment-deploy should include the GitHub secret sync and local fallback remediation commands.\nSTDOUT:\n${planResult.stdout}`); + } + })); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPlanEnvironmentDeployBackendArtifactInputBlockerWorks() { + const result = runJsonScript("scripts/plan-environment-deploy.mjs", [ + "--environment=staging", + "--deployment-source=backend-artifact", + "--output=json", + ]); + + if (result.status !== 1) { + throw new Error(`plan-environment-deploy backend-artifact mode should fail when no backend artifact source file is provided.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const inputBlockers = result.parsed?.inputBlockers || []; + if (!inputBlockers.some((entry) => String(entry).includes("backend-artifact-source-file is required"))) { + throw new Error(`plan-environment-deploy backend-artifact mode did not report the missing backend-artifact-source-file blocker.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectDeployFullStackPackageManifestMissingArtifact() { + withFakePackageManifest((manifestPath) => { + withFakeAwsAllowingS3Cp((env) => { + const result = runScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--template-bucket=my-template-bucket", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--infrastructure-only", + ], env); + + if (result.status === 0) { + throw new Error(`deploy-full-stack package manifest file without api repo unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("Source file not found:")) { + throw new Error(`deploy-full-stack package manifest file without api repo did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); + }, { missingBackendArtifact: true }); +} + +function expectDeployBackendPackageManifestMissingMigrationArtifact() { + withFakePackageManifest((manifestPath) => { + withFakeAwsAllowingS3Cp((env) => { + const result = runScriptWithEnv("scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--run-migrations=true", + "--migration-handler=index.migrate", + ], env); + + if (result.status === 0) { + throw new Error(`deploy-backend package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("Source file not found:")) { + throw new Error(`deploy-backend package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); + }, { missingMigrationArtifact: true }); +} + +function expectDeployAwsPackageManifestMissingMigrationArtifact() { + withFakePackageManifest((manifestPath) => { + withFakeAwsAllowingS3Cp((env) => { + const result = runScriptWithEnv("scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--run-migrations=true", + "--migration-handler=index.migrate", + "--skip-frontend", + ], env); + + if (result.status === 0) { + throw new Error(`deploy-aws package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("Source file not found:")) { + throw new Error(`deploy-aws package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); + }, { missingMigrationArtifact: true }); +} + +function expectDeployFullStackPackageManifestMissingMigrationArtifact() { + withFakePackageManifest((manifestPath) => { + withFakeAwsAllowingS3Cp((env) => { + const result = runScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--template-bucket=my-template-bucket", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--run-migrations=true", + "--migration-handler=index.migrate", + "--infrastructure-only", + ], env); + + if (result.status === 0) { + throw new Error(`deploy-full-stack package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("Source file not found:")) { + throw new Error(`deploy-full-stack package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); + }, { missingMigrationArtifact: true }); +} + +function expectDeployBackendJsonIncludesManifestProvenance() { + withFakePackageManifest((manifestPath) => { + withFakeAwsForBackendDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-backend json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.resolvedPackageManifestFile !== manifestPath) { + throw new Error(`deploy-backend json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); + } + + const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); + if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { + throw new Error(`deploy-backend json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployAwsJsonIncludesManifestProvenance() { + withFakePackageManifest((manifestPath) => { + withFakeAwsForBackendDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-stack-name=example-backend", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--skip-frontend", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-aws json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.resolvedPackageManifestFile !== manifestPath) { + throw new Error(`deploy-aws json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); + } + + const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); + if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { + throw new Error(`deploy-aws json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); + } + + if (parsed.backend?.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { + throw new Error(`deploy-aws nested backend json output did not reflect the uploaded backend artifact key.\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployFullStackJsonIncludesManifestProvenance() { + withFakePackageManifest((manifestPath) => { + withFakeAwsForFullStackDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + `--package-manifest-file=${manifestPath}`, + "--template-bucket=my-template-bucket", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--infrastructure-only", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.resolvedPackageManifestFile !== manifestPath) { + throw new Error(`deploy-full-stack json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); + } + + const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); + if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { + throw new Error(`deploy-full-stack json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployBackendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-backend-output.sample.json"); + + withFakePackageManifest((manifestPath) => { + withFakeAwsForBackendDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-backend output sample", actual, sample); + expectObjectContainsKeys("deploy-backend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + + if (sample.stackName !== "example-backend") { + throw new Error(`deploy-backend output sample should document stackName=example-backend.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket") { + throw new Error(`deploy-backend output sample should document lambdaCodeS3Bucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { + throw new Error(`deploy-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.resolvedPackageManifestFile).includes("package-manifest.sample.json")) { + throw new Error(`deploy-backend output sample should point to the sample manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.resolvedBackendArtifactSourceFile).includes("/")) { + throw new Error(`deploy-backend output sample should show a manifest-relative backend artifact placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + }); +} + +function expectDeployBootstrapOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-bootstrap-output.sample.json"); + + withFakeAwsForBootstrapDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-bootstrap.mjs", [ + "--stack-name=example-bootstrap", + "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-bootstrap output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-bootstrap output sample", actual, sample); + expectObjectContainsKeys("deploy-bootstrap output sample", actual.parameters || {}, sample.parameters || {}, "parameters"); + expectObjectContainsKeys("deploy-bootstrap output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + + if (sample.stackName !== "example-bootstrap") { + throw new Error(`deploy-bootstrap output sample should document stackName=example-bootstrap.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.region !== "us-east-1") { + throw new Error(`deploy-bootstrap output sample should document region=us-east-1.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parameters?.TemplateBucketName !== "b1admin-prod-templates-123456789012") { + throw new Error(`deploy-bootstrap output sample should document the sample template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parameters?.ArtifactBucketName !== "b1admin-prod-artifacts-123456789012") { + throw new Error(`deploy-bootstrap output sample should document the sample artifact bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.outputs?.TemplateBucketName !== "b1admin-prod-templates-123456789012") { + throw new Error(`deploy-bootstrap output sample should document the resolved TemplateBucketName output.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.outputs?.ArtifactBucketName !== "b1admin-prod-artifacts-123456789012") { + throw new Error(`deploy-bootstrap output sample should document the resolved ArtifactBucketName output.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectDeployFrontendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-frontend-output.sample.json"); + + withFakeAwsForFrontendDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--infrastructure-only", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-frontend output sample", actual, sample); + expectObjectContainsKeys("deploy-frontend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + expectObjectContainsKeys("deploy-frontend output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); + + if (sample.stackName !== "example-frontend") { + throw new Error(`deploy-frontend output sample should document stackName=example-frontend.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.environmentName !== "prod" || sample.region !== "us-east-1") { + throw new Error(`deploy-frontend output sample should document the default region/environment identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { + throw new Error(`deploy-frontend output sample should document the resolved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.appUrl !== "https://admin.example.com") { + throw new Error(`deploy-frontend output sample should document the resolved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBuild !== false || sample.infrastructureOnly !== true || sample.frontendPublished !== false) { + throw new Error(`deploy-frontend output sample should document the infrastructure-only JSON result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectDeployFrontendPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-frontend-publish-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendDeploy((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-frontend publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-frontend publish output sample", actual, sample); + expectObjectContainsKeys("deploy-frontend publish output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + expectObjectContainsKeys("deploy-frontend publish output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.stackName !== "example-frontend") { + throw new Error(`deploy-frontend publish output sample should document stackName=example-frontend.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBuild !== false || sample.infrastructureOnly !== false || sample.frontendPublished !== true) { + throw new Error(`deploy-frontend publish output sample should document the build-driven publish result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { + throw new Error(`deploy-frontend publish output sample should document the resolved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.appUrl !== "https://admin.example.com") { + throw new Error(`deploy-frontend publish output sample should document the resolved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-frontend publish output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`deploy-frontend publish output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-frontend publish output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployAwsFullOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-aws-full-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForSplitStackFullDeploy((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--backend-stack-name=example-backend", + "--frontend-stack-name=example-frontend", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--lambda-code-s3-key=b1admin/prod/backend/api.zip", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-aws full output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-aws full output sample", actual, sample); + expectObjectContainsKeys("deploy-aws full output sample", actual.backend || {}, sample.backend || {}, "backend"); + expectObjectContainsKeys("deploy-aws full output sample", actual.backend?.outputs || {}, sample.backend?.outputs || {}, "backend.outputs"); + expectObjectContainsKeys("deploy-aws full output sample", actual.frontend || {}, sample.frontend || {}, "frontend"); + expectObjectContainsKeys("deploy-aws full output sample", actual.frontend?.outputs || {}, sample.frontend?.outputs || {}, "frontend.outputs"); + expectObjectContainsKeys("deploy-aws full output sample", actual.frontend?.backendBuildEnv || {}, sample.frontend?.backendBuildEnv || {}, "frontend.backendBuildEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.skipBackend !== false || sample.skipFrontend !== false || sample.publishFrontendAssets !== false) { + throw new Error(`deploy-aws full output sample should document the standard end-to-end wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolvedArtifactBucket !== "my-artifacts-bucket" || sample.resolvedLambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { + throw new Error(`deploy-aws full output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backend?.stackName !== "example-backend" || sample.frontend?.stackName !== "example-frontend") { + throw new Error(`deploy-aws full output sample should document the nested backend/frontend stack identities.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.frontendPublished !== true || sample.frontend?.infrastructureOnly !== false) { + throw new Error(`deploy-aws full output sample should document the nested build-and-publish frontend result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-aws full output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-aws full output sample contract run did not receive the expected frontend build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectValidateFrontendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-frontend-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=frontend", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-frontend output sample", actual, sample); + expectObjectContainsKeys("validate-frontend output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "frontend" || sample.frontendPublishMode !== false) { + throw new Error(`validate-frontend output sample should document an ok frontend deploy validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { + throw new Error(`validate-frontend output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.frontendDomain !== "admin.example.com") { + throw new Error(`validate-frontend output sample should document the frontend custom domain.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Mode: frontend")) { + throw new Error(`validate-frontend output sample should document the frontend mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { + throw new Error(`validate-frontend output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectStagingBootstrapStarterValidation() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=bootstrap", + "--region=us-east-1", + "--stack-name=b1admin-staging-bootstrap", + "--parameters-file=infrastructure/environments/staging/bootstrap-parameters.json", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`staging bootstrap starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.environmentName !== "staging") { + throw new Error(`staging bootstrap starter validation did not preserve EnvironmentName=staging.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.parametersFile !== "infrastructure/environments/staging/bootstrap-parameters.json") { + throw new Error(`staging bootstrap starter validation did not expose the expected parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.resolved?.artifactBucket !== "replace-me-staging-artifact-bucket") { + throw new Error(`staging bootstrap starter validation did not expose the expected resolved artifact bucket.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectStagingSplitStackStarterValidation() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--region=us-east-1", + "--backend-parameters-file=infrastructure/environments/staging/backend-parameters.json", + "--frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`staging split-stack starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.environmentName !== "staging") { + throw new Error(`staging split-stack starter validation did not preserve EnvironmentName=staging.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.backendParametersFile !== "infrastructure/environments/staging/backend-parameters.json") { + throw new Error(`staging split-stack starter validation did not expose the expected backend parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.frontendParametersFile !== "infrastructure/environments/staging/frontend-parameters.json") { + throw new Error(`staging split-stack starter validation did not expose the expected frontend parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.resolved?.artifactKey !== "b1admin/staging/backend/api.zip") { + throw new Error(`staging split-stack starter validation did not derive the expected staging artifact key.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectStagingDeployScriptStopsOnPlaceholders() { + withRawStarterRepo("staging", ({ rootPath, scriptPath }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + const combined = `${stdout}\n${stderr}`; + + if ((result.status ?? 1) === 0) { + throw new Error(`staging deploy script unexpectedly succeeded with placeholder values still present.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + if (!combined.includes("# Environment Starter Audit: staging") || !combined.includes("Unsafe starter default")) { + throw new Error(`staging deploy script did not stop on starter audit blockers as expected.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + for (const snippet of [ + "Starter audit failed for staging.", + "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", + "yarn plan:environment-deploy -- --environment=staging --output=markdown", + ]) { + if (!combined.includes(snippet)) { + throw new Error(`staging deploy script did not print the expected recovery guidance: ${snippet}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + } + }); +} + +function expectStagingDeployScriptSavesOutputsByDefault() { + withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + env, + }); + + if ((result.status ?? 1) !== 0) { + throw new Error(`staging deploy script failed unexpectedly in the fake harness.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); + const commands = invocations.map((line) => line.split("\u001f")); + const scriptNames = commands.map((parts) => parts[1]); + + const expectedOrder = [ + "audit:environment-starter", + "plan:environment-deploy", + "validate:aws-deploy", + "deploy:bootstrap", + "validate:aws-deploy", + "deploy:aws", + "save:split-stack-outputs", + "verify:split-stack", + ]; + + if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { + throw new Error(`staging deploy script did not run the expected npm command order.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + + const saveInvocation = commands.find((parts) => parts[1] === "save:split-stack-outputs") || []; + if (!saveInvocation.includes("--environment=staging") + || !saveInvocation.includes("--region=us-east-1") + || !saveInvocation.includes("--output-dir=deployment/staging")) { + throw new Error(`staging deploy script did not invoke save:split-stack-outputs with the expected defaults.\nInvocation:\n${JSON.stringify(saveInvocation, null, 2)}`); + } + }); +} + +function expectStagingDeployScriptPreviewOnlyStopsAfterPlan() { + withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + env: { + ...env, + PREVIEW_ONLY: "true", + }, + }); + + if ((result.status ?? 1) !== 0) { + throw new Error(`staging deploy script preview-only mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); + const scriptNames = invocations.map((line) => line.split("\u001f")[1]); + const expectedOrder = [ + "audit:environment-starter", + "plan:environment-deploy", + ]; + + if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { + throw new Error(`staging deploy script preview-only mode should stop after the deploy plan.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + + const combined = `${result.stdout || ""}\n${result.stderr || ""}`; + if (!combined.includes("Preview-only mode enabled; stopping after starter audit and deploy plan.")) { + throw new Error(`staging deploy script preview-only mode did not print the expected stop message.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); +} + +function expectStagingDeployScriptStopsOnUnreadableApiRepo() { + withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { + const unreadableApiRepo = path.join(rootPath, "UnreadableApi"); + fs.mkdirSync(unreadableApiRepo, { recursive: true }); + fs.writeFileSync(path.join(unreadableApiRepo, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.chmodSync(unreadableApiRepo, 0o000); + + try { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + env: { + ...env, + API_REPO_PATH: "./UnreadableApi", + }, + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + const combined = `${stdout}\n${stderr}`; + + if ((result.status ?? 1) === 0) { + throw new Error(`staging deploy script unexpectedly succeeded with an unreadable local Api repo path.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + if (!combined.includes("Local Api repo path is not readable from this shell:") + || !combined.includes("PACKAGE_MANIFEST_FILE") + || !combined.includes("BACKEND_ARTIFACT_SOURCE_FILE")) { + throw new Error(`staging deploy script did not print the expected unreadable-api fallback guidance.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + const invocations = fs.existsSync(logPath) + ? fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean) + : []; + const scriptNames = invocations.map((line) => line.split("\u001f")[1]); + const expectedOrder = [ + "audit:environment-starter", + "plan:environment-deploy", + "validate:aws-deploy", + "deploy:bootstrap", + ]; + + if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { + throw new Error(`staging deploy script should stop before split-stack validation when the local Api repo is unreadable.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + } finally { + fs.chmodSync(unreadableApiRepo, 0o755); + } + }); +} + +function expectValidatorUnreadableApiRepoIncludesFallbackGuidance() { + withUnreadableFakeApiRepo((fakeApiRepoPath) => { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--api-repo-path=${fakeApiRepoPath}`, + "--output=json", + ]); + + if (result.status === 0) { + throw new Error(`validator unexpectedly succeeded for an unreadable api repo package file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + if (!Array.isArray(actual.info) + || !actual.info.some((entry) => String(entry).includes("switch to --package-manifest-file")) + || !actual.info.some((entry) => String(entry).includes("--backend-artifact-source-file")) + || !actual.info.some((entry) => String(entry).includes("GitHub Actions api-repo path"))) { + throw new Error(`validator did not include the expected unreadable-api fallback guidance.\nSTDOUT:\n${result.stdout}`); + } + }); +} + +function expectProdBootstrapStarterValidation() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=bootstrap", + "--region=us-east-1", + "--stack-name=b1admin-prod-bootstrap", + "--parameters-file=infrastructure/environments/prod/bootstrap-parameters.json", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`prod bootstrap starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.environmentName !== "prod") { + throw new Error(`prod bootstrap starter validation did not preserve EnvironmentName=prod.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.parametersFile !== "infrastructure/environments/prod/bootstrap-parameters.json") { + throw new Error(`prod bootstrap starter validation did not expose the expected parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.resolved?.artifactBucket !== "replace-me-prod-artifact-bucket") { + throw new Error(`prod bootstrap starter validation did not expose the expected resolved artifact bucket.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectProdSplitStackStarterValidation() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--region=us-east-1", + "--backend-parameters-file=infrastructure/environments/prod/backend-parameters.json", + "--frontend-parameters-file=infrastructure/environments/prod/frontend-parameters.json", + "--output=json", + ]); + + if (result.status !== 0 || !result.parsed?.ok) { + throw new Error(`prod split-stack starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed.environmentName !== "prod") { + throw new Error(`prod split-stack starter validation did not preserve EnvironmentName=prod.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.backendParametersFile !== "infrastructure/environments/prod/backend-parameters.json") { + throw new Error(`prod split-stack starter validation did not expose the expected backend parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.frontendParametersFile !== "infrastructure/environments/prod/frontend-parameters.json") { + throw new Error(`prod split-stack starter validation did not expose the expected frontend parameters file path.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed.resolved?.artifactKey !== "b1admin/prod/backend/api.zip") { + throw new Error(`prod split-stack starter validation did not derive the expected prod artifact key.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectProdDeployScriptStopsOnPlaceholders() { + withRawStarterRepo("prod", ({ rootPath, scriptPath }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + }); + + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + const combined = `${stdout}\n${stderr}`; + + if ((result.status ?? 1) === 0) { + throw new Error(`prod deploy script unexpectedly succeeded with placeholder values still present.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + + if (!combined.includes("# Environment Starter Audit: prod") || !combined.includes("Unsafe starter default")) { + throw new Error(`prod deploy script did not stop on starter audit blockers as expected.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + for (const snippet of [ + "Starter audit failed for prod.", + "yarn prepare:environment-starter -- --environment=prod --account-id= --output=json", + "yarn plan:environment-deploy -- --environment=prod --output=markdown", + ]) { + if (!combined.includes(snippet)) { + throw new Error(`prod deploy script did not print the expected recovery guidance: ${snippet}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + } + }); +} + +function expectProdDeployScriptCanSkipSavingOutputs() { + withStarterScriptHarness("prod", ({ rootPath, scriptPath, logPath, env }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + env: { + ...env, + SAVE_OUTPUTS_AFTER_DEPLOY: "false", + }, + }); + + if ((result.status ?? 1) !== 0) { + throw new Error(`prod deploy script failed unexpectedly in the fake harness.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); + const commands = invocations.map((line) => line.split("\u001f")); + const scriptNames = commands.map((parts) => parts[1]); + + if (scriptNames.includes("save:split-stack-outputs")) { + throw new Error(`prod deploy script should skip save:split-stack-outputs when SAVE_OUTPUTS_AFTER_DEPLOY=false.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + if (!scriptNames.includes("plan:environment-deploy")) { + throw new Error(`prod deploy script should run plan:environment-deploy before the deploy steps.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + if (!scriptNames.includes("verify:split-stack")) { + throw new Error(`prod deploy script should still verify after deploy when SAVE_OUTPUTS_AFTER_DEPLOY=false.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + }); +} + +function expectProdDeployScriptPreviewOnlyStopsAfterPlan() { + withStarterScriptHarness("prod", ({ rootPath, scriptPath, logPath, env }) => { + const result = spawnSync("bash", [scriptPath], { + cwd: rootPath, + encoding: "utf8", + env: { + ...env, + PREVIEW_ONLY: "true", + }, + }); + + if ((result.status ?? 1) !== 0) { + throw new Error(`prod deploy script preview-only mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); + const scriptNames = invocations.map((line) => line.split("\u001f")[1]); + const expectedOrder = [ + "audit:environment-starter", + "plan:environment-deploy", + ]; + + if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { + throw new Error(`prod deploy script preview-only mode should stop after the deploy plan.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); + } + + const combined = `${result.stdout || ""}\n${result.stderr || ""}`; + if (!combined.includes("Preview-only mode enabled; stopping after starter audit and deploy plan.")) { + throw new Error(`prod deploy script preview-only mode did not print the expected stop message.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); +} + +function expectValidateBootstrapOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-bootstrap-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=bootstrap", + "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-bootstrap output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-bootstrap output sample", actual, sample); + expectObjectContainsKeys("validate-bootstrap output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "bootstrap" || sample.bootstrapMode !== true) { + throw new Error(`validate-bootstrap output sample should document an ok bootstrap validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parametersFile !== "infrastructure/examples/bootstrap-parameters.sample.json") { + throw new Error(`validate-bootstrap output sample should point to the sample bootstrap parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.templateBucket !== "b1admin-prod-templates-123456789012") { + throw new Error(`validate-bootstrap output sample should document the resolved template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "b1admin-prod-artifacts-123456789012") { + throw new Error(`validate-bootstrap output sample should document the resolved artifact bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:bootstrap"))) { + throw new Error(`validate-bootstrap output sample should include a deploy:bootstrap next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidatorBootstrapRespectsEnvironmentName() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-bootstrap-env-")); + const paramsPath = path.join(tempDir, "bootstrap-parameters.json"); + + try { + fs.writeFileSync(paramsPath, `${JSON.stringify({ + ProjectName: "b1admin", + EnvironmentName: "staging", + TemplateBucketName: "bootstrap-staging-templates-123456789012", + ArtifactBucketName: "bootstrap-staging-artifacts-123456789012", + EnableBucketVersioning: "true", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=bootstrap", + "--region=us-east-1", + "--stack-name=b1admin-staging-bootstrap", + `--parameters-file=${paramsPath}`, + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`bootstrap EnvironmentName validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed?.environmentName !== "staging") { + throw new Error(`bootstrap validator did not preserve EnvironmentName from the parameters file.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectValidatorFullStackRespectsEnvironmentName() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-full-stack-env-")); + const paramsPath = path.join(tempDir, "full-stack-parameters.json"); + + try { + fs.writeFileSync(paramsPath, `${JSON.stringify({ + ProjectName: "b1admin", + EnvironmentName: "staging", + BackendTemplateUrl: "https://example-bucket.s3.amazonaws.com/b1admin/backend-api.yaml", + FrontendTemplateUrl: "https://example-bucket.s3.amazonaws.com/b1admin/frontend-site.yaml", + LambdaCodeS3Bucket: "full-stack-staging-artifacts-123456789012", + LambdaCodeS3Key: "b1admin/staging/backend/api.zip", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=full-stack", + "--region=us-east-1", + "--template-bucket=example-template-bucket", + `--parameters-file=${paramsPath}`, + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`full-stack EnvironmentName validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + if (result.parsed?.environmentName !== "staging") { + throw new Error(`full-stack validator did not preserve EnvironmentName from the parameters file.\nSTDOUT:\n${result.stdout}`); + } + + if (result.parsed?.resolved?.artifactKey !== "b1admin/staging/backend/api.zip") { + throw new Error(`full-stack validator did not derive the staging artifact key from EnvironmentName.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectValidateApiMigrationsOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-api-migrations-output.sample.json"); + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-api-migrations-sample-")); + + try { + const fakeApiRepoPath = path.join(tempDir, "api"); + const outputsPath = path.join(tempDir, "outputs.json"); + const secretPath = path.join(tempDir, "database-secret.json"); + + fs.mkdirSync(path.join(fakeApiRepoPath, "tools", "migrations", "attendance"), { recursive: true }); + fs.mkdirSync(path.join(fakeApiRepoPath, "node_modules"), { recursive: true }); + fs.writeFileSync(path.join(fakeApiRepoPath, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.writeFileSync(path.join(fakeApiRepoPath, "tools", "migrate.ts"), "export {};\n"); + fs.writeFileSync(path.join(fakeApiRepoPath, "tools", "kysely-config.ts"), "const MODULES = [\"membership\", \"attendance\"] as const;\nexport { MODULES };\n"); + fs.writeFileSync(path.join(fakeApiRepoPath, "serverless.yml"), "functions:\n socket:\n handler: lambda.socket\n"); + fs.writeFileSync(outputsPath, `${JSON.stringify({ + DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", + DatabasePort: "3306", + AttendanceDatabaseName: "attendance", + }, null, 2)}\n`); + fs.writeFileSync(secretPath, `${JSON.stringify({ + username: "churchapps", + password: "replace-me", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=api-migrations", + `--api-repo-path=${fakeApiRepoPath}`, + `--outputs-file=${outputsPath}`, + `--db-secret-file=${secretPath}`, + "--action=status", + "--module=attendance", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-api-migrations output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-api-migrations output sample", actual, sample); + expectObjectContainsKeys("validate-api-migrations output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "api-migrations" || sample.apiMigrationsMode !== true) { + throw new Error(`validate-api-migrations output sample should document an ok standalone api-migrations validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.resolved?.apiRepoMigrationModules) || sample.resolved.apiRepoMigrationModules.join(",") !== "membership,attendance") { + throw new Error(`validate-api-migrations output sample should document the detected migration module set.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.resolved?.apiRepoMigrationDirectories) || sample.resolved.apiRepoMigrationDirectories.join(",") !== "attendance") { + throw new Error(`validate-api-migrations output sample should document the detected migration directory set.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Standalone Api CLI migration validation")) { + throw new Error(`validate-api-migrations output sample should document the standalone migration validator mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("API migration outputs file: /abs/path/to/"))) { + throw new Error(`validate-api-migrations output sample should show the outputs-file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("yarn run:api-migrations -- --api-repo-path="))) { + throw new Error(`validate-api-migrations output sample should include the standalone run:api-migrations next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { + throw new Error(`validate-api-migrations output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectValidateBackendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-backend-output.sample.json"); + + withFakePackageManifest((manifestPath) => { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=backend", + "--stack-name=example-backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-backend output sample", actual, sample); + expectObjectContainsKeys("validate-backend output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "backend") { + throw new Error(`validate-backend output sample should document an ok backend validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { + throw new Error(`validate-backend output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactKey !== "b1admin/prod/backend/api.zip") { + throw new Error(`validate-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.resolved?.packageManifestFile || "").includes("package-manifest.sample.json")) { + throw new Error(`validate-backend output sample should point to the sample manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.resolved?.backendArtifactSource || "").includes("/")) { + throw new Error(`validate-backend output sample should show a manifest-relative backend artifact placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("upload:backend-artifact"))) { + throw new Error(`validate-backend output sample should include an upload:backend-artifact next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:backend"))) { + throw new Error(`validate-backend output sample should include a deploy:backend next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectValidateSplitStackOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-split-stack-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-split-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-split-stack output sample", actual, sample); + expectObjectContainsKeys("validate-split-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "split-stack" || sample.splitStackPublishOnly !== false) { + throw new Error(`validate-split-stack output sample should document an ok non-publish split-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendParametersFile !== "infrastructure/examples/backend-parameters.sample.json") { + throw new Error(`validate-split-stack output sample should point to the sample backend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { + throw new Error(`validate-split-stack output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { + throw new Error(`validate-split-stack output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { + throw new Error(`validate-split-stack output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Split-stack validation: backend + frontend")) { + throw new Error(`validate-split-stack output sample should document the split-stack validation mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend parameters file: /abs/path/to/"))) { + throw new Error(`validate-split-stack output sample should show the frontend parameters file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.errors) || sample.errors.length !== 0 || !Array.isArray(sample.warnings) || sample.warnings.length !== 0) { + throw new Error(`validate-split-stack output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateSplitStackFrontendInfraOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--frontend-infrastructure-only", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-split-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-split-stack frontend-infrastructure output sample", actual, sample); + expectObjectContainsKeys("validate-split-stack frontend-infrastructure output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "split-stack" || sample.frontendInfrastructureOnly !== true) { + throw new Error(`validate-split-stack frontend-infrastructure output sample should document an ok hosting-only split-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Frontend infrastructure-only deploy requested.")) { + throw new Error(`validate-split-stack frontend-infrastructure output sample should document the frontend infrastructure-only mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("frontend hosting provisioned but frontend asset publishing deferred"))) { + throw new Error(`validate-split-stack frontend-infrastructure output sample should document the deferred frontend publish phase.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { + throw new Error(`validate-split-stack frontend-infrastructure output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateFullStackOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-full-stack-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--template-bucket=my-template-bucket", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-full-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-full-stack output sample", actual, sample); + expectObjectContainsKeys("validate-full-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "full-stack" || sample.fullStackPublishOnly !== false) { + throw new Error(`validate-full-stack output sample should document an ok non-publish full-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parametersFile !== "infrastructure/examples/full-stack-parameters.sample.json") { + throw new Error(`validate-full-stack output sample should point to the sample full-stack parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.templateBucket !== "my-template-bucket") { + throw new Error(`validate-full-stack output sample should document templateBucket=my-template-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { + throw new Error(`validate-full-stack output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Template bucket: my-template-bucket")) { + throw new Error(`validate-full-stack output sample should document the resolved template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.errors) || sample.errors.length !== 0 || !Array.isArray(sample.warnings) || sample.warnings.length !== 0) { + throw new Error(`validate-full-stack output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateFullStackFrontendInfraOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--template-bucket=my-template-bucket", + "--frontend-infrastructure-only", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-full-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-full-stack frontend-infrastructure output sample", actual, sample); + expectObjectContainsKeys("validate-full-stack frontend-infrastructure output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "full-stack" || sample.frontendInfrastructureOnly !== true) { + throw new Error(`validate-full-stack frontend-infrastructure output sample should document an ok hosting-only full-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Frontend infrastructure-only deploy requested.")) { + throw new Error(`validate-full-stack frontend-infrastructure output sample should document the frontend infrastructure-only mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("frontend asset publishing deferred"))) { + throw new Error(`validate-full-stack frontend-infrastructure output sample should document the deferred frontend publish phase.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { + throw new Error(`validate-full-stack frontend-infrastructure output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateFullStackPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-full-stack-publish-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--skip-infrastructure", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-full-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-full-stack publish output sample", actual, sample); + expectObjectContainsKeys("validate-full-stack publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "full-stack" || sample.fullStackPublishOnly !== true) { + throw new Error(`validate-full-stack publish output sample should document an ok full-stack publish-only validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parametersFile !== "infrastructure/examples/full-stack-parameters.sample.json") { + throw new Error(`validate-full-stack publish output sample should point to the sample full-stack parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { + throw new Error(`validate-full-stack publish output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { + throw new Error(`validate-full-stack publish output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend outputs file: /abs/path/to/"))) { + throw new Error(`validate-full-stack publish output sample should show the frontend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Backend outputs file: /abs/path/to/"))) { + throw new Error(`validate-full-stack publish output sample should show the backend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { + throw new Error(`validate-full-stack publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:full-stack"))) { + throw new Error(`validate-full-stack publish output sample should include a deploy:full-stack next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateSplitStackPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-split-stack-publish-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-split-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-split-stack publish output sample", actual, sample); + expectObjectContainsKeys("validate-split-stack publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "split-stack" || sample.splitStackPublishOnly !== true) { + throw new Error(`validate-split-stack publish output sample should document an ok split-stack publish-only validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendParametersFile !== "infrastructure/examples/backend-parameters.sample.json") { + throw new Error(`validate-split-stack publish output sample should point to the sample backend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { + throw new Error(`validate-split-stack publish output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { + throw new Error(`validate-split-stack publish output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { + throw new Error(`validate-split-stack publish output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend outputs file: /abs/path/to/"))) { + throw new Error(`validate-split-stack publish output sample should show the frontend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend parameters file: /abs/path/to/"))) { + throw new Error(`validate-split-stack publish output sample should show the frontend parameters file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { + throw new Error(`validate-split-stack publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:aws"))) { + throw new Error(`validate-split-stack publish output sample should include a deploy:aws next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectValidateFrontendPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/validate-frontend-publish-output.sample.json"); + + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=frontend-publish", + "--bucket=example-frontend-bucket", + "--distribution-id=EXAMPLE123", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validate-frontend publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("validate-frontend publish output sample", actual, sample); + expectObjectContainsKeys("validate-frontend publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "frontend-publish" || sample.frontendPublishMode !== true) { + throw new Error(`validate-frontend publish output sample should document an ok frontend-publish validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Backend outputs file: /abs/path/to/"))) { + throw new Error(`validate-frontend publish output sample should show the backend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Frontend publish bucket: example-frontend-bucket")) { + throw new Error(`validate-frontend publish output sample should document the publish bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.info) || !sample.info.includes("Frontend distribution ID: EXAMPLE123")) { + throw new Error(`validate-frontend publish output sample should document the distribution id.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { + throw new Error(`validate-frontend publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("publish:frontend-assets"))) { + throw new Error(`validate-frontend publish output sample should include a publish:frontend-assets next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectUploadBackendArtifactOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/upload-backend-artifact-output.sample.json"); + + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-upload-backend-sample-")); + const sourceFile = path.join(tempDir, "api.zip"); + fs.writeFileSync(sourceFile, "fake backend zip"); + + try { + withFakeAwsForUploadBackendArtifact((env) => { + const result = runJsonScriptWithEnv("scripts/upload-backend-artifact.mjs", [ + "--bootstrap-stack-name=example-bootstrap", + `--source-file=${path.relative(rootDir, sourceFile)}`, + "--artifact-key=b1admin/backend/api.zip", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`upload-backend-artifact output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("upload-backend-artifact output sample", actual, sample); + + if (sample.artifactLabel !== "Backend artifact") { + throw new Error(`upload-backend-artifact output sample should document the default artifact label.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.bucket !== "my-artifacts-bucket") { + throw new Error(`upload-backend-artifact output sample should document bucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.key !== "b1admin/backend/api.zip") { + throw new Error(`upload-backend-artifact output sample should document key=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.s3Uri !== "s3://my-artifacts-bucket/b1admin/backend/api.zip") { + throw new Error(`upload-backend-artifact output sample should document the uploaded S3 URI.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.bootstrapStackName !== "example-bootstrap") { + throw new Error(`upload-backend-artifact output sample should document bootstrapStackName=example-bootstrap.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.sourceFile).includes("/abs/path/to/")) { + throw new Error(`upload-backend-artifact output sample should show a source file placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPublishLambdaLayerOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/publish-lambda-layer-output.sample.json"); + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-publish-layer-sample-")); + const sourceFile = path.join(tempDir, "layer.zip"); + fs.writeFileSync(sourceFile, "fake layer zip"); + + try { + withFakeAwsForPublishLambdaLayer((env) => { + const result = runJsonScriptWithEnv("scripts/publish-lambda-layer.mjs", [ + "--layer-name=b1admin-prod-dependencies", + `--source-file=${path.relative(rootDir, sourceFile)}`, + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`publish-lambda-layer output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("publish-lambda-layer output sample", actual, sample); + expectObjectContainsKeys("publish-lambda-layer output sample", actual.Content || {}, sample.Content || {}, "Content"); + + if (sample.LayerArn !== "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies") { + throw new Error(`publish-lambda-layer output sample should document the layer ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.LayerVersionArn !== "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3") { + throw new Error(`publish-lambda-layer output sample should document the layer version ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.Version !== 3) { + throw new Error(`publish-lambda-layer output sample should document Version=3.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.CompatibleRuntimes) || sample.CompatibleRuntimes[0] !== "nodejs22.x") { + throw new Error(`publish-lambda-layer output sample should document the default compatible runtime.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.CompatibleArchitectures) || sample.CompatibleArchitectures[0] !== "arm64") { + throw new Error(`publish-lambda-layer output sample should document the default compatible architecture.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectSyncAppConfigSecretOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/sync-app-config-secret-output.sample.json"); + + withFakeAwsForSyncAppConfigSecret((env) => { + const result = runJsonScriptWithEnv("scripts/sync-app-config-secret.mjs", [ + "--secret-file=infrastructure/examples/app-config-secret.sample.json", + "--secret-name=b1admin-prod-app-config", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`sync-app-config-secret output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("sync-app-config-secret output sample", actual, sample); + + if (sample.action !== "created") { + throw new Error(`sync-app-config-secret output sample should document the created path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.name !== "b1admin-prod-app-config") { + throw new Error(`sync-app-config-secret output sample should document the secret name.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.arn !== "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123") { + throw new Error(`sync-app-config-secret output sample should document the created secret ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.versionId !== "11111111-2222-3333-4444-555555555555") { + throw new Error(`sync-app-config-secret output sample should document the returned secret version id.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectSyncGithubAppConfigSecretOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/sync-github-app-config-secret-output.sample.json"); + + withFakeGhForSyncGithubAppConfigSecret(({ env, capturePath }) => { + const result = runJsonScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ + "--environment=staging", + "--secret-file=infrastructure/examples/app-config-secret.sample.json", + "--repo=ChurchApps/B1Admin", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`sync-github-app-config-secret output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + const capture = JSON.parse(fs.readFileSync(capturePath, "utf8")); + expectObjectContainsKeys("sync-github-app-config-secret output sample", actual, sample); + + if (sample.action !== "stored") { + throw new Error(`sync-github-app-config-secret output sample should document the stored path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.secretName !== "AWS_APP_CONFIG_SECRET_JSON") { + throw new Error(`sync-github-app-config-secret output sample should document the GitHub secret name.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.githubEnvironment !== "aws-staging") { + throw new Error(`sync-github-app-config-secret output sample should document the derived GitHub environment.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.repo !== "ChurchApps/B1Admin") { + throw new Error(`sync-github-app-config-secret output sample should document the target GitHub repository.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.keyCount !== 19) { + throw new Error(`sync-github-app-config-secret output sample should document keyCount=19 for the checked sample input.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.commandPreview || "").includes("gh secret set 'AWS_APP_CONFIG_SECRET_JSON'")) { + throw new Error(`sync-github-app-config-secret output sample should document the reusable gh command preview.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + + if (capture.args[0] !== "secret" || capture.args[1] !== "set" || capture.args[2] !== "AWS_APP_CONFIG_SECRET_JSON") { + throw new Error(`sync-github-app-config-secret did not call gh secret set with the expected secret name.\nCapture:\n${JSON.stringify(capture, null, 2)}`); + } + if (!capture.args.includes("--env") || !capture.args.includes("aws-staging")) { + throw new Error(`sync-github-app-config-secret did not pass the expected GitHub environment.\nCapture:\n${JSON.stringify(capture, null, 2)}`); + } + if (!capture.args.includes("--repo") || !capture.args.includes("ChurchApps/B1Admin")) { + throw new Error(`sync-github-app-config-secret did not pass the expected GitHub repository.\nCapture:\n${JSON.stringify(capture, null, 2)}`); + } + if (!capture.args.includes("--app") || !capture.args.includes("actions")) { + throw new Error(`sync-github-app-config-secret did not scope the secret to GitHub Actions.\nCapture:\n${JSON.stringify(capture, null, 2)}`); + } + if (!capture.secretBody || typeof capture.secretBody.jwtSecret !== "string" || typeof capture.secretBody.encryptionKey !== "string") { + throw new Error(`sync-github-app-config-secret did not pass the normalized JSON secret body.\nCapture:\n${JSON.stringify(capture, null, 2)}`); + } + }); +} + +function expectSyncLegacySsmOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/sync-legacy-ssm-output.sample.json"); + + withFakeAwsForSyncLegacySsm((env) => { + const result = runJsonScriptWithEnv("scripts/sync-legacy-ssm-parameters.mjs", [ + "--stack-name=example-backend", + "--environment=prod", + "--dry-run=true", + "--app-config-secret-file=infrastructure/examples/app-config-secret.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`sync-legacy-ssm output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("sync-legacy-ssm output sample", actual, sample); + + if (sample.stackName !== "example-backend" || sample.environment !== "prod" || sample.prefix !== "/prod") { + throw new Error(`sync-legacy-ssm output sample should document the default stack/environment/prefix identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.dryRun !== true || sample.overwrite !== true) { + throw new Error(`sync-legacy-ssm output sample should document the dry-run overwrite defaults.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.parameterCount !== 10) { + throw new Error(`sync-legacy-ssm output sample should document parameterCount=10 for the checked sample inputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!Array.isArray(sample.parameters) || !sample.parameters.some((entry) => entry.name === "/prod/webPushSubject" && entry.value === "mailto:support@example.com")) { + throw new Error(`sync-legacy-ssm output sample should include the sample webPushSubject parameter.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectDispatchGithubAwsDeployOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/dispatch-github-aws-deploy-output.sample.json"); + const relativeEnvDir = ".tmp-dispatch-github-deploy-env"; + const tempDir = path.join(rootDir, relativeEnvDir); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + `--environment-dir=${relativeEnvDir}`, + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy sample verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + const manifestPath = path.join(tempDir, "package-manifest.json"); + fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + withFakeGhForDispatchGithubAwsDeploy(({ env }) => { + const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + `--environment-dir=${relativeEnvDir}`, + "--deployment-source=package-manifest", + `--package-manifest-file=${relativeEnvDir}/package-manifest.json`, + "--repo=ChurchApps/B1Admin", + "--dry-run=true", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`dispatch-github-aws-deploy output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("dispatch-github-aws-deploy output sample", actual, sample); + + if (sample.action !== "validated") { + throw new Error(`dispatch-github-aws-deploy output sample should document the dry-run validated path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.workflowEnvironmentName !== "aws-staging") { + throw new Error(`dispatch-github-aws-deploy output sample should document the GitHub environment name.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.deploymentSource !== "package-manifest") { + throw new Error(`dispatch-github-aws-deploy output sample should document the package-manifest deployment source.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.previewOnly !== false) { + throw new Error(`dispatch-github-aws-deploy output sample should document the default non-preview dispatch path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.syncAppConfigSecret !== true || sample.secretSync?.attempted !== true || sample.secretSync?.performed !== false) { + throw new Error(`dispatch-github-aws-deploy output sample should document the dry-run secret sync path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.secretSync?.command || "").includes("sync:github-app-config-secret")) { + throw new Error(`dispatch-github-aws-deploy output sample should document the GitHub secret sync helper command.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.dispatchCommand || "").includes("gh workflow run deploy-aws-self-hosted.yml")) { + throw new Error(`dispatch-github-aws-deploy output sample should document the workflow dispatch command.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.workflowInputs?.preview_only !== "false" || !String(sample.dispatchCommand || "").includes("preview_only='false'")) { + throw new Error(`dispatch-github-aws-deploy output sample should document the preview_only workflow input explicitly.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.followUpCommands?.listRuns || "").includes("gh run list --workflow deploy-aws-self-hosted.yml") + || !String(sample.followUpCommands?.watchLatestRun || "").includes("gh run watch $(") + || !String(sample.followUpCommands?.viewLatestRun || "").includes("gh run view $(")) { + throw new Error(`dispatch-github-aws-deploy output sample should document the post-dispatch GitHub run follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectRunApiMigrationsOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/run-api-migrations-output.sample.json"); + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-run-api-migrations-sample-")); + + try { + fs.mkdirSync(path.join(tempDir, "tools", "migrations", "attendance"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.writeFileSync(path.join(tempDir, "tools", "migrate.ts"), "export {};\n"); + fs.writeFileSync(path.join(tempDir, "tools", "kysely-config.ts"), "const MODULES = [\"attendance\"] as const;\nexport { MODULES };\n"); + + const result = runJsonScript("scripts/run-api-migrations.mjs", [ + `--api-repo-path=${tempDir}`, + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--module=attendance", + "--action=status", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`run-api-migrations output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("run-api-migrations output sample", actual, sample); + expectObjectContainsKeys("run-api-migrations output sample", actual.connectionStrings || {}, sample.connectionStrings || {}, "connectionStrings"); + + if (sample.apiRepoPath !== "") { + throw new Error(`run-api-migrations output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.command !== " migrate --action=status --module=attendance") { + throw new Error(`run-api-migrations output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.outputsFile !== "infrastructure/examples/backend-stack-outputs.sample.json" || sample.module !== "attendance" || sample.action !== "status") { + throw new Error(`run-api-migrations output sample should document the checked sample invocation.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.resolvedDbSecretSource).includes("/abs/path/to/")) { + throw new Error(`run-api-migrations output sample should show a db-secret placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(actual.command || "").includes("migrate --action=status --module=attendance")) { + throw new Error(`run-api-migrations contract run did not expose the expected migration command.\nSTDOUT:\n${result.stdout}`); + } + if (sample.connectionStrings?.ATTENDANCE_CONNECTION_STRING !== "mysql://churchapps:***@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance") { + throw new Error(`run-api-migrations output sample should document the redacted attendance connection string.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.executed !== false || sample.dryRun !== true) { + throw new Error(`run-api-migrations output sample should document the dry-run non-executed state.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectPublishFrontendOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/publish-frontend-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`publish-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("publish-frontend output sample", actual, sample); + expectObjectContainsKeys("publish-frontend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + expectObjectContainsKeys("publish-frontend output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { + throw new Error(`publish-frontend output sample should document the saved publish target outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.appUrl !== "https://admin.example.com") { + throw new Error(`publish-frontend output sample should document the saved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`publish-frontend output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`publish-frontend output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBuild !== false || sample.frontendPublished !== true) { + throw new Error(`publish-frontend output sample should document a successful build-driven publish.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`publish-frontend output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectVerifySplitStackOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/verify-split-stack-output.sample.json"); + + const result = runJsonScript("scripts/verify-split-stack.mjs", [ + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--check-aws=false", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`verify-split-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("verify-split-stack output sample", actual, sample); + expectObjectContainsKeys("verify-split-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + + if (sample.ok !== true || sample.mode !== "split-stack" || sample.checkAws !== false) { + throw new Error(`verify-split-stack output sample should document a successful outputs-file verification run with AWS checks disabled.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { + throw new Error(`verify-split-stack output sample should point to the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendOutputsFile !== "infrastructure/examples/frontend-outputs.sample.json") { + throw new Error(`verify-split-stack output sample should point to the sample frontend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.apiBaseUrl !== "https://api.example.com") { + throw new Error(`verify-split-stack output sample should document the resolved API base URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`verify-split-stack output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.frontendBucketName !== "example-frontend-bucket" || sample.resolved?.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`verify-split-stack output sample should document the resolved frontend hosting outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + const backendSourceCheck = sample.checks?.find((check) => check.name === "backend outputs source"); + if (!backendSourceCheck || !String(backendSourceCheck.detail).includes("/abs/path/to/B1Admin/infrastructure/examples/backend-outputs.sample.json")) { + throw new Error(`verify-split-stack output sample should show the backend outputs placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + const frontendSourceCheck = sample.checks?.find((check) => check.name === "frontend outputs source"); + if (!frontendSourceCheck || !String(frontendSourceCheck.detail).includes("/abs/path/to/B1Admin/infrastructure/examples/frontend-outputs.sample.json")) { + throw new Error(`verify-split-stack output sample should show the frontend outputs placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + const skippedAwsCheck = sample.checks?.find((check) => check.name === "frontend bucket aws reachability"); + if (!skippedAwsCheck || skippedAwsCheck.skipped !== true) { + throw new Error(`verify-split-stack output sample should show the skipped AWS reachability check when --check-aws=false.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } +} + +function expectSaveSplitStackOutputsOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/save-split-stack-outputs-output.sample.json"); + const outputDir = ".tmp-save-split-stack-contract"; + const outputDirPath = path.join(rootDir, outputDir); + + fs.rmSync(outputDirPath, { recursive: true, force: true }); + + try { + withFakeAwsForSaveSplitStackOutputs((env) => { + fs.mkdirSync(outputDirPath, { recursive: true }); + fs.writeFileSync(path.join(outputDirPath, "preflight-plan.md"), "# Preflight Plan\n"); + + const result = runJsonScriptWithEnv("scripts/save-split-stack-outputs.mjs", [ + "--environment=staging", + "--region=us-east-1", + `--output-dir=${outputDir}`, + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`save-split-stack-outputs output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("save-split-stack-outputs output sample", actual, sample); + expectObjectContainsKeys("save-split-stack-outputs output sample", actual.files || {}, sample.files || {}, "files"); + expectObjectContainsKeys("save-split-stack-outputs output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); + expectObjectContainsKeys("save-split-stack-outputs output sample", actual.followUpCommands || {}, sample.followUpCommands || {}, "followUpCommands"); + + if (sample.ok !== true || sample.environment !== "staging" || sample.region !== "us-east-1") { + throw new Error(`save-split-stack-outputs output sample should document a successful staging capture run.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.stackNames?.backend !== "b1admin-staging-backend" || sample.stackNames?.frontend !== "b1admin-staging-frontend") { + throw new Error(`save-split-stack-outputs output sample should document the derived staging stack names.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.apiBaseUrl !== "https://api.example.com" || sample.resolved?.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`save-split-stack-outputs output sample should document the resolved staging URLs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.resolved?.frontendBucketName !== "example-frontend-bucket" || sample.resolved?.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`save-split-stack-outputs output sample should document the resolved frontend hosting outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.files?.backendOutputsFile !== ".tmp-save-split-stack-contract/backend-outputs.json" + || sample.files?.frontendOutputsFile !== ".tmp-save-split-stack-contract/frontend-outputs.json" + || sample.files?.summaryFile !== ".tmp-save-split-stack-contract/deployment-summary.json" + || sample.files?.preflightPlanFile !== ".tmp-save-split-stack-contract/preflight-plan.md") { + throw new Error(`save-split-stack-outputs output sample should document the saved output file locations.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (!String(sample.followUpCommands?.showDeploymentSummary || "").includes("yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown") + || !String(sample.followUpCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json") + || !String(sample.followUpCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets")) { + throw new Error(`save-split-stack-outputs output sample should document the follow-up commands that reuse the saved files.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + } finally { + fs.rmSync(outputDirPath, { recursive: true, force: true }); + } +} + +function expectSaveSplitStackOutputsEnvironmentModeWorks() { + const outputDir = ".tmp-save-split-stack-outputs"; + const outputDirPath = path.join(rootDir, outputDir); + + fs.rmSync(outputDirPath, { recursive: true, force: true }); + + try { + withFakeAwsForSaveSplitStackOutputs((env) => { + fs.mkdirSync(outputDirPath, { recursive: true }); + fs.writeFileSync(path.join(outputDirPath, "preflight-plan.md"), "# Preflight Plan\n"); + + const result = runJsonScriptWithEnv("scripts/save-split-stack-outputs.mjs", [ + "--environment=staging", + "--region=us-east-1", + `--output-dir=${outputDir}`, + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`save-split-stack-outputs environment mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + const backendOutputsPath = path.join(rootDir, outputDir, "backend-outputs.json"); + const frontendOutputsPath = path.join(rootDir, outputDir, "frontend-outputs.json"); + const summaryPath = path.join(rootDir, outputDir, "deployment-summary.json"); + + if (!fs.existsSync(backendOutputsPath) || !fs.existsSync(frontendOutputsPath) || !fs.existsSync(summaryPath)) { + throw new Error(`save-split-stack-outputs did not write all expected output files.\nSTDOUT:\n${result.stdout}`); + } + + const backendFile = readJsonFile(path.relative(rootDir, backendOutputsPath)); + const frontendFile = readJsonFile(path.relative(rootDir, frontendOutputsPath)); + const summaryFile = readJsonFile(path.relative(rootDir, summaryPath)); + + if (backendFile.Stacks?.[0]?.Outputs?.find((output) => output.OutputKey === "ApiBaseUrl")?.OutputValue !== "https://api.example.com") { + throw new Error(`save-split-stack-outputs did not save the raw backend stack outputs.\nSaved backend file:\n${JSON.stringify(backendFile, null, 2)}`); + } + if (frontendFile.Stacks?.[0]?.Outputs?.find((output) => output.OutputKey === "AppUrl")?.OutputValue !== "https://admin.example.com") { + throw new Error(`save-split-stack-outputs did not save the raw frontend stack outputs.\nSaved frontend file:\n${JSON.stringify(frontendFile, null, 2)}`); + } + if (summaryFile.resolved?.appConfigSecretArn !== "arn:aws:secretsmanager:us-east-1:123456789012:secret:example") { + throw new Error(`save-split-stack-outputs summary did not capture the backend secret ARN.\nSaved summary file:\n${JSON.stringify(summaryFile, null, 2)}`); + } + if (summaryFile.files?.preflightPlanFile !== `${outputDir}/preflight-plan.md`) { + throw new Error(`save-split-stack-outputs summary did not capture the preflight plan file when present.\nSaved summary file:\n${JSON.stringify(summaryFile, null, 2)}`); + } + if (actual.followUpCommands?.showDeploymentSummary !== `yarn show:deployment-summary -- --summary-file=${outputDir}/deployment-summary.json --output=markdown`) { + throw new Error(`save-split-stack-outputs did not return the expected summary-render follow-up command.\nSTDOUT:\n${result.stdout}`); + } + if (actual.followUpCommands?.publishFrontendAssetsFromSavedOutputs !== `yarn publish:frontend-assets -- --frontend-outputs-file=${outputDir}/frontend-outputs.json --backend-outputs-file=${outputDir}/backend-outputs.json`) { + throw new Error(`save-split-stack-outputs did not return the expected publish follow-up command.\nSTDOUT:\n${result.stdout}`); + } + }); + } finally { + fs.rmSync(outputDirPath, { recursive: true, force: true }); + } +} + +function expectSaveSplitStackOutputsMissingArgsIsClean() { + const result = runJsonScript("scripts/save-split-stack-outputs.mjs", [ + "--output=json", + ]); + + if (result.status === 0) { + throw new Error(`save-split-stack-outputs unexpectedly succeeded without stack names or environment.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const errors = result.parsed?.errors || []; + if (!errors.includes("Provide --backend-stack-name or --environment.") || !errors.includes("Provide --frontend-stack-name or --environment.")) { + throw new Error(`save-split-stack-outputs did not report the missing required inputs cleanly.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectShowDeploymentSummaryMarkdownWorks() { + const result = runScript("scripts/show-deployment-summary.mjs", [ + "--summary-file=infrastructure/examples/save-split-stack-outputs-output.sample.json", + "--output=markdown", + ]); + + if (result.status !== 0) { + throw new Error(`show-deployment-summary markdown run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const stdout = result.stdout || ""; + for (const snippet of [ + "## staging summary", + "API base URL", + "CloudFront distribution", + "### Saved files", + "### Follow-up commands", + "Summary file:", + "Preflight plan:", + ]) { + if (!stdout.includes(snippet)) { + throw new Error(`show-deployment-summary markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectShowDeploymentSummaryCommandsWorks() { + const result = runScript("scripts/show-deployment-summary.mjs", [ + "--summary-file=infrastructure/examples/save-split-stack-outputs-output.sample.json", + "--output=commands", + ]); + + if (result.status !== 0) { + throw new Error(`show-deployment-summary commands run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const stdout = result.stdout || ""; + for (const snippet of [ + "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json", + "yarn publish:frontend-assets -- --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json", + "yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown", + ]) { + if (!stdout.includes(snippet)) { + throw new Error(`show-deployment-summary commands output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); + } + } +} + +function expectShowDeploymentSummaryMissingFileIsClean() { + const result = runJsonScript("scripts/show-deployment-summary.mjs", [ + "--summary-file=does-not-exist.json", + "--output=json", + ]); + + if (result.status === 0) { + throw new Error(`show-deployment-summary unexpectedly succeeded with a missing summary file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const errors = result.parsed?.errors || []; + if (!errors.some((message) => String(message).includes('Could not load deployment summary "does-not-exist.json"'))) { + throw new Error(`show-deployment-summary did not report the missing summary file cleanly.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectDeployAwsPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-aws-publish-output.sample.json"); + + withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendPublish((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--skip-build", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-aws publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-aws publish output sample", actual, sample); + expectObjectContainsKeys("deploy-aws publish output sample", actual.frontendPublish || {}, sample.frontendPublish || {}, "frontendPublish"); + expectObjectContainsKeys("deploy-aws publish output sample", actual.frontendPublish?.outputs || {}, sample.frontendPublish?.outputs || {}, "frontendPublish.outputs"); + + if (sample.region !== "us-east-1" || sample.environment !== "prod" || sample.projectName !== "b1admin") { + throw new Error(`deploy-aws publish output sample should document the default region/environment/project identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBackend !== true || sample.skipFrontend !== true || sample.skipBuild !== true || sample.publishFrontendAssets !== true) { + throw new Error(`deploy-aws publish output sample should document the publish-only skip-build flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendOutputsFile !== "infrastructure/examples/frontend-outputs.sample.json") { + throw new Error(`deploy-aws publish output sample should point at the sample frontend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublish?.bucket !== "example-frontend-bucket" || sample.frontendPublish?.distributionId !== "EXAMPLE123") { + throw new Error(`deploy-aws publish output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublish?.appUrl !== "https://admin.example.com") { + throw new Error(`deploy-aws publish output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublish?.skipBuild !== true || sample.frontendPublish?.frontendPublished !== true) { + throw new Error(`deploy-aws publish output sample should document a successful skip-build publish helper result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + }); +} + +function expectDeployAwsFrontendInfraOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-aws-frontend-infra-output.sample.json"); + + withFakeAwsForFrontendDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--skip-backend", + "--frontend-infrastructure-only", + "--frontend-stack-name=example-frontend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-aws frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual, sample); + expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend || {}, sample.frontend || {}, "frontend"); + expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend?.outputs || {}, sample.frontend?.outputs || {}, "frontend.outputs"); + expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend?.backendBuildEnv || {}, sample.frontend?.backendBuildEnv || {}, "frontend.backendBuildEnv"); + + if (sample.region !== "us-east-1" || sample.environment !== "prod" || sample.projectName !== "b1admin") { + throw new Error(`deploy-aws frontend-infrastructure output sample should document the default region/environment/project identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBackend !== true || sample.frontendInfrastructureOnly !== true || sample.publishFrontendAssets !== false) { + throw new Error(`deploy-aws frontend-infrastructure output sample should document the staged hosting-only wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { + throw new Error(`deploy-aws frontend-infrastructure output sample should point at the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.bucket !== "example-frontend-bucket" || sample.frontend?.distributionId !== "EXAMPLE123") { + throw new Error(`deploy-aws frontend-infrastructure output sample should document the resolved frontend hosting target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.appUrl !== "https://admin.example.com") { + throw new Error(`deploy-aws frontend-infrastructure output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-aws frontend-infrastructure output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontend?.infrastructureOnly !== true || sample.frontend?.frontendPublished !== false) { + throw new Error(`deploy-aws frontend-infrastructure output sample should document the nested infrastructure-only frontend result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectDeployFullStackFrontendInfraOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json"); + + withFakeAwsForFullStackDeploy((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--template-bucket=my-template-bucket", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--lambda-code-s3-key=b1admin/prod/backend/api.zip", + "--frontend-infrastructure-only", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual, sample); + expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); + + if (sample.stackName !== "example-full-stack" || sample.region !== "us-east-1" || sample.environmentName !== "prod") { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the default region/environment/stack identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendInfrastructureOnly !== true || sample.infrastructureOnly !== false || sample.publishFrontendAssets !== false) { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the hosting-only non-publish flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket" || sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved frontend hosting target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document REACT_APP_API_BASE from full-stack outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublished !== false) { + throw new Error(`deploy-full-stack frontend-infrastructure output sample should document that frontend publishing is deferred.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); +} + +function expectDeployFullStackFullOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-full-stack-full-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFullStackDeploy((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--template-bucket=my-template-bucket", + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--lambda-code-s3-key=b1admin/prod/backend/api.zip", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack full output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-full-stack full output sample", actual, sample); + expectObjectContainsKeys("deploy-full-stack full output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); + expectObjectContainsKeys("deploy-full-stack full output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.stackName !== "example-full-stack" || sample.region !== "us-east-1" || sample.environmentName !== "prod") { + throw new Error(`deploy-full-stack full output sample should document the default region/environment/stack identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipInfrastructure !== false || sample.publishFrontendAssets !== false || sample.frontendInfrastructureOnly !== false || sample.infrastructureOnly !== false) { + throw new Error(`deploy-full-stack full output sample should document the standard end-to-end wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket" || sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { + throw new Error(`deploy-full-stack full output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublished !== true || sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`deploy-full-stack full output sample should document the published frontend target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`deploy-full-stack full output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-full-stack full output sample should document REACT_APP_API_BASE from full-stack outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-full-stack full output sample contract run did not receive the expected frontend build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployFullStackPublishOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-full-stack-publish-output.sample.json"); + + withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendPublish((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--skip-infrastructure", + "--publish-frontend-assets", + "--skip-build", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-full-stack publish output sample", actual, sample); + + if (sample.region !== "us-east-1" || sample.environmentName !== "prod") { + throw new Error(`deploy-full-stack publish output sample should document the default region/environment identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.publishFrontendAssets !== true || sample.skipInfrastructure !== true || sample.skipBuild !== true) { + throw new Error(`deploy-full-stack publish output sample should document the publish-only skip-build flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`deploy-full-stack publish output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`deploy-full-stack publish output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublished !== true) { + throw new Error(`deploy-full-stack publish output sample should document a successful publish-only result.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + }); + }); +} + +function expectDeployAwsPublishBuildOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-aws-publish-build-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-aws publish build output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-aws publish build output sample", actual, sample); + expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish || {}, sample.frontendPublish || {}, "frontendPublish"); + expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish?.outputs || {}, sample.frontendPublish?.outputs || {}, "frontendPublish.outputs"); + expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish?.backendBuildEnv || {}, sample.frontendPublish?.backendBuildEnv || {}, "frontendPublish.backendBuildEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { + throw new Error(`deploy-aws publish build output sample should point at the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.skipBuild !== false || sample.frontendPublish?.skipBuild !== false) { + throw new Error(`deploy-aws publish build output sample should document the build-driven publish flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublish?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-aws publish build output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendPublish?.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`deploy-aws publish build output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-aws publish build output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectDeployFullStackPublishBuildOutputSampleMatchesContract() { + const sample = readJsonFile("infrastructure/examples/deploy-full-stack-publish-build-output.sample.json"); + + withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--skip-infrastructure", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack publish build output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + expectObjectContainsKeys("deploy-full-stack publish build output sample", actual, sample); + expectObjectContainsKeys("deploy-full-stack publish build output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); + + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (sample.skipBuild !== false || sample.publishFrontendAssets !== true || sample.skipInfrastructure !== true) { + throw new Error(`deploy-full-stack publish build output sample should document the build-driven publish-only flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`deploy-full-stack publish build output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`deploy-full-stack publish build output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-full-stack publish build output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (sample.frontendEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`deploy-full-stack publish build output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-full-stack publish build output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }); +} + +function expectError(name, invocation, expectedMessage) { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", invocation); + if (result.status === 0) { + throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + const errors = result.parsed?.errors || []; + if (!errors.some((message) => message.includes(expectedMessage))) { + throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } +} + +function expectScriptError(name, scriptPath, invocation, expectedMessage) { + const result = runScript(scriptPath, invocation); + if (result.status === 0) { + throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes(expectedMessage)) { + throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } +} + +function expectScriptErrorClean(name, scriptPath, invocation, expectedMessage) { + const result = runScript(scriptPath, invocation); + if (result.status === 0) { + throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes(expectedMessage)) { + throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const unwantedFragments = ["node:internal/errors", "Node.js v"]; + const unexpected = unwantedFragments.find((fragment) => combined.includes(fragment)); + if (unexpected) { + throw new Error(`${name} still leaked a raw Node stack trace fragment "${unexpected}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } +} + +function expectScriptOk(name, scriptPath, invocation) { + const result = runScript(scriptPath, invocation); + if (result.status !== 0) { + throw new Error(`${name} failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } +} + +function withFakeApiRepoWithoutNodeModules(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-api-repo-")); + try { + fs.mkdirSync(path.join(tempDir, "tools"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.writeFileSync(path.join(tempDir, "tools", "migrate.ts"), "export {};\n"); + callback(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakePackagableApiRepo(callback, options = {}) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-packagable-api-repo-")); + + try { + fs.mkdirSync(path.join(tempDir, "config"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "dist"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "node_modules", "fake-dependency"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "tools", "migrations", "membership"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "config", "default.json"), "{}\n"); + fs.writeFileSync(path.join(tempDir, "dist", "index.js"), "export const ok = true;\n"); + fs.writeFileSync(path.join(tempDir, "lambda.js"), "exports.handler = async () => ({ statusCode: 200, body: 'ok' });\n"); + fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + scripts: { + "build:prod": "echo build", + "build-layer": "echo build-layer", + }, + }, null, 2)}\n`); + fs.writeFileSync(path.join(tempDir, "node_modules", "fake-dependency", "index.js"), "module.exports = {};\n"); + fs.writeFileSync(path.join(tempDir, "serverless.yml"), `service: fake-api +provider: + name: aws + runtime: nodejs22.x +functions: + web: + handler: lambda.web + socket: + handler: lambda.socket + timer15Min: + handler: lambda.timer15Min +environment: + MEMBERSHIP_CONNECTION_STRING: \${ssm:/prod/membershipConnectionString} +`); + fs.writeFileSync(path.join(tempDir, "tools", "kysely-config.ts"), `const MODULES = ["membership", "attendance"] as const;\nexport { MODULES };\n`); + + if (options.includeLayer) { + fs.mkdirSync(path.join(tempDir, "layer", "nodejs"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "layer", "nodejs", "index.js"), "module.exports = {};\n"); + } + + callback(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withUnreadableFakeApiRepo(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-unreadable-api-repo-")); + const packageJsonPath = path.join(tempDir, "package.json"); + + try { + fs.writeFileSync(packageJsonPath, `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.chmodSync(packageJsonPath, 0o000); + callback(tempDir); + } finally { + try { + fs.chmodSync(packageJsonPath, 0o644); + } catch {} + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withUnreadableFakeApiRepoDirectory(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-unreadable-api-repo-dir-")); + const packageJsonPath = path.join(tempDir, "package.json"); + + try { + fs.writeFileSync(packageJsonPath, `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + fs.chmodSync(tempDir, 0o000); + callback(tempDir); + } finally { + try { + fs.chmodSync(tempDir, 0o755); + } catch {} + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectAuditApiRepoContractWorks() { + withFakePackagableApiRepo((fakeApiRepoPath) => { + const result = runJsonScript("scripts/audit-api-repo-contract.mjs", [ + `--api-repo-path=${fakeApiRepoPath}`, + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`audit-api-repo-contract should succeed for a readable fake Api repo.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + if (actual.ok !== true || actual.packaging?.autoPackageReady !== true || actual.contract?.ready !== true) { + throw new Error(`audit-api-repo-contract did not report the expected ready state.\nSTDOUT:\n${result.stdout}`); + } + if (actual.recommended?.packageMode !== "layered-or-self-contained") { + throw new Error(`audit-api-repo-contract did not recommend the expected package mode.\nSTDOUT:\n${result.stdout}`); + } + if (!Array.isArray(actual.migrations?.modules) || !actual.migrations.modules.includes("membership") || !actual.migrations.modules.includes("attendance")) { + throw new Error(`audit-api-repo-contract did not detect the expected migration modules.\nSTDOUT:\n${result.stdout}`); + } + }, { includeLayer: true }); +} + +function expectAuditApiRepoContractUnreadablePathIsClean() { + const result = runJsonScript("scripts/audit-api-repo-contract.mjs", [ + "--api-repo-path=/definitely/missing/api-repo", + "--output=json", + ]); + + if (result.status === 0) { + throw new Error(`audit-api-repo-contract unexpectedly succeeded for a missing repo path.\nSTDOUT:\n${result.stdout}`); + } + + const actual = result.parsed || {}; + if (actual.ok !== false || !Array.isArray(actual.errors) || !actual.errors.some((entry) => String(entry).includes("API repo path not found:"))) { + throw new Error(`audit-api-repo-contract did not return the expected missing-path error.\nSTDOUT:\n${result.stdout}`); + } +} + +function withFakePackageManifest(callback, options = {}) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-manifest-")); + const manifestPath = path.join(tempDir, "api-test-self-contained.manifest.json"); + const backendArtifactAbsolutePath = options.missingBackendArtifact + ? path.join(tempDir, "missing-api.zip") + : path.join(tempDir, "api-test-self-contained.zip"); + const migrationArtifactAbsolutePath = (options.includeMigrationArtifact || options.missingMigrationArtifact) + ? path.join(tempDir, "api-test-migrations.zip") + : ""; + const layerArtifactAbsolutePath = options.includeLayerArtifact + ? path.join(tempDir, "api-test-dependencies-layer.zip") + : ""; + + try { + if (!options.missingBackendArtifact) { + fs.writeFileSync(backendArtifactAbsolutePath, "fake artifact\n"); + } + if (options.includeMigrationArtifact && !options.missingMigrationArtifact) { + fs.writeFileSync(migrationArtifactAbsolutePath, "fake migration artifact\n"); + } + if (options.includeLayerArtifact) { + fs.writeFileSync(layerArtifactAbsolutePath, "fake layer\n"); + } + + fs.writeFileSync(manifestPath, `${JSON.stringify({ + apiRepoPath: path.join(rootDir, "..", "Api"), + packageMode: options.packageMode || "self-contained", + environment: "test", + build: false, + buildCommand: "build:prod", + buildLayer: Boolean(options.includeLayerArtifact), + buildLayerCommand: options.includeLayerArtifact ? "build-layer" : "", + backendArtifactPath: path.basename(backendArtifactAbsolutePath), + migrationArtifactPath: migrationArtifactAbsolutePath ? path.basename(migrationArtifactAbsolutePath) : "", + dependenciesLayerArtifactPath: layerArtifactAbsolutePath ? path.basename(layerArtifactAbsolutePath) : "", + manifestPath: path.basename(manifestPath), + recommendedNextSteps: { + uploadBackendArtifact: "yarn upload:backend-artifact -- --source-file=fake.zip", + deployMode: "Use the resulting backend zip directly.", + }, + includedBackendEntries: ["dist"], + }, null, 2)}\n`); + callback(manifestPath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeFrontendBuildOutput(callback) { + const distDir = path.join(rootDir, "dist"); + const backupDir = `${distDir}.backup-smoke`; + const hadDist = fs.existsSync(distDir); + + try { + if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + if (hadDist) { + fs.renameSync(distDir, backupDir); + } + + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, "index.html"), "smoke\n"); + fs.writeFileSync(path.join(distDir, "sw.js"), "self.addEventListener('install', () => {});\n"); + callback(); + } finally { + fs.rmSync(distDir, { recursive: true, force: true }); + if (hadDist && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, distDir); + } else if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } +} + +function withMissingFrontendBuildOutput(callback) { + const distDir = path.join(rootDir, "dist"); + const backupDir = `${distDir}.backup-smoke-missing`; + const hadDist = fs.existsSync(distDir); + + try { + if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + if (hadDist) { + fs.renameSync(distDir, backupDir); + } + + callback(); + } finally { + fs.rmSync(distDir, { recursive: true, force: true }); + if (hadDist && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, distDir); + } else if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } +} + +function withMissingFrontendNodeModules(callback) { + const nodeModulesDir = path.join(rootDir, "node_modules"); + const backupDir = `${nodeModulesDir}.backup-smoke-missing-deps`; + const hadNodeModules = fs.existsSync(nodeModulesDir); + + try { + if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + if (hadNodeModules) { + fs.renameSync(nodeModulesDir, backupDir); + } + + callback(); + } finally { + fs.rmSync(nodeModulesDir, { recursive: true, force: true }); + if (hadNodeModules && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, nodeModulesDir); + } else if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } +} + +function writeFakeFrontendDependencyMarker(nodeModulesDir) { + const viteCliPath = path.join(nodeModulesDir, "vite", "dist", "node", "cli.js"); + fs.mkdirSync(path.dirname(viteCliPath), { recursive: true }); + fs.writeFileSync(viteCliPath, "export {};\n"); +} + +function withStarterScriptHarness(environmentName, callback) { + const tempRoot = fs.mkdtempSync(path.join(rootDir, `.tmp-${environmentName}-starter-script-`)); + const environmentRoot = path.join(tempRoot, "infrastructure", "environments", environmentName); + const sourceRoot = path.join(rootDir, "infrastructure", "environments", environmentName); + const fakeBin = path.join(tempRoot, "bin"); + const fakeApiRepo = path.join(tempRoot, "Api"); + const npmPath = path.join(fakeBin, "npm"); + const logPath = path.join(tempRoot, "npm-invocations.log"); + + try { + fs.mkdirSync(environmentRoot, { recursive: true }); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(fakeApiRepo, { recursive: true }); + + for (const fileName of fs.readdirSync(sourceRoot)) { + const sourcePath = path.join(sourceRoot, fileName); + const targetPath = path.join(environmentRoot, fileName); + fs.copyFileSync(sourcePath, targetPath); + } + + for (const jsonName of ["bootstrap-parameters.json", "backend-parameters.json"]) { + const jsonPath = path.join(environmentRoot, jsonName); + const updated = fs.readFileSync(jsonPath, "utf8").replaceAll("replace-me", "ready"); + fs.writeFileSync(jsonPath, updated); + } + + fs.writeFileSync(path.join(fakeApiRepo, "package.json"), `${JSON.stringify({ + name: "fake-api-repo", + private: true, + }, null, 2)}\n`); + + fs.writeFileSync(npmPath, `#!/usr/bin/env node +import fs from "node:fs"; +const line = process.argv.slice(2).join("\\u001f"); +fs.appendFileSync(${JSON.stringify(logPath)}, line + "\\n"); +process.exit(0); +`); + fs.chmodSync(npmPath, 0o755); + + callback({ + rootPath: tempRoot, + scriptPath: path.join(environmentRoot, "deploy-split-stack.sh"), + logPath, + env: { + ...process.env, + API_REPO_PATH: "./Api", + PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ""}`, + }, + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function replaceStarterBackendDefaults(environmentDir, environmentName = "staging") { + const backendPath = path.join(environmentDir, "backend-parameters.json"); + const backend = JSON.parse(fs.readFileSync(backendPath, "utf8")); + const suffix = environmentName === "prod" ? "prod" : environmentName; + + backend.WebsiteBaseUrl = `https://{subdomain}.${suffix}.b1test.org`; + backend.ContentRootUrl = `https://content-${suffix}.b1test.org`; + backend.B1AdminRootUrl = `https://admin-${suffix}.b1test.org`; + backend.CorsOrigin = `https://admin-${suffix}.b1test.org`; + backend.StoreApiUrl = `https://store-${suffix}.b1test.org`; + backend.TransferUrl = `https://transfer-${suffix}.b1test.org`; + backend.SupportEmail = `support@${suffix}.b1test.org`; + backend.SupportPhone = "800-555-0199"; + backend.SupportSiteUrl = `https://support-${suffix}.b1test.org`; + + fs.writeFileSync(backendPath, `${JSON.stringify(backend, null, 2)}\n`); +} + +function restoreStarterTemplateDefaults(environmentDir, environmentName = "staging") { + const bootstrapPath = path.join(environmentDir, "bootstrap-parameters.json"); + const backendPath = path.join(environmentDir, "backend-parameters.json"); + const secretTemplatePath = path.join(environmentDir, "app-config-secret.template.json"); + const environmentSuffix = environmentName === "prod" ? "" : `-${environmentName}`; + + const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, "utf8")); + bootstrap.TemplateBucketName = `replace-me-b1admin-${environmentName}-templates-123456789012`; + bootstrap.ArtifactBucketName = `replace-me-b1admin-${environmentName}-artifacts-123456789012`; + fs.writeFileSync(bootstrapPath, `${JSON.stringify(bootstrap, null, 2)}\n`); + + const backend = JSON.parse(fs.readFileSync(backendPath, "utf8")); + backend.LambdaCodeS3Bucket = `replace-me-b1admin-${environmentName}-artifacts-123456789012`; + backend.WebsiteBaseUrl = "https://{subdomain}.example.com"; + backend.ContentRootUrl = `https://content${environmentSuffix}.example.com`; + backend.B1AdminRootUrl = `https://admin${environmentSuffix}.example.com`; + backend.CorsOrigin = `https://admin${environmentSuffix}.example.com`; + backend.StoreApiUrl = `https://store${environmentSuffix}.example.com`; + backend.TransferUrl = `https://transfer${environmentSuffix}.example.com`; + backend.SupportEmail = "support@example.com"; + backend.SupportPhone = "555-555-5555"; + backend.SupportSiteUrl = "https://support.example.com"; + backend.MobileAppUrl = ""; + backend.DomainCnameTarget = ""; + backend.DomainATarget = ""; + backend.DefaultStockPhoto = ""; + backend.GoogleAnalyticsTag = ""; + fs.writeFileSync(backendPath, `${JSON.stringify(backend, null, 2)}\n`); + + const secretTemplate = JSON.parse(fs.readFileSync(secretTemplatePath, "utf8")); + secretTemplate.jwtSecret = "replace-me-long-random-jwt-secret"; + secretTemplate.encryptionKey = "replace-me-long-random-encryption-key"; + secretTemplate.webPushSubject = "mailto:support@example.com"; + fs.writeFileSync(secretTemplatePath, `${JSON.stringify(secretTemplate, null, 2)}\n`); +} + +function withRawStarterEnvironment(environmentName, callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, `.tmp-raw-${environmentName}-starter-`)); + + try { + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", environmentName, fileName), + path.join(tempDir, fileName), + ); + } + + restoreStarterTemplateDefaults(tempDir, environmentName); + callback(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withRawStarterRepo(environmentName, callback) { + const tempRoot = fs.mkdtempSync(path.join(rootDir, `.tmp-raw-${environmentName}-repo-`)); + const tempScripts = path.join(tempRoot, "scripts"); + const tempEnvRoot = path.join(tempRoot, "infrastructure", "environments", environmentName); + + try { + fs.mkdirSync(tempScripts, { recursive: true }); + fs.mkdirSync(tempEnvRoot, { recursive: true }); + + fs.copyFileSync(path.join(rootDir, "package.json"), path.join(tempRoot, "package.json")); + fs.cpSync(path.join(rootDir, "scripts"), tempScripts, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + "deploy-split-stack.sh", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", environmentName, fileName), + path.join(tempEnvRoot, fileName), + ); + } + + restoreStarterTemplateDefaults(tempEnvRoot, environmentName); + callback({ + rootPath: tempRoot, + scriptPath: path.join(tempEnvRoot, "deploy-split-stack.sh"), + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function withFakeFrontendNodeModules(callback) { + const nodeModulesDir = path.join(rootDir, "node_modules"); + const backupDir = `${nodeModulesDir}.backup-smoke`; + const hadNodeModules = fs.existsSync(nodeModulesDir); + + try { + if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + if (hadNodeModules) { + fs.renameSync(nodeModulesDir, backupDir); + } + + fs.mkdirSync(nodeModulesDir, { recursive: true }); + writeFakeFrontendDependencyMarker(nodeModulesDir); + callback(); + } finally { + fs.rmSync(nodeModulesDir, { recursive: true, force: true }); + if (hadNodeModules && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, nodeModulesDir); + } else if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } +} + +function withFakeFrontendBuildHarness(callback) { + const nodeModulesDir = path.join(rootDir, "node_modules"); + const nodeModulesBackupDir = `${nodeModulesDir}.backup-smoke`; + const distDir = path.join(rootDir, "dist"); + const distBackupDir = `${distDir}.backup-smoke`; + const toolsDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-frontend-build-")); + const vitePath = path.join(toolsDir, "vite"); + const envCapturePath = path.join(toolsDir, "build-env.json"); + const hadNodeModules = fs.existsSync(nodeModulesDir); + const hadDist = fs.existsSync(distDir); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +if (args[0] !== "build") { + process.stderr.write("Unexpected vite invocation: " + args.join(" ") + "\\n"); + process.exit(1); +} +const rootDir = ${JSON.stringify(rootDir)}; +const capturePath = ${JSON.stringify(envCapturePath)}; +const distDir = path.join(rootDir, "dist"); +fs.mkdirSync(distDir, { recursive: true }); +fs.writeFileSync(path.join(distDir, "index.html"), "fake build\\n"); +fs.writeFileSync(path.join(distDir, "sw.js"), "self.addEventListener('install', () => {});\\n"); +fs.writeFileSync(capturePath, JSON.stringify({ + REACT_APP_STAGE: process.env.REACT_APP_STAGE || "", + REACT_APP_API_BASE: process.env.REACT_APP_API_BASE || "", + REACT_APP_CONTENT_ROOT: process.env.REACT_APP_CONTENT_ROOT || "", + REACT_APP_B1_WEBSITE_URL: process.env.REACT_APP_B1_WEBSITE_URL || "", + REACT_APP_LESSONS_API: process.env.REACT_APP_LESSONS_API || "", + REACT_APP_TRANSFER_URL: process.env.REACT_APP_TRANSFER_URL || "", + REACT_APP_SUPPORT_EMAIL: process.env.REACT_APP_SUPPORT_EMAIL || "", + REACT_APP_SUPPORT_PHONE: process.env.REACT_APP_SUPPORT_PHONE || "", + REACT_APP_SUPPORT_SITE_URL: process.env.REACT_APP_SUPPORT_SITE_URL || "", + REACT_APP_MOBILE_APP_URL: process.env.REACT_APP_MOBILE_APP_URL || "", + REACT_APP_DOMAIN_CNAME_TARGET: process.env.REACT_APP_DOMAIN_CNAME_TARGET || "", + REACT_APP_DOMAIN_A_TARGET: process.env.REACT_APP_DOMAIN_A_TARGET || "", + REACT_APP_DEFAULT_STOCK_PHOTO: process.env.REACT_APP_DEFAULT_STOCK_PHOTO || "", +}, null, 2) + "\\n"); +`; + + try { + if (fs.existsSync(nodeModulesBackupDir)) fs.rmSync(nodeModulesBackupDir, { recursive: true, force: true }); + if (fs.existsSync(distBackupDir)) fs.rmSync(distBackupDir, { recursive: true, force: true }); + if (hadNodeModules) fs.renameSync(nodeModulesDir, nodeModulesBackupDir); + if (hadDist) fs.renameSync(distDir, distBackupDir); + + fs.mkdirSync(nodeModulesDir, { recursive: true }); + writeFakeFrontendDependencyMarker(nodeModulesDir); + fs.writeFileSync(vitePath, script); + fs.chmodSync(vitePath, 0o755); + + callback({ + PATH: `${toolsDir}${path.delimiter}${process.env.PATH || ""}`, + envCapturePath, + }); + } finally { + fs.rmSync(distDir, { recursive: true, force: true }); + if (hadDist && fs.existsSync(distBackupDir)) fs.renameSync(distBackupDir, distDir); + else if (fs.existsSync(distBackupDir)) fs.rmSync(distBackupDir, { recursive: true, force: true }); + + fs.rmSync(nodeModulesDir, { recursive: true, force: true }); + if (hadNodeModules && fs.existsSync(nodeModulesBackupDir)) fs.renameSync(nodeModulesBackupDir, nodeModulesDir); + else if (fs.existsSync(nodeModulesBackupDir)) fs.rmSync(nodeModulesBackupDir, { recursive: true, force: true }); + + fs.rmSync(toolsDir, { recursive: true, force: true }); + } +} + +function withFakeAwsAllowingS3Cp(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-s3cp-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "s3" && args[1] === "cp") { + process.exit(0); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForBackendDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-backend-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "s3" && args[1] === "cp") { + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "deploy") { + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-backend") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForBootstrapDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-bootstrap-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "cloudformation" && args[1] === "deploy") { + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-bootstrap") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "TemplateBucketName", OutputValue: "b1admin-prod-templates-123456789012" }, + { OutputKey: "ArtifactBucketName", OutputValue: "b1admin-prod-artifacts-123456789012" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForFullStackDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-full-stack-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "s3" && (args[1] === "cp" || args[1] === "sync")) { + process.exit(0); +} +if (args[0] === "cloudfront" && args[1] === "create-invalidation") { + process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "deploy") { + process.exit(0); +} + if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-full-stack") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" }, + { OutputKey: "FrontendBucketName", OutputValue: "example-frontend-bucket" }, + { OutputKey: "FrontendDistributionId", OutputValue: "EXAMPLE123" }, + { OutputKey: "FrontendAppUrl", OutputValue: "https://admin.example.com" }, + { OutputKey: "PublicApiBaseUrl", OutputValue: "https://api.example.com" }, + { OutputKey: "ContentRootUrl", OutputValue: "https://content.example.com" }, + { OutputKey: "WebsiteBaseUrl", OutputValue: "https://{subdomain}.example.com" }, + { OutputKey: "LessonsApiUrl", OutputValue: "https://lessons-api.example.com" }, + { OutputKey: "TransferUrl", OutputValue: "https://transfer.example.com" }, + { OutputKey: "SupportEmail", OutputValue: "support@example.com" }, + { OutputKey: "SupportPhone", OutputValue: "555-555-5555" }, + { OutputKey: "SupportSiteUrl", OutputValue: "https://support.example.com" }, + { OutputKey: "MobileAppUrl", OutputValue: "https://example.com/app" }, + { OutputKey: "DomainCnameTarget", OutputValue: "proxy.example.com" }, + { OutputKey: "DomainATarget", OutputValue: "203.0.113.10" }, + { OutputKey: "DefaultStockPhoto", OutputValue: "https://content.example.com/stockPhotos/default.jpg" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForFrontendDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-frontend-")); + const awsPath = path.join(tempDir, "aws"); + const statePath = path.join(tempDir, "state.json"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +const args = process.argv.slice(2); +const statePath = ${JSON.stringify(statePath)}; +let state = { describeCount: 0 }; +if (fs.existsSync(statePath)) { + state = JSON.parse(fs.readFileSync(statePath, "utf8")); +} +if (args[0] === "cloudformation" && args[1] === "deploy") { + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-frontend") { + state.describeCount += 1; + fs.writeFileSync(statePath, JSON.stringify(state)); + if (state.describeCount > 1) { + process.stderr.write("Unexpected repeated frontend stack lookup\\n"); + process.exit(1); + } + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, + { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" }, + { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +if (args[0] === "s3" && (args[1] === "sync" || args[1] === "cp")) { + process.exit(0); +} +if (args[0] === "cloudfront" && args[1] === "create-invalidation") { + process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); + process.exit(0); +} +process.stderr.write("Unexpected aws command: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForSplitStackFullDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-split-stack-full-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "s3" && (args[1] === "cp" || args[1] === "sync")) { + process.exit(0); +} +if (args[0] === "cloudfront" && args[1] === "create-invalidation") { + process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "deploy") { + process.exit(0); +} +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-backend") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "ApiBaseUrl", OutputValue: "https://api.example.com" }, + { OutputKey: "ContentRootUrl", OutputValue: "https://content.example.com" }, + { OutputKey: "WebsiteBaseUrl", OutputValue: "https://{subdomain}.example.com" }, + { OutputKey: "LessonsApiUrl", OutputValue: "https://lessons-api.example.com" }, + { OutputKey: "TransferUrl", OutputValue: "https://transfer.example.com" }, + { OutputKey: "SupportEmail", OutputValue: "support@example.com" }, + { OutputKey: "SupportPhone", OutputValue: "555-555-5555" }, + { OutputKey: "SupportSiteUrl", OutputValue: "https://support.example.com" }, + { OutputKey: "MobileAppUrl", OutputValue: "https://example.com/app" }, + { OutputKey: "DomainCnameTarget", OutputValue: "proxy.example.com" }, + { OutputKey: "DomainATarget", OutputValue: "203.0.113.10" }, + { OutputKey: "DefaultStockPhoto", OutputValue: "https://content.example.com/stockPhotos/default.jpg" }, + { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123" }, + { OutputKey: "DatabaseEndpoint", OutputValue: "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com" }, + { OutputKey: "DatabasePort", OutputValue: "3306" }, + { OutputKey: "DatabaseSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123" }, + { OutputKey: "MembershipDatabaseName", OutputValue: "membership" }, + { OutputKey: "AttendanceDatabaseName", OutputValue: "attendance" }, + { OutputKey: "ContentDatabaseName", OutputValue: "content" }, + { OutputKey: "GivingDatabaseName", OutputValue: "giving" }, + { OutputKey: "MessagingDatabaseName", OutputValue: "messaging" }, + { OutputKey: "DoingDatabaseName", OutputValue: "doing" }, + { OutputKey: "ReportingDatabaseName", OutputValue: "reporting" } + ] + }] + })); + process.exit(0); + } + if (stackName === "example-frontend") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, + { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" }, + { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForFrontendPublish(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-frontend-publish-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "s3" && args[1] === "sync") { + process.exit(0); +} +if (args[0] === "s3" && args[1] === "cp") { + process.exit(0); +} +if (args[0] === "cloudfront" && args[1] === "create-invalidation") { + process.exit(0); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForSaveSplitStackOutputs(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-save-split-stack-outputs-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "b1admin-staging-backend") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "ApiBaseUrl", OutputValue: "https://api.example.com" }, + { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" } + ] + }] + })); + process.exit(0); + } + if (stackName === "b1admin-staging-frontend") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" }, + { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, + { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForUploadBackendArtifact(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-upload-backend-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + const stackNameIndex = args.indexOf("--stack-name"); + const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; + if (stackName === "example-bootstrap") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "ArtifactBucketName", OutputValue: "my-artifacts-bucket" } + ] + }] + })); + process.exit(0); + } + process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); + process.exit(1); +} +if (args[0] === "s3" && args[1] === "cp") { + process.exit(0); +} +process.stderr.write("Unexpected aws command: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForPublishLambdaLayer(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-publish-layer-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "lambda" && args[1] === "publish-layer-version") { + process.stdout.write(JSON.stringify({ + Content: { + Location: "https://lambda.us-east-1.amazonaws.com/2018-10-31/layers/b1admin-prod-dependencies/versions/3", + CodeSha256: "examplecodesha256value=", + CodeSize: 12345 + }, + LayerArn: "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies", + LayerVersionArn: "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3", + Description: "Published by B1Admin AWS deployment tooling", + CreatedDate: "2026-01-15T12:34:56.000+0000", + Version: 3, + CompatibleRuntimes: ["nodejs22.x"], + CompatibleArchitectures: ["arm64"] + })); + process.exit(0); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForSyncAppConfigSecret(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-sync-app-config-secret-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "secretsmanager" && args[1] === "describe-secret") { + process.stderr.write("An error occurred (ResourceNotFoundException) when calling the DescribeSecret operation: Secrets Manager can't find the specified secret.\\n"); + process.exit(254); +} +if (args[0] === "secretsmanager" && args[1] === "create-secret") { + process.stdout.write(JSON.stringify({ + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", + Name: "b1admin-prod-app-config", + VersionId: "11111111-2222-3333-4444-555555555555" + })); + process.exit(0); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeGhForSyncGithubAppConfigSecret(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-sync-app-config-secret-")); + const ghPath = path.join(tempDir, "gh"); + const capturePath = path.join(tempDir, "capture.json"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +const args = process.argv.slice(2); +if (args[0] === "auth" && args[1] === "status") { + process.exit(0); +} +if (args[0] === "secret" && args[1] === "set") { + const bodyIndex = args.indexOf("--body"); + const secretBody = bodyIndex >= 0 ? JSON.parse(args[bodyIndex + 1] || "{}") : null; + fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ args, secretBody }, null, 2) + "\\n"); + process.exit(0); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + callback({ + env: { + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }, + capturePath, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFailingGhForSyncGithubAppConfigSecret(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-sync-app-config-secret-fail-")); + const ghPath = path.join(tempDir, "gh"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "auth" && args[1] === "status") { + process.stdout.write("github.com\\n"); + process.stderr.write(" X Failed to log in to github.com account example (default)\\n"); + process.stderr.write(" - Active account: true\\n"); + process.stderr.write(" - The token in default is invalid.\\n"); + process.exit(1); +} +process.stderr.write("mock gh failure\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeGhForDispatchGithubAwsDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-dispatch-github-aws-deploy-")); + const ghPath = path.join(tempDir, "gh"); + const capturePath = path.join(tempDir, "capture.jsonl"); + const script = `#!/usr/bin/env node +import fs from "node:fs"; +const args = process.argv.slice(2); +let capture = { args }; +if (args[0] === "auth" && args[1] === "status") { + capture.kind = "auth"; +} else if (args[0] === "secret" && args[1] === "set") { + const bodyIndex = args.indexOf("--body"); + capture.secretBody = bodyIndex >= 0 ? JSON.parse(args[bodyIndex + 1] || "{}") : null; + capture.kind = "secret"; +} else if (args[0] === "workflow" && args[1] === "run") { + capture.kind = "workflow"; +} else { + process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); + process.exit(1); +} +fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify(capture) + "\\n"); +process.exit(0); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + callback({ + env: { + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }, + capturePath, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFailingGhForDispatchGithubAwsDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-dispatch-github-aws-deploy-fail-")); + const ghPath = path.join(tempDir, "gh"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "auth" && args[1] === "status") { + process.stdout.write("github.com\\n"); + process.stderr.write(" X Failed to log in to github.com account example (default)\\n"); + process.stderr.write(" - Active account: true\\n"); + process.stderr.write(" - The token in default is invalid.\\n"); + process.exit(1); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withNetworkFailingGhForPlanEnvironmentDeploy(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-plan-environment-network-fail-")); + const ghPath = path.join(tempDir, "gh"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "auth" && args[1] === "status") { + process.stderr.write("error connecting to github.com\\n"); + process.stderr.write("check your internet connection or https://githubstatus.com\\n"); + process.exit(1); +} +process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(ghPath, script); + fs.chmodSync(ghPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withFakeAwsForSyncLegacySsm(callback) { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-sync-legacy-ssm-")); + const awsPath = path.join(tempDir, "aws"); + const script = `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "cloudformation" && args[1] === "describe-stacks") { + process.stdout.write(JSON.stringify({ + Stacks: [{ + Outputs: [ + { OutputKey: "DatabaseEndpoint", OutputValue: "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com" }, + { OutputKey: "DatabasePort", OutputValue: "3306" }, + { OutputKey: "DatabaseSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123" }, + { OutputKey: "MembershipDatabaseName", OutputValue: "membership" }, + { OutputKey: "AttendanceDatabaseName", OutputValue: "attendance" }, + { OutputKey: "ContentDatabaseName", OutputValue: "content" }, + { OutputKey: "GivingDatabaseName", OutputValue: "giving" }, + { OutputKey: "MessagingDatabaseName", OutputValue: "messaging" }, + { OutputKey: "DoingDatabaseName", OutputValue: "doing" }, + { OutputKey: "ReportingDatabaseName", OutputValue: "reporting" } + ] + }] + })); + process.exit(0); +} +if (args[0] === "secretsmanager" && args[1] === "get-secret-value") { + process.stdout.write(JSON.stringify({ + SecretString: JSON.stringify({ + username: "churchapps", + password: "replace-me" + }) + })); + process.exit(0); +} +process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); +process.exit(1); +`; + + try { + fs.writeFileSync(awsPath, script); + fs.chmodSync(awsPath, 0o755); + callback({ + PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectDeployFrontendSkipBuildIgnoresBackendStack() { + withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendDeploy((env) => { + const result = runScriptWithEnv("scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--backend-stack-name=definitely-not-a-real-backend-stack", + "--skip-build", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-frontend skip-build unexpectedly failed.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (combined.includes("Could not read backend stack")) { + throw new Error(`deploy-frontend skip-build still tried to read backend stack.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + if (combined.includes("Unexpected repeated frontend stack lookup")) { + throw new Error(`deploy-frontend still re-read frontend stack outputs during publish.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + if (!combined.includes("Deployment complete.")) { + throw new Error(`deploy-frontend skip-build did not complete successfully.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }); + }); +} + +function expectApiMigrationConnectionStringEncoding() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-api-migration-encoding-")); + try { + const secretPath = path.join(tempDir, "database-secret.json"); + fs.writeFileSync(secretPath, `${JSON.stringify({ + username: "church@apps", + password: "p@ss word/with:symbols", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + `--db-secret-file=${secretPath}`, + "--module=all", + "--action=status", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`run-api-migrations dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const membershipConnectionString = result.parsed?.connectionStrings?.MEMBERSHIP_CONNECTION_STRING || ""; + if (!membershipConnectionString.includes("mysql://church%40apps:***@")) { + throw new Error(`Encoded connection string was not redacted/encoded as expected.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectApiMigrationSingleModuleMinimalOutputs() { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-api-migration-single-module-")); + try { + const outputsPath = path.join(tempDir, "outputs.json"); + const secretPath = path.join(tempDir, "database-secret.json"); + + fs.writeFileSync(outputsPath, `${JSON.stringify({ + DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", + DatabasePort: "3306", + AttendanceDatabaseName: "attendance", + }, null, 2)}\n`); + fs.writeFileSync(secretPath, `${JSON.stringify({ + username: "churchapps", + password: "replace-me", + }, null, 2)}\n`); + + const result = runJsonScript("scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + `--outputs-file=${outputsPath}`, + `--db-secret-file=${secretPath}`, + "--module=attendance", + "--action=status", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`single-module migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const keys = Object.keys(result.parsed?.connectionStrings || {}); + const expectedKeys = ["ATTENDANCE_CONNECTION_STRING"]; + if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) { + throw new Error(`single-module migration generated unexpected connection strings: ${keys.join(", ")}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function expectApiMigrationAllModuleRepoSupportSignal() { + const result = runJsonScript("scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--module=all", + "--action=status", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`all-module migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const effectiveModules = result.parsed?.effectiveModules || []; + const skippedConfiguredModules = result.parsed?.skippedConfiguredModules || []; + if (!Array.isArray(effectiveModules) || effectiveModules.length === 0) { + throw new Error(`all-module migration dry run did not report effective modules.\nSTDOUT:\n${result.stdout}`); + } + if (!skippedConfiguredModules.includes("reporting")) { + throw new Error(`all-module migration dry run did not surface reporting as skipped.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectApiMigrationReportingSupportSignal() { + const result = runJsonScript("scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--module=reporting", + "--action=status", + "--dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`reporting migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const warnings = result.parsed?.warnings || []; + if (!warnings.some((warning) => String(warning).includes("No migration directory exists"))) { + throw new Error(`reporting migration dry run did not surface missing migration directory.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectValidatorReportingMigrationNoNextStep() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--api-repo-path=../Api", + "--run-api-migrations=true", + "--api-migration-module=reporting", + "--api-migration-dry-run=true", + "--output=json", + ]); + + if (result.status !== 0) { + throw new Error(`validator reporting migration scenario failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const nextSteps = result.parsed?.nextSteps || []; + if (nextSteps.some((step) => String(step).includes("run:api-migrations"))) { + throw new Error(`validator still suggested run:api-migrations for unsupported reporting migrations.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectStandaloneValidatorReportingMigrationNoNextStep() { + const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ + "--mode=api-migrations", + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--action=status", + "--module=reporting", + "--output=json", + ]); + + if (result.status === 0) { + throw new Error(`standalone validator reporting scenario unexpectedly passed.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const nextSteps = result.parsed?.nextSteps || []; + if (nextSteps.some((step) => String(step).includes("run:api-migrations"))) { + throw new Error(`standalone validator still suggested run:api-migrations for unsupported reporting migrations.\nSTDOUT:\n${result.stdout}`); + } +} + +function expectApiMigrationReportingFailsOutsideDryRun() { + const result = runScript("scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--module=reporting", + "--action=status", + ]); + + if (result.status === 0) { + throw new Error(`reporting migration unexpectedly succeeded outside dry-run mode.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("has no tools/migrations/reporting directory")) { + throw new Error(`reporting migration failure did not mention missing migration directory.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } +} + +function runCase(name, fn, results) { + try { + fn(); + results.push({ name, ok: true }); + } catch (error) { + results.push({ + name, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function main() { + const scriptsToCheck = [ + "scripts/deploy-bootstrap.mjs", + "scripts/audit-api-repo-contract.mjs", + "scripts/deploy-frontend.mjs", + "scripts/deploy-backend.mjs", + "scripts/deploy-aws.mjs", + "scripts/deploy-full-stack.mjs", + "scripts/upload-backend-artifact.mjs", + "scripts/publish-frontend-assets.mjs", + "scripts/package-api-backend.mjs", + "scripts/installer-common.mjs", + "scripts/installer-init.mjs", + "scripts/installer-customer-values.mjs", + "scripts/installer-update.mjs", + "scripts/installer-run.mjs", + "scripts/installer-start.mjs", + "scripts/setup-private-deployment-repo.mjs", + "scripts/installer-app-config-secret.mjs", + "scripts/installer-aws-handoff.mjs", + "scripts/installer-aws-roles.mjs", + "scripts/installer-configure.mjs", + "scripts/installer-doctor.mjs", + "scripts/installer-aws-preflight.mjs", + "scripts/installer-preflight.mjs", + "scripts/installer-deploy.mjs", + "scripts/installer-observe.mjs", + "scripts/installer-report.mjs", + "scripts/installer-verify.mjs", + "scripts/installer-bootstrap-admin.mjs", + "scripts/installer-adopt-frontend-origin.mjs", + "scripts/installer-github-setup.mjs", + "scripts/installer-github-readiness.mjs", + "scripts/installer-browser-smoke.mjs", + "scripts/audit-environment-starter.mjs", + "scripts/prepare-environment-starter.mjs", + "scripts/plan-environment-deploy.mjs", + "scripts/show-rollout-status.mjs", + "scripts/dispatch-github-aws-deploy.mjs", + "scripts/save-split-stack-outputs.mjs", + "scripts/show-deployment-summary.mjs", + "scripts/verify-split-stack.mjs", + "scripts/run-api-migrations.mjs", + "scripts/publish-lambda-layer.mjs", + "scripts/sync-app-config-secret.mjs", + "scripts/sync-github-app-config-secret.mjs", + "scripts/sync-legacy-ssm-parameters.mjs", + "scripts/validate-aws-deploy.mjs", + "scripts/smoke-aws-tooling.mjs", + ]; + const shellScriptsToCheck = [ + "infrastructure/environments/prod/deploy-split-stack.sh", + "infrastructure/environments/staging/deploy-split-stack.sh", + ]; + const templatesToParse = [ + "infrastructure/cloudformation/bootstrap.yaml", + "infrastructure/cloudformation/frontend-site.yaml", + "infrastructure/cloudformation/backend-api.yaml", + "infrastructure/cloudformation/full-stack.yaml", + ]; + const workflowsToParse = [ + ".github/workflows/deploy-aws-self-hosted.yml", + ".github/workflows/deploy-demo.yml", + ".github/workflows/deploy-prod.yml", + ".github/workflows/deploy-staging.yml", + ]; + const jsonFilesToParse = [ + "infrastructure/environments/customer-values.sample.json", + "infrastructure/examples/app-config-secret.sample.json", + "infrastructure/examples/audit-environment-starter-output.sample.json", + "infrastructure/examples/plan-environment-deploy-output.sample.json", + "infrastructure/examples/prepare-environment-starter-output.sample.json", + "infrastructure/examples/deploy-backend-output.sample.json", + "infrastructure/examples/backend-stack-outputs.sample.json", + "infrastructure/examples/backend-outputs.sample.json", + "infrastructure/examples/backend-parameters.sample.json", + "infrastructure/examples/bootstrap-parameters.sample.json", + "infrastructure/examples/database-secret.sample.json", + "infrastructure/examples/deploy-bootstrap-output.sample.json", + "infrastructure/examples/deploy-aws-frontend-infra-output.sample.json", + "infrastructure/examples/deploy-aws-full-output.sample.json", + "infrastructure/examples/deploy-aws-publish-build-output.sample.json", + "infrastructure/examples/deploy-aws-publish-output.sample.json", + "infrastructure/examples/deploy-frontend-output.sample.json", + "infrastructure/examples/deploy-frontend-publish-output.sample.json", + "infrastructure/examples/dispatch-github-aws-deploy-output.sample.json", + "infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json", + "infrastructure/examples/deploy-full-stack-full-output.sample.json", + "infrastructure/examples/deploy-full-stack-publish-build-output.sample.json", + "infrastructure/examples/deploy-full-stack-publish-output.sample.json", + "infrastructure/examples/frontend-outputs.sample.json", + "infrastructure/examples/frontend-parameters.sample.json", + "infrastructure/examples/full-stack-parameters.sample.json", + "infrastructure/examples/package-api-backend-output.sample.json", + "infrastructure/examples/package-manifest.sample.json", + "infrastructure/examples/publish-lambda-layer-output.sample.json", + "infrastructure/examples/publish-frontend-output.sample.json", + "infrastructure/examples/run-api-migrations-output.sample.json", + "infrastructure/examples/save-split-stack-outputs-output.sample.json", + "infrastructure/examples/show-rollout-status-output.sample.json", + "infrastructure/examples/sync-app-config-secret-output.sample.json", + "infrastructure/examples/sync-github-app-config-secret-output.sample.json", + "infrastructure/examples/sync-legacy-ssm-output.sample.json", + "infrastructure/examples/upload-backend-artifact-output.sample.json", + "infrastructure/examples/verify-split-stack-output.sample.json", + "infrastructure/examples/validate-api-migrations-output.sample.json", + "infrastructure/examples/validate-backend-output.sample.json", + "infrastructure/examples/validate-bootstrap-output.sample.json", + "infrastructure/examples/validate-frontend-output.sample.json", + "infrastructure/examples/validate-frontend-publish-output.sample.json", + "infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json", + "infrastructure/examples/validate-full-stack-output.sample.json", + "infrastructure/examples/validate-full-stack-publish-output.sample.json", + "infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json", + "infrastructure/examples/validate-split-stack-output.sample.json", + "infrastructure/examples/validate-split-stack-publish-output.sample.json", + "infrastructure/environments/prod/app-config-secret.template.json", + "infrastructure/environments/prod/backend-parameters.json", + "infrastructure/environments/prod/bootstrap-parameters.json", + "infrastructure/environments/prod/frontend-parameters.json", + "infrastructure/environments/staging/app-config-secret.template.json", + "infrastructure/environments/staging/backend-parameters.json", + "infrastructure/environments/staging/bootstrap-parameters.json", + "infrastructure/environments/staging/frontend-parameters.json", + ]; + const siblingApiRepoPath = path.resolve(rootDir, "..", "Api"); + const siblingApiRepoReadable = canReadFile(path.join(siblingApiRepoPath, "package.json")) + && canReadFile(path.join(siblingApiRepoPath, "serverless.yml")) + && canReadFile(path.join(siblingApiRepoPath, "tools", "kysely-config.ts")) + && canReadDirectory(path.join(siblingApiRepoPath, "tools", "migrations")); + + const results = []; + + scriptsToCheck.forEach((scriptPath) => runCase(`parse ${scriptPath}`, () => runCheck(scriptPath), results)); + shellScriptsToCheck.forEach((scriptPath) => runCase(`parse ${scriptPath}`, () => runShellCheck(scriptPath), results)); + templatesToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runYamlParse(filePath), results)); + workflowsToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runYamlParse(filePath), results)); + jsonFilesToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runJsonParse(filePath), results)); + runCase("json example contract coverage is complete", () => expectJsonExampleContractCoverage(jsonFilesToParse), results); + runCase("environment starter kits stay in sync", () => expectEnvironmentStarterParity(), results); + runCase("deploy-aws workflow uploads deployment evidence artifact", () => expectDeployAwsWorkflowUploadsEvidenceArtifact(), results); + + if (siblingApiRepoReadable) { + runCase("api repo serverless env key coverage", () => checkBackendTemplateContainsApiRepoEnvKeys(siblingApiRepoPath), results); + runCase("package-api-backend child failure is clean", () => expectScriptErrorClean("package-api-backend child failure is clean", "scripts/package-api-backend.mjs", [ + "--api-repo-path=../Api", + "--build-command=definitely-not-a-real-build-command", + ], "definitely-not-a-real-build-command"), results); + runCase("run-api-migrations dry run", () => expectScriptOk("run-api-migrations dry run", "scripts/run-api-migrations.mjs", [ + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--module=all", + "--action=status", + "--dry-run=true", + "--output=json", + ]), results); + runCase("run-api-migrations connection string encoding", () => expectApiMigrationConnectionStringEncoding(), results); + runCase("run-api-migrations single-module minimal outputs", () => expectApiMigrationSingleModuleMinimalOutputs(), results); + runCase("run-api-migrations all-module repo support signal", () => expectApiMigrationAllModuleRepoSupportSignal(), results); + runCase("run-api-migrations reporting support signal", () => expectApiMigrationReportingSupportSignal(), results); + runCase("run-api-migrations reporting fails outside dry run", () => expectApiMigrationReportingFailsOutsideDryRun(), results); + runCase("validator reporting migration no next step", () => expectValidatorReportingMigrationNoNextStep(), results); + runCase("standalone validator reporting migration no next step", () => expectStandaloneValidatorReportingMigrationNoNextStep(), results); + } else { + addSkippedResults(results, [ + "api repo serverless env key coverage", + "package-api-backend child failure is clean", + "run-api-migrations dry run", + "run-api-migrations connection string encoding", + "run-api-migrations single-module minimal outputs", + "run-api-migrations all-module repo support signal", + "run-api-migrations reporting support signal", + "run-api-migrations reporting fails outside dry run", + "validator reporting migration no next step", + "standalone validator reporting migration no next step", + ]); + } + + runCase("package-api-backend unreadable package file is clean", () => withUnreadableFakeApiRepo((fakeApiRepoPath) => { + expectScriptErrorClean("package-api-backend unreadable package file is clean", "scripts/package-api-backend.mjs", [ + `--api-repo-path=${fakeApiRepoPath}`, + "--build=false", + ], "API package.json is not readable:"); + }), results); + + runCase("audit-api-repo-contract output works", () => expectAuditApiRepoContractWorks(), results); + runCase("audit-api-repo-contract missing path is clean", () => expectAuditApiRepoContractUnreadablePathIsClean(), results); + runCase("package-api-backend json includes manifest deploy hints", () => expectPackageApiBackendJsonIncludesManifestDeployHints(), results); + runCase("package-api-backend output sample matches contract", () => expectPackageApiBackendOutputSampleMatchesContract(), results); + runCase("audit-environment-starter output sample matches contract", () => expectAuditEnvironmentStarterOutputSampleMatchesContract(), results); + runCase("audit-environment-starter markdown output works", () => expectAuditEnvironmentStarterMarkdownOutputWorks(), results); + runCase("plan-environment-deploy output sample matches contract", () => expectPlanEnvironmentDeployOutputSampleMatchesContract(), results); + runCase("show-rollout-status output sample matches contract", () => expectShowRolloutStatusOutputSampleMatchesContract(), results); + runCase("plan-environment-deploy commands output works", () => expectPlanEnvironmentDeployCommandsOutputWorks(), results); + runCase("installer setup scaffolds private deployment repo", () => expectInstallerSetupScaffoldsPrivateDeploymentRepo(), results); + runCase("installer init creates guided starting point", () => expectInstallerInitCreatesGuidedStartingPoint(), results); + runCase("installer customer-values writes guided answers", () => expectInstallerCustomerValuesWritesGuidedAnswers(), results); + runCase("installer run executes guided step", () => expectInstallerRunExecutesGuidedStep(), results); + runCase("installer update dry-run plans guided update", () => expectInstallerUpdateDryRun(), results); + runCase("installer start recommends next step", () => expectInstallerStartRecommendsNextStep(), results); + runCase("customer file awsRegion alias works", () => expectCustomerFileAwsRegionAliasWorks(), results); + runCase("installer aws handoff writes admin document", () => expectInstallerAwsHandoffWritesAdminDocument(), results); + runCase("installer aws roles generates policy files", () => expectInstallerAwsRolesGeneratesPolicyFiles(), results); + runCase("installer github setup plans and writes secrets", () => expectInstallerGithubSetupPlansAndWritesSecrets(), results); + runCase("installer github readiness checks environment secrets", () => expectInstallerGithubReadinessChecksEnvironmentSecrets(), results); + runCase("installer observe summarizes downloaded evidence", () => expectInstallerObserveSummarizesDownloadedEvidence(), results); + runCase("installer observe downloads preview artifact fallback", () => expectInstallerObserveDownloadsPreviewArtifactFallback(), results); + runCase("installer observe warns on incomplete deployment artifact", () => expectInstallerObserveWarnsOnIncompleteDeploymentArtifact(), results); + runCase("installer report generates rollout record", () => expectInstallerReportGeneratesRolloutRecord(), results); + runCase("show-rollout-status summarizes multiple environments", () => expectShowRolloutStatusSummarizesMultipleEnvironments(), results); + runCase("show-rollout-status commands output works", () => expectShowRolloutStatusCommandsOutputWorks(), results); + runCase("plan-environment-deploy markdown output works", () => expectPlanEnvironmentDeployMarkdownOutputWorks(), results); + runCase("plan-environment-deploy ready package-manifest mode works", () => expectPlanEnvironmentDeployReadyPackageManifestModeWorks(), results); + runCase("plan-environment-deploy github needs secret materialization", () => expectPlanEnvironmentDeployGithubNeedsSecretMaterializationWorks(), results); + runCase("plan-environment-deploy local-only execution blocker works", () => expectPlanEnvironmentDeployLocalOnlyExecutionBlockerWorks(), results); + runCase("plan-environment-deploy unreadable api-repo local-only blocker works", () => expectPlanEnvironmentDeployUnreadableApiRepoLocalOnlyBlockerWorks(), results); + runCase("plan-environment-deploy github-only path still needs gh auth", () => expectPlanEnvironmentDeployGithubOnlyNeedsGhAuthWorks(), results); + runCase("plan-environment-deploy gh network failure works", () => expectPlanEnvironmentDeployGhNetworkFailureWorks(), results); + runCase("plan-environment-deploy execution remediation command works", () => expectPlanEnvironmentDeployExecutionRemediationCommandWorks(), results); + runCase("plan-environment-deploy backend-artifact input blocker works", () => expectPlanEnvironmentDeployBackendArtifactInputBlockerWorks(), results); + runCase("prepare-environment-starter output sample matches contract", () => expectPrepareEnvironmentStarterOutputSampleMatchesContract(), results); + runCase("prepare-environment-starter commands output works", () => expectPrepareEnvironmentStarterCommandsOutputWorks(), results); + runCase("prepare-environment-starter markdown output works", () => expectPrepareEnvironmentStarterMarkdownOutputWorks(), results); + runCase("prepare-environment-starter write mode clears generated blockers", () => expectPrepareEnvironmentStarterWriteModeClearsGeneratedBlockers(), results); + runCase("prepare-environment-starter write mode can clear starter defaults", () => expectPrepareEnvironmentStarterWriteModeCanClearStarterDefaults(), results); + runCase("prepare-environment-starter root-domain shortcut works", () => expectPrepareEnvironmentStarterRootDomainShortcutWorks(), results); + runCase("prepare-environment-starter custom-domain inputs work", () => expectPrepareEnvironmentStarterCustomDomainInputsWork(), results); + runCase("prepare-environment-starter write mode can skip secret file", () => expectPrepareEnvironmentStarterWriteModeCanSkipSecretFile(), results); + runCase("prepare-environment-starter optional public fields work", () => expectPrepareEnvironmentStarterOptionalPublicFieldsWork(), results); + runCase("save-split-stack-outputs output sample matches contract", () => expectSaveSplitStackOutputsOutputSampleMatchesContract(), results); + runCase("save-split-stack-outputs environment mode works", () => expectSaveSplitStackOutputsEnvironmentModeWorks(), results); + runCase("save-split-stack-outputs missing args is clean", () => expectSaveSplitStackOutputsMissingArgsIsClean(), results); + runCase("show-deployment-summary markdown works", () => expectShowDeploymentSummaryMarkdownWorks(), results); + runCase("show-deployment-summary commands works", () => expectShowDeploymentSummaryCommandsWorks(), results); + runCase("show-deployment-summary missing file is clean", () => expectShowDeploymentSummaryMissingFileIsClean(), results); + runCase("package manifest sample matches contract", () => expectPackageManifestSampleMatchesContract(), results); + runCase("deploy-bootstrap output sample matches contract", () => expectDeployBootstrapOutputSampleMatchesContract(), results); + runCase("deploy-frontend output sample matches contract", () => expectDeployFrontendOutputSampleMatchesContract(), results); + runCase("deploy-frontend publish output sample matches contract", () => expectDeployFrontendPublishOutputSampleMatchesContract(), results); + runCase("deploy-backend output sample matches contract", () => expectDeployBackendOutputSampleMatchesContract(), results); + runCase("deploy-aws frontend-infrastructure output sample matches contract", () => expectDeployAwsFrontendInfraOutputSampleMatchesContract(), results); + runCase("deploy-aws full output sample matches contract", () => expectDeployAwsFullOutputSampleMatchesContract(), results); + runCase("deploy-aws publish output sample matches contract", () => expectDeployAwsPublishOutputSampleMatchesContract(), results); + runCase("deploy-aws publish build output sample matches contract", () => expectDeployAwsPublishBuildOutputSampleMatchesContract(), results); + runCase("deploy-full-stack frontend-infrastructure output sample matches contract", () => expectDeployFullStackFrontendInfraOutputSampleMatchesContract(), results); + runCase("deploy-full-stack full output sample matches contract", () => expectDeployFullStackFullOutputSampleMatchesContract(), results); + runCase("deploy-full-stack publish output sample matches contract", () => expectDeployFullStackPublishOutputSampleMatchesContract(), results); + runCase("deploy-full-stack publish build output sample matches contract", () => expectDeployFullStackPublishBuildOutputSampleMatchesContract(), results); + runCase("publish-lambda-layer output sample matches contract", () => expectPublishLambdaLayerOutputSampleMatchesContract(), results); + runCase("dispatch-github-aws-deploy output sample matches contract", () => expectDispatchGithubAwsDeployOutputSampleMatchesContract(), results); + runCase("run-api-migrations output sample matches contract", () => expectRunApiMigrationsOutputSampleMatchesContract(), results); + runCase("sync-app-config-secret output sample matches contract", () => expectSyncAppConfigSecretOutputSampleMatchesContract(), results); + runCase("sync-github-app-config-secret output sample matches contract", () => expectSyncGithubAppConfigSecretOutputSampleMatchesContract(), results); + runCase("sync-legacy-ssm output sample matches contract", () => expectSyncLegacySsmOutputSampleMatchesContract(), results); + runCase("upload-backend-artifact output sample matches contract", () => expectUploadBackendArtifactOutputSampleMatchesContract(), results); + runCase("validate-api-migrations output sample matches contract", () => expectValidateApiMigrationsOutputSampleMatchesContract(), results); + runCase("validate-backend output sample matches contract", () => expectValidateBackendOutputSampleMatchesContract(), results); + runCase("validate-bootstrap output sample matches contract", () => expectValidateBootstrapOutputSampleMatchesContract(), results); + runCase("prod bootstrap starter validation works", () => expectProdBootstrapStarterValidation(), results); + runCase("prod split-stack starter validation works", () => expectProdSplitStackStarterValidation(), results); + runCase("prod deploy script stops on placeholders", () => expectProdDeployScriptStopsOnPlaceholders(), results); + runCase("prod deploy script can skip saving outputs", () => expectProdDeployScriptCanSkipSavingOutputs(), results); + runCase("prod deploy script preview-only mode stops after plan", () => expectProdDeployScriptPreviewOnlyStopsAfterPlan(), results); + runCase("staging bootstrap starter validation works", () => expectStagingBootstrapStarterValidation(), results); + runCase("staging split-stack starter validation works", () => expectStagingSplitStackStarterValidation(), results); + runCase("staging deploy script stops on placeholders", () => expectStagingDeployScriptStopsOnPlaceholders(), results); + runCase("staging deploy script saves outputs by default", () => expectStagingDeployScriptSavesOutputsByDefault(), results); + runCase("staging deploy script preview-only mode stops after plan", () => expectStagingDeployScriptPreviewOnlyStopsAfterPlan(), results); + runCase("staging deploy script stops on unreadable api repo", () => expectStagingDeployScriptStopsOnUnreadableApiRepo(), results); + runCase("validate-frontend output sample matches contract", () => expectValidateFrontendOutputSampleMatchesContract(), results); + runCase("validate-frontend publish output sample matches contract", () => expectValidateFrontendPublishOutputSampleMatchesContract(), results); + runCase("validate-full-stack frontend-infrastructure output sample matches contract", () => expectValidateFullStackFrontendInfraOutputSampleMatchesContract(), results); + runCase("validate-full-stack output sample matches contract", () => expectValidateFullStackOutputSampleMatchesContract(), results); + runCase("validate-full-stack publish output sample matches contract", () => expectValidateFullStackPublishOutputSampleMatchesContract(), results); + runCase("validate-split-stack frontend-infrastructure output sample matches contract", () => expectValidateSplitStackFrontendInfraOutputSampleMatchesContract(), results); + runCase("validate-split-stack output sample matches contract", () => expectValidateSplitStackOutputSampleMatchesContract(), results); + runCase("validate-split-stack publish output sample matches contract", () => expectValidateSplitStackPublishOutputSampleMatchesContract(), results); + runCase("publish-frontend output sample matches contract", () => expectPublishFrontendOutputSampleMatchesContract(), results); + runCase("verify-split-stack output sample matches contract", () => expectVerifySplitStackOutputSampleMatchesContract(), results); + + runCase("validator unreadable api repo package file", () => withUnreadableFakeApiRepo((fakeApiRepoPath) => { + expectError("validator unreadable api repo package file", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--api-repo-path=${fakeApiRepoPath}`, + "--output=json", + ], "API repo package.json is not readable:"); + }), results); + runCase("validator unreadable api repo fallback guidance", () => expectValidatorUnreadableApiRepoIncludesFallbackGuidance(), results); + + runCase("validator frontend mode", () => expectOk("frontend mode", [ + "--mode=frontend", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--output=json", + ]), results); + + runCase("validator bootstrap mode respects EnvironmentName", () => expectValidatorBootstrapRespectsEnvironmentName(), results); + runCase("validator full-stack mode respects EnvironmentName", () => expectValidatorFullStackRespectsEnvironmentName(), results); + + runCase("validator bootstrap mode", () => expectOk("bootstrap mode", [ + "--mode=bootstrap", + "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", + "--output=json", + ]), results); + + runCase("validator bootstrap next step keeps stack-name", () => expectBootstrapValidatorNextStep(), results); + runCase("validator package manifest next step reuses artifact path", () => expectPackageManifestValidatorNextStep(), results); + runCase("validator package manifest migration next step reuses artifact path", () => expectPackageManifestValidatorMigrationNextStep(), results); + + runCase("validator split-stack mode", () => expectOk("split-stack mode", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--output=json", + ]), results); + + runCase("validator full-stack mode", () => expectOk("full-stack mode", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--template-bucket=my-template-bucket", + "--output=json", + ]), results); + + runCase("validator frontend publish mode", () => expectOk("frontend publish mode", [ + "--mode=frontend-publish", + "--bucket=example-frontend-bucket", + "--distribution-id=EXAMPLE123", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ]), results); + + runCase("validator split-stack mode with backend outputs file", () => expectOk("split-stack mode with backend outputs file", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ]), results); + + runCase("validator backend mode with package manifest file", () => withFakePackageManifest((manifestPath) => { + expectOk("backend mode with package manifest file", [ + "--mode=backend", + "--stack-name=example-backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--output=json", + ]); + }), results); + + runCase("validator env var fallback with underscores", () => { + const result = runJsonScriptWithEnv("scripts/validate-aws-deploy.mjs", [ + "--mode=split-stack", + "--output=json", + ], { + BACKEND_PARAMETERS_FILE: "infrastructure/examples/backend-parameters.sample.json", + FRONTEND_PARAMETERS_FILE: "infrastructure/examples/frontend-parameters.sample.json", + }); + + if (result.status !== 0) { + throw new Error(`validator env var fallback with underscores failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + if (!result.parsed?.ok) { + throw new Error(`validator env var fallback with underscores returned ok=false unexpectedly.\nSTDOUT:\n${result.stdout}`); + } + }, results); + + if (siblingApiRepoReadable) { + runCase("validator api-migrations mode", () => expectOk("api-migrations mode", [ + "--mode=api-migrations", + "--api-repo-path=../Api", + "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", + "--db-secret-file=infrastructure/examples/database-secret.sample.json", + "--action=status", + "--module=all", + "--dry-run=true", + "--output=json", + ]), results); + } else { + addSkippedResults(results, ["validator api-migrations mode"]); + } + + runCase("validator split-stack invalid publish combo", () => expectError("split-stack invalid publish combo", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--frontend-infrastructure-only", + "--publish-frontend-assets", + "--output=json", + ], "cannot be combined with --frontend-infrastructure-only"), results); + + runCase("validator full-stack invalid publish combo", () => expectError("full-stack invalid publish combo", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--template-bucket=my-template-bucket", + "--infrastructure-only", + "--publish-frontend-assets", + "--output=json", + ], "cannot be combined with --infrastructure-only"), results); + + runCase("validator frontend invalid skip-build combo", () => expectError("frontend invalid skip-build combo", [ + "--mode=frontend", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--infrastructure-only", + "--skip-build", + "--output=json", + ], "has no effect together with --infrastructure-only"), results); + + runCase("validator missing parameters file", () => expectError("missing parameters file", [ + "--mode=bootstrap", + "--parameters-file=does-not-exist.json", + "--output=json", + ], "Parameters file could not be loaded"), results); + + if (siblingApiRepoReadable) { + runCase("validator api-migrations missing target", () => expectError("api-migrations missing target", [ + "--mode=api-migrations", + "--api-repo-path=../Api", + "--output=json", + ], "Api-migrations mode needs --stack-name or --outputs-file"), results); + + runCase("validator reporting migration requires dry run", () => expectError("reporting migration requires dry run", [ + "--mode=backend", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--api-repo-path=../Api", + "--run-api-migrations=true", + "--api-migration-module=reporting", + "--output=json", + ], "Direct reporting migrations are not currently runnable outside dry-run mode"), results); + + runCase("validator api-migrations single-module minimal outputs", () => { + const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-api-migration-")); + try { + const outputsPath = path.join(tempDir, "outputs.json"); + const secretPath = path.join(tempDir, "database-secret.json"); + fs.writeFileSync(outputsPath, `${JSON.stringify({ + DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", + DatabasePort: "3306", + AttendanceDatabaseName: "attendance", + }, null, 2)}\n`); + fs.writeFileSync(secretPath, `${JSON.stringify({ + username: "churchapps", + password: "replace-me", + }, null, 2)}\n`); + + expectOk("api-migrations single-module minimal outputs", [ + "--mode=api-migrations", + "--api-repo-path=../Api", + `--outputs-file=${outputsPath}`, + `--db-secret-file=${secretPath}`, + "--action=status", + "--module=attendance", + "--dry-run=true", + "--output=json", + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, results); + } else { + addSkippedResults(results, [ + "validator api-migrations missing target", + "validator reporting migration requires dry run", + "validator api-migrations single-module minimal outputs", + ]); + } + + runCase("validator unreadable bootstrap stack", () => expectError("unreadable bootstrap stack", [ + "--mode=full-stack", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--output=json", + ], 'Bootstrap stack "definitely-not-a-real-bootstrap-stack" could not be read'), results); + + runCase("validator full-stack publish-only ignores bootstrap stack", () => expectOk("full-stack publish-only ignores bootstrap stack", [ + "--mode=full-stack", + "--stack-name=example-full-stack", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--skip-infrastructure", + "--publish-frontend-assets", + "--output=json", + ]), results); + + runCase("validator full-stack publish-only with outputs files", () => expectOk("full-stack publish-only with outputs files", [ + "--mode=full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--skip-infrastructure", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ]), results); + + runCase("validator split-stack publish-only ignores bootstrap stack", () => expectOk("split-stack publish-only ignores bootstrap stack", [ + "--mode=split-stack", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--output=json", + ]), results); + + runCase("validator split-stack publish-only with frontend outputs file", () => expectOk("split-stack publish-only with frontend outputs file", [ + "--mode=split-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ]), results); + + runCase("deploy-frontend invalid skip-build combo", () => expectScriptError("deploy-frontend invalid skip-build combo", "scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--infrastructure-only", + "--skip-build", + ], "--skip-build has no effect when --infrastructure-only is set."), results); + + runCase("deploy-frontend missing build output in skip-build mode", () => withMissingFrontendBuildOutput(() => { + expectScriptError("deploy-frontend missing build output in skip-build mode", "scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--skip-build", + ], "Build output not found:"); + }), results); + + runCase("deploy-frontend skip-build ignores backend stack", () => expectDeployFrontendSkipBuildIgnoresBackendStack(), results); + + runCase("deploy-bootstrap duplicate bucket names", () => expectScriptError("deploy-bootstrap duplicate bucket names", "scripts/deploy-bootstrap.mjs", [ + "--stack-name=example-bootstrap", + "--template-bucket-name=example-bootstrap-bucket", + "--artifact-bucket-name=example-bootstrap-bucket", + ], "TemplateBucketName and ArtifactBucketName must be different"), results); + + runCase("deploy-bootstrap missing parameters file", () => expectScriptError("deploy-bootstrap missing parameters file", "scripts/deploy-bootstrap.mjs", [ + "--stack-name=example-bootstrap", + "--parameters-file=does-not-exist.json", + ], 'Could not load parameters file "does-not-exist.json"'), results); + + runCase("deploy-frontend missing parameters file", () => expectScriptError("deploy-frontend missing parameters file", "scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--parameters-file=does-not-exist.json", + ], 'Could not load parameters file "does-not-exist.json"'), results); + + runCase("deploy-frontend unreadable backend stack", () => expectScriptError("deploy-frontend unreadable backend stack", "scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--backend-stack-name=definitely-not-a-real-backend-stack", + "--infrastructure-only", + ], 'Could not read backend stack "definitely-not-a-real-backend-stack"'), results); + + runCase("deploy-frontend env var fallback with underscores", () => { + const result = runScriptWithEnv("scripts/deploy-frontend.mjs", [ + "--infrastructure-only", + ], { + STACK_NAME: "example-frontend", + BACKEND_OUTPUTS_FILE: "does-not-exist.json", + }); + + if (result.status === 0) { + throw new Error(`deploy-frontend env var fallback with underscores unexpectedly passed.\nSTDOUT:\n${result.stdout}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes('Could not load backend outputs file "does-not-exist.json"')) { + throw new Error(`deploy-frontend env var fallback with underscores did not include expected backend outputs file error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }, results); + + runCase("deploy-frontend missing backend outputs file", () => expectScriptError("deploy-frontend missing backend outputs file", "scripts/deploy-frontend.mjs", [ + "--stack-name=example-frontend", + "--backend-outputs-file=does-not-exist.json", + "--infrastructure-only", + ], 'Could not load backend outputs file "does-not-exist.json"'), results); + + runCase("deploy-backend missing parameters file", () => expectScriptError("deploy-backend missing parameters file", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--parameters-file=does-not-exist.json", + ], 'Could not load parameters file "does-not-exist.json"'), results); + + runCase("deploy-backend package manifest file without api repo", () => withFakePackageManifest((manifestPath) => { + expectScriptError("deploy-backend package manifest file without api repo", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + ], "Source file not found:"); + }, { missingBackendArtifact: true }), results); + runCase("deploy-backend json includes manifest provenance", () => expectDeployBackendJsonIncludesManifestProvenance(), results); + runCase("deploy-backend package manifest missing migration artifact", () => expectDeployBackendPackageManifestMissingMigrationArtifact(), results); + + runCase("deploy-backend unreadable bootstrap stack", () => expectScriptError("deploy-backend unreadable bootstrap stack", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--output=json", + ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); + + if (siblingApiRepoReadable) { + runCase("deploy-backend unsupported reporting migration target", () => expectScriptError("deploy-backend unsupported reporting migration target", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--run-api-migrations=true", + "--api-migration-module=reporting", + ], "Refusing to deploy with --run-api-migrations=true for an unsupported migration target"), results); + } else { + addSkippedResults(results, ["deploy-backend unsupported reporting migration target"]); + } + + runCase("deploy-backend invalid migration action", () => expectScriptError("deploy-backend invalid migration action", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--run-api-migrations=true", + "--api-migration-action=nope", + "--api-migration-dry-run=true", + ], 'Invalid api-migration-action "nope"'), results); + + runCase("deploy-backend missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { + expectScriptError("deploy-backend missing migration repo dependencies", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", + "--run-api-migrations=true", + `--api-migration-api-repo-path=${fakeApiRepoPath}`, + ], "API migration repo dependencies are not installed"); + }), results); + + runCase("deploy-full-stack missing parameters file", () => expectScriptError("deploy-full-stack missing parameters file", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--parameters-file=does-not-exist.json", + ], 'Could not load parameters file "does-not-exist.json"'), results); + + runCase("deploy-full-stack package manifest file without api repo", () => expectDeployFullStackPackageManifestMissingArtifact(), results); + runCase("deploy-full-stack json includes manifest provenance", () => expectDeployFullStackJsonIncludesManifestProvenance(), results); + runCase("deploy-full-stack package manifest missing migration artifact", () => expectDeployFullStackPackageManifestMissingMigrationArtifact(), results); + + runCase("deploy-full-stack publish-only ignores bootstrap stack", () => withFakeFrontendBuildOutput(() => { + expectScriptError("deploy-full-stack publish-only ignores bootstrap stack", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--skip-infrastructure", + "--publish-frontend-assets", + "--skip-build", + ], 'Could not read full-stack "example-full-stack"'); + }), results); + + runCase("deploy-full-stack publish-only accepts frontend outputs file without stack", () => withFakeFrontendBuildOutput(() => { + expectScriptErrorClean("deploy-full-stack publish-only accepts frontend outputs file without stack", "scripts/deploy-full-stack.mjs", [ + "--skip-infrastructure", + "--publish-frontend-assets", + "--skip-build", + "--frontend-outputs-file=does-not-exist.json", + ], 'Could not load frontend outputs file "does-not-exist.json"'); + }), results); + + runCase("deploy-full-stack publish-only with frontend outputs file works", () => withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendPublish((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--skip-infrastructure", + "--publish-frontend-assets", + "--skip-build", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack publish-only with frontend outputs file failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.frontendBucketName !== "example-frontend-bucket") { + throw new Error(`deploy-full-stack publish-only did not reuse the saved bucket from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendDistributionId !== "EXAMPLE123") { + throw new Error(`deploy-full-stack publish-only did not reuse the saved distribution from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendAppUrl !== "https://admin.example.com") { + throw new Error(`deploy-full-stack publish-only did not reuse the saved app URL from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (!parsed.frontendPublished || parsed.frontendEnv === undefined) { + throw new Error(`deploy-full-stack publish-only did not complete the outputs-driven publish follow-up cleanly.\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + runCase("deploy-full-stack publish-only backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ + "--skip-infrastructure", + "--publish-frontend-assets", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-full-stack publish-only backend outputs build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (parsed.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-full-stack publish-only did not expose REACT_APP_API_BASE from saved backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { + throw new Error(`deploy-full-stack publish-only did not expose REACT_APP_SUPPORT_EMAIL from saved backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + if (siblingApiRepoReadable) { + runCase("deploy-full-stack unsupported reporting migration target", () => expectScriptError("deploy-full-stack unsupported reporting migration target", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--run-api-migrations=true", + "--api-migration-module=reporting", + ], "Refusing to deploy with --run-api-migrations=true for an unsupported direct migration target"), results); + } else { + addSkippedResults(results, ["deploy-full-stack unsupported reporting migration target"]); + } + + runCase("deploy-full-stack invalid migration action", () => expectScriptError("deploy-full-stack invalid migration action", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--run-api-migrations=true", + "--api-migration-action=nope", + "--api-migration-dry-run=true", + ], 'Invalid api-migration-action "nope"'), results); + + runCase("deploy-full-stack missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { + expectScriptError("deploy-full-stack missing migration repo dependencies", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--run-api-migrations=true", + `--api-migration-api-repo-path=${fakeApiRepoPath}`, + ], "API migration repo dependencies are not installed"); + }), results); + + runCase("deploy-full-stack missing frontend dependencies", () => withMissingFrontendNodeModules(() => expectScriptError("deploy-full-stack missing frontend dependencies", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + ], "Frontend dependencies are not installed:")), results); + + runCase("deploy-aws missing backend parameters file", () => expectScriptError("deploy-aws missing backend parameters file", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=does-not-exist.json", + ], 'Could not load parameters file "does-not-exist.json"'), results); + + runCase("deploy-aws unreadable bootstrap stack", () => expectScriptError("deploy-aws unreadable bootstrap stack", "scripts/deploy-aws.mjs", [ + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--skip-frontend", + ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); + + runCase("deploy-aws package manifest file without api repo", () => withFakePackageManifest((manifestPath) => { + expectScriptError("deploy-aws package manifest file without api repo", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + `--package-manifest-file=${manifestPath}`, + "--lambda-code-s3-bucket=my-artifacts-bucket", + "--skip-frontend", + ], "Source file not found:"); + }, { missingBackendArtifact: true }), results); + runCase("deploy-aws json includes manifest provenance", () => expectDeployAwsJsonIncludesManifestProvenance(), results); + runCase("deploy-aws package manifest missing migration artifact", () => expectDeployAwsPackageManifestMissingMigrationArtifact(), results); + + runCase("deploy-aws publish-only ignores bootstrap stack", () => withFakeFrontendBuildOutput(() => { + expectScriptErrorClean("deploy-aws publish-only ignores bootstrap stack", "scripts/deploy-aws.mjs", [ + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--frontend-stack-name=definitely-not-a-real-frontend-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-stack-name=definitely-not-a-real-backend-stack", + "--skip-build", + ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'); + }), results); + + runCase("deploy-aws invalid publish combo", () => expectScriptError("deploy-aws invalid publish combo", "scripts/deploy-aws.mjs", [ + "--region=us-east-1", + "--project-name=b1admin", + "--environment=prod", + "--frontend-infrastructure-only", + "--publish-frontend-assets", + ], "--publish-frontend-assets cannot be combined with --frontend-infrastructure-only."), results); + + runCase("deploy-aws missing backend outputs file for frontend deploy", () => expectScriptError("deploy-aws missing backend outputs file for frontend deploy", "scripts/deploy-aws.mjs", [ + "--skip-backend", + "--frontend-infrastructure-only", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-outputs-file=does-not-exist.json", + ], 'Could not load backend outputs file "does-not-exist.json"'), results); + + runCase("deploy-aws missing backend outputs file for publish-only", () => withFakeFrontendNodeModules(() => { + expectScriptError("deploy-aws missing backend outputs file for publish-only", "scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-outputs-file=does-not-exist.json", + ], 'Could not load backend outputs file "does-not-exist.json"'); + }), results); + + runCase("deploy-aws publish-only prefers frontend outputs file over frontend stack", () => withFakeFrontendBuildOutput(() => { + expectScriptErrorClean("deploy-aws publish-only prefers frontend outputs file over frontend stack", "scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--skip-build", + "--frontend-stack-name=definitely-not-a-real-frontend-stack", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-outputs-file=does-not-exist.json", + ], 'Could not load frontend outputs file "does-not-exist.json"'); + }), results); + + runCase("deploy-aws publish-only with frontend outputs file works", () => withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendPublish((env) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--skip-build", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`deploy-aws publish-only with frontend outputs file failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.frontendPublish?.bucket !== "example-frontend-bucket") { + throw new Error(`deploy-aws publish-only did not reuse the saved bucket from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendPublish?.distributionId !== "EXAMPLE123") { + throw new Error(`deploy-aws publish-only did not reuse the saved distribution from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendPublish?.appUrl !== "https://admin.example.com") { + throw new Error(`deploy-aws publish-only did not reuse the saved app URL from frontend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (!parsed.frontendPublish?.frontendPublished || parsed.skipBuild !== true || parsed.skipFrontend !== true || parsed.skipBackend !== true) { + throw new Error(`deploy-aws publish-only did not complete the outputs-driven skip-build follow-up cleanly.\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + runCase("deploy-aws publish-only backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`deploy-aws publish-only backend outputs build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (parsed.frontendPublish?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-aws publish-only did not expose REACT_APP_API_BASE from saved backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.frontendPublish?.backendBuildEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { + throw new Error(`deploy-aws publish-only did not expose REACT_APP_SUPPORT_EMAIL from saved backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`deploy-aws publish-only did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`deploy-aws publish-only did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`deploy-aws publish-only did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + if (siblingApiRepoReadable) { + runCase("deploy-aws unsupported reporting migration target", () => expectScriptError("deploy-aws unsupported reporting migration target", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--run-api-migrations=true", + "--api-migration-module=reporting", + "--skip-frontend", + ], "Refusing to deploy with --run-api-migrations=true for an unsupported migration target"), results); + } else { + addSkippedResults(results, ["deploy-aws unsupported reporting migration target"]); + } + + runCase("deploy-aws invalid migration action", () => expectScriptError("deploy-aws invalid migration action", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--run-api-migrations=true", + "--api-migration-action=nope", + "--api-migration-dry-run=true", + "--skip-frontend", + ], 'Invalid api-migration-action "nope"'), results); + + runCase("deploy-aws run-api-migrations requires backend step", () => expectScriptError("deploy-aws run-api-migrations requires backend step", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--run-api-migrations=true", + "--skip-backend", + "--skip-frontend", + ], "--run-api-migrations=true requires the backend deploy step"), results); + + runCase("deploy-aws missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { + expectScriptError("deploy-aws missing migration repo dependencies", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--run-api-migrations=true", + `--api-migration-api-repo-path=${fakeApiRepoPath}`, + "--skip-frontend", + ], "API migration repo dependencies are not installed"); + }), results); + + runCase("deploy-aws missing frontend dependencies", () => withMissingFrontendNodeModules(() => expectScriptError("deploy-aws missing frontend dependencies", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--skip-backend", + ], "Frontend dependencies are not installed:")), results); + + runCase("deploy-full-stack invalid publish combo", () => expectScriptError("deploy-full-stack invalid publish combo", "scripts/deploy-full-stack.mjs", [ + "--stack-name=b1admin-prod", + "--publish-frontend-assets", + ], "--publish-frontend-assets is only needed for the later publish-only phase."), results); + + runCase("deploy-full-stack run-api-migrations requires infrastructure phase", () => expectScriptError("deploy-full-stack run-api-migrations requires infrastructure phase", "scripts/deploy-full-stack.mjs", [ + "--stack-name=example-full-stack", + "--run-api-migrations=true", + "--skip-infrastructure", + "--publish-frontend-assets", + ], "--run-api-migrations=true is only supported during the infrastructure deploy phase"), results); + + runCase("deploy-aws publish-only missing frontend dependencies is clean", () => withMissingFrontendNodeModules(() => expectScriptErrorClean("deploy-aws publish-only missing frontend dependencies is clean", "scripts/deploy-aws.mjs", [ + "--skip-backend", + "--skip-frontend", + "--publish-frontend-assets", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--frontend-stack-name=definitely-not-a-real-frontend-stack", + "--backend-stack-name=definitely-not-a-real-backend-stack", + ], "Frontend dependencies are not installed:")), results); + + runCase("publish-frontend-assets missing outputs file", () => expectScriptError("publish-frontend-assets missing outputs file", "scripts/publish-frontend-assets.mjs", [ + "--frontend-outputs-file=does-not-exist.json", + "--skip-build", + "--bucket=example-bucket", + "--distribution-id=EXAMPLE123", + ], 'Could not load frontend outputs file "does-not-exist.json"'), results); + + runCase("publish-frontend-assets missing build output in skip-build mode", () => withMissingFrontendBuildOutput(() => { + expectScriptError("publish-frontend-assets missing build output in skip-build mode", "scripts/publish-frontend-assets.mjs", [ + "--bucket=example-bucket", + "--distribution-id=EXAMPLE123", + "--skip-build", + ], "Build output not found:"); + }), results); + + runCase("publish-frontend-assets unreadable frontend stack", () => expectScriptError("publish-frontend-assets unreadable frontend stack", "scripts/publish-frontend-assets.mjs", [ + "--stack-name=definitely-not-a-real-frontend-stack", + ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'), results); + + runCase("publish-frontend-assets frontend outputs file works", () => withFakeFrontendBuildOutput(() => { + withFakeAwsForFrontendPublish((env) => { + const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--skip-build", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`publish-frontend-assets frontend outputs file run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + if (parsed.bucket !== "example-frontend-bucket") { + throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved bucket.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.distributionId !== "EXAMPLE123") { + throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved distribution.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.appUrl !== "https://admin.example.com") { + throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved app URL.\nSTDOUT:\n${result.stdout}`); + } + if (!parsed.frontendPublished || parsed.skipBuild !== true) { + throw new Error(`publish-frontend-assets frontend outputs file did not complete the skip-build publish flow cleanly.\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + runCase("publish-frontend-assets backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { + withFakeAwsForFrontendPublish((awsEnv) => { + const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ + "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", + "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", + "--output=json", + ], { + ...awsEnv, + PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, + }); + + if (result.status !== 0) { + throw new Error(`publish-frontend-assets backend outputs file build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const parsed = result.parsed || {}; + const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); + if (parsed.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`publish-frontend-assets did not expose REACT_APP_API_BASE from backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (parsed.backendBuildEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { + throw new Error(`publish-frontend-assets did not expose REACT_APP_SUPPORT_EMAIL from backend outputs.\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { + throw new Error(`publish-frontend-assets did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { + throw new Error(`publish-frontend-assets did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + if (capturedEnv.REACT_APP_STAGE !== "prod") { + throw new Error(`publish-frontend-assets did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); + } + }); + }), results); + + runCase("publish-frontend-assets skip-build ignores backend stack", () => withFakeFrontendBuildOutput(() => { + expectScriptErrorClean("publish-frontend-assets skip-build ignores backend stack", "scripts/publish-frontend-assets.mjs", [ + "--stack-name=definitely-not-a-real-frontend-stack", + "--backend-stack-name=definitely-not-a-real-backend-stack", + "--skip-build", + ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'); + }), results); + + runCase("publish-lambda-layer invalid source file", () => expectScriptErrorClean("publish-lambda-layer invalid source file", "scripts/publish-lambda-layer.mjs", [ + "--source-file=package.json", + "--layer-name=test-layer", + ], "Source file must be a .zip archive"), results); + + runCase("sync-app-config-secret lookup failure is clean", () => expectScriptErrorClean("sync-app-config-secret lookup failure is clean", "scripts/sync-app-config-secret.mjs", [ + "--secret-file=infrastructure/examples/app-config-secret.sample.json", + "--secret-name=test-secret", + ], 'Could not look up Secrets Manager secret "test-secret"'), results); + + runCase("sync-github-app-config-secret gh failure is clean", () => withFailingGhForSyncGithubAppConfigSecret((env) => { + const result = runScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ + "--environment=staging", + "--secret-file=infrastructure/examples/app-config-secret.sample.json", + "--repo=ChurchApps/B1Admin", + ], env); + + if (result.status === 0) { + throw new Error(`sync-github-app-config-secret unexpectedly succeeded during mocked gh failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again.")) { + throw new Error(`sync-github-app-config-secret failure did not surface the gh auth guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }), results); + + runCase("sync-github-app-config-secret gh network failure is clean", () => withNetworkFailingGhForPlanEnvironmentDeploy((env) => { + const result = runScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ + "--environment=staging", + "--secret-file=infrastructure/examples/app-config-secret.sample.json", + "--repo=ChurchApps/B1Admin", + ], env); + + if (result.status === 0) { + throw new Error(`sync-github-app-config-secret unexpectedly succeeded during mocked gh network failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("GitHub CLI could not reach github.com from this machine. Check network access and GitHub availability before syncing this secret from here.")) { + throw new Error(`sync-github-app-config-secret did not surface the gh connectivity guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + }), results); + + runCase("dispatch-github-aws-deploy dispatches workflow after secret sync", () => withFakeGhForDispatchGithubAwsDeploy(({ env, capturePath }) => { + const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-run"); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-run", + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy runtime verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-run", + "--deployment-source=package-manifest", + "--package-manifest-file=.tmp-dispatch-github-deploy-run/package-manifest.json", + "--repo=ChurchApps/B1Admin", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`dispatch-github-aws-deploy runtime verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + const secretCapture = captures.find((entry) => entry.kind === "secret"); + const workflowCapture = captures.find((entry) => entry.kind === "workflow"); + + if (actual.action !== "dispatched" || actual.secretSync?.performed !== true) { + throw new Error(`dispatch-github-aws-deploy should report a real dispatch after syncing the GitHub secret.\nSTDOUT:\n${result.stdout}`); + } + if (actual.previewOnly !== false || actual.workflowInputs?.preview_only !== "false") { + throw new Error(`dispatch-github-aws-deploy should preserve the default non-preview workflow input.\nSTDOUT:\n${result.stdout}`); + } + if (!String(actual.followUpCommands?.watchLatestRun || "").includes("gh run watch $(") + || !String(actual.followUpCommands?.viewLatestRun || "").includes("gh run view $(")) { + throw new Error(`dispatch-github-aws-deploy should expose follow-up commands for the latest GitHub Actions run.\nSTDOUT:\n${result.stdout}`); + } + if (!secretCapture || !workflowCapture) { + throw new Error(`dispatch-github-aws-deploy should call both gh secret set and gh workflow run.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + if (!secretCapture.args.includes("--env") || !secretCapture.args.includes("aws-staging")) { + throw new Error(`dispatch-github-aws-deploy did not sync the expected GitHub environment secret.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + if (!workflowCapture.args.includes("--repo") || !workflowCapture.args.includes("ChurchApps/B1Admin")) { + throw new Error(`dispatch-github-aws-deploy did not dispatch the workflow against the expected repository.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + if (!workflowCapture.args.includes("-f") || !workflowCapture.args.includes("sync_app_config_secret=true")) { + throw new Error(`dispatch-github-aws-deploy did not enable sync_app_config_secret in the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + if (!workflowCapture.args.includes("preview_only=false")) { + throw new Error(`dispatch-github-aws-deploy did not pass preview_only=false into the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), results); + + runCase("dispatch-github-aws-deploy can dispatch preview-only mode", () => withFakeGhForDispatchGithubAwsDeploy(({ env, capturePath }) => { + const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-preview-only"); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-preview-only", + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy preview-only verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-preview-only", + "--deployment-source=package-manifest", + "--package-manifest-file=.tmp-dispatch-github-deploy-preview-only/package-manifest.json", + "--repo=ChurchApps/B1Admin", + "--preview-only=true", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`dispatch-github-aws-deploy preview-only verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + const workflowCapture = captures.find((entry) => entry.kind === "workflow"); + + if (actual.previewOnly !== true || actual.workflowInputs?.preview_only !== "true") { + throw new Error(`dispatch-github-aws-deploy should expose preview-only mode in its JSON result.\nSTDOUT:\n${result.stdout}`); + } + if (!workflowCapture || !workflowCapture.args.includes("preview_only=true")) { + throw new Error(`dispatch-github-aws-deploy did not pass preview_only=true into the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), results); + + runCase("dispatch-github-aws-deploy gh auth failure is clean", () => withFailingGhForDispatchGithubAwsDeploy((env) => { + const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-auth-fail"); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-auth-fail", + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy auth failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const result = runScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-auth-fail", + "--deployment-source=package-manifest", + "--package-manifest-file=.tmp-dispatch-github-deploy-auth-fail/package-manifest.json", + "--repo=ChurchApps/B1Admin", + ], env); + + if (result.status === 0) { + throw new Error(`dispatch-github-aws-deploy unexpectedly succeeded during mocked gh auth failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again.")) { + throw new Error(`dispatch-github-aws-deploy did not surface the gh auth guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), results); + + runCase("dispatch-github-aws-deploy gh network failure is clean", () => withNetworkFailingGhForPlanEnvironmentDeploy((env) => { + const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-network-fail"); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-network-fail", + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy network failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const result = runScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-network-fail", + "--deployment-source=package-manifest", + "--package-manifest-file=.tmp-dispatch-github-deploy-network-fail/package-manifest.json", + "--repo=ChurchApps/B1Admin", + ], env); + + if (result.status === 0) { + throw new Error(`dispatch-github-aws-deploy unexpectedly succeeded during mocked gh network failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (!combined.includes("GitHub CLI could not reach github.com from this machine. Check network access and GitHub availability before dispatching the workflow from here.")) { + throw new Error(`dispatch-github-aws-deploy did not surface the gh connectivity guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), results); + + runCase("dispatch-github-aws-deploy can skip gh auth check explicitly", () => withFailingGhForDispatchGithubAwsDeploy((env) => { + const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-skip-auth"); + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + for (const fileName of [ + "bootstrap-parameters.json", + "backend-parameters.json", + "frontend-parameters.json", + "app-config-secret.template.json", + ]) { + fs.copyFileSync( + path.join(rootDir, "infrastructure", "environments", "staging", fileName), + path.join(tempDir, fileName), + ); + } + + const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-skip-auth", + "--account-id=123456789012", + "--write=true", + "--output=json", + ]); + if (prepareResult.status !== 0) { + throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy skip-auth verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); + } + + replaceStarterBackendDefaults(tempDir, "staging"); + fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); + + const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ + "--environment=staging", + "--environment-dir=.tmp-dispatch-github-deploy-skip-auth", + "--deployment-source=package-manifest", + "--package-manifest-file=.tmp-dispatch-github-deploy-skip-auth/package-manifest.json", + "--repo=ChurchApps/B1Admin", + "--dry-run=true", + "--skip-gh-auth-check=true", + "--output=json", + ], env); + + if (result.status !== 0) { + throw new Error(`dispatch-github-aws-deploy skip-gh-auth-check verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); + } + + const actual = result.parsed || {}; + if (actual.action !== "validated" || actual.secretSync?.attempted !== true) { + throw new Error(`dispatch-github-aws-deploy should still produce a dry-run validation result when skip-gh-auth-check=true.\nSTDOUT:\n${result.stdout}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), results); + + runCase("upload-backend-artifact unreadable bootstrap stack", () => expectScriptError("upload-backend-artifact unreadable bootstrap stack", "scripts/upload-backend-artifact.mjs", [ + "--source-file=package.json", + "--artifact-key=test.zip", + "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", + ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); + + runCase("sync-legacy-ssm unreadable stack", () => expectScriptErrorClean("sync-legacy-ssm unreadable stack", "scripts/sync-legacy-ssm-parameters.mjs", [ + "--stack-name=test", + "--environment=prod", + "--dry-run=true", + ], 'Could not read stack "test"'), results); + + const failed = results.filter((result) => !result.ok); + const summary = { + ok: failed.length === 0, + total: results.length, + passed: results.length - failed.length, + failed: failed.length, + results, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (failed.length > 0) process.exit(1); + return; + } + + if (failed.length > 0) { + failed.forEach((result) => process.stderr.write(`FAILED: ${result.name}\n${result.error}\n\n`)); + process.exit(1); + } + + process.stdout.write("AWS tooling smoke checks passed.\n"); +} + +main(); diff --git a/scripts/sync-app-config-secret.mjs b/scripts/sync-app-config-secret.mjs new file mode 100644 index 000000000..e808c5a3d --- /dev/null +++ b/scripts/sync-app-config-secret.mjs @@ -0,0 +1,217 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function runJson(command, args) { + try { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`AWS command failed: ${message}`); + } +} + +function tryRunJson(command, args) { + try { + return { + ok: true, + value: JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })), + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + stderr: error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : "", + }; + } +} + +function parseAndValidateSecret(secretString, sourceLabel) { + let parsed; + try { + parsed = JSON.parse(secretString); + } catch (error) { + console.error(`Secret file is not valid JSON: ${sourceLabel}`); + process.exit(1); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error(`Secret file must contain a JSON object: ${sourceLabel}`); + process.exit(1); + } + + const requiredKeys = ["jwtSecret", "encryptionKey"]; + const missingRequiredKeys = requiredKeys.filter((key) => typeof parsed[key] !== "string" || parsed[key].trim() === ""); + if (missingRequiredKeys.length > 0) { + console.error(`Secret file is missing required non-empty string keys: ${missingRequiredKeys.join(", ")}`); + process.exit(1); + } + + return parsed; +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const secretFile = getArg("secret-file"); + const secretName = getArg("secret-name"); + const secretId = getArg("secret-id"); + const description = getArg("description", "B1Admin backend app config"); + const kmsKeyId = getArg("kms-key-id"); + const outputMode = getArg("output", "text"); + + requireValue("secret-file", secretFile); + if (!secretName && !secretId) { + console.error("Missing required value: secret-name or secret-id"); + process.exit(1); + } + + const resolvedSecretFile = path.resolve(rootDir, secretFile); + if (!fs.existsSync(resolvedSecretFile)) { + console.error(`Secret file not found: ${resolvedSecretFile}`); + process.exit(1); + } + + const secretString = fs.readFileSync(resolvedSecretFile, "utf8"); + parseAndValidateSecret(secretString, resolvedSecretFile); + + const lookupId = secretId || secretName; + const describe = tryRunJson("aws", [ + "secretsmanager", + "describe-secret", + "--secret-id", + lookupId, + "--region", + region, + "--output", + "json", + ]); + + const notFound = !describe.ok && ( + describe.error.includes("ResourceNotFoundException") || + describe.stderr.includes("ResourceNotFoundException") || + describe.error.includes("can't find the specified secret") || + describe.stderr.includes("can't find the specified secret") + ); + + let response; + if (describe.ok) { + try { + response = JSON.parse(execFileSync("aws", [ + "secretsmanager", + "put-secret-value", + "--secret-id", + describe.value.ARN || lookupId, + "--secret-string", + secretString, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not update Secrets Manager secret "${describe.value.Name || lookupId}": ${message}`); + } + response = { + action: "updated", + arn: describe.value.ARN, + name: describe.value.Name, + versionId: response.VersionId, + }; + } else if (!notFound) { + fail(`Could not look up Secrets Manager secret "${lookupId}": ${describe.error}`); + } else { + requireValue("secret-name", secretName); + const args = [ + "secretsmanager", + "create-secret", + "--name", + secretName, + "--description", + description, + "--secret-string", + secretString, + "--region", + region, + "--output", + "json", + ]; + if (kmsKeyId) args.push("--kms-key-id", kmsKeyId); + let created; + try { + created = JSON.parse(execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not create Secrets Manager secret "${secretName}": ${message}`); + } + response = { + action: "created", + arn: created.ARN, + name: created.Name, + versionId: created.VersionId, + }; + } + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + return; + } + + console.log("\nApp config secret sync complete."); + console.log(`Action: ${response.action}`); + console.log(`Secret name: ${response.name}`); + console.log(`Secret ARN: ${response.arn}`); + console.log(`Version ID: ${response.versionId}`); +} + +main(); diff --git a/scripts/sync-github-app-config-secret.mjs b/scripts/sync-github-app-config-secret.mjs new file mode 100644 index 000000000..55a5001a6 --- /dev/null +++ b/scripts/sync-github-app-config-secret.mjs @@ -0,0 +1,176 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getGithubCliReadiness } from "./lib/github-cli-readiness.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function parseAndValidateSecret(secretString, sourceLabel) { + let parsed; + try { + parsed = JSON.parse(secretString); + } catch { + fail(`Secret file is not valid JSON: ${sourceLabel}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + fail(`Secret file must contain a JSON object: ${sourceLabel}`); + } + + const requiredKeys = ["jwtSecret", "encryptionKey"]; + const missingRequiredKeys = requiredKeys.filter((key) => typeof parsed[key] !== "string" || parsed[key].trim() === ""); + if (missingRequiredKeys.length > 0) { + fail(`Secret file is missing required non-empty string keys: ${missingRequiredKeys.join(", ")}`); + } + + return parsed; +} + +function runGhSecretSet(args) { + try { + execFileSync("gh", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + fail("GitHub CLI is not installed or not available on PATH."); + } + + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr || "") : ""; + const message = error instanceof Error ? error.message : String(error); + fail(`GitHub CLI secret sync failed: ${stderr.trim() || message}`); + } +} + +function ensureGhAuth() { + const readiness = getGithubCliReadiness({ + cwd: rootDir, + connectivityAction: "syncing this secret from here.", + }); + + if (!readiness.ok) { + fail(readiness.blockers[0] || "GitHub CLI auth check failed."); + } +} + +function main() { + const deploymentEnvironment = getArg("environment"); + const githubEnvironment = getArg("github-environment") || (deploymentEnvironment ? `aws-${deploymentEnvironment}` : ""); + const secretFile = getArg("secret-file"); + const secretName = getArg("secret-name", "AWS_APP_CONFIG_SECRET_JSON"); + const repo = getArg("repo"); + const outputMode = getArg("output", "text").toLowerCase(); + const dryRun = getArg("dry-run", "false").toLowerCase() === "true"; + const skipGhAuthCheck = getArg("skip-gh-auth-check", "false").toLowerCase() === "true"; + + requireValue("secret-file", secretFile); + requireValue("github-environment or environment", githubEnvironment); + + const resolvedSecretFile = path.resolve(rootDir, secretFile); + if (!fs.existsSync(resolvedSecretFile)) { + fail(`Secret file not found: ${resolvedSecretFile}`); + } + + const secretString = fs.readFileSync(resolvedSecretFile, "utf8"); + const parsedSecret = parseAndValidateSecret(secretString, resolvedSecretFile); + const normalizedSecret = JSON.stringify(parsedSecret); + const relativeSecretFile = path.relative(rootDir, resolvedSecretFile) || path.basename(resolvedSecretFile); + const commandPreviewParts = [ + "gh secret set", + shellQuote(secretName), + "--env", + shellQuote(githubEnvironment), + "--app", + shellQuote("actions"), + ]; + if (repo) { + commandPreviewParts.push("--repo", shellQuote(repo)); + } + const commandPreview = `${commandPreviewParts.join(" ")} < ${shellQuote(relativeSecretFile)}`; + + if (!skipGhAuthCheck) { + ensureGhAuth(); + } + + if (!dryRun) { + const ghArgs = [ + "secret", + "set", + secretName, + "--env", + githubEnvironment, + "--app", + "actions", + "--body", + normalizedSecret, + ]; + if (repo) { + ghArgs.push("--repo", repo); + } + runGhSecretSet(ghArgs); + } + + const response = { + action: dryRun ? "validated" : "stored", + secretName, + githubEnvironment, + repo: repo || null, + scope: "environment", + app: "actions", + sourceFile: relativeSecretFile, + keyCount: Object.keys(parsedSecret).length, + normalizedJsonLength: normalizedSecret.length, + commandPreview, + }; + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + return; + } + + console.log("\nGitHub app-config secret sync complete."); + console.log(`Action: ${response.action}`); + console.log(`Secret name: ${response.secretName}`); + console.log(`GitHub environment: ${response.githubEnvironment}`); + console.log(`Repository: ${response.repo || "(current repository)"}`); + console.log(`Source file: ${response.sourceFile}`); + console.log(`JSON keys: ${response.keyCount}`); + console.log(`Reusable command: ${response.commandPreview}`); +} + +main(); diff --git a/scripts/sync-legacy-ssm-parameters.mjs b/scripts/sync-legacy-ssm-parameters.mjs new file mode 100644 index 000000000..1e11e2b06 --- /dev/null +++ b/scripts/sync-legacy-ssm-parameters.mjs @@ -0,0 +1,281 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function parseBoolean(value, fallback) { + if (value === "") return fallback; + return value.toLowerCase() === "true"; +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function exitForCommandError(error) { + if (error && typeof error === "object") { + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function run(command, args) { + console.log(`\n> ${command} ${args.join(" ")}`); + try { + execFileSync(command, args, { + cwd: rootDir, + stdio: "inherit", + }); + } catch (error) { + exitForCommandError(error); + } +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region) { + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read stack "${stackName}": ${message}`); + } +} + +function readJsonFile(filePath) { + const resolved = path.resolve(rootDir, filePath); + if (!fs.existsSync(resolved)) { + console.error(`JSON file not found: ${resolved}`); + process.exit(1); + } + try { + return JSON.parse(fs.readFileSync(resolved, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not load JSON file "${resolved}": ${message}`); + } +} + +function getSecretJson(secretId, region) { + let result; + try { + result = runJson("aws", [ + "secretsmanager", + "get-secret-value", + "--secret-id", + secretId, + "--region", + region, + "--output", + "json", + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read Secrets Manager secret "${secretId}": ${message}`); + } + + if (!result.SecretString) { + fail(`Secret does not contain SecretString: ${secretId}`); + } + + try { + return JSON.parse(result.SecretString); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`SecretString for "${secretId}" is not valid JSON: ${message}`); + } +} + +function buildMysqlConnectionString({ username, password, host, port, database }) { + return `mysql://${username}:${password}@${host}:${port}/${database}`; +} + +function compactParameters(entries, includeEmpty) { + return entries.filter((entry) => includeEmpty || entry.value !== ""); +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name"); + const environment = getArg("environment"); + const prefix = getArg("prefix", `/${environment}`); + const appConfigSecretFile = getArg("app-config-secret-file"); + const appConfigSecretArn = getArg("app-config-secret-arn"); + const includeEmpty = parseBoolean(getArg("include-empty", "false"), false); + const overwrite = parseBoolean(getArg("overwrite", "true"), true); + const dryRun = parseBoolean(getArg("dry-run", "false"), false); + const outputMode = getArg("output", "text"); + + requireValue("stack-name", stackName); + requireValue("environment", environment); + + const outputs = getStackOutputsSafe(stackName, region); + const requiredOutputKeys = [ + "DatabaseEndpoint", + "DatabasePort", + "DatabaseSecretArn", + "MembershipDatabaseName", + "AttendanceDatabaseName", + "ContentDatabaseName", + "GivingDatabaseName", + "MessagingDatabaseName", + "DoingDatabaseName", + "ReportingDatabaseName", + ]; + + for (const key of requiredOutputKeys) { + requireValue(`stack output ${key}`, outputs[key] || ""); + } + + const dbSecret = getSecretJson(outputs.DatabaseSecretArn, region); + requireValue("database secret username", dbSecret.username || ""); + requireValue("database secret password", dbSecret.password || ""); + + let appConfig = {}; + if (appConfigSecretFile) appConfig = readJsonFile(appConfigSecretFile); + else if (appConfigSecretArn) appConfig = getSecretJson(appConfigSecretArn, region); + else if (outputs.AppConfigSecretArn) appConfig = getSecretJson(outputs.AppConfigSecretArn, region); + + const dbBase = { + username: dbSecret.username, + password: dbSecret.password, + host: outputs.DatabaseEndpoint, + port: outputs.DatabasePort, + }; + + const parameters = compactParameters([ + { name: `${prefix}/jwtSecret`, value: appConfig.jwtSecret || "" }, + { name: `${prefix}/encryptionKey`, value: appConfig.encryptionKey || "" }, + { name: `${prefix}/membershipApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.MembershipDatabaseName }) }, + { name: `${prefix}/attendanceApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.AttendanceDatabaseName }) }, + { name: `${prefix}/contentApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.ContentDatabaseName }) }, + { name: `${prefix}/givingApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.GivingDatabaseName }) }, + { name: `${prefix}/messagingApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.MessagingDatabaseName }) }, + { name: `${prefix}/doingApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.DoingDatabaseName }) }, + { name: `${prefix}/reportingApi/connectionString`, value: buildMysqlConnectionString({ ...dbBase, database: outputs.ReportingDatabaseName }) }, + { name: `${prefix}/hubspotKey`, value: appConfig.hubspotKey || "" }, + { name: `${prefix}/mauticUrl`, value: appConfig.mauticUrl || "" }, + { name: `${prefix}/mauticUser`, value: appConfig.mauticUser || "" }, + { name: `${prefix}/mauticPassword`, value: appConfig.mauticPassword || "" }, + { name: `${prefix}/caddyHost`, value: appConfig.caddyHost || "" }, + { name: `${prefix}/caddyPort`, value: appConfig.caddyPort || "" }, + { name: `${prefix}/youTubeApiKey`, value: appConfig.youTubeApiKey || "" }, + { name: `${prefix}/pexelsKey`, value: appConfig.pexelsKey || "" }, + { name: `${prefix}/vimeoToken`, value: appConfig.vimeoToken || "" }, + { name: `${prefix}/apiBibleKey`, value: appConfig.apiBibleKey || "" }, + { name: `${prefix}/youVersionApiKey`, value: appConfig.youVersionApiKey || "" }, + { name: `${prefix}/praiseChartsConsumerKey`, value: appConfig.praiseChartsConsumerKey || "" }, + { name: `${prefix}/praiseChartsConsumerSecret`, value: appConfig.praiseChartsConsumerSecret || "" }, + { name: `${prefix}/recaptcha-secret-key`, value: appConfig.googleRecaptchaSecretKey || "" }, + { name: `${prefix}/openRouterApiKey`, value: appConfig.openRouterApiKey || "" }, + { name: `${prefix}/openAiApiKey`, value: appConfig.openAiApiKey || "" }, + { name: `${prefix}/webPushPublicKey`, value: appConfig.webPushPublicKey || "" }, + { name: `${prefix}/webPushPrivateKey`, value: appConfig.webPushPrivateKey || "" }, + { name: `${prefix}/webPushSubject`, value: appConfig.webPushSubject || "" }, + ], includeEmpty); + + const result = { + stackName, + region, + environment, + prefix, + overwrite, + dryRun, + parameterCount: parameters.length, + parameters, + }; + + if (outputMode === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + if (dryRun) { + console.log("\nLegacy SSM parameter sync dry run."); + console.log(`Stack: ${stackName}`); + console.log(`Prefix: ${prefix}`); + console.log(`Parameters: ${parameters.length}`); + parameters.forEach((parameter) => console.log(`- ${parameter.name}`)); + return; + } + + for (const parameter of parameters) { + run("aws", [ + "ssm", + "put-parameter", + "--name", + parameter.name, + "--value", + parameter.value, + "--type", + "SecureString", + "--region", + region, + ...(overwrite ? ["--overwrite"] : []), + ]); + } + + console.log("\nLegacy SSM parameter sync complete."); + console.log(`Stack: ${stackName}`); + console.log(`Prefix: ${prefix}`); + console.log(`Parameters synced: ${parameters.length}`); +} + +main(); diff --git a/scripts/upload-backend-artifact.mjs b/scripts/upload-backend-artifact.mjs new file mode 100644 index 000000000..3c8f7b439 --- /dev/null +++ b/scripts/upload-backend-artifact.mjs @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function exitForCommandError(error, quiet = false) { + if (error && typeof error === "object") { + if (quiet && error.stdout) process.stderr.write(String(error.stdout)); + if (quiet && error.stderr) process.stderr.write(String(error.stderr)); + const status = typeof error.status === "number" ? error.status : 1; + process.exit(status); + } + + process.exit(1); +} + +function run(command, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + + try { + return execFileSync(command, args, { + cwd: rootDir, + stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", + encoding: quiet ? "utf8" : undefined, + maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, + ...execOptions, + }); + } catch (error) { + exitForCommandError(error, quiet); + } +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function requireValue(name, value) { + if (!value) { + console.error(`Missing required value: ${name}`); + process.exit(1); + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region, label) { + if (!stackName) return {}; + + try { + return getStackOutputs(stackName, region); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const bootstrapStackName = getArg("bootstrap-stack-name"); + const sourceFile = getArg("source-file"); + const bucketArg = getArg("artifact-bucket"); + const key = getArg("artifact-key"); + const artifactLabel = getArg("artifact-label", "Backend artifact"); + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + + requireValue("source-file", sourceFile); + + const resolvedSource = path.resolve(rootDir, sourceFile); + if (!fs.existsSync(resolvedSource)) { + console.error(`Source file not found: ${resolvedSource}`); + process.exit(1); + } + + const bootstrapOutputs = getStackOutputsSafe(bootstrapStackName, region, "bootstrap stack"); + const bucket = bucketArg || bootstrapOutputs.ArtifactBucketName || ""; + + requireValue("artifact-bucket or bootstrap-stack-name", bucket); + requireValue("artifact-key", key); + + run("aws", [ + "s3", + "cp", + resolvedSource, + `s3://${bucket}/${key}`, + "--region", + region, + ], { quiet: jsonOutput }); + + const result = { + artifactLabel, + region, + bucket, + key, + sourceFile: resolvedSource, + s3Uri: `s3://${bucket}/${key}`, + bootstrapStackName, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + console.log(`\n${artifactLabel} upload complete.`); + console.log(`Bucket: ${bucket}`); + console.log(`Key: ${key}`); + console.log(`S3 URI: s3://${bucket}/${key}`); +} + +main(); diff --git a/scripts/validate-aws-deploy.mjs b/scripts/validate-aws-deploy.mjs new file mode 100644 index 000000000..8372d2ba1 --- /dev/null +++ b/scripts/validate-aws-deploy.mjs @@ -0,0 +1,1470 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function runJson(command, args) { + return JSON.parse(execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); +} + +function run(command, args) { + return execFileSync(command, args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function getStackOutputs(stackName, region) { + const response = runJson("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ]); + + return normalizeOutputs(response); +} + +function getStackOutputsSafe(stackName, region) { + if (!stackName) { + return { ok: true, outputs: {} }; + } + + try { + return { + ok: true, + outputs: getStackOutputs(stackName, region), + }; + } catch (error) { + return { + ok: false, + outputs: {}, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function loadParamsFile(filePath) { + if (!filePath) return {}; + const resolved = path.resolve(rootDir, filePath); + const data = JSON.parse(fs.readFileSync(resolved, "utf8")); + if (Array.isArray(data)) return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); + return data; +} + +function loadParamsFileSafe(filePath, label, errors) { + if (!filePath) return {}; + + try { + return loadParamsFile(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${label} could not be loaded: ${message}`); + return {}; + } +} + +function getValue(name, params = {}) { + const cli = getArg(name); + if (cli !== "") return cli; + + const camelKey = name + .split("-") + .map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + const pascalKey = camelKey.charAt(0).toUpperCase() + camelKey.slice(1); + return params[pascalKey] ?? params[camelKey] ?? ""; +} + +function getEnvironmentValue(params = {}) { + return getValue("environment", params) || getValue("environment-name", params); +} + +function existsLocalFile(filePath) { + return filePath ? fs.existsSync(path.resolve(rootDir, filePath)) : false; +} + +function canReadPath(targetPath) { + try { + fs.accessSync(targetPath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function readJsonIfExists(filePath) { + if (!filePath) return null; + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function tryReadJsonIfExists(filePath) { + if (!filePath) return { data: null, error: "" }; + if (!fs.existsSync(filePath)) return { data: null, error: "" }; + + try { + return { + data: JSON.parse(fs.readFileSync(filePath, "utf8")), + error: "", + }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function tryReadTextIfExists(filePath) { + if (!filePath) return { data: "", error: "" }; + if (!fs.existsSync(filePath)) return { data: "", error: "" }; + + try { + return { + data: fs.readFileSync(filePath, "utf8"), + error: "", + }; + } catch (error) { + return { + data: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function tryExec(command, args) { + try { + return { ok: true, output: run(command, args) }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function resolveManifestArtifactPath(manifestFilePath, artifactPath) { + if (!artifactPath) return ""; + if (path.isAbsolute(artifactPath)) return artifactPath; + return path.resolve(path.dirname(manifestFilePath), artifactPath); +} + +function validateLocalAppConfigSecret(filePath) { + const resolved = path.resolve(rootDir, filePath); + const raw = fs.readFileSync(resolved, "utf8"); + const parsed = JSON.parse(raw); + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Secret file must contain a JSON object."); + } + + const requiredKeys = ["jwtSecret", "encryptionKey"]; + const missingRequiredKeys = requiredKeys.filter((key) => typeof parsed[key] !== "string" || parsed[key].trim() === ""); + if (missingRequiredKeys.length > 0) { + throw new Error(`Missing required non-empty string keys: ${missingRequiredKeys.join(", ")}`); + } + + return parsed; +} + +function deriveArtifactKey(projectName, environmentName, fileName) { + return `${projectName}/${environmentName}/backend/${fileName}`; +} + +function validateS3BucketName(label, value, errors) { + if (!value) return; + + const looksLikeIpv4Address = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value); + const validCharacters = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value); + const hasAdjacentPeriods = value.includes(".."); + const hasDashPeriodCombo = value.includes("-.") || value.includes(".-"); + + if (value.length < 3 || value.length > 63 || looksLikeIpv4Address || !validCharacters || hasAdjacentPeriods || hasDashPeriodCombo) { + errors.push(`${label} must be a valid S3 bucket name when provided explicitly.`); + } +} + +function validateApiMigrationDbSecretObject(parsedSecret, errors) { + if (!parsedSecret || typeof parsedSecret !== "object" || Array.isArray(parsedSecret)) { + errors.push("API migration DB secret file must contain a JSON object."); + return; + } + + if (typeof parsedSecret.username !== "string" || parsedSecret.username.trim() === "") { + errors.push("API migration DB secret file is missing a non-empty username."); + } + + if (typeof parsedSecret.password !== "string" || parsedSecret.password.trim() === "") { + errors.push("API migration DB secret file is missing a non-empty password."); + } +} + +function getApiMigrationRequiredOutputKeys(moduleName) { + const moduleOutputKeys = { + membership: "MembershipDatabaseName", + attendance: "AttendanceDatabaseName", + content: "ContentDatabaseName", + giving: "GivingDatabaseName", + messaging: "MessagingDatabaseName", + doing: "DoingDatabaseName", + reporting: "ReportingDatabaseName", + }; + + const baseKeys = ["DatabaseEndpoint", "DatabasePort"]; + if (moduleName === "all") { + return [...baseKeys, ...Object.values(moduleOutputKeys)]; + } + + return [...baseKeys, moduleOutputKeys[moduleName]].filter(Boolean); +} + +function buildApiMigrationCommand(runner, args) { + if (runner === "data-api") { + return `node scripts/run-api-migrations-data-api.mjs ${args.join(" ")}`; + } + + return `yarn run:api-migrations -- ${args.join(" ")}`; +} + +function loadApiRepoMigrationModules(apiRepoPath) { + const kyselyConfigPath = path.join(apiRepoPath, "tools", "kysely-config.ts"); + if (!fs.existsSync(kyselyConfigPath)) return []; + + try { + const source = fs.readFileSync(kyselyConfigPath, "utf8"); + const match = source.match(/const\s+MODULES\s*=\s*\[(.*?)\]\s+as const/s); + if (!match) return []; + + return Array.from(match[1].matchAll(/"([^"]+)"/g)).map((item) => item[1]); + } catch { + return []; + } +} + +function loadApiRepoMigrationDirectories(apiRepoPath) { + const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); + if (!fs.existsSync(migrationsRoot)) return []; + + try { + return fs.readdirSync(migrationsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch { + return []; + } +} + +function main() { + const errors = []; + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const stackName = getArg("stack-name"); + const bootstrapStackName = getArg("bootstrap-stack-name"); + const backendParametersFile = getArg("backend-parameters-file"); + const frontendParametersFile = getArg("frontend-parameters-file"); + const parametersFile = getArg("parameters-file"); + const fullStackParametersFile = parametersFile; + const mode = getArg("mode", "full-stack"); + const splitStackMode = mode === "split-stack" || mode === "aws"; + const frontendPublishMode = mode === "frontend-publish" || mode === "publish-frontend"; + const bootstrapMode = mode === "bootstrap"; + const apiMigrationsMode = mode === "api-migrations" || mode === "run-api-migrations"; + const apiRepoPathArg = getArg("api-repo-path"); + const packageManifestFile = getArg("package-manifest-file"); + const packageManifestPath = packageManifestFile ? path.resolve(rootDir, packageManifestFile) : ""; + const packageManifestResult = tryReadJsonIfExists(packageManifestPath); + const packageManifest = packageManifestResult.data; + const packageManifestBackendArtifactPath = resolveManifestArtifactPath(packageManifestPath, packageManifest?.backendArtifactPath || ""); + const packageManifestMigrationArtifactPath = resolveManifestArtifactPath(packageManifestPath, packageManifest?.migrationArtifactPath || ""); + const packageManifestDependenciesLayerArtifactPath = resolveManifestArtifactPath(packageManifestPath, packageManifest?.dependenciesLayerArtifactPath || ""); + const packageApiBackend = !apiMigrationsMode && !packageManifestFile && (apiRepoPathArg !== "" || getArg("package-api-backend", "false").toLowerCase() === "true"); + const packageMode = getArg("package-mode", "self-contained"); + const checkAws = getArg("check-aws", "false").toLowerCase() === "true"; + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + const infrastructureOnly = process.argv.includes("--infrastructure-only"); + const frontendInfrastructureOnly = process.argv.includes("--frontend-infrastructure-only"); + const skipInfrastructure = process.argv.includes("--skip-infrastructure"); + const publishFrontendAssets = process.argv.includes("--publish-frontend-assets"); + const skipBackend = process.argv.includes("--skip-backend"); + const skipFrontend = process.argv.includes("--skip-frontend"); + const backendParams = loadParamsFileSafe(backendParametersFile, "Backend parameters file", errors); + const frontendParams = loadParamsFileSafe(frontendParametersFile, "Frontend parameters file", errors); + const fullStackParams = loadParamsFileSafe(parametersFile, "Parameters file", errors); + const apiRepoPath = apiRepoPathArg ? path.resolve(rootDir, apiRepoPathArg) : ""; + const apiRepoPackageResult = tryReadJsonIfExists(apiRepoPath ? path.join(apiRepoPath, "package.json") : ""); + const apiRepoPackage = apiRepoPackageResult.data; + const apiRepoLayerPackageResult = tryReadJsonIfExists(apiRepoPath ? path.join(apiRepoPath, "tools", "layer-package.json") : ""); + const apiRepoLayerPackage = apiRepoLayerPackageResult.data; + const apiRepoServerlessResult = tryReadTextIfExists(apiRepoPath ? path.join(apiRepoPath, "serverless.yml") : ""); + const apiRepoServerlessText = apiRepoServerlessResult.data; + const splitStackPublishOnly = splitStackMode && publishFrontendAssets && skipBackend && skipFrontend; + const fullStackPublishOnly = mode === "full-stack" && skipInfrastructure && publishFrontendAssets; + const backendRelevant = !bootstrapMode && mode !== "frontend" && !frontendPublishMode && !apiMigrationsMode && !fullStackPublishOnly && !splitStackPublishOnly; + const params = mode === "backend" + ? backendParams + : mode === "frontend" + ? frontendParams + : bootstrapMode + ? fullStackParams + : splitStackMode + ? backendParams + : fullStackParams; + const projectName = getValue("project-name", params) || frontendParams.ProjectName || "b1admin"; + const environmentName = getEnvironmentValue(params) || frontendParams.EnvironmentName || "prod"; + const frontendProjectName = getValue("project-name", frontendParams) || projectName; + const frontendEnvironmentName = getEnvironmentValue(frontendParams) || environmentName; + const frontendParamMode = mode === "frontend" || splitStackMode; + const frontendValidationParams = mode === "frontend" + ? frontendParams + : splitStackMode + ? frontendParams + : params; + const frontendOutputsFile = getArg("frontend-outputs-file"); + const frontendPublishStackName = getArg("stack-name"); + const frontendPublishBucket = getArg("bucket"); + const frontendPublishDistributionId = getArg("distribution-id"); + const frontendOutputsFileResult = tryReadJsonIfExists(frontendOutputsFile ? path.resolve(rootDir, frontendOutputsFile) : ""); + const frontendOutputsFromFile = normalizeOutputs(frontendOutputsFileResult.data); + const frontendOutputsFileBucket = frontendOutputsFromFile.SiteBucketName || frontendOutputsFromFile.FrontendBucketName || ""; + const frontendOutputsFileDistributionId = frontendOutputsFromFile.CloudFrontDistributionId || frontendOutputsFromFile.FrontendDistributionId || ""; + const backendOutputsFile = getArg("backend-outputs-file"); + const skipBuild = process.argv.includes("--skip-build"); + let fullStackPublishOutputs = {}; + const apiMigrationStackName = getArg("stack-name"); + const apiMigrationOutputsFile = getArg("outputs-file"); + + const shouldLookupBootstrapStack = Boolean( + bootstrapStackName + && !bootstrapMode + && !frontendPublishMode + && !apiMigrationsMode + && !fullStackPublishOnly + && !splitStackPublishOnly + ); + const bootstrapStackLookup = shouldLookupBootstrapStack + ? getStackOutputsSafe(bootstrapStackName, region) + : { ok: true, outputs: {} }; + const bootstrapOutputs = bootstrapStackLookup.outputs; + const templateBucket = bootstrapMode + ? getValue("template-bucket-name", params) + : getValue("template-bucket", params) || bootstrapOutputs.TemplateBucketName || ""; + const artifactBucket = bootstrapMode + ? getValue("artifact-bucket-name", params) + : getValue("lambda-code-s3-bucket", params) || bootstrapOutputs.ArtifactBucketName || ""; + const artifactSource = getArg("backend-artifact-source-file"); + const resolvedArtifactSource = artifactSource || packageManifestBackendArtifactPath; + const explicitArtifactKey = getValue("lambda-code-s3-key", params) || getArg("backend-artifact-key"); + const artifactKey = explicitArtifactKey || ((resolvedArtifactSource || packageApiBackend || packageManifestFile) ? deriveArtifactKey(projectName, environmentName, "api.zip") : ""); + const dependenciesLayerSource = getArg("dependencies-layer-source-file"); + const resolvedDependenciesLayerSource = dependenciesLayerSource || packageManifestDependenciesLayerArtifactPath; + const appConfigSecretFile = getArg("app-config-secret-file"); + const syncLegacySsm = getArg("sync-legacy-ssm", "false").toLowerCase() === "true"; + const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; + const standaloneMigrationActionArg = apiMigrationsMode ? getArg("action") : ""; + const standaloneMigrationModuleArg = apiMigrationsMode ? getArg("module") : ""; + const apiMigrationAction = getArg("api-migration-action", standaloneMigrationActionArg || "up"); + const apiMigrationModule = getArg("api-migration-module", standaloneMigrationModuleArg || "all"); + const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationApiRepoPathArg = getArg("api-migration-api-repo-path", apiRepoPathArg || "../Api"); + const apiMigrationApiRepoPath = path.resolve(rootDir, apiMigrationApiRepoPathArg); + const standaloneMigrationDbSecretArn = apiMigrationsMode ? getArg("db-secret-arn") : ""; + const standaloneMigrationDbSecretFile = apiMigrationsMode ? getArg("db-secret-file") : ""; + const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn", standaloneMigrationDbSecretArn); + const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file", standaloneMigrationDbSecretFile); + const standaloneMigrationDryRun = apiMigrationsMode ? getArg("dry-run") : ""; + const apiMigrationDryRun = getArg("api-migration-dry-run", standaloneMigrationDryRun || "false").toLowerCase() === "true"; + const migrationValidationRequested = runApiMigrations || apiMigrationsMode; + const createNatGateway = (getValue("create-nat-gateway", params) || "true").toLowerCase(); + const runMigrations = (getValue("run-migrations", params) || "false").toLowerCase() === "true"; + const migrationBucket = getValue("migration-code-s3-bucket", params) || artifactBucket; + const migrationSource = getArg("migration-artifact-source-file"); + const resolvedMigrationSource = migrationSource || packageManifestMigrationArtifactPath; + const explicitMigrationKey = getValue("migration-code-s3-key", params); + const migrationKey = explicitMigrationKey || (resolvedMigrationSource ? deriveArtifactKey(projectName, environmentName, "migrations.zip") : ""); + const migrationHandler = getValue("migration-handler", params); + const frontendDomain = frontendParamMode + ? getValue("alternate-domain-name", frontendValidationParams) + : getValue("frontend-alternate-domain-name", params); + const frontendCert = frontendParamMode + ? getValue("acm-certificate-arn", frontendValidationParams) + : getValue("frontend-acm-certificate-arn", params); + const frontendZone = frontendParamMode + ? getValue("hosted-zone-id", frontendValidationParams) + : getValue("frontend-hosted-zone-id", params); + const apiDomain = getValue("api-custom-domain-name", params); + const apiCert = getValue("api-certificate-arn", params); + const apiZone = getValue("api-hosted-zone-id", params); + const lambdaHandler = getValue("lambda-handler", params); + const lambdaRuntime = getValue("lambda-runtime", params); + const dependenciesLayerArn = getValue("dependencies-layer-arn", params); + const observabilityLayerArn = getValue("observability-layer-arn", params); + const lambdaNodeOptions = getValue("lambda-node-options", params); + const databaseEngine = getValue("database-engine", params); + const databasePort = getValue("database-port", params); + const enableWebSocketApi = (getValue("enable-web-socket-api", params) || "true").toLowerCase(); + const enableScheduledWorkers = (getValue("enable-scheduled-workers", params) || "true").toLowerCase(); + const appConfigSecretArn = getValue("app-config-secret-arn", params); + const aiProvider = getValue("ai-provider", params); + const emailOnRegistration = getValue("email-on-registration", params); + const caddyHost = getValue("caddy-host", params); + const caddyPort = getValue("caddy-port", params); + const fileStore = getValue("file-store", params); + const manageAssetBucket = (getValue("manage-asset-bucket", params) || "true").toLowerCase(); + const contentRootUrl = getValue("content-root-url", params); + + const info = []; + const warnings = []; + let apiRepoFallbackSuggested = false; + let apiMigrationRepoFallbackSuggested = false; + let detectedApiRepoMigrationModules = []; + let detectedApiRepoMigrationDirectories = []; + + info.push(`Mode: ${mode}`); + info.push(`Region: ${region}`); + if (splitStackMode) info.push("Split-stack validation: backend + frontend"); + if (frontendPublishMode) info.push("Frontend asset publish validation"); + if (apiMigrationsMode) info.push(`Standalone Api migration validation (${apiMigrationRunner})`); + if (splitStackPublishOnly) info.push("Split-stack publish-only follow-up: backend and frontend deploy steps will be skipped."); + if (fullStackPublishOnly) info.push("Full-stack publish-only follow-up: infrastructure changes will be skipped."); + if (infrastructureOnly) info.push("Infrastructure-only deploy requested."); + if (frontendInfrastructureOnly) info.push("Frontend infrastructure-only deploy requested."); + if (skipInfrastructure) info.push("Infrastructure deploy step will be skipped."); + if (bootstrapMode && stackName) info.push(`Bootstrap deploy stack: ${stackName}`); + if (bootstrapStackName) info.push(`Bootstrap stack: ${bootstrapStackName}`); + if (apiRepoPath) info.push(`API repo path: ${apiRepoPath}`); + if (packageApiBackend) info.push(`Auto-package backend: true (${packageMode})`); + if (packageManifestFile) info.push(`Package manifest file: ${packageManifestPath}`); + if (templateBucket) info.push(`Template bucket: ${templateBucket}`); + if (artifactBucket) info.push(`Artifact bucket: ${artifactBucket}`); + if (artifactKey) info.push(`Artifact key: ${artifactKey}${explicitArtifactKey ? "" : " (derived default)"}`); + if (migrationBucket && migrationBucket !== artifactBucket) info.push(`Migration artifact bucket: ${migrationBucket}`); + if (migrationKey) info.push(`Migration artifact key: ${migrationKey}${explicitMigrationKey ? "" : " (derived default)"}`); + if (appConfigSecretArn) info.push(`App config secret ARN: ${appConfigSecretArn}`); + if (appConfigSecretFile) info.push(`App config secret file: ${path.resolve(rootDir, appConfigSecretFile)}`); + if (syncLegacySsm) info.push("Legacy SSM sync requested after deploy."); + if (runApiMigrations) info.push(`API migrations requested after deploy: action=${apiMigrationAction}, module=${apiMigrationModule}`); + if (migrationValidationRequested) info.push(`API migration runner: ${apiMigrationRunner}`); + if (runApiMigrations) info.push(`API migration repo path: ${apiMigrationApiRepoPath}`); + if (apiMigrationDbSecretArn) info.push(`API migration DB secret ARN: ${apiMigrationDbSecretArn}`); + if (apiMigrationDbSecretFile) info.push(`API migration DB secret file: ${path.resolve(rootDir, apiMigrationDbSecretFile)}`); + if (apiMigrationsMode) info.push(`API migration action: ${apiMigrationAction}`); + if (apiMigrationsMode) info.push(`API migration module: ${apiMigrationModule}`); + if (apiMigrationsMode) info.push(`API migration repo path: ${apiMigrationApiRepoPath}`); + if (apiMigrationsMode && apiMigrationStackName) info.push(`API migration stack: ${apiMigrationStackName}`); + if (apiMigrationsMode && apiMigrationOutputsFile) info.push(`API migration outputs file: ${path.resolve(rootDir, apiMigrationOutputsFile)}`); + if (apiMigrationsMode && apiMigrationDbSecretArn) info.push(`API migration DB secret ARN: ${apiMigrationDbSecretArn}`); + if (apiMigrationsMode && apiMigrationDbSecretFile) info.push(`API migration DB secret file: ${path.resolve(rootDir, apiMigrationDbSecretFile)}`); + if (apiMigrationsMode && apiMigrationDryRun) info.push("Standalone API migration helper is in dry-run mode."); + if (dependenciesLayerArn) info.push(`Dependencies layer ARN: ${dependenciesLayerArn}`); + if (observabilityLayerArn) info.push(`Observability layer ARN: ${observabilityLayerArn}`); + if (dependenciesLayerSource) info.push(`Dependencies layer source file: ${path.resolve(rootDir, dependenciesLayerSource)}`); + if (frontendOutputsFile) info.push(`Frontend outputs file: ${path.resolve(rootDir, frontendOutputsFile)}`); + if (backendOutputsFile) info.push(`Backend outputs file: ${path.resolve(rootDir, backendOutputsFile)}`); + if ((frontendPublishMode || fullStackPublishOnly) && frontendPublishStackName) info.push(`Frontend publish stack: ${frontendPublishStackName}`); + if (frontendPublishBucket) info.push(`Frontend publish bucket: ${frontendPublishBucket}`); + if (frontendPublishDistributionId) info.push(`Frontend distribution ID: ${frontendPublishDistributionId}`); + if (skipBackend) info.push("Backend deploy step will be skipped."); + if (skipFrontend) info.push("Frontend deploy step will be skipped."); + if (skipBuild) info.push("Frontend publish will skip build."); + + if (bootstrapStackName && !bootstrapStackLookup.ok) { + errors.push(`Bootstrap stack "${bootstrapStackName}" could not be read: ${bootstrapStackLookup.error}`); + } + + if (bootstrapMode && templateBucket) { + validateS3BucketName("TemplateBucketName", templateBucket, errors); + } + + if (bootstrapMode && artifactBucket) { + validateS3BucketName("ArtifactBucketName", artifactBucket, errors); + } + + if (bootstrapMode && templateBucket && artifactBucket && templateBucket === artifactBucket) { + errors.push("TemplateBucketName and ArtifactBucketName must be different when both are set explicitly."); + } + + if (bootstrapMode && !templateBucket) { + info.push("TemplateBucketName is not set explicitly. CloudFormation will generate the template bucket name."); + } + + if (bootstrapMode && !artifactBucket) { + info.push("ArtifactBucketName is not set explicitly. CloudFormation will generate the artifact bucket name."); + } + + if (shouldLookupBootstrapStack && bootstrapStackLookup.ok) { + if (mode === "full-stack" && !bootstrapOutputs.TemplateBucketName) { + errors.push(`Bootstrap stack "${bootstrapStackName}" is missing TemplateBucketName.`); + } + if (backendRelevant && !bootstrapOutputs.ArtifactBucketName) { + errors.push(`Bootstrap stack "${bootstrapStackName}" is missing ArtifactBucketName.`); + } + } + + if (mode === "full-stack" && !skipInfrastructure && !templateBucket) { + errors.push("No template bucket resolved. Pass --template-bucket, provide TemplateBucketName in the parameters file, or use --bootstrap-stack-name."); + } + + if (backendRelevant && !artifactBucket) { + errors.push("No artifact bucket resolved. Pass --lambda-code-s3-bucket, include LambdaCodeS3Bucket in the parameters file, or use --bootstrap-stack-name."); + } + + if (backendRelevant && !artifactKey) { + warnings.push("No Lambda artifact key resolved yet. Pass LambdaCodeS3Key or provide a backend artifact source/auto-packaging path so the wrappers can derive a default key."); + } + + if (backendRelevant && fileStore === "S3" && !getValue("asset-bucket-name", params) && manageAssetBucket !== "true") { + errors.push("FileStore=S3 but no AssetBucketName is set and ManageAssetBucket is not true. The backend will not have a content bucket."); + } + + if (backendRelevant && fileStore === "S3" && !contentRootUrl && !getValue("asset-bucket-name", params) && manageAssetBucket === "true") { + info.push("ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided."); + } + + if (backendRelevant && resolvedArtifactSource && !existsLocalFile(resolvedArtifactSource)) { + errors.push(`Backend artifact source file not found: ${path.resolve(rootDir, resolvedArtifactSource)}`); + } + + if (backendRelevant && resolvedDependenciesLayerSource && !existsLocalFile(resolvedDependenciesLayerSource)) { + errors.push(`Dependencies layer source file not found: ${path.resolve(rootDir, resolvedDependenciesLayerSource)}`); + } + + if (backendRelevant && appConfigSecretFile && !existsLocalFile(appConfigSecretFile)) { + errors.push(`App config secret file not found: ${path.resolve(rootDir, appConfigSecretFile)}`); + } + + if (backendRelevant && appConfigSecretFile && existsLocalFile(appConfigSecretFile)) { + try { + validateLocalAppConfigSecret(appConfigSecretFile); + } catch (error) { + errors.push(`App config secret file is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (backendRelevant && resolvedMigrationSource && !existsLocalFile(resolvedMigrationSource)) { + errors.push(`Migration artifact source file not found: ${path.resolve(rootDir, resolvedMigrationSource)}`); + } + + if (backendRelevant && runMigrations && !migrationHandler) { + errors.push("RunMigrations=true but no MigrationHandler was provided."); + } + + if (backendRelevant && resolvedMigrationSource && !migrationKey) { + errors.push("A migration artifact source file was provided but no MigrationCodeS3Key could be resolved."); + } + + if (backendRelevant && runMigrations && migrationKey && !migrationBucket) { + errors.push("RunMigrations=true with MigrationCodeS3Key set, but no migration artifact bucket could be resolved."); + } + + if (backendRelevant && syncLegacySsm && !appConfigSecretFile && !appConfigSecretArn) { + warnings.push("Legacy SSM sync is enabled without an app-config secret source. The helper can still write database connection parameters, but non-database secret/config paths like jwtSecret and provider keys will be skipped."); + } + + if (runApiMigrations && (mode === "bootstrap" || mode === "frontend" || frontendPublishMode)) { + errors.push("RunApiMigrations is only supported for backend, split-stack, and full-stack deploy flows."); + } + + if (runApiMigrations && splitStackMode && skipBackend) { + errors.push("Split-stack --run-api-migrations requires the backend deploy step. Remove --skip-backend or run yarn run:api-migrations separately afterward."); + } + + if (runApiMigrations && mode === "full-stack" && skipInfrastructure) { + errors.push("Full-stack --run-api-migrations is only supported during the infrastructure deploy phase. Remove --skip-infrastructure or run yarn run:api-migrations separately afterward."); + } + + if (migrationValidationRequested && !["up", "down", "status"].includes(apiMigrationAction)) { + errors.push(`Invalid api-migration-action "${apiMigrationAction}". Use up, down, or status.`); + } + + if (migrationValidationRequested && apiMigrationModule !== "all" && !["membership", "attendance", "content", "giving", "messaging", "doing", "reporting"].includes(apiMigrationModule)) { + errors.push(`Invalid api-migration-module "${apiMigrationModule}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); + } + + if (migrationValidationRequested && !["direct", "data-api"].includes(apiMigrationRunner)) { + errors.push(`Invalid api-migration-runner "${apiMigrationRunner}". Use direct or data-api.`); + } + + if (migrationValidationRequested && !fs.existsSync(apiMigrationApiRepoPath)) { + errors.push(`API migration repo path not found: ${apiMigrationApiRepoPath}`); + } + + if (migrationValidationRequested && fs.existsSync(apiMigrationApiRepoPath)) { + if (!canReadPath(apiMigrationApiRepoPath)) { + errors.push(`API migration repo path is not readable: ${apiMigrationApiRepoPath}`); + apiMigrationRepoFallbackSuggested = true; + } + const apiRepoMigrationModules = loadApiRepoMigrationModules(apiMigrationApiRepoPath); + const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(apiMigrationApiRepoPath); + detectedApiRepoMigrationModules = apiRepoMigrationModules; + detectedApiRepoMigrationDirectories = apiRepoMigrationDirectories; + if (!fs.existsSync(path.join(apiMigrationApiRepoPath, "package.json"))) { + errors.push(`API migration repo is missing package.json: ${path.join(apiMigrationApiRepoPath, "package.json")}`); + } else if (!canReadPath(path.join(apiMigrationApiRepoPath, "package.json"))) { + errors.push(`API migration repo package.json is not readable: ${path.join(apiMigrationApiRepoPath, "package.json")}`); + apiMigrationRepoFallbackSuggested = true; + } + if (apiMigrationRunner === "direct") { + if (!fs.existsSync(path.join(apiMigrationApiRepoPath, "tools", "migrate.ts"))) { + errors.push(`API migration repo is missing tools/migrate.ts: ${path.join(apiMigrationApiRepoPath, "tools", "migrate.ts")}`); + } else if (!canReadPath(path.join(apiMigrationApiRepoPath, "tools", "migrate.ts"))) { + errors.push(`API migration repo migrate tool is not readable: ${path.join(apiMigrationApiRepoPath, "tools", "migrate.ts")}`); + } + if (!fs.existsSync(path.join(apiMigrationApiRepoPath, "node_modules"))) { + errors.push(`API migration repo dependencies are not installed: ${path.join(apiMigrationApiRepoPath, "node_modules")}`); + } else if (!canReadPath(path.join(apiMigrationApiRepoPath, "node_modules"))) { + errors.push(`API migration repo dependencies are not readable: ${path.join(apiMigrationApiRepoPath, "node_modules")}`); + apiMigrationRepoFallbackSuggested = true; + } + } else { + const localTypescriptPath = path.join(rootDir, "node_modules", "typescript", "package.json"); + if (!fs.existsSync(localTypescriptPath)) { + errors.push(`B1Admin is missing typescript for Data API migrations: ${localTypescriptPath}`); + } else if (!canReadPath(localTypescriptPath)) { + errors.push(`B1Admin typescript package is not readable: ${localTypescriptPath}`); + } + } + if (apiRepoMigrationModules.length > 0) { + info.push(`API repo migration modules: ${apiRepoMigrationModules.join(", ")}`); + if (apiMigrationModule === "all" && !apiRepoMigrationModules.includes("reporting")) { + warnings.push("The current Api repo's --module=all migration set does not include reporting. Run --module=reporting separately only after the backend repo adds reporting migration support."); + } + if (apiMigrationModule !== "all" && !apiRepoMigrationModules.includes(apiMigrationModule)) { + warnings.push(`The current Api repo's --module=all migration set does not include ${apiMigrationModule}. That module may still be invokable directly, but it is outside the repo's normal aggregate migration path.`); + } + } + if (apiRepoMigrationDirectories.length > 0) { + info.push(`API repo migration directories: ${apiRepoMigrationDirectories.join(", ")}`); + if (apiMigrationModule !== "all" && !apiRepoMigrationDirectories.includes(apiMigrationModule)) { + if (apiMigrationDryRun) { + warnings.push(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. A ${apiMigrationRunner} migration run for ${apiMigrationModule} will currently skip without applying anything.`); + } else { + errors.push(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. ${apiMigrationRunner === "direct" ? "Direct" : "Data API"} ${apiMigrationModule} migrations are not currently runnable outside dry-run mode.`); + } + } + } + } + + if (migrationValidationRequested && apiMigrationDbSecretFile && !existsLocalFile(apiMigrationDbSecretFile)) { + errors.push(`API migration DB secret file not found: ${path.resolve(rootDir, apiMigrationDbSecretFile)}`); + } + + if (migrationValidationRequested && apiMigrationDbSecretFile && existsLocalFile(apiMigrationDbSecretFile)) { + const parsedSecret = readJsonIfExists(path.resolve(rootDir, apiMigrationDbSecretFile)); + validateApiMigrationDbSecretObject(parsedSecret, errors); + } + + if (apiMigrationsMode) { + if (!apiMigrationStackName && !apiMigrationOutputsFile) { + errors.push("Api-migrations mode needs --stack-name or --outputs-file."); + } + + if (apiMigrationStackName && apiMigrationOutputsFile) { + warnings.push("Api-migrations mode received both --stack-name and --outputs-file. The helper can use both, but that is usually redundant."); + } + + if (apiMigrationOutputsFile && !existsLocalFile(apiMigrationOutputsFile)) { + errors.push(`API migration outputs file not found: ${path.resolve(rootDir, apiMigrationOutputsFile)}`); + } + + if (apiMigrationOutputsFile && existsLocalFile(apiMigrationOutputsFile)) { + const parsedOutputs = readJsonIfExists(path.resolve(rootDir, apiMigrationOutputsFile)); + const normalizedOutputs = normalizeOutputs(parsedOutputs); + const requiredOutputKeys = getApiMigrationRequiredOutputKeys(apiMigrationModule); + const missingOutputKeys = requiredOutputKeys.filter((key) => typeof normalizedOutputs[key] !== "string" || normalizedOutputs[key].trim() === ""); + if (missingOutputKeys.length > 0) { + errors.push(`API migration outputs file is missing required outputs: ${missingOutputKeys.join(", ")}`); + } + if ( + apiMigrationRunner === "data-api" + && !apiMigrationDbSecretArn + && (typeof normalizedOutputs.DatabaseSecretArn !== "string" || normalizedOutputs.DatabaseSecretArn.trim() === "") + ) { + errors.push("Data API migration outputs file must include DatabaseSecretArn unless you provide --db-secret-arn."); + } else if (!apiMigrationDbSecretArn && !apiMigrationDbSecretFile && (typeof normalizedOutputs.DatabaseSecretArn !== "string" || normalizedOutputs.DatabaseSecretArn.trim() === "")) { + errors.push("API migration outputs file must include DatabaseSecretArn unless you provide --db-secret-arn or --db-secret-file."); + } + } + + if (!apiMigrationDbSecretArn && !apiMigrationDbSecretFile) { + info.push("API migrations will resolve the database secret from stack/outputs metadata if available."); + } else if (apiMigrationRunner === "data-api" && apiMigrationDbSecretFile && !apiMigrationDbSecretArn) { + warnings.push("Data API migrations cannot use a raw DB secret file by itself. Provide --db-secret-arn or rely on DatabaseSecretArn from stack outputs."); + } + + if (apiMigrationDbSecretFile && !existsLocalFile(apiMigrationDbSecretFile)) { + errors.push(`API migration DB secret file not found: ${path.resolve(rootDir, apiMigrationDbSecretFile)}`); + } + + if (apiMigrationDbSecretFile && existsLocalFile(apiMigrationDbSecretFile)) { + const parsedSecret = readJsonIfExists(path.resolve(rootDir, apiMigrationDbSecretFile)); + validateApiMigrationDbSecretObject(parsedSecret, errors); + } + } + + if (apiDomain && !apiCert) { + errors.push("ApiCustomDomainName is set but ApiCertificateArn is missing."); + } + + if (apiDomain && !apiZone) { + warnings.push("ApiCustomDomainName is set but ApiHostedZoneId is missing. The API custom domain can still be created, but Route53 alias records will not be managed."); + } + + if (frontendDomain && !frontendCert) { + errors.push( + frontendParamMode + ? "AlternateDomainName is set but AcmCertificateArn is missing." + : "FrontendAlternateDomainName is set but FrontendAcmCertificateArn is missing." + ); + } + + if (frontendZone && !frontendDomain) { + errors.push("A frontend hosted zone ID was provided but no frontend custom domain was configured."); + } + + if (mode === "full-stack" && !skipInfrastructure && !artifactKey) { + errors.push("Full-stack mode needs a LambdaCodeS3Key, a --backend-artifact-key, or a backend artifact source/auto-packaging path so the wrapper can derive one."); + } + + if (mode === "full-stack" && skipInfrastructure && !publishFrontendAssets) { + errors.push("Full-stack --skip-infrastructure is only valid together with --publish-frontend-assets."); + } + + if (mode === "full-stack" && publishFrontendAssets && infrastructureOnly) { + errors.push("Full-stack --publish-frontend-assets cannot be combined with --infrastructure-only. Provision infrastructure first, then run a later publish-only phase with --skip-infrastructure --publish-frontend-assets."); + } + + if (mode === "full-stack" && publishFrontendAssets && frontendInfrastructureOnly) { + errors.push("Full-stack --publish-frontend-assets cannot be combined with --frontend-infrastructure-only. Use --frontend-infrastructure-only for the hosting-only phase, then run a later publish-only phase with --skip-infrastructure --publish-frontend-assets."); + } + + if (mode === "full-stack" && publishFrontendAssets && !skipInfrastructure) { + errors.push("Full-stack --publish-frontend-assets is only needed for the later publish-only phase. Omit it for a normal full-stack deploy, or pair it with --skip-infrastructure for the second phase."); + } + + if (mode === "full-stack" && skipInfrastructure && infrastructureOnly) { + errors.push("Full-stack --skip-infrastructure cannot be combined with --infrastructure-only."); + } + + if (mode === "full-stack" && skipInfrastructure && frontendInfrastructureOnly) { + errors.push("Full-stack --skip-infrastructure cannot be combined with --frontend-infrastructure-only."); + } + + if (fullStackPublishOnly && !frontendPublishStackName && !frontendOutputsFile && (!frontendPublishBucket || !frontendPublishDistributionId)) { + errors.push("Full-stack publish-only follow-up mode needs --stack-name, --frontend-outputs-file, or both --bucket and --distribution-id."); + } + + if (fullStackPublishOnly && !skipBuild && !frontendPublishStackName && !backendOutputsFile) { + errors.push("Full-stack publish-only follow-up mode needs --stack-name or --backend-outputs-file when a frontend build is required."); + } + + if (fullStackPublishOnly && !skipBuild && !fs.existsSync(path.join(rootDir, "node_modules"))) { + warnings.push(`Full-stack publish-only follow-up will build the app, but node_modules was not found at ${path.join(rootDir, "node_modules")}. Run yarn install first or use --skip-build with an existing dist/.`); + } + + if (fullStackPublishOnly && skipBuild && !fs.existsSync(path.join(rootDir, "dist"))) { + errors.push(`Full-stack publish-only follow-up was told to skip the build, but dist/ was not found at ${path.join(rootDir, "dist")}.`); + } + + if (splitStackPublishOnly && !skipBuild && !fs.existsSync(path.join(rootDir, "node_modules"))) { + warnings.push(`Split-stack publish-only follow-up will build the app, but node_modules was not found at ${path.join(rootDir, "node_modules")}. Run yarn install first or use --skip-build with an existing dist/.`); + } + + if (splitStackPublishOnly && skipBuild && !fs.existsSync(path.join(rootDir, "dist"))) { + errors.push(`Split-stack publish-only follow-up was told to skip the build, but dist/ was not found at ${path.join(rootDir, "dist")}.`); + } + + if (splitStackMode && publishFrontendAssets && frontendInfrastructureOnly) { + errors.push("Split-stack --publish-frontend-assets cannot be combined with --frontend-infrastructure-only. Use --frontend-infrastructure-only for the hosting-only phase, then run a later publish follow-up with --skip-frontend --publish-frontend-assets."); + } + + if (splitStackMode && publishFrontendAssets && !skipFrontend) { + errors.push("Split-stack --publish-frontend-assets only applies to staged frontend follow-up runs. Use it in a later phase with --skip-frontend after the frontend hosting stack already exists."); + } + + if (splitStackMode && publishFrontendAssets && !skipBackend && skipFrontend) { + warnings.push("Split-stack publish follow-up is usually a second-phase step. With --skip-frontend but not --skip-backend, deploy:aws will still run the backend deploy before publishing frontend assets."); + } + + if (splitStackMode && frontendInfrastructureOnly && skipFrontend) { + errors.push("Split-stack --frontend-infrastructure-only and --skip-frontend cannot be used together. Skipping the frontend step prevents frontend hosting from being provisioned."); + } + + if (splitStackMode && skipBuild && !publishFrontendAssets && !skipFrontend && !frontendInfrastructureOnly) { + errors.push("Split-stack --skip-build only applies when frontend assets are being published. Use it with a normal frontend deploy, a staged publish follow-up, or publish:frontend-assets directly."); + } + + if (splitStackMode && skipBuild && !publishFrontendAssets && (skipFrontend || frontendInfrastructureOnly)) { + errors.push("Split-stack --skip-build has no effect when frontend publishing is deferred. Remove it or use it later during the publish phase."); + } + + if (splitStackMode && skipBackend && skipFrontend && !publishFrontendAssets) { + errors.push("Split-stack has nothing to do when both backend and frontend deploy steps are skipped and no staged frontend publish was requested."); + } + + if ((mode === "frontend" || splitStackMode || mode === "full-stack") && backendOutputsFile && !existsLocalFile(backendOutputsFile)) { + errors.push(`Backend outputs file not found: ${path.resolve(rootDir, backendOutputsFile)}`); + } + + if ((splitStackPublishOnly || fullStackPublishOnly) && frontendOutputsFile && !existsLocalFile(frontendOutputsFile)) { + errors.push(`Frontend outputs file not found: ${path.resolve(rootDir, frontendOutputsFile)}`); + } + + if ((frontendPublishMode || splitStackPublishOnly || fullStackPublishOnly) && frontendOutputsFile && frontendOutputsFileResult.error) { + errors.push(`Frontend outputs file could not be loaded: ${frontendOutputsFileResult.error}`); + } + + if ((frontendPublishMode || splitStackPublishOnly || fullStackPublishOnly) && frontendOutputsFile && !frontendOutputsFileResult.error) { + if (!frontendOutputsFileBucket) { + errors.push(`Frontend outputs file "${path.resolve(rootDir, frontendOutputsFile)}" is missing SiteBucketName or FrontendBucketName.`); + } + if (!frontendOutputsFileDistributionId) { + errors.push(`Frontend outputs file "${path.resolve(rootDir, frontendOutputsFile)}" is missing CloudFrontDistributionId or FrontendDistributionId.`); + } + } + + if (splitStackPublishOnly && ((frontendPublishBucket && !frontendPublishDistributionId) || (!frontendPublishBucket && frontendPublishDistributionId))) { + errors.push("Split-stack publish-only requires --bucket and --distribution-id together when not using stack outputs."); + } + + if (fullStackPublishOnly && ((frontendPublishBucket && !frontendPublishDistributionId) || (!frontendPublishBucket && frontendPublishDistributionId))) { + errors.push("Full-stack publish-only requires --bucket and --distribution-id together when not using stack outputs."); + } + + if (frontendPublishMode) { + if (!frontendPublishStackName && !frontendOutputsFile && (!frontendPublishBucket || !frontendPublishDistributionId)) { + errors.push("Frontend publish mode needs --stack-name, --frontend-outputs-file, or both --bucket and --distribution-id."); + } + if ((frontendPublishBucket && !frontendPublishDistributionId) || (!frontendPublishBucket && frontendPublishDistributionId)) { + errors.push("Frontend publish mode requires --bucket and --distribution-id together when not using stack outputs."); + } + if (!skipBuild && !fs.existsSync(path.join(rootDir, "node_modules"))) { + warnings.push(`Frontend publish will build the app, but node_modules was not found at ${path.join(rootDir, "node_modules")}. Run yarn install first or use --skip-build with an existing dist/.`); + } + if (skipBuild && !fs.existsSync(path.join(rootDir, "dist"))) { + errors.push(`Frontend publish was told to skip the build, but dist/ was not found at ${path.join(rootDir, "dist")}.`); + } + } + + if (mode === "frontend" && frontendInfrastructureOnly) { + warnings.push("Frontend infrastructure-only is already the natural outcome of running deploy:frontend with --infrastructure-only. Use that flag on the deploy helper, not on the validator."); + } + + if (mode === "frontend" && skipBuild && infrastructureOnly) { + errors.push("Frontend --skip-build has no effect together with --infrastructure-only. Remove it and use --skip-build later during the frontend publish phase."); + } + + if (infrastructureOnly && frontendInfrastructureOnly) { + warnings.push("Both infrastructure-only flags are set. The broader infrastructure-only mode makes frontend-infrastructure-only redundant."); + } + + if (splitStackMode && infrastructureOnly) { + warnings.push("Split-stack deploys do not support a global --infrastructure-only mode. Use --skip-frontend, --skip-backend, or --frontend-infrastructure-only depending on the rollout shape you want."); + } + + if (mode === "full-stack" && frontendInfrastructureOnly) { + info.push("Full-stack validation will assume backend plus frontend hosting infrastructure now, with frontend asset publishing deferred."); + } + + if (splitStackMode && frontendInfrastructureOnly) { + info.push("Split-stack validation will assume backend deploy now, with frontend hosting provisioned but frontend asset publishing deferred."); + } + + if (splitStackMode && !frontendParametersFile) { + warnings.push("Split-stack mode works best with --frontend-parameters-file so frontend hosting settings stay file-driven."); + } + + if (splitStackMode && frontendParametersFile && Object.keys(frontendParams).length === 0) { + warnings.push("A frontend parameters file path was provided, but it resolved to no parameters."); + } + + if (splitStackMode && frontendParametersFile) { + info.push(`Frontend parameters file: ${path.resolve(rootDir, frontendParametersFile)}`); + if (frontendProjectName !== projectName || frontendEnvironmentName !== environmentName) { + warnings.push(`Frontend parameter file resolves to ${frontendProjectName}/${frontendEnvironmentName}, while backend settings resolve to ${projectName}/${environmentName}. Make sure that mismatch is intentional.`); + } + } + + if (backendRelevant && createNatGateway === "false") { + warnings.push("CreateNatGateway=false: the stack now provisions private S3 and Secrets Manager endpoints, but any other outbound internet or AWS service access your backend needs must be handled with additional VPC endpoints or by enabling NAT."); + } + + if (apiRepoPath) { + if (!fs.existsSync(apiRepoPath)) { + errors.push(`API repo path not found: ${apiRepoPath}`); + } else { + if (!canReadPath(apiRepoPath)) { + errors.push(`API repo path is not readable: ${apiRepoPath}`); + apiRepoFallbackSuggested = true; + } + if (apiRepoPackageResult.error) { + errors.push(`API repo package.json is not readable: ${path.join(apiRepoPath, "package.json")} (${apiRepoPackageResult.error})`); + apiRepoFallbackSuggested = true; + } + if (apiRepoServerlessResult.error) { + warnings.push(`API repo serverless.yml is not readable, so multi-function contract checks were skipped: ${path.join(apiRepoPath, "serverless.yml")} (${apiRepoServerlessResult.error})`); + } + if (apiRepoLayerPackageResult.error) { + warnings.push(`API repo tools/layer-package.json is not readable, so layered dependency checks were skipped: ${path.join(apiRepoPath, "tools", "layer-package.json")} (${apiRepoLayerPackageResult.error})`); + } + if (!apiRepoPackage) { + warnings.push("API repo package.json was not found, so runtime/package checks were skipped."); + } else { + const lambdaNodeOptionsText = String(lambdaNodeOptions || ""); + const requestsSentryAutoImport = lambdaNodeOptionsText.includes("@sentry/aws-serverless/awslambda-auto"); + const layerDependencies = apiRepoLayerPackage?.dependencies && typeof apiRepoLayerPackage.dependencies === "object" + ? apiRepoLayerPackage.dependencies + : {}; + const layerIncludesSentry = Object.prototype.hasOwnProperty.call(layerDependencies, "@sentry/aws-serverless"); + + if (backendRelevant && lambdaRuntime && lambdaRuntime !== "nodejs22.x") { + warnings.push(`The Api repo is configured for nodejs22.x, but the current LambdaRuntime resolves to "${lambdaRuntime}".`); + } + if (backendRelevant && lambdaHandler && lambdaHandler !== "lambda.web") { + warnings.push(`The Api repo's main HTTP handler is lambda.web, but the current LambdaHandler resolves to "${lambdaHandler}".`); + } + if (backendRelevant && databaseEngine && databaseEngine !== "aurora-mysql") { + warnings.push(`The Api repo currently expects MySQL-style module connection strings, but the current DatabaseEngine resolves to "${databaseEngine}".`); + } + if (backendRelevant && databasePort && String(databasePort) !== "3306") { + warnings.push(`The Api repo's MySQL connection parser expects port 3306 by default, but the current DatabasePort resolves to "${databasePort}".`); + } + if (backendRelevant && enableWebSocketApi !== "true") { + warnings.push("The Api repo defines a socket Lambda and WebSocket API flow, but EnableWebSocketApi is not true."); + } + if (backendRelevant && enableScheduledWorkers !== "true") { + warnings.push("The Api repo defines scheduled timer workers, but EnableScheduledWorkers is not true."); + } + if (backendRelevant && !appConfigSecretArn) { + warnings.push("No AppConfigSecretArn is set. The Api repo can still boot for some paths, but many non-database runtime secrets/config values will remain unset."); + } + if (backendRelevant && appConfigSecretArn) { + info.push("You can mirror the current stack back into the Api repo's legacy SSM layout with yarn sync:legacy-ssm if you still depend on Serverless-era parameter names or CLI tooling."); + } + if (backendRelevant && runMigrations) { + warnings.push("RunMigrations is enabled, but the real Api repo currently exposes CLI migration tooling under tools/migrate.ts rather than a proven Lambda migration handler. Treat MigrationHandler as a custom integration you still need to supply and validate."); + } + if (backendRelevant && !runApiMigrations) { + info.push("You can also run the real Api repo's CLI migrations after deploy with yarn run:api-migrations or by adding --run-api-migrations=true to deploy:backend / deploy:aws / deploy:full-stack."); + } + if (backendRelevant && !lambdaNodeOptions) { + info.push("No LambdaNodeOptions value is set. That is fine unless you intentionally package observability auto-instrumentation such as Sentry."); + } + if (backendRelevant && requestsSentryAutoImport && packageMode === "layered" && !layerIncludesSentry) { + errors.push("LambdaNodeOptions requests @sentry/aws-serverless auto-import, but the current Api repo layered dependency manifest does not include @sentry/aws-serverless. Remove LambdaNodeOptions or add the package to tools/layer-package.json before deploying a layered backend."); + } + if (backendRelevant && !dependenciesLayerArn && !observabilityLayerArn) { + warnings.push("No Lambda layer ARNs are configured. That is fine for a fully self-contained zip, but the current Api repo's Serverless deployment uses Lambda layers for dependencies and Sentry."); + } + if (backendRelevant && !aiProvider) { + warnings.push("No AiProvider is set. The Api repo will fall back to config defaults, but your self-hosted deployment may want this explicit."); + } + if (backendRelevant && emailOnRegistration === "") { + warnings.push("EmailOnRegistration is not set. The Api repo will fall back to its config file value, which may not match your self-hosted install."); + } + if (backendRelevant && ((!caddyHost && caddyPort) || (caddyHost && !caddyPort))) { + warnings.push("CaddyHost and CaddyPort should usually be configured together when using the Api repo's Caddy integration."); + } + } + + if (apiRepoServerlessText) { + if (!/handler:\s+lambda\.socket/m.test(apiRepoServerlessText)) { + warnings.push("API repo serverless.yml did not expose the expected lambda.socket handler; verify backend repo assumptions manually."); + } else if (backendRelevant && (enableWebSocketApi !== "true" || enableScheduledWorkers !== "true")) { + warnings.push("The Api repo defines additional socket/timer Lambdas. The current CloudFormation configuration is still not enabling all of them."); + } + if (backendRelevant && !/MEMBERSHIP_CONNECTION_STRING/m.test(apiRepoServerlessText)) { + warnings.push("API repo serverless.yml did not show MEMBERSHIP_CONNECTION_STRING wiring; verify module database assumptions manually."); + } + } else { + warnings.push("API repo serverless.yml was not found, so multi-function contract checks were skipped."); + } + } + } + + if (backendRelevant && packageApiBackend) { + if (!apiRepoPath) { + errors.push("Auto-packaging was requested but no API repo path was provided. Pass --api-repo-path."); + } else { + const apiNodeModulesPath = path.join(apiRepoPath, "node_modules"); + if (!fs.existsSync(apiNodeModulesPath)) { + errors.push(`Auto-packaging requires installed Api repo dependencies, but node_modules was not found at ${apiNodeModulesPath}. Run 'corepack yarn install' in the Api repo first.`); + } else if (!canReadPath(apiNodeModulesPath)) { + errors.push(`Auto-packaging requires readable Api repo dependencies, but node_modules is not readable at ${apiNodeModulesPath}.`); + } + if (packageMode === "layered") { + info.push("Layered auto-packaging will reuse the generated layer zip automatically unless you override it with explicit layer inputs."); + } + } + } + + if (backendRelevant && packageManifestFile) { + if (!existsLocalFile(packageManifestFile)) { + errors.push(`Package manifest file not found: ${packageManifestPath}`); + } else if (packageManifestResult.error) { + errors.push(`Package manifest file could not be loaded: ${packageManifestResult.error}`); + } else { + if (!packageManifest?.backendArtifactPath) { + errors.push(`Package manifest file "${packageManifestPath}" is missing backendArtifactPath.`); + } + if (packageManifestBackendArtifactPath && !fs.existsSync(packageManifestBackendArtifactPath)) { + errors.push(`Package manifest backend artifact not found: ${packageManifestBackendArtifactPath}`); + } + if (packageManifestMigrationArtifactPath && !fs.existsSync(packageManifestMigrationArtifactPath)) { + errors.push(`Package manifest migration artifact not found: ${packageManifestMigrationArtifactPath}`); + } + if (packageManifestDependenciesLayerArtifactPath && !fs.existsSync(packageManifestDependenciesLayerArtifactPath)) { + errors.push(`Package manifest dependencies layer artifact not found: ${packageManifestDependenciesLayerArtifactPath}`); + } + if (packageMode === "layered" && !dependenciesLayerArn && !dependenciesLayerSource && !packageManifest?.dependenciesLayerArtifactPath) { + warnings.push("Package manifest did not include a dependenciesLayerArtifactPath for layered packaging. Provide a layer source file or ARN if your backend package depends on a separate layer."); + } + } + } + + if (checkAws) { + info.push("AWS-side checks: enabled"); + + const callerIdentity = tryExec("aws", ["sts", "get-caller-identity", "--output", "json"]); + if (!callerIdentity.ok) { + errors.push(`AWS credentials or network check failed: ${callerIdentity.error}`); + } else { + const parsed = JSON.parse(callerIdentity.output); + info.push(`AWS account: ${parsed.Account}`); + info.push(`AWS ARN: ${parsed.Arn}`); + } + + if ((mode === "full-stack" || bootstrapMode) && templateBucket) { + const templateBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", templateBucket]); + if (bootstrapMode) { + if (templateBucketCheck.ok) { + warnings.push(`Template bucket "${templateBucket}" already exists and is accessible. Bootstrap bucket creation may fail unless that bucket is already managed by the target stack.`); + } + } else if (!templateBucketCheck.ok) { + errors.push(`Template bucket "${templateBucket}" is not accessible: ${templateBucketCheck.error}`); + } + } + + if ((backendRelevant || bootstrapMode) && artifactBucket) { + const artifactBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", artifactBucket]); + if (bootstrapMode) { + if (artifactBucketCheck.ok) { + warnings.push(`Artifact bucket "${artifactBucket}" already exists and is accessible. Bootstrap bucket creation may fail unless that bucket is already managed by the target stack.`); + } + } else if (!artifactBucketCheck.ok) { + errors.push(`Artifact bucket "${artifactBucket}" is not accessible: ${artifactBucketCheck.error}`); + } + } + + if (backendRelevant && migrationBucket && migrationBucket !== artifactBucket) { + const migrationBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", migrationBucket]); + if (!migrationBucketCheck.ok) errors.push(`Migration artifact bucket "${migrationBucket}" is not accessible: ${migrationBucketCheck.error}`); + } + + if (backendRelevant && artifactBucket && artifactKey && !artifactSource) { + const artifactCheck = tryExec("aws", ["s3api", "head-object", "--bucket", artifactBucket, "--key", artifactKey]); + if (!artifactCheck.ok) { + warnings.push(`Artifact s3://${artifactBucket}/${artifactKey} was not found or is not accessible yet: ${artifactCheck.error}`); + } + } + + if (backendRelevant && runMigrations && migrationBucket && migrationKey && !migrationSource) { + const migrationArtifactCheck = tryExec("aws", ["s3api", "head-object", "--bucket", migrationBucket, "--key", migrationKey]); + if (!migrationArtifactCheck.ok) { + warnings.push(`Migration artifact s3://${migrationBucket}/${migrationKey} was not found or is not accessible yet: ${migrationArtifactCheck.error}`); + } + } + + if (frontendCert) { + const frontendCertCheck = tryExec("aws", ["acm", "describe-certificate", "--certificate-arn", frontendCert, "--region", "us-east-1"]); + if (!frontendCertCheck.ok) errors.push(`Frontend certificate is not accessible in us-east-1: ${frontendCertCheck.error}`); + } + + const resolvedFrontendPublishBucket = frontendPublishBucket || frontendOutputsFileBucket; + const resolvedFrontendPublishDistributionId = frontendPublishDistributionId || frontendOutputsFileDistributionId; + + if (frontendPublishMode && resolvedFrontendPublishBucket) { + const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", resolvedFrontendPublishBucket]); + if (!publishBucketCheck.ok) errors.push(`Frontend publish bucket "${resolvedFrontendPublishBucket}" is not accessible: ${publishBucketCheck.error}`); + } + + if (frontendPublishMode && resolvedFrontendPublishDistributionId) { + const distributionCheck = tryExec("aws", ["cloudfront", "get-distribution", "--id", resolvedFrontendPublishDistributionId]); + if (!distributionCheck.ok) errors.push(`Frontend distribution "${resolvedFrontendPublishDistributionId}" is not accessible: ${distributionCheck.error}`); + } + + if (splitStackPublishOnly && resolvedFrontendPublishBucket) { + const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", resolvedFrontendPublishBucket]); + if (!publishBucketCheck.ok) errors.push(`Split-stack publish bucket "${resolvedFrontendPublishBucket}" is not accessible: ${publishBucketCheck.error}`); + } + + if (splitStackPublishOnly && resolvedFrontendPublishDistributionId) { + const distributionCheck = tryExec("aws", ["cloudfront", "get-distribution", "--id", resolvedFrontendPublishDistributionId]); + if (!distributionCheck.ok) errors.push(`Split-stack publish distribution "${resolvedFrontendPublishDistributionId}" is not accessible: ${distributionCheck.error}`); + } + + if (splitStackPublishOnly && !frontendOutputsFile && !frontendPublishBucket && frontendStackName) { + const splitStackDescribe = tryExec("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + frontendStackName, + "--region", + region, + "--output", + "json", + ]); + if (!splitStackDescribe.ok) { + errors.push(`Split-stack publish-only target "${frontendStackName}" is not accessible: ${splitStackDescribe.error}`); + } else { + const splitStackOutputs = normalizeOutputs(JSON.parse(splitStackDescribe.output)); + if (!splitStackOutputs.SiteBucketName) { + errors.push(`Split-stack publish-only target "${frontendStackName}" is missing SiteBucketName output.`); + } + if (!splitStackOutputs.CloudFrontDistributionId) { + errors.push(`Split-stack publish-only target "${frontendStackName}" is missing CloudFrontDistributionId output.`); + } + if (splitStackOutputs.SiteBucketName) { + const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", splitStackOutputs.SiteBucketName]); + if (!publishBucketCheck.ok) { + errors.push(`Split-stack publish bucket "${splitStackOutputs.SiteBucketName}" is not accessible: ${publishBucketCheck.error}`); + } + } + if (splitStackOutputs.CloudFrontDistributionId) { + const distributionCheck = tryExec("aws", ["cloudfront", "get-distribution", "--id", splitStackOutputs.CloudFrontDistributionId]); + if (!distributionCheck.ok) { + errors.push(`Split-stack distribution "${splitStackOutputs.CloudFrontDistributionId}" is not accessible: ${distributionCheck.error}`); + } + } + } + } + + if (fullStackPublishOnly && resolvedFrontendPublishBucket) { + const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", resolvedFrontendPublishBucket]); + if (!publishBucketCheck.ok) errors.push(`Full-stack publish bucket "${resolvedFrontendPublishBucket}" is not accessible: ${publishBucketCheck.error}`); + } + + if (fullStackPublishOnly && resolvedFrontendPublishDistributionId) { + const distributionCheck = tryExec("aws", ["cloudfront", "get-distribution", "--id", resolvedFrontendPublishDistributionId]); + if (!distributionCheck.ok) errors.push(`Full-stack distribution "${resolvedFrontendPublishDistributionId}" is not accessible: ${distributionCheck.error}`); + } + + if (fullStackPublishOnly && !frontendOutputsFile && !frontendPublishBucket && frontendPublishStackName) { + const fullStackDescribe = tryExec("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + frontendPublishStackName, + "--region", + region, + "--output", + "json", + ]); + if (!fullStackDescribe.ok) { + errors.push(`Full-stack publish-only target "${frontendPublishStackName}" is not accessible: ${fullStackDescribe.error}`); + } else { + fullStackPublishOutputs = normalizeOutputs(JSON.parse(fullStackDescribe.output)); + if (!fullStackPublishOutputs.FrontendBucketName) { + errors.push(`Full-stack publish-only target "${frontendPublishStackName}" is missing FrontendBucketName output.`); + } + if (!fullStackPublishOutputs.FrontendDistributionId) { + errors.push(`Full-stack publish-only target "${frontendPublishStackName}" is missing FrontendDistributionId output.`); + } + if (fullStackPublishOutputs.FrontendBucketName) { + const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", fullStackPublishOutputs.FrontendBucketName]); + if (!publishBucketCheck.ok) { + errors.push(`Full-stack publish bucket "${fullStackPublishOutputs.FrontendBucketName}" is not accessible: ${publishBucketCheck.error}`); + } + } + if (fullStackPublishOutputs.FrontendDistributionId) { + const distributionCheck = tryExec("aws", ["cloudfront", "get-distribution", "--id", fullStackPublishOutputs.FrontendDistributionId]); + if (!distributionCheck.ok) { + errors.push(`Full-stack distribution "${fullStackPublishOutputs.FrontendDistributionId}" is not accessible: ${distributionCheck.error}`); + } + } + } + } + + if (apiCert) { + const apiCertCheck = tryExec("aws", ["acm", "describe-certificate", "--certificate-arn", apiCert, "--region", region]); + if (!apiCertCheck.ok) errors.push(`API certificate is not accessible in ${region}: ${apiCertCheck.error}`); + } + + if (appConfigSecretArn) { + const appConfigSecretCheck = tryExec("aws", ["secretsmanager", "describe-secret", "--secret-id", appConfigSecretArn, "--region", region]); + if (!appConfigSecretCheck.ok) { + errors.push(`App config secret is not accessible in ${region}: ${appConfigSecretCheck.error}`); + } + } + + if (apiMigrationsMode && apiMigrationStackName) { + const migrationStackDescribe = tryExec("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + apiMigrationStackName, + "--region", + region, + "--output", + "json", + ]); + if (!migrationStackDescribe.ok) { + errors.push(`API migration stack "${apiMigrationStackName}" is not accessible: ${migrationStackDescribe.error}`); + } else { + const migrationOutputs = normalizeOutputs(JSON.parse(migrationStackDescribe.output)); + const requiredOutputKeys = getApiMigrationRequiredOutputKeys(apiMigrationModule); + requiredOutputKeys.forEach((key) => { + if (!migrationOutputs[key]) errors.push(`API migration stack "${apiMigrationStackName}" is missing ${key} output.`); + }); + const resolvedSecretArn = apiMigrationDbSecretArn || migrationOutputs.DatabaseSecretArn; + if (!apiMigrationDbSecretFile && !resolvedSecretArn) { + errors.push(`API migration stack "${apiMigrationStackName}" is missing DatabaseSecretArn output, and no DB secret override was provided.`); + } + } + } + + if (apiMigrationsMode && apiMigrationDbSecretArn) { + const migrationDbSecretCheck = tryExec("aws", ["secretsmanager", "describe-secret", "--secret-id", apiMigrationDbSecretArn, "--region", region]); + if (!migrationDbSecretCheck.ok) { + errors.push(`API migration DB secret is not accessible in ${region}: ${migrationDbSecretCheck.error}`); + } + } + + if (dependenciesLayerArn) { + const dependenciesLayerCheck = tryExec("aws", ["lambda", "get-layer-version-by-arn", "--arn", dependenciesLayerArn, "--region", region]); + if (!dependenciesLayerCheck.ok) { + errors.push(`Dependencies layer ARN is not accessible in ${region}: ${dependenciesLayerCheck.error}`); + } + } + + if (observabilityLayerArn) { + const observabilityLayerCheck = tryExec("aws", ["lambda", "get-layer-version-by-arn", "--arn", observabilityLayerArn, "--region", region]); + if (!observabilityLayerCheck.ok) { + errors.push(`Observability layer ARN is not accessible in ${region}: ${observabilityLayerCheck.error}`); + } + } + } + + const nextSteps = []; + if (apiRepoFallbackSuggested) { + info.push("The local Api repo path is present but unreadable. For local deploys, switch to --package-manifest-file or --backend-artifact-source-file, or use the GitHub Actions api-repo path if that runner can read the backend repo."); + } + if (apiMigrationRepoFallbackSuggested) { + info.push("The local API migration repo path is present but unreadable. Run migrations from a machine or runner that can read that checkout, or defer --run-api-migrations until that access issue is resolved."); + } + if (bootstrapMode) { + const bootstrapArgs = []; + if (region) bootstrapArgs.push(`--region=${region}`); + if (parametersFile) bootstrapArgs.push(`--parameters-file=${parametersFile}`); + bootstrapArgs.push(`--stack-name=${stackName || ""}`); + nextSteps.push(`yarn deploy:bootstrap -- ${bootstrapArgs.join(" ")}`); + } + if (backendRelevant && resolvedArtifactSource && artifactBucket && artifactKey) { + nextSteps.push(`yarn upload:backend-artifact -- --bootstrap-stack-name=${bootstrapStackName || ""} --source-file=${resolvedArtifactSource} --artifact-key=${artifactKey}`); + } + if (backendRelevant && resolvedMigrationSource && migrationBucket && migrationKey) { + nextSteps.push(`yarn upload:backend-artifact -- --bootstrap-stack-name=${bootstrapStackName || ""} --source-file=${resolvedMigrationSource} --artifact-key=${migrationKey} --artifact-bucket=${migrationBucket} --artifact-label="Migration artifact"`); + } + if (frontendPublishMode) { + const publishArgs = []; + if (frontendPublishStackName) publishArgs.push(`--stack-name=${frontendPublishStackName}`); + else if (frontendOutputsFile) publishArgs.push(`--frontend-outputs-file=${frontendOutputsFile}`); + else { + if (frontendPublishBucket) publishArgs.push(`--bucket=${frontendPublishBucket}`); + if (frontendPublishDistributionId) publishArgs.push(`--distribution-id=${frontendPublishDistributionId}`); + } + if (backendOutputsFile) publishArgs.push(`--backend-outputs-file=${backendOutputsFile}`); + else if (getArg("backend-stack-name")) publishArgs.push(`--backend-stack-name=${getArg("backend-stack-name")}`); + if (skipBuild) publishArgs.push("--skip-build"); + nextSteps.push(`yarn publish:frontend-assets -- ${publishArgs.join(" ")}`.trim()); + } + if (apiMigrationsMode) { + const helperWouldSkipDirectModule = apiMigrationModule !== "all" + && detectedApiRepoMigrationDirectories.length > 0 + && !detectedApiRepoMigrationDirectories.includes(apiMigrationModule); + + if (helperWouldSkipDirectModule && !apiMigrationDryRun) { + info.push(`No next-step migration command was suggested for ${apiMigrationModule}, because the current Api repo has no tools/migrations/${apiMigrationModule} directory.`); + } else { + const migrationArgs = []; + migrationArgs.push(`--api-repo-path=${apiMigrationApiRepoPathArg}`); + migrationArgs.push(`--action=${apiMigrationAction}`); + migrationArgs.push(`--module=${apiMigrationModule}`); + migrationArgs.push(`--region=${region}`); + if (apiMigrationStackName) migrationArgs.push(`--stack-name=${apiMigrationStackName}`); + if (apiMigrationOutputsFile) migrationArgs.push(`--outputs-file=${apiMigrationOutputsFile}`); + if (apiMigrationDbSecretArn) migrationArgs.push(`--db-secret-arn=${apiMigrationDbSecretArn}`); + if (apiMigrationDbSecretFile) migrationArgs.push(`--db-secret-file=${apiMigrationDbSecretFile}`); + if (apiMigrationDryRun) migrationArgs.push("--dry-run=true"); + nextSteps.push(buildApiMigrationCommand(apiMigrationRunner, migrationArgs)); + } + } + if (fullStackPublishOnly) { + const publishArgs = []; + if (frontendPublishStackName) publishArgs.push(`--stack-name=${frontendPublishStackName}`); + if (frontendOutputsFile) publishArgs.push(`--frontend-outputs-file=${frontendOutputsFile}`); + if (frontendPublishBucket) publishArgs.push(`--bucket=${frontendPublishBucket}`); + if (frontendPublishDistributionId) publishArgs.push(`--distribution-id=${frontendPublishDistributionId}`); + if (getArg("app-url")) publishArgs.push(`--app-url=${getArg("app-url")}`); + if (backendOutputsFile) publishArgs.push(`--backend-outputs-file=${backendOutputsFile}`); + if (fullStackParametersFile) publishArgs.push(`--parameters-file=${fullStackParametersFile}`); + publishArgs.push("--skip-infrastructure"); + publishArgs.push("--publish-frontend-assets"); + if (skipBuild) publishArgs.push("--skip-build"); + nextSteps.push(`yarn deploy:full-stack -- ${publishArgs.join(" ")}`); + } + if (splitStackPublishOnly) { + const publishArgs = []; + if (region) publishArgs.push(`--region=${region}`); + if (projectName) publishArgs.push(`--project-name=${projectName}`); + if (environmentName) publishArgs.push(`--environment=${environmentName}`); + if (frontendParametersFile) publishArgs.push(`--frontend-parameters-file=${frontendParametersFile}`); + if (backendParametersFile) publishArgs.push(`--backend-parameters-file=${backendParametersFile}`); + if (frontendOutputsFile) publishArgs.push(`--frontend-outputs-file=${frontendOutputsFile}`); + if (frontendPublishBucket) publishArgs.push(`--bucket=${frontendPublishBucket}`); + if (frontendPublishDistributionId) publishArgs.push(`--distribution-id=${frontendPublishDistributionId}`); + if (getArg("app-url")) publishArgs.push(`--app-url=${getArg("app-url")}`); + if (backendOutputsFile) publishArgs.push(`--backend-outputs-file=${backendOutputsFile}`); + else if (getArg("backend-stack-name")) publishArgs.push(`--backend-stack-name=${getArg("backend-stack-name")}`); + publishArgs.push("--skip-backend"); + publishArgs.push("--skip-frontend"); + publishArgs.push("--publish-frontend-assets"); + if (skipBuild) publishArgs.push("--skip-build"); + nextSteps.push(`yarn deploy:aws -- ${publishArgs.join(" ")}`); + } + if (backendRelevant && runApiMigrations) { + const helperWouldSkipDirectModule = apiMigrationModule !== "all" + && detectedApiRepoMigrationDirectories.length > 0 + && !detectedApiRepoMigrationDirectories.includes(apiMigrationModule); + + if (helperWouldSkipDirectModule) { + info.push(`No follow-up migration step was suggested for ${apiMigrationModule}, because the current Api repo has no tools/migrations/${apiMigrationModule} directory.`); + } else { + const migrationArgs = []; + migrationArgs.push(`--api-repo-path=${apiMigrationApiRepoPathArg}`); + migrationArgs.push(`--action=${apiMigrationAction}`); + migrationArgs.push(`--module=${apiMigrationModule}`); + migrationArgs.push(`--region=${region}`); + if (mode === "backend" || mode === "full-stack") { + migrationArgs.push(`--stack-name=${getArg("stack-name") || ""}`); + } else if (splitStackMode) { + migrationArgs.push(`--stack-name=${getArg("backend-stack-name") || `${projectName}-${environmentName}-backend`}`); + } + if (apiMigrationDbSecretArn) migrationArgs.push(`--db-secret-arn=${apiMigrationDbSecretArn}`); + if (apiMigrationDbSecretFile) migrationArgs.push(`--db-secret-file=${apiMigrationDbSecretFile}`); + if (apiMigrationDryRun) migrationArgs.push("--dry-run=true"); + nextSteps.push(buildApiMigrationCommand(apiMigrationRunner, migrationArgs)); + } + } + + const result = { + ok: errors.length === 0, + mode, + region, + projectName, + environmentName, + bootstrapMode, + apiMigrationsMode, + splitStackMode, + splitStackPublishOnly, + frontendPublishMode, + fullStackPublishOnly, + checkAws, + infrastructureOnly, + frontendInfrastructureOnly, + stackName, + parametersFile, + bootstrapStackName, + backendParametersFile, + frontendParametersFile, + fullStackParametersFile, + resolved: { + packageManifestFile: packageManifestPath, + backendArtifactSource: resolvedArtifactSource, + migrationArtifactSource: resolvedMigrationSource, + dependenciesLayerSource: resolvedDependenciesLayerSource, + templateBucket, + artifactBucket, + artifactKey, + migrationBucket, + migrationKey, + frontendDomain, + frontendCert, + frontendZone, + apiDomain, + apiCert, + apiZone, + appConfigSecretArn, + dependenciesLayerArn, + observabilityLayerArn, + apiRepoMigrationModules: detectedApiRepoMigrationModules, + apiRepoMigrationDirectories: detectedApiRepoMigrationDirectories, + }, + info, + warnings, + errors, + nextSteps, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (errors.length > 0) process.exit(1); + return; + } + + console.log("\nDeployment preflight"); + info.forEach((line) => console.log(`- ${line}`)); + + if (warnings.length > 0) { + console.log("\nWarnings"); + warnings.forEach((line) => console.log(`- ${line}`)); + } + + if (errors.length > 0) { + console.log("\nErrors"); + errors.forEach((line) => console.log(`- ${line}`)); + process.exit(1); + } + + console.log("\nPreflight passed."); + nextSteps.forEach((step) => console.log(`Next: ${step}`)); +} + +main(); diff --git a/scripts/verify-split-stack.mjs b/scripts/verify-split-stack.mjs new file mode 100644 index 000000000..3f3aba009 --- /dev/null +++ b/scripts/verify-split-stack.mjs @@ -0,0 +1,313 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function getArg(name, fallback = "") { + const prefix = `--${name}=`; + const bareFlag = `--${name}`; + for (let index = 0; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + if (arg === bareFlag) { + const next = process.argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) return next; + } + } + const envName = name.toUpperCase().replace(/-/g, "_"); + return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; +} + +function normalizeOutputs(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); + if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); + if (raw.Outputs) return normalizeOutputs(raw.Outputs); + return raw; +} + +function readOutputsFile(filePath, label) { + const resolved = path.resolve(rootDir, filePath); + try { + return normalizeOutputs(JSON.parse(fs.readFileSync(resolved, "utf8"))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not load ${label} "${filePath}": ${message}`); + } +} + +function getStackOutputs(stackName, region, label) { + try { + const response = JSON.parse(execFileSync("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + stackName, + "--region", + region, + "--output", + "json", + ], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + })); + return normalizeOutputs(response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read ${label} "${stackName}": ${message}`); + } +} + +function getOutputValue(outputs, keys) { + for (const key of keys) { + if (outputs[key]) return outputs[key]; + } + return ""; +} + +function runAwsCheck(args, label) { + try { + execFileSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + return { ok: true, detail: label }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, detail: `${label}: ${message}` }; + } +} + +function runHttpCheck(url) { + try { + execFileSync("curl", ["-fsSIL", url], { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + return { ok: true, detail: `HTTP check passed for ${url}` }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, detail: `HTTP check failed for ${url}: ${message}` }; + } +} + +function main() { + const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); + const backendStackName = getArg("backend-stack-name"); + const frontendStackName = getArg("frontend-stack-name"); + const backendOutputsFile = getArg("backend-outputs-file"); + const frontendOutputsFile = getArg("frontend-outputs-file"); + const frontendAppUrlArg = getArg("frontend-app-url"); + const frontendBucketArg = getArg("frontend-bucket"); + const frontendDistributionIdArg = getArg("frontend-distribution-id"); + const apiBaseUrlArg = getArg("api-base-url"); + const apiProbeUrl = getArg("api-probe-url"); + const checkAws = getArg("check-aws", "true").toLowerCase() === "true"; + const checkHttp = getArg("check-http", "false").toLowerCase() === "true"; + const outputMode = getArg("output", "text").toLowerCase(); + const jsonOutput = outputMode === "json"; + + const checks = []; + const errors = []; + + if (!backendStackName && !backendOutputsFile) { + errors.push("Provide --backend-stack-name or --backend-outputs-file."); + } + if (!frontendStackName && !frontendOutputsFile) { + errors.push("Provide --frontend-stack-name or --frontend-outputs-file."); + } + if (errors.length > 0) { + const result = { + ok: false, + mode: "split-stack", + region, + backendStackName, + frontendStackName, + backendOutputsFile, + frontendOutputsFile, + checkAws, + checkHttp, + resolved: {}, + checks, + errors, + }; + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + errors.forEach((line) => console.error(line)); + } + process.exit(1); + } + + let backendOutputs = {}; + let frontendOutputs = {}; + + try { + backendOutputs = backendOutputsFile + ? readOutputsFile(backendOutputsFile, "backend outputs file") + : getStackOutputs(backendStackName, region, "backend stack"); + checks.push({ + name: "backend outputs source", + ok: true, + detail: backendOutputsFile ? `Loaded backend outputs file ${path.resolve(rootDir, backendOutputsFile)}` : `Read backend stack ${backendStackName}`, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + checks.push({ + name: "backend outputs source", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + + try { + frontendOutputs = frontendOutputsFile + ? readOutputsFile(frontendOutputsFile, "frontend outputs file") + : getStackOutputs(frontendStackName, region, "frontend stack"); + checks.push({ + name: "frontend outputs source", + ok: true, + detail: frontendOutputsFile ? `Loaded frontend outputs file ${path.resolve(rootDir, frontendOutputsFile)}` : `Read frontend stack ${frontendStackName}`, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + checks.push({ + name: "frontend outputs source", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + + const resolved = { + apiBaseUrl: apiBaseUrlArg || getOutputValue(backendOutputs, ["ApiBaseUrl", "PublicApiBaseUrl", "ReactAppApiBase"]), + contentRootUrl: getOutputValue(backendOutputs, ["ContentRootUrl", "ReactAppContentRoot"]), + websiteBaseUrl: getOutputValue(backendOutputs, ["WebsiteBaseUrl", "ReactAppB1WebsiteUrl"]), + frontendAppUrl: frontendAppUrlArg || getOutputValue(frontendOutputs, ["AppUrl", "FrontendAppUrl"]), + frontendBucketName: frontendBucketArg || getOutputValue(frontendOutputs, ["SiteBucketName", "FrontendBucketName"]), + frontendDistributionId: frontendDistributionIdArg || getOutputValue(frontendOutputs, ["CloudFrontDistributionId", "FrontendDistributionId"]), + }; + + [ + ["api base url output", resolved.apiBaseUrl, "ApiBaseUrl/PublicApiBaseUrl"], + ["frontend app url output", resolved.frontendAppUrl, "AppUrl/FrontendAppUrl"], + ["frontend bucket output", resolved.frontendBucketName, "SiteBucketName/FrontendBucketName"], + ["frontend distribution output", resolved.frontendDistributionId, "CloudFrontDistributionId/FrontendDistributionId"], + ].forEach(([name, value, source]) => { + const ok = Boolean(value); + checks.push({ + name, + ok, + detail: ok ? `Resolved from ${source}: ${value}` : `Missing ${source}`, + }); + if (!ok) errors.push(`Missing required output for ${name}.`); + }); + + if (checkAws && resolved.frontendBucketName) { + const bucketCheck = runAwsCheck([ + "s3api", + "head-bucket", + "--bucket", + resolved.frontendBucketName, + ], `Bucket ${resolved.frontendBucketName} is reachable`); + checks.push({ name: "frontend bucket aws reachability", ...bucketCheck }); + if (!bucketCheck.ok) errors.push(bucketCheck.detail); + } else { + checks.push({ + name: "frontend bucket aws reachability", + ok: true, + skipped: true, + detail: checkAws ? "Skipped because no frontend bucket was resolved." : "Skipped because --check-aws=false.", + }); + } + + if (checkAws && resolved.frontendDistributionId) { + const distributionCheck = runAwsCheck([ + "cloudfront", + "get-distribution", + "--id", + resolved.frontendDistributionId, + ], `Distribution ${resolved.frontendDistributionId} is reachable`); + checks.push({ name: "frontend distribution aws reachability", ...distributionCheck }); + if (!distributionCheck.ok) errors.push(distributionCheck.detail); + } else { + checks.push({ + name: "frontend distribution aws reachability", + ok: true, + skipped: true, + detail: checkAws ? "Skipped because no distribution ID was resolved." : "Skipped because --check-aws=false.", + }); + } + + if (checkHttp && resolved.frontendAppUrl) { + const httpCheck = runHttpCheck(resolved.frontendAppUrl); + checks.push({ name: "frontend app http reachability", ...httpCheck }); + if (!httpCheck.ok) errors.push(httpCheck.detail); + } else { + checks.push({ + name: "frontend app http reachability", + ok: true, + skipped: true, + detail: checkHttp ? "Skipped because no frontend app URL was resolved." : "Skipped because --check-http=false.", + }); + } + + if (checkHttp && apiProbeUrl) { + const apiHttpCheck = runHttpCheck(apiProbeUrl); + checks.push({ name: "api probe http reachability", ...apiHttpCheck }); + if (!apiHttpCheck.ok) errors.push(apiHttpCheck.detail); + } else { + checks.push({ + name: "api probe http reachability", + ok: true, + skipped: true, + detail: checkHttp ? "Skipped because no --api-probe-url was provided." : "Skipped because --check-http=false.", + }); + } + + const result = { + ok: errors.length === 0, + mode: "split-stack", + region, + backendStackName, + frontendStackName, + backendOutputsFile, + frontendOutputsFile, + checkAws, + checkHttp, + resolved, + checks, + errors, + }; + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + console.log(`Split-stack verification (${result.ok ? "ok" : "failed"})`); + console.log(`Region: ${region}`); + if (backendStackName) console.log(`Backend stack: ${backendStackName}`); + if (frontendStackName) console.log(`Frontend stack: ${frontendStackName}`); + if (backendOutputsFile) console.log(`Backend outputs file: ${path.resolve(rootDir, backendOutputsFile)}`); + if (frontendOutputsFile) console.log(`Frontend outputs file: ${path.resolve(rootDir, frontendOutputsFile)}`); + console.log(""); + checks.forEach((check) => { + const status = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL"; + console.log(`[${status}] ${check.name}: ${check.detail}`); + }); + if (errors.length > 0) { + console.log(""); + errors.forEach((line) => console.error(line)); + } + } + + process.exit(result.ok ? 0 : 1); +} + +main(); From f4f1903ea46d61a5cd175674b24a51cde21a2891 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 13:40:25 -0500 Subject: [PATCH 2/9] Fix deploy-breaking bugs and tighten IAM sample policies - Workflow: replace npm ci / npm cache with corepack+yarn (repo is Yarn Berry; npm ci failed on every run) - upload-backend-artifact: return the S3 object VersionId so redeploys pin LambdaCodeS3ObjectVersion and Lambda code updates are not silently skipped - publish-lambda-layer: accept --content-bucket/--content-key/ --content-object-version (the flags deploy-aws and deploy-backend already pass) as an S3 alternative to --source-file - backend-api.yaml: add DependsOn (DatabaseInstanceWriter, MigrationLogGroup) to MigrationRunner so migrations cannot fire before the DB instance or log group exist - validate-aws-deploy: fix undefined frontendStackName reference (ReferenceError on the split-stack publish-only path) - deploy-aws: treat --run-api-migrations=false / --run-bootstrap-admin=false as disabled (string "false" was truthy) - IAM samples: scope role management, PassRole, and Secrets Manager access to project-prefixed ARNs; split CreateServiceLinkedRole into its own correctly conditioned statement; scope the OIDC role's secret read, SSM writes, rds-data, and layer publish to project resources Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy-aws-self-hosted.yml | 5 +- .../cloudformation/backend-api.yaml | 3 ++ ...upload-backend-artifact-output.sample.json | 1 + ...loudformation-execution-policy.sample.json | 48 ++++++++++++++----- .../iam/github-oidc-deploy-policy.sample.json | 43 ++++++++++++++--- scripts/deploy-aws.mjs | 11 +++-- scripts/publish-lambda-layer.mjs | 35 ++++++++++---- scripts/upload-backend-artifact.mjs | 21 ++++++++ scripts/validate-aws-deploy.mjs | 10 ++-- 9 files changed, 138 insertions(+), 39 deletions(-) diff --git a/.github/workflows/deploy-aws-self-hosted.yml b/.github/workflows/deploy-aws-self-hosted.yml index 8c7c51481..4fb2a4af0 100644 --- a/.github/workflows/deploy-aws-self-hosted.yml +++ b/.github/workflows/deploy-aws-self-hosted.yml @@ -134,7 +134,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - cache: npm - name: Validate workflow inputs run: | @@ -188,7 +187,9 @@ jobs: aws-region: ${{ inputs.aws_region }} - name: Install Dependencies - run: npm ci + run: | + corepack enable + yarn install - name: Install Api Dependencies if: ${{ inputs.deployment_source == 'api-repo' }} diff --git a/infrastructure/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml index 432a501e2..399f54dcc 100644 --- a/infrastructure/cloudformation/backend-api.yaml +++ b/infrastructure/cloudformation/backend-api.yaml @@ -1201,6 +1201,9 @@ Resources: MigrationRunner: Type: Custom::MigrationRunner Condition: RunMigrationResources + DependsOn: + - DatabaseInstanceWriter + - MigrationLogGroup Properties: ServiceToken: !GetAtt MigrationFunction.Arn MigrationTrigger: !Ref MigrationTrigger diff --git a/infrastructure/examples/upload-backend-artifact-output.sample.json b/infrastructure/examples/upload-backend-artifact-output.sample.json index 108ed1a31..4ae25c00e 100644 --- a/infrastructure/examples/upload-backend-artifact-output.sample.json +++ b/infrastructure/examples/upload-backend-artifact-output.sample.json @@ -5,5 +5,6 @@ "key": "b1admin/backend/api.zip", "sourceFile": "/abs/path/to/api.zip", "s3Uri": "s3://my-artifacts-bucket/b1admin/backend/api.zip", + "versionId": "3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY+MTRCxf3vjVBH40Nr8X8gdRQBpUMLUo", "bootstrapStackName": "example-bootstrap" } diff --git a/infrastructure/iam/cloudformation-execution-policy.sample.json b/infrastructure/iam/cloudformation-execution-policy.sample.json index c30306682..87bfdfa7c 100644 --- a/infrastructure/iam/cloudformation-execution-policy.sample.json +++ b/infrastructure/iam/cloudformation-execution-policy.sample.json @@ -62,25 +62,56 @@ "rds:ModifyDBCluster", "rds:ModifyDBInstance", "rds:RemoveTagsFromResource", - "rds-data:*", + "rds-data:*" + ], + "Resource": "*" + }, + { + "Sid": "SecretsRandomPassword", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetRandomPassword" + ], + "Resource": "*" + }, + { + "Sid": "ProjectSecrets", + "Effect": "Allow", + "Action": [ "secretsmanager:CreateSecret", "secretsmanager:DeleteSecret", "secretsmanager:DescribeSecret", - "secretsmanager:GetRandomPassword", "secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue", "secretsmanager:TagResource", "secretsmanager:UntagResource", "secretsmanager:UpdateSecret" ], - "Resource": "*" + "Resource": [ + "arn:aws:secretsmanager:::secret://*", + "arn:aws:secretsmanager:::secret:--*" + ] + }, + { + "Sid": "IamServiceLinkedRoles", + "Effect": "Allow", + "Action": [ + "iam:CreateServiceLinkedRole" + ], + "Resource": "arn:aws:iam:::role/aws-service-role/rds.amazonaws.com/*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": [ + "rds.amazonaws.com" + ] + } + } }, { "Sid": "IamForStackManagedRoles", "Effect": "Allow", "Action": [ "iam:AttachRolePolicy", - "iam:CreateServiceLinkedRole", "iam:CreateRole", "iam:DeleteRole", "iam:DeleteRolePolicy", @@ -95,14 +126,7 @@ "iam:UntagRole", "iam:UpdateRole" ], - "Resource": "*", - "Condition": { - "StringEqualsIfExists": { - "iam:AWSServiceName": [ - "rds.amazonaws.com" - ] - } - } + "Resource": "arn:aws:iam:::role/--*" }, { "Sid": "LambdaLogsApiEvents", diff --git a/infrastructure/iam/github-oidc-deploy-policy.sample.json b/infrastructure/iam/github-oidc-deploy-policy.sample.json index b8b3321f9..c5cf1d694 100644 --- a/infrastructure/iam/github-oidc-deploy-policy.sample.json +++ b/infrastructure/iam/github-oidc-deploy-policy.sample.json @@ -99,20 +99,51 @@ ] }, { - "Sid": "OptionalValidationAndExtensions", + "Sid": "OptionalCertificateValidation", + "Effect": "Allow", + "Action": [ + "acm:DescribeCertificate" + ], + "Resource": "*" + }, + { + "Sid": "OptionalLayerPublish", + "Effect": "Allow", + "Action": [ + "lambda:PublishLayerVersion" + ], + "Resource": "arn:aws:lambda:::layer:--*" + }, + { + "Sid": "OptionalDataApiMigrations", "Effect": "Allow", "Action": [ - "acm:DescribeCertificate", - "lambda:PublishLayerVersion", "rds-data:BeginTransaction", "rds-data:CommitTransaction", "rds-data:ExecuteStatement", - "rds-data:RollbackTransaction", - "secretsmanager:GetSecretValue", + "rds-data:RollbackTransaction" + ], + "Resource": "arn:aws:rds:::cluster:--*" + }, + { + "Sid": "OptionalSecretRead", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret://*", + "arn:aws:secretsmanager:::secret:--*" + ] + }, + { + "Sid": "OptionalLegacySsmSync", + "Effect": "Allow", + "Action": [ "ssm:PutParameter", "ssm:AddTagsToResource" ], - "Resource": "*" + "Resource": "arn:aws:ssm:::parameter//*" } ] } diff --git a/scripts/deploy-aws.mjs b/scripts/deploy-aws.mjs index f914528a4..7634b5ecc 100644 --- a/scripts/deploy-aws.mjs +++ b/scripts/deploy-aws.mjs @@ -408,17 +408,20 @@ function main() { process.exit(1); } - if (runApiMigrations && skipBackend) { + const runApiMigrationsEnabled = runApiMigrations.toLowerCase() === "true"; + const runBootstrapAdminEnabled = runBootstrapAdmin.toLowerCase() === "true"; + + if (runApiMigrationsEnabled && skipBackend) { console.error("--run-api-migrations=true requires the backend deploy step. Remove --skip-backend or run yarn run:api-migrations separately afterward."); process.exit(1); } - if (runBootstrapAdmin && skipBackend) { + if (runBootstrapAdminEnabled && skipBackend) { console.error("--run-bootstrap-admin=true requires the backend deploy step. Remove --skip-backend or run yarn run:bootstrap-admin separately afterward."); process.exit(1); } - if (runApiMigrations) { + if (runApiMigrationsEnabled) { validateApiMigrationArgs(apiMigrationAction || "up", apiMigrationModule || "all"); validateApiMigrationRunner(apiMigrationRunner); } @@ -428,7 +431,7 @@ function main() { process.exit(1); } - if (runApiMigrations) { + if (runApiMigrationsEnabled) { const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath || apiRepoPath || "../Api"); if (!fs.existsSync(resolvedApiMigrationRepoPath)) { fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); diff --git a/scripts/publish-lambda-layer.mjs b/scripts/publish-lambda-layer.mjs index 624114925..05f3055a6 100644 --- a/scripts/publish-lambda-layer.mjs +++ b/scripts/publish-lambda-layer.mjs @@ -58,6 +58,9 @@ function main() { const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); const layerName = getArg("layer-name"); const sourceFile = getArg("source-file"); + const contentBucket = getArg("content-bucket"); + const contentKey = getArg("content-key"); + const contentObjectVersion = getArg("content-object-version"); const description = getArg("description", "Published by B1Admin AWS deployment tooling"); const licenseInfo = getArg("license-info"); const compatibleRuntimes = parseCsv(getArg("compatible-runtimes", "nodejs22.x")); @@ -65,15 +68,28 @@ function main() { const outputMode = getArg("output", "text"); requireValue("layer-name", layerName); - requireValue("source-file", sourceFile); - - const resolvedSource = path.resolve(rootDir, sourceFile); - if (!fs.existsSync(resolvedSource)) { - console.error(`Source file not found: ${resolvedSource}`); - process.exit(1); + if (!sourceFile && !(contentBucket && contentKey)) { + fail("Provide either --source-file or --content-bucket and --content-key."); } - if (path.extname(resolvedSource).toLowerCase() !== ".zip") { - fail(`Source file must be a .zip archive: ${resolvedSource}`); + if (sourceFile && (contentBucket || contentKey)) { + fail("Provide --source-file or --content-bucket/--content-key, not both."); + } + + let contentArg; + if (sourceFile) { + const resolvedSource = path.resolve(rootDir, sourceFile); + if (!fs.existsSync(resolvedSource)) { + console.error(`Source file not found: ${resolvedSource}`); + process.exit(1); + } + if (path.extname(resolvedSource).toLowerCase() !== ".zip") { + fail(`Source file must be a .zip archive: ${resolvedSource}`); + } + contentArg = ["--zip-file", `fileb://${resolvedSource}`]; + } else { + const contentParts = [`S3Bucket=${contentBucket}`, `S3Key=${contentKey}`]; + if (contentObjectVersion) contentParts.push(`S3ObjectVersion=${contentObjectVersion}`); + contentArg = ["--content", contentParts.join(",")]; } const args = [ @@ -81,8 +97,7 @@ function main() { "publish-layer-version", "--layer-name", layerName, - "--zip-file", - `fileb://${resolvedSource}`, + ...contentArg, "--description", description, "--region", diff --git a/scripts/upload-backend-artifact.mjs b/scripts/upload-backend-artifact.mjs index 3c8f7b439..da86132f9 100644 --- a/scripts/upload-backend-artifact.mjs +++ b/scripts/upload-backend-artifact.mjs @@ -136,6 +136,25 @@ function main() { region, ], { quiet: jsonOutput }); + let versionId = ""; + try { + const headResponse = runJson("aws", [ + "s3api", + "head-object", + "--bucket", + bucket, + "--key", + key, + "--region", + region, + "--output", + "json", + ]); + versionId = headResponse.VersionId || ""; + } catch { + versionId = ""; + } + const result = { artifactLabel, region, @@ -143,6 +162,7 @@ function main() { key, sourceFile: resolvedSource, s3Uri: `s3://${bucket}/${key}`, + versionId, bootstrapStackName, }; @@ -155,6 +175,7 @@ function main() { console.log(`Bucket: ${bucket}`); console.log(`Key: ${key}`); console.log(`S3 URI: s3://${bucket}/${key}`); + if (versionId) console.log(`VersionId: ${versionId}`); } main(); diff --git a/scripts/validate-aws-deploy.mjs b/scripts/validate-aws-deploy.mjs index 8372d2ba1..431d955a2 100644 --- a/scripts/validate-aws-deploy.mjs +++ b/scripts/validate-aws-deploy.mjs @@ -1140,26 +1140,26 @@ function main() { if (!distributionCheck.ok) errors.push(`Split-stack publish distribution "${resolvedFrontendPublishDistributionId}" is not accessible: ${distributionCheck.error}`); } - if (splitStackPublishOnly && !frontendOutputsFile && !frontendPublishBucket && frontendStackName) { + if (splitStackPublishOnly && !frontendOutputsFile && !frontendPublishBucket && frontendPublishStackName) { const splitStackDescribe = tryExec("aws", [ "cloudformation", "describe-stacks", "--stack-name", - frontendStackName, + frontendPublishStackName, "--region", region, "--output", "json", ]); if (!splitStackDescribe.ok) { - errors.push(`Split-stack publish-only target "${frontendStackName}" is not accessible: ${splitStackDescribe.error}`); + errors.push(`Split-stack publish-only target "${frontendPublishStackName}" is not accessible: ${splitStackDescribe.error}`); } else { const splitStackOutputs = normalizeOutputs(JSON.parse(splitStackDescribe.output)); if (!splitStackOutputs.SiteBucketName) { - errors.push(`Split-stack publish-only target "${frontendStackName}" is missing SiteBucketName output.`); + errors.push(`Split-stack publish-only target "${frontendPublishStackName}" is missing SiteBucketName output.`); } if (!splitStackOutputs.CloudFrontDistributionId) { - errors.push(`Split-stack publish-only target "${frontendStackName}" is missing CloudFrontDistributionId output.`); + errors.push(`Split-stack publish-only target "${frontendPublishStackName}" is missing CloudFrontDistributionId output.`); } if (splitStackOutputs.SiteBucketName) { const publishBucketCheck = tryExec("aws", ["s3api", "head-bucket", "--bucket", splitStackOutputs.SiteBucketName]); From e862fe584c9914eed364512223f060cc9073ad97 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:05:47 -0500 Subject: [PATCH 3/9] Usability quick wins for non-technical installers From a three-part usability audit (novice doc walkthrough, wizard UX review, prerequisite-burden inventory) aimed at letting a non-technical church admin complete the install: - Yarn/Corepack: doctor now checks for yarn and suggests `corepack enable`; start-here quick checks and local-runtime cover yarn with an explicit warning against `npm install -g yarn` (previously yarn was required by every command but never checked or explained) - Cost and time: start-here's "What Costs Money?" now leads with a dollar estimate (~$80-90/mo prod-only, itemized) plus a 2-4 hour time expectation and a billing-alarm suggestion - Solo-admin framing: "Who Does What" and aws-account-access now say plainly that in a small church all three roles are usually the same person and the operator can run the IAM commands themselves - infrastructure/README.md gets a banner routing novices to start-here.md (previously never linked) - Tool install links for Node, Git, GitHub CLI, and AWS CLI - first-rollout-checklist defines $DEPLOY_REPO/$DEPLOY_ENV_DIR before using them; deployment-repository clone example uses HTTPS instead of SSH; root README dev steps use yarn (npm install is rejected by preinstall) - installer:customer-values generates a temporary admin password on Enter (unambiguous alphabet, xxxx-xxxx-xxxx) instead of requiring the user to invent one - installer:doctor reads region/account-id/repo from customer-values (not just env vars) and its next steps point at the interview instead of advising env var exports Co-Authored-By: Claude Fable 5 --- README.md | 7 ++-- infrastructure/README.md | 2 + .../environments/first-rollout-checklist.md | 7 ++++ .../environments/setup/aws-account-access.md | 2 +- .../setup/deployment-repository.md | 4 +- .../environments/setup/local-runtime.md | 15 +++++-- infrastructure/environments/start-here.md | 31 ++++++++++---- scripts/installer-customer-values.mjs | 15 ++++++- scripts/installer-doctor.mjs | 42 ++++++++++--------- 9 files changed, 90 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 50b1eb3d4..6e1343a85 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,10 @@ If you would like to contribute in any way, head over to our [Slack Channel](htt If you'd like to set up the project locally, see our [development guide](https://churchapps.org/dev). The short version is: 1. Copy `dotenv.sample.txt` to `.env` and updated it to point to the appropriate API urls. -2. Install the dependencies with: `npm install` -3. Run `npm run postinstall` to get language files -4. run `npm start` to launch the project. +2. Enable Yarn with `corepack enable` (this project uses Yarn Berry; plain `npm install` is rejected). +3. Install the dependencies with: `yarn install` +4. Run `yarn postinstall` to get language files +5. run `yarn start` to launch the project. ### ⚙️ Payment Gateway Setup diff --git a/infrastructure/README.md b/infrastructure/README.md index be5a2ca85..72e06c744 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,5 +1,7 @@ # AWS Deployment +> **Setting up B1Admin for your church or organization?** You do not need this page. Follow the guided installer instead: [Start Here](./environments/start-here.md). This page is the technical reference for operators who want to understand or customize the templates and scripts the installer uses. + This repo now includes AWS deployment building blocks for both the B1Admin frontend and a backend foundation: - Bootstrap stack: [`cloudformation/bootstrap.yaml`](./cloudformation/bootstrap.yaml) diff --git a/infrastructure/environments/first-rollout-checklist.md b/infrastructure/environments/first-rollout-checklist.md index 8fe9c16ee..4434001e1 100644 --- a/infrastructure/environments/first-rollout-checklist.md +++ b/infrastructure/environments/first-rollout-checklist.md @@ -2,6 +2,13 @@ Use this after the first real `staging` or `prod` AWS rollout. +The commands below use two shell variables. Set them first (adjust if your private repository uses a different name or location): + +```bash +export DEPLOY_REPO=/b1admin-deploy +export DEPLOY_ENV_DIR=../b1admin-deploy/environments +``` + ## Before Deploy 1. Confirm all `replace-me` placeholders and starter-only default values such as `example.com`, `support@example.com`, `mailto:support@example.com`, and `555-555-5555` are gone from the target environment folder. diff --git a/infrastructure/environments/setup/aws-account-access.md b/infrastructure/environments/setup/aws-account-access.md index ad2446ccb..e33ba38f1 100644 --- a/infrastructure/environments/setup/aws-account-access.md +++ b/infrastructure/environments/setup/aws-account-access.md @@ -2,7 +2,7 @@ You need access to the AWS account where B1Admin will run. Staging is optional. If you use staging, keep staging and prod separated by their environment names and IAM roles. -For the recommended GitHub Actions deployment, an AWS administrator must create these for each environment you plan to deploy: +For the recommended GitHub Actions deployment, an AWS administrator must create these for each environment you plan to deploy. If nobody else manages your AWS account, the AWS administrator is you: sign in with your normal AWS CLI login (it needs permission to create IAM roles, which the account owner has) and run the printed commands yourself. - a GitHub OIDC provider for `token.actions.githubusercontent.com`, if the account does not already have one - a GitHub deploy role for each environment diff --git a/infrastructure/environments/setup/deployment-repository.md b/infrastructure/environments/setup/deployment-repository.md index 23e842621..678fe8962 100644 --- a/infrastructure/environments/setup/deployment-repository.md +++ b/infrastructure/environments/setup/deployment-repository.md @@ -15,9 +15,11 @@ gh repo create /b1admin-deploy --private --clone If you use the GitHub website, clone it from that same parent folder before continuing: ```bash -git clone git@github.com:/b1admin-deploy.git b1admin-deploy +git clone https://github.com//b1admin-deploy.git b1admin-deploy ``` +The HTTPS address above works with the same GitHub CLI sign-in you already have. Only use the SSH form (`git@github.com:...`) if you have already set up SSH keys with GitHub. + Check the folder layout before running installer commands: ```text diff --git a/infrastructure/environments/setup/local-runtime.md b/infrastructure/environments/setup/local-runtime.md index ccae3c1b0..395d06e4a 100644 --- a/infrastructure/environments/setup/local-runtime.md +++ b/infrastructure/environments/setup/local-runtime.md @@ -2,19 +2,28 @@ Install Node.js and npm on the machine where you will run the installer commands. -Use the version supported by this repository's `package.json`. If your organization already has a standard Node installer or version manager, use that. Otherwise, install the current long-term support Node.js release. +Use the version supported by this repository's `package.json` (Node.js 20 or newer). If your organization already has a standard Node installer or version manager, use that. Otherwise, install the current long-term support Node.js release from [nodejs.org](https://nodejs.org/en/download). Git is available from [git-scm.com](https://git-scm.com/downloads) if it is not already installed. + +This repository uses a pinned version of Yarn, managed by Corepack. Corepack ships with Node.js 20+, so enable it once: + +```bash +corepack enable +``` + +Do not install Yarn with `npm install -g yarn`; that installs an old version this repository rejects. After `corepack enable`, the correct Yarn version is picked up automatically inside the repository folder. Confirm the local tools are available: ```bash node --version npm --version +yarn --version git --version gh --version aws --version ``` -Each command should print a version number. If a command says it was not found, install that tool and open a new terminal before continuing. +Each command should print a version number. If a command says it was not found, install that tool and open a new terminal before continuing. If only `yarn` fails, run `corepack enable` and open a new terminal. The first installer step can create the private deployment workspace before dependencies are installed. When the guided flow reaches first-admin bootstrap or browser smoke, install dependencies from the B1Admin checkout: @@ -30,6 +39,6 @@ yarn installer:doctor -- --output=markdown Before dependencies are installed, the doctor may report dependency-related TODO items. That is expected early in the install. -You are ready for the first installer steps when `node`, `npm`, `git`, `gh`, and `aws` are installed and the `B1Admin` source repository is on your computer. You are ready for first-admin bootstrap and browser smoke when the doctor also shows the B1Admin dependency checks as complete. +You are ready for the first installer steps when `node`, `npm`, `yarn`, `git`, `gh`, and `aws` are installed and the `B1Admin` source repository is on your computer. You are ready for first-admin bootstrap and browser smoke when the doctor also shows the B1Admin dependency checks as complete. [Back to Start Here](../start-here.md) diff --git a/infrastructure/environments/start-here.md b/infrastructure/environments/start-here.md index f4286260e..081b76a89 100644 --- a/infrastructure/environments/start-here.md +++ b/infrastructure/environments/start-here.md @@ -176,11 +176,11 @@ You need a local computer where you can run terminal commands. Have: -- Node.js installed -- yarn installed -- Git installed -- GitHub CLI installed and signed in; the guided runner uses it for GitHub repository, secret, workflow, and evidence steps -- AWS CLI installed and signed in to the target AWS account; the guided runner uses it for AWS readiness, verification, and reset steps +- Node.js 20 or newer installed ([download](https://nodejs.org/en/download)) +- Yarn enabled through Corepack; Node.js 20+ ships with Corepack, so run `corepack enable` once and this repository's pinned Yarn version is used automatically. Do not install Yarn with `npm install -g yarn`; that installs an old version this repository rejects. +- Git installed ([download](https://git-scm.com/downloads)) +- GitHub CLI installed and signed in ([download](https://cli.github.com)); the guided runner uses it for GitHub repository, secret, workflow, and evidence steps +- AWS CLI installed and signed in to the target AWS account ([download](https://aws.amazon.com/cli/)); the guided runner uses it for AWS readiness, verification, and reset steps - a local copy of the B1Admin source repository Quick checks: @@ -188,12 +188,14 @@ Quick checks: ```bash node --version npm --version +corepack enable +yarn --version git --version gh --version aws --version ``` -Each command should print a version number instead of saying the command was not found. +Each command should print a version number instead of saying the command was not found. If `yarn --version` fails after `corepack enable`, open a new terminal and try again. After the repositories are cloned, run this from the `B1Admin` source repository folder: @@ -201,7 +203,7 @@ After the repositories are cloned, run this from the `B1Admin` source repository yarn installer:doctor -- --output=markdown ``` -The doctor report is allowed to show later-step items as not ready before the install starts. At this point, focus on whether `node`, `npm`, `git`, `gh`, `aws`, and the folder paths look correct. +The doctor report is allowed to show later-step items as not ready before the install starts. At this point, focus on whether `node`, `npm`, `yarn`, `git`, `gh`, `aws`, and the folder paths look correct. You do not need to run `yarn install` before the first installer command. The installer will ask for `yarn install` later when it reaches local first-admin bootstrap and browser smoke. @@ -422,6 +424,8 @@ The installer will ask you to run `yarn install` when the flow reaches local fir ## Who Does What +In a small church or organization, all three roles below are usually the same person: you. That is fine. Read each role as "a hat I put on for that step" rather than a separate person you need to find. When a step says to send something to the AWS administrator or GitHub administrator and that is you, just do that step yourself with the same signed-in accounts you have been using. + The deployment operator runs the installer commands, answers the customer setup questions, commits safe files to the user's private repository, dispatches workflows, and checks the final report. The AWS administrator creates or approves the IAM roles from the generated handoff document. They do not need to edit B1Admin code. @@ -572,6 +576,19 @@ For production, consider adding required reviewers to the `aws-prod` GitHub Envi ## What Costs Money? +Plan for roughly **$80-$90 per month** for a production-only install with the default settings, and roughly double that if you also run staging. These are estimates at low, church-scale traffic in `us-east-1` as of mid-2026; check current AWS pricing and your first month's bill. The largest pieces: + +| Resource | Estimated monthly cost | +| --- | --- | +| Aurora Serverless v2 database (0.5 capacity-unit minimum, always on) | ~$44 | +| NAT gateway (default setting `CreateNatGateway: "true"`) | ~$33 plus data charges | +| CloudFront, S3, Lambda, API Gateway, Secrets Manager, logs | ~$3-$10 combined | +| Route53 hosted zone (only with a custom domain) | ~$0.50 | + +Also plan for time: expect the first install to take **2 to 4 focused hours**, longer if you are creating AWS and GitHub accounts from scratch or waiting on someone else to approve access. + +After the install, consider creating an AWS billing alarm (AWS console > Billing > Budgets) so an unexpected charge emails you instead of surprising you at the end of the month. + AWS charges depend on your account, region, usage, and current AWS pricing. In general: - The prod deployment creates AWS resources and can cost money while it is running. diff --git a/scripts/installer-customer-values.mjs b/scripts/installer-customer-values.mjs index f83a40603..a24a36286 100644 --- a/scripts/installer-customer-values.mjs +++ b/scripts/installer-customer-values.mjs @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -101,6 +102,13 @@ async function promptYesNo(rl, label, defaultValue = false) { return promptYesNo(rl, label, defaultValue); } +function generateTemporaryPassword() { + // Unambiguous alphabet: no 0/O, 1/l/I, or symbols that break copy-paste. + const alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"; + const segment = (length) => Array.from({ length }, () => alphabet[crypto.randomInt(alphabet.length)]).join(""); + return `${segment(4)}-${segment(4)}-${segment(4)}`; +} + async function collectInteractive(values) { const rl = readline.createInterface({ input: process.stdin, @@ -119,7 +127,12 @@ async function collectInteractive(values) { values.supportEmail = await promptText(rl, "Support email", values.supportEmail || (values.rootDomain ? `support@${values.rootDomain}` : "")); values.supportPhone = await promptText(rl, "Support phone", values.supportPhone); values.firstAdminEmail = await promptText(rl, "First admin email", values.firstAdminEmail); - values.firstAdminPassword = await promptText(rl, "Temporary first admin password", values.firstAdminPassword); + values.firstAdminPassword = await promptText(rl, "Temporary first admin password (press Enter to generate one)", values.firstAdminPassword); + if (!values.firstAdminPassword) { + values.firstAdminPassword = generateTemporaryPassword(); + console.log(`Generated temporary first admin password: ${values.firstAdminPassword}`); + console.log("Write it down. You will use it for the first sign-in and then change it."); + } values.firstChurchName = await promptText(rl, "First church name", values.firstChurchName); values.b1adminRepo = await promptText(rl, "B1Admin source repository", values.b1adminRepo); values.b1adminRef = await promptText(rl, "B1Admin source branch or tag", values.b1adminRef); diff --git a/scripts/installer-doctor.mjs b/scripts/installer-doctor.mjs index 0707f0054..11e88efc8 100644 --- a/scripts/installer-doctor.mjs +++ b/scripts/installer-doctor.mjs @@ -26,15 +26,6 @@ function checkFile(filePath, label) { }; } -function envValue(name) { - const value = process.env[name] || ""; - return { - name, - ok: value.trim() !== "", - value: value.trim() !== "" ? value : "", - }; -} - function buildEnvironmentChecks(environment, environmentDir) { return { environment, @@ -73,7 +64,7 @@ function renderMarkdown(result) { lines.push(`- ${tool.ok ? "[x]" : "[ ]"} \`${tool.name}\``); }); - lines.push("", "## Shell Values", ""); + lines.push("", "## Setup Values", ""); result.environmentVariables.forEach((entry) => { lines.push(`- ${entry.ok ? "[x]" : "[ ]"} \`${entry.name}\`: \`${entry.value}\``); }); @@ -111,16 +102,26 @@ function main() { const stagingDir = resolveEnvironmentDir("staging", getArg("staging-dir", path.join(deployEnvDir, "staging"))); const prodDir = resolveEnvironmentDir("prod", getArg("prod-dir", path.join(deployEnvDir, "prod"))); - const tools = ["node", "npm", "git", "gh", "aws"].map((name) => ({ + const tools = ["node", "npm", "yarn", "git", "gh", "aws"].map((name) => ({ name, ok: commandExists(name), })); + const awsRegionValue = getArg("region", process.env.AWS_REGION || ""); + const awsAccountIdValue = getArg("account-id", process.env.AWS_ACCOUNT_ID || ""); const environmentVariables = [ - envValue("AWS_REGION"), - envValue("AWS_ACCOUNT_ID"), { - name: "DEPLOY_REPO", + name: "AWS region", + ok: awsRegionValue.trim() !== "", + value: awsRegionValue.trim() !== "" ? awsRegionValue : "", + }, + { + name: "AWS account ID", + ok: awsAccountIdValue.trim() !== "", + value: awsAccountIdValue.trim() !== "" ? awsAccountIdValue : "", + }, + { + name: "Private deploy repo", ok: deployRepo.trim() !== "", value: deployRepo.trim() !== "" ? deployRepo : "", }, @@ -164,13 +165,16 @@ function main() { nextSteps.push("Run `yarn installer:init -- --deploy-repo-dir=../b1admin-deploy --output=markdown`."); } if (!deployRepo) { - nextSteps.push("Set `DEPLOY_REPO=/`."); + nextSteps.push("Run `yarn installer:customer-values` and answer the private repository question."); + } + if (!awsAccountIdValue.trim()) { + nextSteps.push("Run `yarn installer:customer-values` and answer the AWS account ID question."); } - if (!process.env.AWS_ACCOUNT_ID) { - nextSteps.push("Set `AWS_ACCOUNT_ID=`."); + if (!awsRegionValue.trim()) { + nextSteps.push("Run `yarn installer:customer-values` and answer the AWS region question. `us-east-1` is the normal choice."); } - if (!process.env.AWS_REGION) { - nextSteps.push("Set `AWS_REGION=us-east-1` unless your install intentionally uses another region."); + if (!tools.find((tool) => tool.name === "yarn")?.ok) { + nextSteps.push("Enable Yarn by running `corepack enable` (Node.js 20+ includes Corepack), then open a new terminal."); } if (!tools.find((tool) => tool.name === "gh")?.ok) { nextSteps.push("Install GitHub CLI or use the GitHub web UI for environment/secrets/workflow steps."); From 77ead7cc800ea13ee27d1d841c2af5220667c88c Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:32:34 -0500 Subject: [PATCH 4/9] Structural usability: runner executes steps end to end Second tier of the non-technical-installer work. The guided runner can now drive the install from scaffold to report without ejecting the user into copy-paste shell commands: - installer:run executes yarn commands (previously it only recognized npm/cp, so every generated next-command fell back to "run this manually"); failures now surface a plain-English hint (AWS sign-in, GitHub sign-in, missing tool, network) via a shared explainFailure helper instead of raw stderr, and crashes no longer print stack traces - installer:aws-roles --apply=true creates the GitHub OIDC provider and both IAM roles with the operator's own AWS sign-in (idempotent: refreshes trust policy if a role exists), writes apply-result.json evidence, and the state machine gains an "AWS IAM roles created" step gated behind the runner's y/N approval; the admin-handoff document remains the alternative for orgs with a separate AWS admin - new installer:commit syncs the private deployment repo: initializes git, creates the private GitHub repository if missing, stages ONLY the safe file allowlist, refuses to run if the .gitignore protecting secrets is absent, commits, and pushes; the state machine gains a "Private repository synced" step so manual git ceremony disappears - adopt-frontend-origin archives the stale deployment summary and deploy dispatch evidence, so the runner automatically walks the second prod pass (commit -> redeploy -> observe) instead of leaving login broken until the user rereads the docs - environment-setup-wizard announces itself as an advanced tool and points to installer:run; customer-values failures keep answers and say so instead of dumping a stack trace - docs updated to match: no manual git commands on the guided path, IAM self-service framing, automatic second-deploy explanation - smoke walkthrough scenario extended to cover the IAM apply step, both private-repo sync points, and the post-adopt redeploy loop Co-Authored-By: Claude Fable 5 --- .../environments/setup/aws-iam-roles.md | 4 +- .../setup/deployment-repository.md | 4 +- infrastructure/environments/start-here.md | 32 ++-- package.json | 1 + scripts/environment-setup-wizard.mjs | 2 + scripts/installer-adopt-frontend-origin.mjs | 14 +- scripts/installer-aws-roles.mjs | 124 +++++++++++++- scripts/installer-commit.mjs | 158 ++++++++++++++++++ scripts/installer-common.mjs | 35 ++++ scripts/installer-customer-values.mjs | 3 +- scripts/installer-run.mjs | 22 ++- scripts/installer-start.mjs | 20 ++- scripts/smoke-aws-tooling.mjs | 62 +++++++ 13 files changed, 440 insertions(+), 41 deletions(-) create mode 100644 scripts/installer-commit.mjs diff --git a/infrastructure/environments/setup/aws-iam-roles.md b/infrastructure/environments/setup/aws-iam-roles.md index 37cb1b66d..fad31ed45 100644 --- a/infrastructure/environments/setup/aws-iam-roles.md +++ b/infrastructure/environments/setup/aws-iam-roles.md @@ -13,7 +13,9 @@ Generate one AWS admin handoff document from the installer: yarn installer:aws-handoff -- --customer-file=../b1admin-deploy/customer-values.json --deploy-repo-dir=../b1admin-deploy --write=true --output=markdown ``` -Send `../b1admin-deploy/aws-admin-handoff.md` to an AWS administrator. Have them run the printed `aws iam ...` commands. If the AWS account already has the GitHub OIDC provider for `token.actions.githubusercontent.com`, they should skip the provider creation command. +If you manage the AWS account yourself (the normal case for a small organization), you do not need to run the printed commands by hand: approve the `AWS IAM roles created` step in the guided runner, or run the role generator with `--apply=true`, and the installer creates the OIDC provider and both roles with your AWS sign-in. It safely skips anything that already exists. + +If a separate person manages your AWS account, send them `../b1admin-deploy/aws-admin-handoff.md` and have them run the printed `aws iam ...` commands. If the AWS account already has the GitHub OIDC provider for `token.actions.githubusercontent.com`, they should skip the provider creation command. Afterward, run the `AWS IAM roles created` step once yourself to confirm the roles exist. If you only need one environment, use the lower-level role generator for prod: diff --git a/infrastructure/environments/setup/deployment-repository.md b/infrastructure/environments/setup/deployment-repository.md index 678fe8962..8d185f464 100644 --- a/infrastructure/environments/setup/deployment-repository.md +++ b/infrastructure/environments/setup/deployment-repository.md @@ -2,9 +2,9 @@ Create a private GitHub repository owned by the organization or team operating B1Admin. A name such as `b1admin-deploy` works well. -This repository is the operator's deployment workspace. It should be created in GitHub first, then cloned beside the B1Admin source repository on the operator machine. +This repository is the operator's deployment workspace. It lives beside the B1Admin source repository on the operator machine. -You can create it in the GitHub website, or with GitHub CLI. +The guided runner can create it for you: when the `Private repository synced` step runs, the installer creates the private GitHub repository if it does not exist yet and connects the local folder to it. If you prefer to create it yourself first, use the GitHub website or GitHub CLI. Run this from the parent folder that will contain both repositories: diff --git a/infrastructure/environments/start-here.md b/infrastructure/environments/start-here.md index 081b76a89..6ca271920 100644 --- a/infrastructure/environments/start-here.md +++ b/infrastructure/environments/start-here.md @@ -348,27 +348,15 @@ yarn installer:init -- \ --output=markdown ``` -3. Commit and push the safe scaffold files from the user's private repository. - -Run these from inside the `B1Admin` source repository folder: - -```bash -git -C ../b1admin-deploy add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments -git -C ../b1admin-deploy commit -m "Add B1Admin deployment scaffold" -git -C ../b1admin-deploy push -``` - -If Git says there is nothing to commit, continue to the next step. - -Do not add `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`. - -If Git says it does not know your name or email, set them once and rerun the commit command: +3. Tell Git your name and email once, if you have never used Git on this computer: ```bash git config --global user.name "Your Name" git config --global user.email "you@example.com" ``` +You do not need to run any other Git commands. The guided runner commits and pushes the safe private-repository files for you (the `Private repository synced` step), only ever touches the safe file list, and creates the private GitHub repository if it does not exist yet. If you prefer to commit manually, `yarn installer:init` prints the safe commands. + 4. Answer the customer setup questions: ```bash @@ -398,10 +386,10 @@ Starting the runner does not mean AWS resources are created immediately. The run When it pauses, read the command it shows. If it looks right, answer `y` to let it continue. If you are not sure, answer `n`; nothing is lost, and you can run `installer:run` again later. -If the runner stops because an outside task is needed, such as an AWS administrator creating IAM roles or a GitHub administrator approving production, complete that task and run the same `installer:run` command again. -If the runner changes safe files in the user's private repository, commit and push those safe files before continuing. +If the runner stops because an outside task is needed, such as a GitHub administrator approving production, complete that task and run the same `installer:run` command again. +When the runner changes safe files in the user's private repository, it commits and pushes them for you in the `Private repository synced` step. -The runner does not create your AWS account, create the GitHub repository, clone repositories to your computer, approve AWS IAM changes, approve protected GitHub deployments, or decide whether a production change is safe. It guides those steps and pauses when a person should review them. +The runner does not create your AWS account, clone repositories to your computer, approve protected GitHub deployments, or decide whether a production change is safe. It guides those steps and pauses when a person should review them. It can create the AWS IAM roles and the private GitHub repository itself, and it always asks before doing so. Example runner pause: @@ -428,15 +416,15 @@ In a small church or organization, all three roles below are usually the same pe The deployment operator runs the installer commands, answers the customer setup questions, commits safe files to the user's private repository, dispatches workflows, and checks the final report. -The AWS administrator creates or approves the IAM roles from the generated handoff document. They do not need to edit B1Admin code. +The AWS administrator creates or approves the IAM roles. If that is you (the normal case for a small organization), just approve the `AWS IAM roles created` step when the runner asks; it creates the roles with your own AWS sign-in. -When the installer creates this file, send it to the AWS administrator: +If a separate person manages your AWS account, send them this generated file instead, and re-run the `AWS IAM roles created` step afterward to confirm the roles exist: ```text ../b1admin-deploy/aws-admin-handoff.md ``` -That file contains the AWS IAM commands and role ARN values needed by the deployment. +That file contains the AWS IAM commands and role ARN values needed by the deployment. They do not need to edit B1Admin code. The GitHub administrator may need to create the user's private repository, grant repository access, approve production environment protection rules, or create source-repository read tokens. @@ -570,7 +558,7 @@ After staging is clean, run the prod command from the previous section. ## Production Notes -If prod does not use a custom frontend domain yet, the first deploy creates a generated CloudFront URL. The installer will then ask you to run `yarn installer:adopt-frontend-origin`, commit and push the private parameter-file change, and rerun the real prod deploy. That second deploy lets browser login work from the generated CloudFront URL. +If prod does not use a custom frontend domain yet, the first deploy creates a generated CloudFront URL, and the backend must then be redeployed once so it accepts logins from that URL. The guided runner walks this second pass automatically: it asks to adopt the URL, commits and pushes the parameter change, and reruns the prod deploy. Just keep answering the runner's prompts; the second deploy is expected, not a sign that something failed. For production, consider adding required reviewers to the `aws-prod` GitHub Environment before the first prod deploy. diff --git a/package.json b/package.json index d22e4b6cd..dc5bce620 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "installer:setup": "node scripts/setup-private-deployment-repo.mjs", "installer:app-config-secret": "node scripts/installer-app-config-secret.mjs", "installer:aws-handoff": "node scripts/installer-aws-handoff.mjs", + "installer:commit": "node scripts/installer-commit.mjs", "installer:aws-roles": "node scripts/installer-aws-roles.mjs", "installer:configure": "node scripts/installer-configure.mjs", "installer:doctor": "node scripts/installer-doctor.mjs", diff --git a/scripts/environment-setup-wizard.mjs b/scripts/environment-setup-wizard.mjs index 8163a2d98..e78456c36 100644 --- a/scripts/environment-setup-wizard.mjs +++ b/scripts/environment-setup-wizard.mjs @@ -172,6 +172,8 @@ async function main() { try { console.log(`Environment setup wizard: ${requestedEnvironment}`); + console.log("Note: this is an advanced tool for editing parameter files directly."); + console.log("For the normal guided install, use `yarn installer:run` instead.\n"); console.log("This will walk through first-deploy values first, then optional custom-domain and integration values."); console.log("Press Enter to keep the suggested default. Type blank only where the wizard says it is allowed.\n"); diff --git a/scripts/installer-adopt-frontend-origin.mjs b/scripts/installer-adopt-frontend-origin.mjs index 8030a7e41..a46253c41 100644 --- a/scripts/installer-adopt-frontend-origin.mjs +++ b/scripts/installer-adopt-frontend-origin.mjs @@ -45,6 +45,16 @@ function main() { params.B1AdminRootUrl = next.B1AdminRootUrl; params.CorsOrigin = next.CorsOrigin; fs.writeFileSync(backendParametersFile, `${JSON.stringify(params, null, 2)}\n`); + // The saved deployment summary now describes a backend whose CORS/root URL + // no longer match the desired parameters. Archive it, along with the deploy + // dispatch evidence (observing the old run would just restore the stale + // summary), so the guided runner walks commit -> deploy -> observe again + // instead of treating the rollout as finished. + fs.renameSync(summaryFile, `${summaryFile.replace(/\.json$/, "")}.pre-adopt.json`); + const dispatchFile = path.join(path.dirname(summaryFile), "last-deploy-dispatch.json"); + if (fs.existsSync(dispatchFile)) { + fs.renameSync(dispatchFile, path.join(path.dirname(summaryFile), "last-deploy-dispatch.pre-adopt.json")); + } } const result = { @@ -58,8 +68,8 @@ function main() { next, followUp: changed ? [ - "Commit and push the updated private environment file.", - `Rerun the real ${environment} deploy so CloudFormation updates the backend CORS/root URL.`, + "The saved deployment summary was archived because the backend must be redeployed with the new CORS/root URL.", + "Run `yarn installer:run` again; it will commit and push the change, rerun the deploy, and observe it.", ] : [], }; diff --git a/scripts/installer-aws-roles.mjs b/scripts/installer-aws-roles.mjs index c60f1891c..9fc944eea 100644 --- a/scripts/installer-aws-roles.mjs +++ b/scripts/installer-aws-roles.mjs @@ -1,7 +1,9 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { boolArg, + explainFailure, failText, getArg, inferDeployRepo, @@ -45,6 +47,68 @@ function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; } +function runAws(args) { + const result = spawnSync("aws", args, { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + return { + ok: result.status === 0, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function applyRole(roleName, trustPath, policyPath, actions) { + const create = runAws(["iam", "create-role", "--role-name", roleName, "--assume-role-policy-document", fileUri(trustPath)]); + if (create.ok) { + actions.push({ ok: true, label: `Created role ${roleName}` }); + } else if (/EntityAlreadyExists/.test(create.stderr)) { + const update = runAws(["iam", "update-assume-role-policy", "--role-name", roleName, "--policy-document", fileUri(trustPath)]); + if (!update.ok) { + actions.push({ ok: false, label: `Could not update trust policy for existing role ${roleName}`, detail: update.stderr.trim() }); + return false; + } + actions.push({ ok: true, label: `Role ${roleName} already existed; refreshed its trust policy` }); + } else { + actions.push({ ok: false, label: `Could not create role ${roleName}`, detail: create.stderr.trim() }); + return false; + } + + const putPolicy = runAws(["iam", "put-role-policy", "--role-name", roleName, "--policy-name", `${roleName}-policy`, "--policy-document", fileUri(policyPath)]); + if (!putPolicy.ok) { + actions.push({ ok: false, label: `Could not attach permissions to role ${roleName}`, detail: putPolicy.stderr.trim() }); + return false; + } + actions.push({ ok: true, label: `Attached permissions policy to ${roleName}` }); + return true; +} + +function applyOidcProvider(actions) { + const list = runAws(["iam", "list-open-id-connect-providers", "--output", "json"]); + if (list.ok) { + try { + const providers = JSON.parse(list.stdout)?.OpenIDConnectProviderList || []; + if (providers.some((provider) => String(provider.Arn || "").endsWith("/token.actions.githubusercontent.com"))) { + actions.push({ ok: true, label: "GitHub OIDC provider already exists" }); + return true; + } + } catch { + // fall through to create + } + } + + const create = runAws(["iam", "create-open-id-connect-provider", "--url", "https://token.actions.githubusercontent.com", "--client-id-list", "sts.amazonaws.com"]); + if (create.ok || /EntityAlreadyExists/.test(create.stderr)) { + actions.push({ ok: true, label: "GitHub OIDC provider is in place" }); + return true; + } + actions.push({ ok: false, label: "Could not create the GitHub OIDC provider", detail: create.stderr.trim() }); + return false; +} + function fileUri(filePath) { return `file://${filePath}`; } @@ -70,6 +134,13 @@ function renderMarkdown(result) { lines.push(`- ${file.written ? "wrote" : "planned"} \`${file.path}\``); }); + if (result.applied) { + lines.push("", "## AWS Apply Results", ""); + result.applied.actions.forEach((action) => { + lines.push(`- ${action.ok ? "OK" : "FAILED"}: ${action.label}${action.detail ? ` - ${action.detail}` : ""}`); + }); + } + lines.push("", "## AWS Commands", ""); result.awsCommands.forEach((command) => lines.push(`- \`${command}\``)); @@ -97,6 +168,7 @@ function main() { const outputDir = path.resolve(rootDir, getArg("output-dir", path.join("infrastructure", "iam", "generated", environment))); const write = boolArg("write", false); const force = boolArg("force", false); + const apply = boolArg("apply", false); if (!accountId.match(/^\d{12}$/)) { failText("--account-id or AWS_ACCOUNT_ID must be a 12-digit AWS account id.", outputMode); @@ -171,6 +243,10 @@ function main() { }); } + if (apply && rendered.some((file) => !fs.existsSync(file.targetPath))) { + failText("The IAM role files have not been written yet. Re-run with `--write=true` (or `--write=true --apply=true`) first.", outputMode); + } + const deployRoleArn = `arn:aws:iam::${accountId}:role/${deployRoleName}`; const cfnRoleArn = `arn:aws:iam::${accountId}:role/${cfnRoleName}`; const fileFor = (key) => rendered.find((file) => file.key === key).path; @@ -186,17 +262,47 @@ function main() { `gh secret set AWS_ROLE_TO_ASSUME --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(deployRoleArn)}`, `gh secret set AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN --repo ${repo} --env ${githubEnvironment} --body ${shellQuote(cfnRoleArn)}`, ]; - const nextSteps = [ - write - ? "Run the AWS commands from an administrator-authenticated shell, skipping the OIDC provider create command if the provider already exists." - : "Re-run with `--write=true` to create the rendered IAM JSON files.", - "Run the GitHub secret commands after the AWS roles exist.", - `Run \`yarn installer:aws-preflight -- --environment=${environment} --account-id=${accountId} --cloudformation-execution-role-arn=${cfnRoleArn} --output=markdown\`.`, - ]; + let applied = null; + if (apply) { + const filePathFor = (key) => rendered.find((file) => file.key === key).targetPath; + const actions = []; + const oidcOk = applyOidcProvider(actions); + const deployOk = oidcOk && applyRole(deployRoleName, filePathFor("githubDeployTrust"), filePathFor("githubDeployPolicy"), actions); + const cfnOk = deployOk && applyRole(cfnRoleName, filePathFor("cloudFormationTrust"), filePathFor("cloudFormationPolicy"), actions); + applied = { + ok: oidcOk && deployOk && cfnOk, + appliedAt: new Date().toISOString(), + accountId, + environment, + roleArns: { deployRoleArn, cfnRoleArn }, + actions, + }; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, "apply-result.json"), `${JSON.stringify(applied, null, 2)}\n`); + } + + const nextSteps = []; + if (applied?.ok) { + nextSteps.push("The AWS roles exist. The installer will confirm the GitHub secrets in the GitHub setup step."); + } else if (apply) { + const failureDetail = applied.actions.filter((action) => !action.ok).map((action) => action.detail || "").join("\n"); + const hint = explainFailure(failureDetail); + nextSteps.push(hint || "Fix the AWS problem shown above and re-run this command, or send `aws-admin-handoff.md` to whoever manages your AWS account."); + } else { + nextSteps.push( + write + ? "Run `--apply=true` to create the roles with your own AWS sign-in, or have an AWS administrator run the AWS commands from `aws-admin-handoff.md`." + : "Re-run with `--write=true` to create the rendered IAM JSON files.", + ); + nextSteps.push("Run the GitHub secret commands after the AWS roles exist."); + } + nextSteps.push(`Run \`yarn installer:aws-preflight -- --environment=${environment} --account-id=${accountId} --cloudformation-execution-role-arn=${cfnRoleArn} --output=markdown\`.`); const result = { - ok: true, + ok: apply ? Boolean(applied?.ok) : true, write, + apply, + applied, accountId, region, repo, @@ -232,6 +338,8 @@ function main() { result.awsCommands.forEach((command) => console.log(command)); result.githubSecretCommands.forEach((command) => console.log(command)); } + + if (!result.ok) process.exit(1); } main(); diff --git a/scripts/installer-commit.mjs b/scripts/installer-commit.mjs new file mode 100644 index 000000000..a9add1ba6 --- /dev/null +++ b/scripts/installer-commit.mjs @@ -0,0 +1,158 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + boolArg, + explainFailure, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +// Only these paths are ever staged. Secrets never appear here, and the +// check-ignore guard below refuses to continue if the .gitignore that keeps +// them out is missing. +const SAFE_PATHS = [ + "README.md", + ".gitignore", + ".github", + "customer-values.sample.json", + "environments", + "iam", + "aws-admin-handoff.md", +]; + +const SENSITIVE_GLOBS = [ + "customer-values.json", + "environments/staging/app-config-secret.json", + "environments/prod/app-config-secret.json", + "environments/staging/bootstrap-admin-secret.json", + "environments/prod/bootstrap-admin-secret.json", +]; + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 20 * 1024 * 1024, + }); + return { + ok: result.status === 0, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function gitIgnores(deployRepoDir, relativePath) { + return run("git", ["check-ignore", "--quiet", relativePath], deployRepoDir).ok; +} + +function main() { + const outputMode = getArg("output", "text").toLowerCase(); + const deployRepoDirArg = getArg("deploy-repo-dir", "../b1admin-deploy"); + const deployRepoDir = path.resolve(rootDir, deployRepoDirArg); + const repo = inferDeployRepo(getArg("repo")); + const message = getArg("message", "B1Admin installer update"); + const push = boolArg("push", true); + const actions = []; + + if (!fs.existsSync(deployRepoDir)) { + failText(`The private deployment folder does not exist yet: ${relativeToRoot(deployRepoDir)}. Run \`yarn installer:init\` first.`, outputMode); + } + if (!fs.existsSync(path.join(deployRepoDir, ".gitignore"))) { + failText("The private deployment folder has no .gitignore, so committing could expose secret files. Run `yarn installer:init` to restore it, then retry.", outputMode); + } + + if (!fs.existsSync(path.join(deployRepoDir, ".git"))) { + const init = run("git", ["init", "-b", "main"], deployRepoDir); + if (!init.ok) failText(`Could not initialize git in ${relativeToRoot(deployRepoDir)}: ${init.stderr.trim()}`, outputMode); + actions.push({ ok: true, label: "Initialized git in the private deployment folder" }); + } + + for (const sensitive of SENSITIVE_GLOBS) { + if (fs.existsSync(path.join(deployRepoDir, sensitive)) && !gitIgnores(deployRepoDir, sensitive)) { + failText(`Refusing to commit: the secret file ${sensitive} is not covered by .gitignore. Run \`yarn installer:init\` to restore the .gitignore, then retry.`, outputMode); + } + } + + const stage = run("git", ["add", "--", ...SAFE_PATHS.filter((entry) => fs.existsSync(path.join(deployRepoDir, entry)))], deployRepoDir); + if (!stage.ok) failText(`Could not stage files: ${stage.stderr.trim()}`, outputMode); + + const staged = run("git", ["diff", "--cached", "--name-only"], deployRepoDir).stdout.trim(); + let committed = false; + if (staged) { + const identityOk = run("git", ["config", "user.email"], deployRepoDir).stdout.trim() !== ""; + if (!identityOk) { + failText('Git needs your name and email once before it can save changes. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then retry.', outputMode); + } + const commit = run("git", ["commit", "-m", message], deployRepoDir); + if (!commit.ok) failText(`Could not commit: ${commit.stderr.trim() || commit.stdout.trim()}`, outputMode); + committed = true; + actions.push({ ok: true, label: `Committed changes: ${staged.split("\n").length} file(s)` }); + } else { + actions.push({ ok: true, label: "No new changes to commit" }); + } + + let pushed = false; + if (push) { + const hasRemote = run("git", ["remote", "get-url", "origin"], deployRepoDir).ok; + if (!hasRemote) { + if (!repo) { + failText("No git remote is configured and no repository name is available. Answer the private repository question in `yarn installer:customer-values`, then retry.", outputMode); + } + const view = run("gh", ["repo", "view", repo, "--json", "name"], deployRepoDir); + if (!view.ok) { + const create = run("gh", ["repo", "create", repo, "--private"], deployRepoDir); + if (!create.ok) { + const hint = explainFailure(create.stderr); + failText(`Could not create the private GitHub repository ${repo}: ${create.stderr.trim()}${hint ? `\n${hint}` : ""}`, outputMode); + } + actions.push({ ok: true, label: `Created private GitHub repository ${repo}` }); + } + const addRemote = run("git", ["remote", "add", "origin", `https://github.com/${repo}.git`], deployRepoDir); + if (!addRemote.ok) failText(`Could not add the git remote: ${addRemote.stderr.trim()}`, outputMode); + actions.push({ ok: true, label: `Connected the folder to https://github.com/${repo}` }); + } + + const pushResult = run("git", ["push", "-u", "origin", "HEAD"], deployRepoDir); + if (!pushResult.ok) { + const hint = explainFailure(pushResult.stderr); + failText(`Could not push to GitHub: ${pushResult.stderr.trim()}${hint ? `\n${hint}` : ""}`, outputMode); + } + pushed = true; + actions.push({ ok: true, label: "Pushed to GitHub" }); + } + + const result = { + ok: true, + deployRepoDir: relativeToRoot(deployRepoDir), + repo, + committed, + pushed, + actions, + }; + + if (outputMode === "json") { + printJson(result); + } else if (outputMode === "markdown" || outputMode === "md") { + const lines = [ + "# Private Repository Sync", + "", + `- Folder: \`${result.deployRepoDir}\``, + `- Repository: \`${repo || ""}\``, + "", + "## Actions", + "", + ]; + result.actions.forEach((action) => lines.push(`- ${action.ok ? "OK" : "FAILED"}: ${action.label}`)); + process.stdout.write(`${lines.join("\n")}\n`); + } else { + result.actions.forEach((action) => console.log(`${action.ok ? "OK" : "FAILED"}: ${action.label}`)); + } +} + +main(); diff --git a/scripts/installer-common.mjs b/scripts/installer-common.mjs index a50a2e680..7eab16529 100644 --- a/scripts/installer-common.mjs +++ b/scripts/installer-common.mjs @@ -195,6 +195,41 @@ export function failText(message, outputMode = "text", extra = {}) { process.exit(1); } +const failureHints = [ + { + pattern: /Unable to locate credentials|InvalidClientTokenId|ExpiredToken|security token.*(?:invalid|expired)|SSO session.*expired|Token has expired/i, + hint: "This looks like an AWS sign-in problem. Sign in to the AWS CLI again using your normal method (for example `aws configure` or your organization's AWS sign-in), then retry.", + }, + { + pattern: /AccessDenied|not authorized to perform/i, + hint: "Your AWS sign-in does not have permission for this action. Sign in as someone who can manage this AWS account, or send `aws-admin-handoff.md` to whoever manages it.", + }, + { + pattern: /gh auth login|not logged into any GitHub hosts|Bad credentials|HTTP 401/i, + hint: "This looks like a GitHub sign-in problem. Run `gh auth login -h github.com`, finish signing in, then retry.", + }, + { + pattern: /command not found|is not recognized as an internal or external command|spawn .* ENOENT/i, + hint: "A required tool is missing on this computer. Run `yarn installer:doctor -- --output=markdown` to see which tools still need to be installed.", + }, + { + pattern: /Could not resolve host|getaddrinfo|ETIMEDOUT|ECONNRESET|ENETUNREACH|network is unreachable/i, + hint: "This looks like a network problem. Check your internet connection, then retry.", + }, + { + pattern: /Could not connect to the endpoint URL|InvalidRegion/i, + hint: "The AWS region looks wrong. Check the AWS region answer in `yarn installer:customer-values` (normally `us-east-1`).", + }, +]; + +export function explainFailure(text) { + const combined = String(text || ""); + for (const { pattern, hint } of failureHints) { + if (pattern.test(combined)) return hint; + } + return ""; +} + export function latestWorkflowRunId(repo) { const args = [ "run", diff --git a/scripts/installer-customer-values.mjs b/scripts/installer-customer-values.mjs index a24a36286..890315b7b 100644 --- a/scripts/installer-customer-values.mjs +++ b/scripts/installer-customer-values.mjs @@ -235,6 +235,7 @@ async function main() { } main().catch((error) => { - console.error(error.stack || error.message); + console.error(`Customer setup hit a problem: ${error instanceof Error ? error.message : String(error)}`); + console.error("Your previous answers are kept. Run `yarn installer:customer-values` again to continue."); process.exit(1); }); diff --git a/scripts/installer-run.mjs b/scripts/installer-run.mjs index 1e1a1a0be..1c71d91af 100644 --- a/scripts/installer-run.mjs +++ b/scripts/installer-run.mjs @@ -3,6 +3,7 @@ import process from "node:process"; import readline from "node:readline/promises"; import { boolArg, + explainFailure, getArg, printJson, rootDir, @@ -84,6 +85,9 @@ function invocationFromCommand(command) { if (!trimmed || trimmed.startsWith("#")) return null; const words = splitWords(trimmed); + if (words[0] === "yarn" && words[1]) { + return { command: "yarn", args: words.slice(1).filter((word) => word !== "--") }; + } if (words[0] === "npm" && words[1] === "install" && words.length === 2) { return { command: "npm", args: ["install"] }; } @@ -99,6 +103,8 @@ function invocationFromCommand(command) { function isApprovalGate(command) { return command.includes("installer:deploy") || command.includes("--write-secrets=true") + || command.includes("--apply=true") + || command.includes("installer:commit") || command.includes("installer:bootstrap-admin") || command.includes("installer:browser-smoke") || command.includes("installer:adopt-frontend-origin") @@ -183,11 +189,12 @@ async function main() { const result = spawnSync(invocation.command, invocation.args, { cwd: rootDir, encoding: "utf8", - stdio: outputMode === "json" ? "pipe" : "inherit", + stdio: outputMode === "json" ? "pipe" : ["inherit", "inherit", "pipe"], maxBuffer: 20 * 1024 * 1024, }); if ((result.status ?? 1) !== 0) { + const hint = explainFailure(`${result.stdout || ""}\n${result.stderr || ""}`); if (outputMode === "json") { printJson({ ok: false, @@ -196,13 +203,18 @@ async function main() { status: result.status ?? 1, stdout: result.stdout || "", stderr: result.stderr || "", + hint, history, }); } else { - console.error("That command did not finish successfully. Fix the issue shown above, then run installer:run again."); + if (result.stderr) process.stderr.write(result.stderr); + console.error("\nThat command did not finish successfully."); + if (hint) console.error(hint); + console.error("After fixing the issue, run installer:run again. It will continue from this step."); } process.exit(result.status ?? 1); } + if (outputMode !== "json" && result.stderr) process.stderr.write(result.stderr); } const message = `Paused after ${options.maxSteps} step(s). Re-run installer:run to continue.`; @@ -217,6 +229,10 @@ async function main() { } main().catch((error) => { - console.error(error.stack || error.message); + const message = error instanceof Error ? error.message : String(error); + const hint = explainFailure(message); + console.error(`The installer hit a problem it could not recover from: ${message}`); + if (hint) console.error(hint); + console.error("Run `yarn installer:doctor -- --output=markdown` for a readiness report, then run installer:run again."); process.exit(1); }); diff --git a/scripts/installer-start.mjs b/scripts/installer-start.mjs index 2b5e0c718..194bb624b 100644 --- a/scripts/installer-start.mjs +++ b/scripts/installer-start.mjs @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { @@ -9,6 +10,15 @@ import { runNodeJson, } from "./installer-common.mjs"; +function gitSynced(deployRepoDir) { + if (!fs.existsSync(path.join(deployRepoDir, ".git"))) return false; + const runGit = (args) => spawnSync("git", args, { cwd: deployRepoDir, encoding: "utf8", stdio: "pipe" }); + const status = runGit(["status", "--porcelain"]); + if (status.status !== 0 || status.stdout.trim() !== "") return false; + const ahead = runGit(["rev-list", "--count", "@{u}..HEAD"]); + return ahead.status === 0 && ahead.stdout.trim() === "0"; +} + function exists(filePath) { return fs.existsSync(filePath); } @@ -151,7 +161,9 @@ function main() { const backendParameters = readJsonIfExists(path.join(environmentDir, "backend-parameters.json")); const preflightPlanFile = path.join(evidenceDir, "preflight-plan.md"); const reportFile = path.join(deploymentRoot, "deployment-report.md"); - const roleFilesExist = exists(roleDir) && fs.readdirSync(roleDir).some((fileName) => fileName.endsWith(".json")); + const roleFilesExist = exists(roleDir) && fs.readdirSync(roleDir).some((fileName) => fileName.endsWith(".json") && fileName !== "apply-result.json"); + const iamApplyResult = readJsonIfExists(path.join(roleDir, "apply-result.json")); + const repoSynced = gitSynced(deployRepoDir); const audit = exists(environmentDir) ? auditEnvironment(environment, deployEnvDirArg, customerFile) : null; const auditBlockerCount = audit?.blockerSummary?.blockerCount ?? null; @@ -161,6 +173,8 @@ function main() { installDeps: "yarn install", setup: `yarn installer:init -- --deploy-repo-dir=${deployRepoDirArg} --output=markdown`, awsHandoff: `yarn installer:aws-handoff -- --customer-file=${relativeToRoot(customerFile)} --deploy-repo-dir=${deployRepoDirArg} --write=true --output=markdown`, + awsApply: `yarn installer:aws-roles -- --environment=${environment} --customer-file=${relativeToRoot(customerFile)} --output-dir=${path.join(deployRepoDirArg, "iam", environment)} --apply=true --output=markdown`, + commit: `yarn installer:commit -- --deploy-repo-dir=${deployRepoDirArg} --customer-file=${relativeToRoot(customerFile)} --push=true --output=markdown`, roles: `yarn installer:aws-roles -- --environment=${environment} --customer-file=${relativeToRoot(customerFile)} --output-dir=${path.join(deployRepoDirArg, "iam", environment)} --write=true --output=markdown`, githubEnvironments: `yarn installer:github-setup -- --repo=${repo || "/"} --write=true --output=markdown`, configurePreview: `yarn installer:configure -- ${envBase} --output=markdown`, @@ -196,11 +210,13 @@ function main() { status(privateDeploymentScaffoldReady, "Private deployment scaffold", privateDeploymentScaffoldReady ? "workflow plus staging/prod folders" : "create the private deployment repo scaffold", commands.setup), status(exists(customerFile), "Customer values file", exists(customerFile) ? "found" : "copy the sample and replace placeholders", exists(sampleCustomerFile) ? commands.createCustomerFile : commands.setup), status(hasCustomerValue("repo", repo) && hasCustomerValue("account-id", accountId), "Core customer values", hasCustomerValue("repo", repo) && hasCustomerValue("account-id", accountId) ? "repo and AWS account id available" : "answer the customer setup questions", commands.editCustomerValues), - status(roleFilesExist, "AWS IAM admin handoff", roleFilesExist ? `prepared${exists(handoffFile) ? " with handoff document" : ""}` : "generate files and commands for an AWS admin", commands.awsHandoff), + status(roleFilesExist, "AWS IAM role files", roleFilesExist ? `prepared${exists(handoffFile) ? " with handoff document" : ""}` : "render the IAM role files and admin handoff document", commands.awsHandoff), + status(iamApplyResult?.ok === true, "AWS IAM roles created", iamApplyResult?.ok === true ? "deploy and CloudFormation roles exist in AWS" : "create the roles with your AWS sign-in (or have your AWS admin run aws-admin-handoff.md, then run this to confirm)", commands.awsApply), status(hasCustomerValue("root-domain", rootDomain) && hasCustomerValue("support-phone", supportPhone), "Environment public values", hasCustomerValue("root-domain", rootDomain) && hasCustomerValue("support-phone", supportPhone) ? "root domain and support phone available" : "answer the customer setup questions", commands.editCustomerValues), status(auditBlockerCount === 0, "Environment parameter files", auditBlockerCount === null ? "not checked yet" : `${auditBlockerCount} blocker(s) remaining`, commands.configureWrite), status(exists(appConfigSecret), "App config secret", exists(appConfigSecret) ? "local secret file exists and should not be committed" : "generate random app secrets locally", commands.appConfig), status(hasCustomerValue("support-email", supportEmail), "Support email", hasCustomerValue("support-email", supportEmail) ? "available for web push subject" : "set supportEmail before syncing app config", commands.editCustomerValues), + status(repoSynced, "Private repository synced", repoSynced ? "no unpushed private repo changes" : "commit and push the private repo changes so the deploy workflow can read them", commands.commit), status(githubReadiness?.ok === true, "GitHub readiness", githubReadiness?.ok === true ? "environments and required secret names confirmed" : "confirm GitHub Environments and required secrets", githubReadinessCommand), status(preflightReadiness?.ok === true, "Installer preflight", preflightReadiness?.ok === true ? "ready for workflow dispatch" : "run local preflight before dispatch", commands.preflight), status(exists(preflightPlanFile), "Preview workflow observed", exists(preflightPlanFile) ? "preflight plan evidence downloaded" : previewDispatch ? "preview dispatch found; observe the run" : "dispatch a preview workflow", previewDispatch ? commands.observePreview : commands.previewDeploy), diff --git a/scripts/smoke-aws-tooling.mjs b/scripts/smoke-aws-tooling.mjs index 7b4bdc00d..a9c83d127 100644 --- a/scripts/smoke-aws-tooling.mjs +++ b/scripts/smoke-aws-tooling.mjs @@ -1629,6 +1629,36 @@ function expectInstallerUpdateDryRun() { } } +function gitCommitAllFixture(repoDir) { + const runGit = (args) => { + const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8", timeout: childProcessTimeoutMs }); + return { status: result.status ?? 1, stdout: result.stdout || "", stderr: result.stderr || "" }; + }; + const requireGit = (args) => { + const result = runGit(args); + if (result.status !== 0) throw new Error(`fixture git ${args.join(" ")} failed: ${result.stderr}`); + return result; + }; + + if (!fs.existsSync(path.join(repoDir, ".git"))) { + requireGit(["init", "-b", "main"]); + requireGit(["config", "user.email", "smoke@example.com"]); + requireGit(["config", "user.name", "Smoke Fixture"]); + const remoteDir = path.join(repoDir, ".remote.git"); + const bare = spawnSync("git", ["init", "--bare", remoteDir], { encoding: "utf8", timeout: childProcessTimeoutMs }); + if ((bare.status ?? 1) !== 0) throw new Error(`fixture bare git init failed: ${bare.stderr}`); + fs.appendFileSync(path.join(repoDir, ".gitignore"), "\n/.remote.git/\n"); + requireGit(["remote", "add", "origin", remoteDir]); + } + + requireGit(["add", "-A"]); + const commit = runGit(["commit", "-m", "fixture commit"]); + if (commit.status !== 0 && !/nothing to commit/.test(`${commit.stdout}${commit.stderr}`)) { + throw new Error(`fixture git commit failed: ${commit.stderr || commit.stdout}`); + } + requireGit(["push", "-u", "origin", "HEAD"]); +} + function expectInstallerStartRecommendsNextStep() { const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-start-")); const nodeModulesDir = path.join(rootDir, "node_modules"); @@ -1745,6 +1775,14 @@ function expectInstallerStartRecommendsNextStep() { throw new Error(`installer start fixture handoff failed.\nSTDOUT:\n${handoff.stdout}\nSTDERR:\n${handoff.stderr}`); } + const needsIamApply = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsIamApply.status !== 0 || !String(needsIamApply.parsed?.nextCommand || "").includes("--apply=true")) { + throw new Error(`installer start should recommend creating the IAM roles after the handoff files exist.\nSTDOUT:\n${needsIamApply.stdout}\nSTDERR:\n${needsIamApply.stderr}`); + } + + fs.mkdirSync(path.join(tempDir, "iam", "staging"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "iam", "staging", "apply-result.json"), JSON.stringify({ ok: true }, null, 2)); + const configure = runJsonScript("scripts/installer-configure.mjs", [ "--environment=staging", `--environment-dir=${path.join(tempDir, "environments", "staging")}`, @@ -1767,6 +1805,12 @@ function expectInstallerStartRecommendsNextStep() { throw new Error(`installer start fixture app-config failed.\nSTDOUT:\n${appConfig.stdout}\nSTDERR:\n${appConfig.stderr}`); } + const needsCommit = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsCommit.status !== 0 || !String(needsCommit.parsed?.nextCommand || "").includes("installer:commit")) { + throw new Error(`installer start should recommend syncing the private repository after local files change.\nSTDOUT:\n${needsCommit.stdout}\nSTDERR:\n${needsCommit.stderr}`); + } + gitCommitAllFixture(tempDir); + const needsGithubReadiness = runJsonScript("scripts/installer-start.mjs", startArgs); if (needsGithubReadiness.status !== 0 || !String(needsGithubReadiness.parsed?.nextCommand || "").includes("installer:github-readiness")) { throw new Error(`installer start should recommend GitHub readiness after local setup is complete.\nSTDOUT:\n${needsGithubReadiness.stdout}\nSTDERR:\n${needsGithubReadiness.stderr}`); @@ -1833,6 +1877,24 @@ function expectInstallerStartRecommendsNextStep() { throw new Error(`installer adopt frontend origin should update backend parameters from deployment evidence.\nSTDOUT:\n${adoptFrontendOrigin.stdout}\nSTDERR:\n${adoptFrontendOrigin.stderr}`); } + const needsPostAdoptCommit = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsPostAdoptCommit.status !== 0 || !String(needsPostAdoptCommit.parsed?.nextCommand || "").includes("installer:commit")) { + throw new Error(`installer start should recommend committing the adopted frontend origin.\nSTDOUT:\n${needsPostAdoptCommit.stdout}\nSTDERR:\n${needsPostAdoptCommit.stderr}`); + } + gitCommitAllFixture(tempDir); + + const needsRedeploy = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsRedeploy.status !== 0 || !String(needsRedeploy.parsed?.nextCommand || "").includes("--confirm=true")) { + throw new Error(`installer start should recommend rerunning the real deploy after adopting the frontend origin.\nSTDOUT:\n${needsRedeploy.stdout}\nSTDERR:\n${needsRedeploy.stderr}`); + } + + fs.writeFileSync(path.join(stagingEvidenceDir, "last-deploy-dispatch.json"), JSON.stringify({ ok: true, runId: 789 }, null, 2)); + const needsObserveRedeploy = runJsonScript("scripts/installer-start.mjs", startArgs); + if (needsObserveRedeploy.status !== 0 || !String(needsObserveRedeploy.parsed?.nextCommand || "").includes("--verify=true")) { + throw new Error(`installer start should recommend observing the post-adopt redeploy.\nSTDOUT:\n${needsObserveRedeploy.stdout}\nSTDERR:\n${needsObserveRedeploy.stderr}`); + } + writeReportEvidenceFixture(deploymentRoot, "staging"); + const needsFirstAdminValues = runJsonScript("scripts/installer-start.mjs", startArgs); if (needsFirstAdminValues.status !== 0 || !String(needsFirstAdminValues.parsed?.nextCommand || "").includes("installer:customer-values") From 40da7baaac761a85e7559b217b2c06fe5702e7c1 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:45:03 -0500 Subject: [PATCH 5/9] Platform tier: no-NAT endpoints, ops runbook, HTTP smoke fallback Third tier of the non-technical-installer work: - backend-api.yaml: the CreateNatGateway=false mode gains Polly and execute-api interface endpoints (the latter only when the WebSocket API is enabled), so text-to-speech and socket pushes work without NAT. The NAT default stays "true" deliberately: outbound email via the SES API and third-party integrations (payment gateways, Mautic, YouTube) have no VPC-endpoint equivalent and would silently break. The trade-off is now documented instead of hidden. - New infrastructure/environments/operations.md day-2 runbook: routine updates, version pinning (pin both repos to a release tag instead of dual `main`), Aurora backup/restore guidance, secret rotation with the applied-at-deploy-time caveat, billing alarms, final-snapshot cleanup after resets, NAT cost trade-off, and where logs live. Linked from start-here (TOC, update section, cost section) and the environments README. - installer:browser-smoke gains --mode=auto|browser|http. Auto falls back to plain HTTP checks (home page + login page reachable and app-shaped) when Playwright or its browser is not installed, so the smoke step no longer forces a Chromium download onto the operator's machine; browser mode reports how to fix the missing browser instead of a raw error. Evidence records which mode ran. Verified against a local HTTP server (success and closed-port failure paths). Co-Authored-By: Claude Fable 5 --- .../cloudformation/backend-api.yaml | 36 ++++++++ infrastructure/environments/README.md | 1 + infrastructure/environments/operations.md | 58 ++++++++++++ infrastructure/environments/start-here.md | 6 +- scripts/installer-browser-smoke.mjs | 88 ++++++++++++++++--- 5 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 infrastructure/environments/operations.md diff --git a/infrastructure/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml index 399f54dcc..87c29b734 100644 --- a/infrastructure/cloudformation/backend-api.yaml +++ b/infrastructure/cloudformation/backend-api.yaml @@ -313,6 +313,9 @@ Rules: Conditions: UseNatGateway: !Equals [!Ref CreateNatGateway, "true"] CreatePrivateAwsEndpoints: !Equals [!Ref CreateNatGateway, "false"] + CreatePrivateExecuteApiEndpoint: !And + - !Condition CreatePrivateAwsEndpoints + - !Condition CreateWebSocketApi CreateWebSocketApi: !Equals [!Ref EnableWebSocketApi, "true"] CreateScheduledWorkers: !Equals [!Ref EnableScheduledWorkers, "true"] RunMigrationResources: !Equals [!Ref RunMigrations, "true"] @@ -578,6 +581,39 @@ Resources: RouteTableIds: - !Ref PrivateRouteTable + PollyVpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Condition: CreatePrivateAwsEndpoints + Properties: + VpcId: !Ref BackendVpc + VpcEndpointType: Interface + PrivateDnsEnabled: true + ServiceName: !Sub "com.amazonaws.${AWS::Region}.polly" + SecurityGroupIds: + - !Ref VpcEndpointSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + + # WebSocket pushes call the API Gateway management API (execute-api) from + # inside the VPC; without NAT this endpoint is the only route to it. + # Note: SES has no usable API interface endpoint, so outbound email (and any + # third-party integration such as payment gateways) still requires the NAT + # gateway. See infrastructure/environments/operations.md. + ExecuteApiVpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Condition: CreatePrivateExecuteApiEndpoint + Properties: + VpcId: !Ref BackendVpc + VpcEndpointType: Interface + PrivateDnsEnabled: true + ServiceName: !Sub "com.amazonaws.${AWS::Region}.execute-api" + SecurityGroupIds: + - !Ref VpcEndpointSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + DatabaseSubnetGroup: Type: AWS::RDS::DBSubnetGroup Properties: diff --git a/infrastructure/environments/README.md b/infrastructure/environments/README.md index c9d40151f..64dc24ef6 100644 --- a/infrastructure/environments/README.md +++ b/infrastructure/environments/README.md @@ -7,6 +7,7 @@ This folder is reference material for the installer. New installers should not s ## What Is Here - [`start-here.md`](./start-here.md): the main step-by-step guide for a new installer. +- [`operations.md`](./operations.md): what to do after launch - updates, version pinning, backups, secrets, and cost control. - [`setup/`](./setup): short prerequisite explanations linked from the main guide. - [`prod/`](./prod): checked-in production starter files copied into the user's private repository. - [`staging/`](./staging): optional practice-environment starter files copied into the user's private repository. diff --git a/infrastructure/environments/operations.md b/infrastructure/environments/operations.md new file mode 100644 index 000000000..92c183f65 --- /dev/null +++ b/infrastructure/environments/operations.md @@ -0,0 +1,58 @@ +# Operations After Launch + +This page covers what to do after B1Admin is live: updates, backups, secrets, and cost control. It assumes the guided installer path from [Start Here](./start-here.md). + +## Routine Updates + +Run updates from the same operator machine, on your schedule: + +```bash +yarn installer:update -- --environment=prod --output=markdown +``` + +Before updating production: + +- Pick a quiet time. Updates are not guaranteed to be zero-downtime. +- Confirm a recent database backup exists (see Backups below). +- If you run staging, update and check staging first. + +## Pin Versions Instead of `main` + +By default the installer deploys the latest `main` branch of both the B1Admin and Api repositories. That is convenient but means every deploy may pick up untested changes, and the two repositories are not guaranteed to have been tested together on any given day. + +For a production install you plan to keep stable, pin both to a release tag or a specific commit: + +1. Run `yarn installer:customer-values` and set "B1Admin source branch or tag" and "Api source branch or tag" to the same release tag (or a commit SHA you have verified together). +2. When you want new features, update both values deliberately, deploy to staging or verify carefully, then update prod. + +Avoid mixing a pinned frontend with an unpinned `main` backend or vice versa; the two move together. + +## Backups and Restore + +The Aurora database is the only place your data lives. Everything else (Lambda functions, the website files) is rebuilt from source on every deploy. + +- Aurora takes automated daily backups. You can also take a manual snapshot any time from the AWS console: RDS > Databases > select the `b1admin-prod-cluster` > Actions > Take snapshot. Take one before every production update. +- To restore: RDS > Snapshots > select the snapshot > Actions > Restore snapshot. This creates a NEW cluster; restoring into the running stack is an advanced operation — if you are not comfortable with it, restore the snapshot, verify the data, and ask for help re-pointing the stack before deleting anything. +- Never delete the running cluster to "clean up." Use `yarn reset:prod -- --dry-run=true` first for any teardown, and read what it plans to do. + +## Secrets + +The application secrets (JWT signing key, encryption key, third-party API keys) live in AWS Secrets Manager under `b1admin//app-config`. + +- To change a value (for example, a new payment-gateway key): edit the local `app-config-secret.json`, run `yarn installer:app-config-secret` / the sync step, and then **run a deploy**. Secret values are applied to the running functions at deploy time, so editing the secret alone does not change the running system. +- Rotating `jwtSecret` signs everyone out; rotating `encryptionKey` can make previously encrypted values unreadable. Do not rotate those two casually — treat them as "only if compromised." +- The first-admin temporary password should have been changed at first sign-in. If not, change it now in the app. + +## Cost Control + +- Create a billing alert once: AWS console > Billing and Cost Management > Budgets > Create budget. A monthly cost budget with an email alert at your expected amount (see [What Costs Money?](./start-here.md#what-costs-money)) catches surprises. +- After any `reset:staging` / `reset:prod`, Aurora leaves a final snapshot by design. Snapshots cost money monthly. When you are sure you do not need one: RDS > Snapshots > select it > Actions > Delete snapshot. +- The NAT gateway (about $33/month) is required for outbound connections to non-AWS services: payment gateways (Stripe/PayPal), Mautic, YouTube lookups, and outgoing email through SES's API. Only set `CreateNatGateway` to `"false"` if you use none of those; AWS-internal features (database, secrets, file storage, text-to-speech, WebSocket pushes) keep working through private endpoints, which have their own smaller cost (roughly $8/month per endpoint). + +## Logs and Troubleshooting + +- Application logs: AWS console > CloudWatch > Log groups > `/aws/lambda/b1admin-prod-api` (and the other `b1admin-prod-*` groups). +- Deploy history and logs: your private repository on GitHub > Actions. +- Local readiness report any time: `yarn installer:doctor -- --output=markdown`. + +[Back to Start Here](./start-here.md) diff --git a/infrastructure/environments/start-here.md b/infrastructure/environments/start-here.md index 6ca271920..b30f42c87 100644 --- a/infrastructure/environments/start-here.md +++ b/infrastructure/environments/start-here.md @@ -31,7 +31,7 @@ Read these sections in order the first time through: 14. [Production Notes](#production-notes) - important production behavior and approval suggestions. 15. [What Costs Money?](#what-costs-money) - shows which parts may create AWS charges. 16. [Final Report](#final-report) - writes the deployment sign-off report. -17. [When You Are Done](#when-you-are-done) - final completion checklist. +17. [When You Are Done](#when-you-are-done) - final completion checklist. After that, [Operations After Launch](./operations.md) covers updates, backups, secrets, and cost control. 18. [Update An Existing Install](#update-an-existing-install) - deploys newer source code into an already installed AWS stack. 19. [Clean Reset](#clean-reset) - removes AWS resources when testing or starting over. 20. [If Something Fails](#if-something-fails) - troubleshooting entry point. @@ -573,6 +573,8 @@ Plan for roughly **$80-$90 per month** for a production-only install with the de | CloudFront, S3, Lambda, API Gateway, Secrets Manager, logs | ~$3-$10 combined | | Route53 hosted zone (only with a custom domain) | ~$0.50 | +The NAT gateway is what lets the backend reach services outside AWS: payment gateways such as Stripe, Mautic, YouTube, and outgoing email. Only consider setting `CreateNatGateway` to `"false"` if you use none of those; the trade-offs are explained in [Operations After Launch](./operations.md#cost-control). + Also plan for time: expect the first install to take **2 to 4 focused hours**, longer if you are creating AWS and GitHub accounts from scratch or waiting on someone else to approve access. After the install, consider creating an AWS billing alarm (AWS console > Billing > Budgets) so an unexpected charge emails you instead of surprising you at the end of the month. @@ -689,7 +691,7 @@ The update command will: The update command still pauses before approval steps. Read each prompt before answering. -If your team deploys a specific branch, tag, or commit instead of latest `main`, switch to that approved version before running `installer:update`, or run with `--skip-pull=true`. +If your team deploys a specific branch, tag, or commit instead of latest `main`, switch to that approved version before running `installer:update`, or run with `--skip-pull=true`. For a stable production install, pin both source repositories to a release tag instead of `main`; see [Operations After Launch](./operations.md#pin-versions-instead-of-main). Important: `installer:update` is a guided update command, not a zero-downtime guarantee. For a production environment with active users, update optional staging first when available, verify login and key workflows, confirm backups or snapshots exist, review the prod preflight and preview output, and run prod during an approved low-traffic or maintenance window. Database migrations, CloudFormation replacements, API changes, and frontend/backend compatibility changes can affect live users if they are not planned carefully. diff --git a/scripts/installer-browser-smoke.mjs b/scripts/installer-browser-smoke.mjs index f713e2ffd..0f656cb78 100644 --- a/scripts/installer-browser-smoke.mjs +++ b/scripts/installer-browser-smoke.mjs @@ -109,11 +109,49 @@ async function runBrowserSmoke({ appUrl, email, password, churchName, route, hea } } +async function runHttpSmoke({ appUrl, timeoutMs }) { + const steps = []; + const errors = []; + + const check = async (name, url) => { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "follow" }); + const body = await response.text(); + const looksLikeApp = response.ok && /<(html|div|script)/i.test(body); + steps.push({ name, ok: looksLikeApp, detail: `${url} -> HTTP ${response.status}` }); + if (!looksLikeApp) errors.push(`${url} responded with HTTP ${response.status} but did not look like the app.`); + return looksLikeApp; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + steps.push({ name, ok: false, detail: `${url} -> ${message}` }); + errors.push(`${url}: ${message}`); + return false; + } + }; + + const homeOk = await check("load app home page", `${appUrl}/`); + const loginOk = await check("load login page", `${appUrl}/login`); + steps.push({ + name: "sign-in check", + ok: true, + skipped: true, + detail: "HTTP mode cannot sign in; sign in once in your own browser to confirm the login works.", + }); + + return { + ok: homeOk && loginOk, + finalUrl: appUrl, + steps, + errors, + }; +} + function renderMarkdown(result) { const lines = [ `# Browser Smoke: ${result.environment}`, "", `- Status: ${result.ok ? "ok" : "failed"}`, + `- Mode: ${result.mode}`, `- App URL: \`${result.appUrl}\``, `- Route: \`${result.route}\``, `- Evidence file: \`${result.outputFile}\``, @@ -151,6 +189,10 @@ async function main() { const route = getArg("route", "/people"); const timeoutMs = Number(getArg("timeout-ms", "30000")); const dryRun = boolArg("dry-run", false); + const modeArg = getArg("mode", "auto").toLowerCase(); + if (!["auto", "browser", "http"].includes(modeArg)) { + failText("--mode must be auto, browser, or http.", outputMode); + } if (!appUrl) failText("--app-url is required, or deployment//deployment-summary.json must contain resolved.frontendAppUrl.", outputMode); if (!email) failText("--email or firstAdminEmail in --customer-file is required.", outputMode); @@ -163,22 +205,48 @@ async function main() { { name: "dry run", ok: true, detail: "Browser was not launched." }, ], }; + let mode = dryRun ? "dry-run" : modeArg; if (!dryRun) { - smoke = await runBrowserSmoke({ - appUrl, - email, - password, - churchName, - route, - headed: boolArg("headed", false), - timeoutMs, - screenshotFile, - }); + if (modeArg === "http") { + smoke = await runHttpSmoke({ appUrl, timeoutMs }); + mode = "http"; + } else { + try { + smoke = await runBrowserSmoke({ + appUrl, + email, + password, + churchName, + route, + headed: boolArg("headed", false), + timeoutMs, + screenshotFile, + }); + mode = "browser"; + } catch (error) { + // Playwright missing or its browser is not installed. In auto mode, + // fall back to plain HTTP checks so the smoke step still verifies the + // site is reachable; in browser mode, surface the problem. + const message = error instanceof Error ? error.message : String(error); + if (modeArg === "browser") { + failText(`Could not launch the test browser: ${message}\nRun \`yarn install\` (and \`yarn playwright install chromium\` if asked), or re-run with --mode=http.`, outputMode); + } + smoke = await runHttpSmoke({ appUrl, timeoutMs }); + mode = "http"; + smoke.steps.unshift({ + name: "browser fallback", + ok: true, + skipped: true, + detail: "The test browser is not installed, so HTTP checks were used instead.", + }); + } + } } const result = { ok: smoke.ok, + mode, environment, appUrl, route, From c2cd79328eb741555ecca1468458399504dd5f4b Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:58:17 -0500 Subject: [PATCH 6/9] Security: private asset bucket, real CORS, secrets off argv and logs Fixes the round-1 security review findings: - ManagedAssetBucket is no longer world-readable. Public access is fully blocked and uploaded assets are served through a new CloudFront distribution with Origin Access Control (same pattern as the frontend site); CONTENT_ROOT / CONTENT_ROOT_URL env vars and the ContentRootUrl stack output now point at the distribution domain, and the bucket's CORS rule is scoped to the CorsOrigin parameter. New AssetDistributionId output. Externally supplied AssetBucketName deployments are unchanged. - API Gateway CORS now honors the CorsOrigin parameter instead of a hardcoded "*" (the parameter default remains "*", tightened automatically when the installer adopts the frontend origin). - sync-legacy-ssm-parameters no longer leaks secrets: parameter values go to AWS via a 0600 temp --cli-input-json file instead of argv, the echoed command hides the value, JSON/text output lists parameter names only, --output=json actually syncs instead of silently returning early, and connection-string credentials are URL-encoded. Sample and smoke contract updated to assert values never appear. - sync-app-config-secret passes the secret to the AWS CLI as --secret-string file:// instead of putting the full secret JSON on the process argv. - deploy-aws/deploy-backend/deploy-full-stack mask password and secret-string values in their echoed command lines. - operations.md gains a security-posture section, including the honest limitation that secrets reach Lambda as env vars resolved at deploy time (runtime fetching would require upstream Api changes) and what that implies for AWS account access. Co-Authored-By: Claude Fable 5 --- .../cloudformation/backend-api.yaml | 100 ++++++++++++++---- infrastructure/environments/operations.md | 6 ++ .../sync-legacy-ssm-output.sample.json | 30 ++---- scripts/deploy-aws.mjs | 6 +- scripts/deploy-backend.mjs | 6 +- scripts/deploy-full-stack.mjs | 6 +- scripts/smoke-aws-tooling.mjs | 5 +- scripts/sync-app-config-secret.mjs | 4 +- scripts/sync-legacy-ssm-parameters.mjs | 70 ++++++------ 9 files changed, 154 insertions(+), 79 deletions(-) diff --git a/infrastructure/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml index 87c29b734..168250e0c 100644 --- a/infrastructure/cloudformation/backend-api.yaml +++ b/infrastructure/cloudformation/backend-api.yaml @@ -807,21 +807,64 @@ Resources: CorsRules: - AllowedHeaders: ["*"] AllowedMethods: [GET, HEAD, PUT] - AllowedOrigins: ["*"] + AllowedOrigins: [!Ref CorsOrigin] ExposedHeaders: [ETag] MaxAge: 3000 OwnershipControls: Rules: - ObjectOwnership: BucketOwnerEnforced PublicAccessBlockConfiguration: - BlockPublicAcls: false - BlockPublicPolicy: false - IgnorePublicAcls: false - RestrictPublicBuckets: false + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true VersioningConfiguration: Status: Enabled - ManagedAssetBucketPublicReadPolicy: + # Uploaded assets are served through CloudFront with Origin Access Control + # instead of a world-readable bucket; CONTENT_ROOT points at the + # distribution domain. + ManagedAssetOriginAccessControl: + Type: AWS::CloudFront::OriginAccessControl + Condition: CreateManagedAssetBucket + Properties: + OriginAccessControlConfig: + Name: !Sub "${ProjectName}-${EnvironmentName}-assets-oac" + OriginAccessControlOriginType: s3 + SigningBehavior: always + SigningProtocol: sigv4 + + ManagedAssetDistribution: + Type: AWS::CloudFront::Distribution + Condition: CreateManagedAssetBucket + Properties: + DistributionConfig: + Enabled: true + Comment: !Sub "${ProjectName} ${EnvironmentName} uploaded assets" + HttpVersion: http2 + PriceClass: PriceClass_100 + Origins: + - Id: managed-assets + DomainName: !GetAtt ManagedAssetBucket.RegionalDomainName + OriginAccessControlId: !GetAtt ManagedAssetOriginAccessControl.Id + S3OriginConfig: + OriginAccessIdentity: "" + DefaultCacheBehavior: + TargetOriginId: managed-assets + ViewerProtocolPolicy: redirect-to-https + AllowedMethods: [GET, HEAD] + CachedMethods: [GET, HEAD] + # AWS managed CachingOptimized cache policy + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 + # AWS managed CORS-S3Origin origin request policy + OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf + Tags: + - Key: Project + Value: !Ref ProjectName + - Key: Environment + Value: !Ref EnvironmentName + + ManagedAssetBucketCloudFrontReadPolicy: Type: AWS::S3::BucketPolicy Condition: CreateManagedAssetBucket Properties: @@ -830,10 +873,14 @@ Resources: Version: "2012-10-17" Statement: - Effect: Allow - Principal: "*" + Principal: + Service: cloudfront.amazonaws.com Action: - s3:GetObject Resource: !Sub "arn:${AWS::Partition}:s3:::${ManagedAssetBucket}/*" + Condition: + StringEquals: + AWS:SourceArn: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${ManagedAssetDistribution}" ApiLogGroup: Type: AWS::Logs::LogGroup @@ -1014,18 +1061,20 @@ Resources: - !Ref ContentRootUrl - !If - HasResolvedAssetBucket - - !Sub - - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - "" CONTENT_ROOT: !If - HasContentRootUrl - !Ref ContentRootUrl - !If - HasResolvedAssetBucket - - !Sub - - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - "" B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] TRANSFER_URL: !Ref TransferUrl @@ -1223,9 +1272,10 @@ Resources: - !Ref ContentRootUrl - !If - HasResolvedAssetBucket - - !Sub - - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - "" B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] Tags: @@ -1318,9 +1368,10 @@ Resources: - !Ref ContentRootUrl - !If - HasResolvedAssetBucket - - !Sub - - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - "" B1ADMIN_ROOT: !If [HasB1AdminRootUrl, !Ref B1AdminRootUrl, !Ref WebsiteBaseUrl] Tags: @@ -1693,7 +1744,7 @@ Resources: - DELETE - OPTIONS AllowOrigins: - - "*" + - !Ref CorsOrigin AllowHeaders: - authorization - content-type @@ -1854,10 +1905,13 @@ Outputs: - !Ref ContentRootUrl - !If - HasResolvedAssetBucket - - !Sub - - "https://${ResolvedBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - - ResolvedBucketName: !If [CreateManagedAssetBucket, !Ref ManagedAssetBucket, !Ref AssetBucketName] + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" - "" + AssetDistributionId: + Value: !If [CreateManagedAssetBucket, !Ref ManagedAssetDistribution, ""] WebsiteBaseUrl: Value: !Ref WebsiteBaseUrl LessonsApiUrl: diff --git a/infrastructure/environments/operations.md b/infrastructure/environments/operations.md index 92c183f65..e000de91b 100644 --- a/infrastructure/environments/operations.md +++ b/infrastructure/environments/operations.md @@ -49,6 +49,12 @@ The application secrets (JWT signing key, encryption key, third-party API keys) - After any `reset:staging` / `reset:prod`, Aurora leaves a final snapshot by design. Snapshots cost money monthly. When you are sure you do not need one: RDS > Snapshots > select it > Actions > Delete snapshot. - The NAT gateway (about $33/month) is required for outbound connections to non-AWS services: payment gateways (Stripe/PayPal), Mautic, YouTube lookups, and outgoing email through SES's API. Only set `CreateNatGateway` to `"false"` if you use none of those; AWS-internal features (database, secrets, file storage, text-to-speech, WebSocket pushes) keep working through private endpoints, which have their own smaller cost (roughly $8/month per endpoint). +## Security Posture Notes + +- Application secrets are delivered to the Lambda functions as environment variables, resolved from Secrets Manager at deploy time. This is why a redeploy is needed after changing a secret, and it means anyone whose AWS access allows reading Lambda function configuration can read the values. Keep AWS console/CLI access to this account limited to people you would trust with the secrets themselves. (Fetching secrets at runtime instead would require changes to the upstream Api application.) +- Uploaded assets are served through CloudFront, not from a public S3 bucket. The asset bucket itself blocks all public access. +- The API only accepts browser requests from your site's own address (the CORS origin adopted during install), not from arbitrary websites. + ## Logs and Troubleshooting - Application logs: AWS console > CloudWatch > Log groups > `/aws/lambda/b1admin-prod-api` (and the other `b1admin-prod-*` groups). diff --git a/infrastructure/examples/sync-legacy-ssm-output.sample.json b/infrastructure/examples/sync-legacy-ssm-output.sample.json index 281a40e08..541a15b26 100644 --- a/infrastructure/examples/sync-legacy-ssm-output.sample.json +++ b/infrastructure/examples/sync-legacy-ssm-output.sample.json @@ -8,44 +8,34 @@ "parameterCount": 10, "parameters": [ { - "name": "/prod/jwtSecret", - "value": "replace-me" + "name": "/prod/jwtSecret" }, { - "name": "/prod/encryptionKey", - "value": "replace-me" + "name": "/prod/encryptionKey" }, { - "name": "/prod/membershipApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/membership" + "name": "/prod/membershipApi/connectionString" }, { - "name": "/prod/attendanceApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance" + "name": "/prod/attendanceApi/connectionString" }, { - "name": "/prod/contentApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/content" + "name": "/prod/contentApi/connectionString" }, { - "name": "/prod/givingApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/giving" + "name": "/prod/givingApi/connectionString" }, { - "name": "/prod/messagingApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/messaging" + "name": "/prod/messagingApi/connectionString" }, { - "name": "/prod/doingApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/doing" + "name": "/prod/doingApi/connectionString" }, { - "name": "/prod/reportingApi/connectionString", - "value": "mysql://churchapps:replace-me@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/reporting" + "name": "/prod/reportingApi/connectionString" }, { - "name": "/prod/webPushSubject", - "value": "mailto:support@example.com" + "name": "/prod/webPushSubject" } ] } diff --git a/scripts/deploy-aws.mjs b/scripts/deploy-aws.mjs index 7634b5ecc..6586090e4 100644 --- a/scripts/deploy-aws.mjs +++ b/scripts/deploy-aws.mjs @@ -40,9 +40,13 @@ function exitForCommandError(error, quiet = false) { process.exit(1); } +function maskSensitiveArg(arg) { + return String(arg).replace(/^(--[^=]*(?:password|secret-string)[^=]*=).+/i, "$1"); +} + function run(scriptPath, args, options = {}) { const { quiet = false, ...execOptions } = options; - if (!quiet) console.log(`\n> node ${scriptPath} ${args.join(" ")}`); + if (!quiet) console.log(`\n> node ${scriptPath} ${args.map(maskSensitiveArg).join(" ")}`); try { return execFileSync("node", [scriptPath, ...args], { diff --git a/scripts/deploy-backend.mjs b/scripts/deploy-backend.mjs index 8a92bc652..ae91a0c66 100644 --- a/scripts/deploy-backend.mjs +++ b/scripts/deploy-backend.mjs @@ -32,9 +32,13 @@ function exitForCommandError(error, quiet = false) { process.exit(1); } +function maskSensitiveArg(arg) { + return String(arg).replace(/^(--[^=]*(?:password|secret-string)[^=]*=).+/i, "$1"); +} + function run(command, args, options = {}) { const { quiet = false, ...execOptions } = options; - if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + if (!quiet) console.log(`\n> ${command} ${args.map(maskSensitiveArg).join(" ")}`); try { return execFileSync(command, args, { diff --git a/scripts/deploy-full-stack.mjs b/scripts/deploy-full-stack.mjs index c12306d98..caa4d0ae4 100644 --- a/scripts/deploy-full-stack.mjs +++ b/scripts/deploy-full-stack.mjs @@ -35,9 +35,13 @@ function exitForCommandError(error, quiet = false) { process.exit(1); } +function maskSensitiveArg(arg) { + return String(arg).replace(/^(--[^=]*(?:password|secret-string)[^=]*=).+/i, "$1"); +} + function run(command, args, options = {}) { const { quiet = false, ...execOptions } = options; - if (!quiet) console.log(`\n> ${command} ${args.join(" ")}`); + if (!quiet) console.log(`\n> ${command} ${args.map(maskSensitiveArg).join(" ")}`); try { return execFileSync(command, args, { diff --git a/scripts/smoke-aws-tooling.mjs b/scripts/smoke-aws-tooling.mjs index a9c83d127..37a2dab5f 100644 --- a/scripts/smoke-aws-tooling.mjs +++ b/scripts/smoke-aws-tooling.mjs @@ -5145,9 +5145,12 @@ function expectSyncLegacySsmOutputSampleMatchesContract() { if (sample.parameterCount !== 10) { throw new Error(`sync-legacy-ssm output sample should document parameterCount=10 for the checked sample inputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); } - if (!Array.isArray(sample.parameters) || !sample.parameters.some((entry) => entry.name === "/prod/webPushSubject" && entry.value === "mailto:support@example.com")) { + if (!Array.isArray(sample.parameters) || !sample.parameters.some((entry) => entry.name === "/prod/webPushSubject")) { throw new Error(`sync-legacy-ssm output sample should include the sample webPushSubject parameter.\nSample:\n${JSON.stringify(sample, null, 2)}`); } + if (sample.parameters.some((entry) => "value" in entry) || (actual.parameters || []).some((entry) => "value" in entry)) { + throw new Error(`sync-legacy-ssm output must list parameter names only; values are secrets and must not appear.\nSample:\n${JSON.stringify(sample, null, 2)}`); + } }); } diff --git a/scripts/sync-app-config-secret.mjs b/scripts/sync-app-config-secret.mjs index e808c5a3d..08edd1084 100644 --- a/scripts/sync-app-config-secret.mjs +++ b/scripts/sync-app-config-secret.mjs @@ -142,7 +142,7 @@ function main() { "--secret-id", describe.value.ARN || lookupId, "--secret-string", - secretString, + `file://${resolvedSecretFile}`, "--region", region, "--output", @@ -175,7 +175,7 @@ function main() { "--description", description, "--secret-string", - secretString, + `file://${resolvedSecretFile}`, "--region", region, "--output", diff --git a/scripts/sync-legacy-ssm-parameters.mjs b/scripts/sync-legacy-ssm-parameters.mjs index 1e11e2b06..ed6debe50 100644 --- a/scripts/sync-legacy-ssm-parameters.mjs +++ b/scripts/sync-legacy-ssm-parameters.mjs @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -55,18 +56,6 @@ function runJson(command, args) { })); } -function run(command, args) { - console.log(`\n> ${command} ${args.join(" ")}`); - try { - execFileSync(command, args, { - cwd: rootDir, - stdio: "inherit", - }); - } catch (error) { - exitForCommandError(error); - } -} - function normalizeOutputs(raw) { if (!raw) return {}; if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); @@ -144,7 +133,37 @@ function getSecretJson(secretId, region) { } function buildMysqlConnectionString({ username, password, host, port, database }) { - return `mysql://${username}:${password}@${host}:${port}/${database}`; + return `mysql://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}/${database}`; +} + +// Writes each parameter through a 0600 temp file (--cli-input-json) so the +// secret value never appears on the process argv or in the echoed command. +function putParameter(parameter, region, overwrite) { + console.log(`\n> aws ssm put-parameter --name ${parameter.name} --type SecureString (value hidden)`); + const tempFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "b1admin-ssm-")), "put-parameter.json"); + try { + fs.writeFileSync(tempFile, JSON.stringify({ + Name: parameter.name, + Value: parameter.value, + Type: "SecureString", + Overwrite: overwrite, + }), { mode: 0o600 }); + execFileSync("aws", [ + "ssm", + "put-parameter", + "--cli-input-json", + `file://${tempFile}`, + "--region", + region, + ], { + cwd: rootDir, + stdio: "inherit", + }); + } catch (error) { + exitForCommandError(error); + } finally { + fs.rmSync(path.dirname(tempFile), { recursive: true, force: true }); + } } function compactParameters(entries, includeEmpty) { @@ -231,6 +250,13 @@ function main() { { name: `${prefix}/webPushSubject`, value: appConfig.webPushSubject || "" }, ], includeEmpty); + if (!dryRun) { + for (const parameter of parameters) { + putParameter(parameter, region, overwrite); + } + } + + // Parameter values are secrets; report names only. const result = { stackName, region, @@ -239,7 +265,7 @@ function main() { overwrite, dryRun, parameterCount: parameters.length, - parameters, + parameters: parameters.map((parameter) => ({ name: parameter.name })), }; if (outputMode === "json") { @@ -256,22 +282,6 @@ function main() { return; } - for (const parameter of parameters) { - run("aws", [ - "ssm", - "put-parameter", - "--name", - parameter.name, - "--value", - parameter.value, - "--type", - "SecureString", - "--region", - region, - ...(overwrite ? ["--overwrite"] : []), - ]); - } - console.log("\nLegacy SSM parameter sync complete."); console.log(`Stack: ${stackName}`); console.log(`Prefix: ${prefix}`); From 0d0da0cc1c337e77f6ce9360707264ad03aee06e Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 15:29:26 -0500 Subject: [PATCH 7/9] Slim the PR: one deploy path, one migration runner, focused docs Reviewer-driven diet. The PR shipped three overlapping ways to deploy the same stack; this commits to the single golden path the guided installer uses (split-stack via GitHub Actions) and removes the parallel alternatives: - Remove the full-stack deployment mode: deploy-full-stack.mjs, full-stack.yaml, its 8 output/parameter samples, and the package.json entry. validate:aws-deploy now defaults to --mode=split-stack and exits with a clear message if --mode=full-stack is requested. - Remove the "direct" API migration runner (run-api-migrations.mjs and its sample): migrations run through the RDS Data API everywhere. deploy-backend/deploy-aws default to data-api and explain the removal if direct is requested; run:api-migrations now maps to the data-api script. - Remove tools superseded by the guided installer: environment-setup-wizard.mjs (replaced by installer:customer-values) and launch-staging.mjs (replaced by installer:run). - Docs: deployment-workbook.md removed; infrastructure/README.md rewritten from a 1,186-line manual-flow reference into a ~55-line architecture overview that defers to start-here.md and operations.md. - Smoke suite updated: ~32 scenarios and 19 helper functions for the removed tools deleted (916 lines), replaced by 4 scenarios asserting the new removal errors; script/template/sample check lists updated. Full suite passes. Net: -5,200 lines, -15 files versus the previous branch state. Co-Authored-By: Claude Fable 5 --- infrastructure/README.md | 1199 +---------------- infrastructure/cloudformation/full-stack.yaml | 499 ------- infrastructure/environments/README.md | 1 - .../environments/deployment-workbook.md | 289 ---- infrastructure/environments/start-here.md | 1 - ...ll-stack-frontend-infra-output.sample.json | 64 - .../deploy-full-stack-full-output.sample.json | 64 - ...ull-stack-publish-build-output.sample.json | 47 - ...ploy-full-stack-publish-output.sample.json | 34 - .../full-stack-parameters.sample.json | 88 -- .../package-api-backend-output.sample.json | 3 +- .../examples/package-manifest.sample.json | 3 +- .../run-api-migrations-output.sample.json | 28 - ...ll-stack-frontend-infra-output.sample.json | 58 - .../validate-full-stack-output.sample.json | 56 - ...date-full-stack-publish-output.sample.json | 62 - package.json | 5 +- scripts/deploy-aws.mjs | 15 +- scripts/deploy-backend.mjs | 19 +- scripts/deploy-full-stack.mjs | 925 ------------- scripts/environment-setup-wizard.mjs | 360 ----- scripts/launch-staging.mjs | 189 --- scripts/package-api-backend.mjs | 3 +- scripts/run-api-migrations.mjs | 378 ------ scripts/smoke-aws-tooling.mjs | 933 +------------ scripts/validate-aws-deploy.mjs | 8 +- 26 files changed, 70 insertions(+), 5261 deletions(-) delete mode 100644 infrastructure/cloudformation/full-stack.yaml delete mode 100644 infrastructure/environments/deployment-workbook.md delete mode 100644 infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json delete mode 100644 infrastructure/examples/deploy-full-stack-full-output.sample.json delete mode 100644 infrastructure/examples/deploy-full-stack-publish-build-output.sample.json delete mode 100644 infrastructure/examples/deploy-full-stack-publish-output.sample.json delete mode 100644 infrastructure/examples/full-stack-parameters.sample.json delete mode 100644 infrastructure/examples/run-api-migrations-output.sample.json delete mode 100644 infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json delete mode 100644 infrastructure/examples/validate-full-stack-output.sample.json delete mode 100644 infrastructure/examples/validate-full-stack-publish-output.sample.json delete mode 100644 scripts/deploy-full-stack.mjs delete mode 100644 scripts/environment-setup-wizard.mjs delete mode 100644 scripts/launch-staging.mjs delete mode 100644 scripts/run-api-migrations.mjs diff --git a/infrastructure/README.md b/infrastructure/README.md index 72e06c744..c87197d35 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -2,1185 +2,46 @@ > **Setting up B1Admin for your church or organization?** You do not need this page. Follow the guided installer instead: [Start Here](./environments/start-here.md). This page is the technical reference for operators who want to understand or customize the templates and scripts the installer uses. -This repo now includes AWS deployment building blocks for both the B1Admin frontend and a backend foundation: +## Architecture -- Bootstrap stack: [`cloudformation/bootstrap.yaml`](./cloudformation/bootstrap.yaml) -- Frontend stack: [`cloudformation/frontend-site.yaml`](./cloudformation/frontend-site.yaml) -- Backend stack: [`cloudformation/backend-api.yaml`](./cloudformation/backend-api.yaml) -- Full-stack nested template: [`cloudformation/full-stack.yaml`](./cloudformation/full-stack.yaml) -- Full deployment wrapper: [`../scripts/deploy-aws.mjs`](../scripts/deploy-aws.mjs) -- Nested-stack deploy helper: [`../scripts/deploy-full-stack.mjs`](../scripts/deploy-full-stack.mjs) +A self-hosted B1Admin deployment is three CloudFormation stacks per environment, deployed in order: -## What This Repo Owns +| Stack | Template | What it owns | +| --- | --- | --- | +| Bootstrap | [`cloudformation/bootstrap.yaml`](./cloudformation/bootstrap.yaml) | Versioned S3 buckets for CloudFormation templates and Lambda artifacts | +| Backend | [`cloudformation/backend-api.yaml`](./cloudformation/backend-api.yaml) | VPC, Aurora Serverless v2 (MySQL, Data API enabled), the Api Lambda functions and timers, HTTP + WebSocket API Gateway, Secrets Manager wiring, the managed asset bucket behind CloudFront | +| Frontend | [`cloudformation/frontend-site.yaml`](./cloudformation/frontend-site.yaml) | The site S3 bucket behind CloudFront with Origin Access Control, optional custom domain via ACM/Route53 | -- S3 bucket for frontend assets -- CloudFront distribution with SPA routing fallback -- Optional Route53 alias record -- Asset upload + cache invalidation workflow -- Backend VPC with public/private subnets -- Optional NAT gateway for private Lambda egress -- Private AWS service endpoints for S3 and Secrets Manager when NAT is disabled -- Aurora Serverless v2 cluster -- Lambda execution role and VPC networking -- HTTP API Gateway in front of the backend Lambda -- Default server-side encryption on deployment and frontend S3 buckets -- Aurora cluster snapshot preservation on stack delete or replacement -- Retained S3 buckets so stack teardown does not fail on non-empty deployment buckets -- Common runtime IAM for WebSocket management, S3 asset storage, SES mail sending, and Polly speech synthesis +The backend Lambda code comes from the [ChurchApps Api](https://github.com/ChurchApps/Api) repository, packaged as a zip (self-contained or with a dependencies layer) and uploaded to the bootstrap artifact bucket. Database migrations run through the RDS Data API, so no machine needs network access to the database. -## What This Repo Does Not Own +## Deployment flow -This repo still does not contain the backend API application source or database migrations. The infrastructure here can provision the AWS foundation, but you still need: +The supported path is the guided installer (`yarn installer:run`), which dispatches the GitHub Actions workflow in the operator's private deployment repository. The workflow authenticates to AWS with GitHub OIDC (see [`iam/README.md`](./iam/README.md)), then runs the same scripts an operator can run locally: -- A packaged backend Lambda artifact uploaded to S3 -- API application code that can run inside Lambda -- Database schema migrations and optional initial-admin bootstrap data -- Any additional supporting services your backend uses +- `yarn deploy:bootstrap` - create/update the bootstrap stack. +- `yarn package:api-backend` - build the Api backend zip (and optional layer) from an Api checkout. +- `yarn upload:backend-artifact` / `yarn publish:lambda-layer` - stage artifacts in S3. +- `yarn deploy:backend` - deploy the backend stack; with `--run-api-migrations=true` it runs Data API migrations, and `--run-bootstrap-admin=true` seeds the first admin. +- `yarn deploy:frontend` / `yarn publish:frontend-assets` - deploy the frontend stack and publish the built site. +- `yarn deploy:aws` - orchestrates the backend + frontend sequence in one command. +- `yarn verify:split-stack` - post-deploy verification of both stacks. +- `yarn validate:aws-deploy` - pre-deploy validation (`--mode=split-stack`, `--mode=bootstrap`, `--mode=backend`, `--mode=frontend`, `--mode=api-migrations`). -The frontend should still treat backend/public values as inputs, not assumptions. +Each environment folder under [`environments/`](./environments) ships starter parameter files and a `deploy-split-stack.sh` wrapper that chains validate -> bootstrap -> backend -> frontend -> verify for that environment. -If you also have the real Api repo checked out locally, this repo now includes a backend packaging helper: +## Configuration -- `yarn package:api-backend -- --api-repo-path=` -- `yarn audit:api-repo-contract -- --api-repo-path= --output=markdown` +- Environment parameter files: `environments//{bootstrap,backend,frontend}-parameters.json`, rendered from the operator's `customer-values.json` by `yarn installer:configure`. +- Application secrets: one Secrets Manager secret per environment (`b1admin//app-config`), generated by `yarn installer:app-config-secret` and synced by `yarn sync:app-config-secret`. Values reach the Lambda functions as environment variables at deploy time; see [operations](./environments/operations.md#security-posture-notes). +- Legacy compatibility: `yarn sync:legacy-ssm` mirrors the stack's values into the Serverless-era SSM parameter layout for Api tooling that still reads it. +- IAM: sample OIDC deploy-role and CloudFormation execution-role policies live in [`iam/`](./iam), scoped to `b1admin--*` resources. The installer renders and can apply them (`yarn installer:aws-roles -- --apply=true`). -By default it builds the Api repo and creates a self-contained Lambda zip that works with the current CloudFormation backend path. It can also package a layered artifact set with `--package-mode=layered` when you want to stay closer to the Api repo's current Serverless packaging model. -The first successful live private-repo staging rollout on June 24, 2026 ultimately required the layered path because the self-contained Api artifact exceeded Lambda's 250 MB unzipped limit. -The helper now works with the Api repo's Yarn Berry setup through Corepack, so it does not require a globally installed `yarn` binary as long as `corepack` is available. -If the referenced Api repo path exists but key files are unreadable in the current environment, the helper now fails early with a direct readability error instead of falling through into a later Corepack/Yarn child-process failure. -There is also a layer publication helper when you want to promote a packaged dependency layer into AWS directly: +## Cost and teardown -- `yarn publish:lambda-layer -- --layer-name= --source-file=` +See [What Costs Money?](./environments/start-here.md#what-costs-money) for the monthly estimate (roughly $80-90 for prod with defaults) and [Operations After Launch](./environments/operations.md) for updates, backups, secret rotation, and billing alarms. `yarn reset:prod -- --dry-run=true` previews teardown; Aurora final snapshots are retained by design. -And there is now a Secrets Manager sync helper for the backend's non-database runtime config: +## Conventions -- `yarn sync:app-config-secret -- --secret-name= --secret-file=infrastructure/examples/app-config-secret.sample.json` - -If you want the same JSON pushed into a GitHub Actions deployment environment secret for the self-hosted workflow, there is also: - -- `yarn sync:github-app-config-secret -- --environment=staging --secret-file=infrastructure/environments/staging/app-config-secret.json` - -And if you want one wrapper to sync that GitHub environment secret first and then dispatch the workflow with the checked inputs, there is also: - -- `yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --repo=ChurchApps/B1Admin` - -That wrapper now checks `gh auth status` even in `--dry-run=true` mode so a local validation run only reports success when this machine can really use GitHub CLI for the next step. It now distinguishes missing `gh`, invalid GitHub auth, and basic connectivity failures to `github.com`. If you need an offline/test-only dry run, pass `--skip-gh-auth-check=true`. - -If you still rely on the real Api repo's legacy Parameter Store layout, there is also an SSM compatibility helper: - -- `yarn sync:legacy-ssm -- --stack-name= --environment=prod --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` - -## Real Api Repo Contract - -The real backend repo, typically checked out beside this repo at a path such as `../Api`, uses a more specific Lambda contract than a generic single-handler API: - -- primary HTTP Lambda handler: `lambda.web` -- runtime: `nodejs22.x` -- additional Lambda entrypoints in the same package for WebSocket and timer workloads -- deploy-time config in Serverless today is sourced heavily from SSM Parameter Store -- application boot expects `ENVIRONMENT` or `STAGE`, not just `APP_ENV` -- application boot expects MySQL-style per-module connection strings such as `MEMBERSHIP_CONNECTION_STRING` - -The templates in this repo now default the main backend Lambda to `lambda.web` on `nodejs22.x`, and they set `ENVIRONMENT` plus `STAGE` alongside `APP_ENV`. -They also now default the database layer toward Aurora MySQL and generate module-specific MySQL connection strings for membership, attendance, content, giving, messaging, doing, and reporting, plus `DOING_MEMBERSHIP_CONNECTION_STRING`. -They can also provision the Api repo's `lambda.socket` WebSocket handler and the scheduled timer handlers from the same packaged artifact. -They now also expose the Api repo's core runtime config knobs for file storage, mail system, delivery provider, admin URL, store API URL, socket URL, and CORS without needing a separate Serverless-only config layer for those basics. -They also support an optional `AppConfigSecretArn` that can supply the Api repo's non-database secrets and provider keys from a single Secrets Manager JSON document. -They now also support optional Lambda layer ARNs plus `NODE_OPTIONS`, so you can deploy either a fully self-contained backend zip or a packaging flow closer to the Api repo's current Serverless layer setup. -The deployment helpers now also capture the S3 `VersionId` returned by the artifact bucket and pass it into CloudFormation, so backend redeploys pick up changed zip contents even when the artifact key stays the same. - -There are still important gaps between the current CloudFormation backend stack and the full Api repo deployment model: - -- it still reads many deploy-time secrets/config values from SSM Parameter Store today -- the existing Serverless deployment uses a broader IAM/service integration footprint than this stack currently models -- the exact backend packaging/build pipeline is still driven from the Api repo, not from this repo - -That means the current backend template is closer to an AWS hosting foundation for the Api repo than a one-to-one replacement for its existing `serverless.yml`. - -## Stack Layout - -### `bootstrap` - -The bootstrap template provisions: - -- S3 bucket for nested CloudFormation templates -- S3 bucket for Lambda/build artifacts -- Server-side encryption on both buckets by default -- CloudFormation retain policies on both buckets to avoid deleting shared deployment assets automatically - -Use it once per environment or account before the other deployment flows if you do not already have suitable buckets. - -### `backend-api` - -The backend template provisions: - -- VPC -- 2 public subnets -- 2 private subnets -- Optional NAT gateway -- Private S3 gateway endpoint and Secrets Manager interface endpoint when NAT is disabled -- Lambda security group -- Aurora Serverless v2 cluster and writer instance -- HTTP API Gateway -- Optional WebSocket API Gateway wired to `lambda.socket` -- Optional scheduled worker Lambdas for the Api repo timer handlers -- Lambda function sourced from a packaged zip in S3 -- Optional managed S3 asset/content bucket for uploaded media when `FileStore=S3` -- IAM permissions for WebSocket connection management, optional asset-bucket S3 access, SES sending when `MailSystem=SES`, and Polly speech synthesis - -The Aurora cluster now uses CloudFormation `DeletionPolicy: Snapshot` and `UpdateReplacePolicy: Snapshot`, so accidental stack deletion or cluster replacement preserves a final DB snapshot instead of dropping the data immediately. - -The backend template now also generates its own Secrets Manager database password with a URL-safe character set instead of relying on Aurora's opaque managed password generation. That matters because the real Api repo currently consumes MySQL connection URLs from environment variables, and unescaped `@` or `/` characters in a generated password can break that parser. - -### `frontend-site` - -The frontend template provisions: - -- S3 asset bucket -- CloudFront distribution -- Optional Route53 alias records -- Server-side encryption on the asset bucket by default -- CloudFormation retain policy on the asset bucket so uploaded site files are not auto-deleted during stack teardown - -Because these buckets are retained, deleting the related CloudFormation stack will leave the S3 buckets behind. If you intentionally want to remove them, empty and delete the buckets manually after the stack is gone. - -### `deploy:aws` - -The wrapper script deploys the backend stack first, then deploys the frontend stack while automatically importing backend outputs into the frontend build. If you already have a saved backend outputs JSON, you can now pass `--backend-outputs-file=...` to the split-stack wrapper so the frontend half reuses that file instead of reading the backend stack directly. In the later staged publish-only phase (`--skip-backend --skip-frontend --publish-frontend-assets`), it no longer needs the bootstrap stack because that step only reuses existing frontend/backend stack outputs. - -### `full-stack` - -The full-stack CloudFormation template composes the backend and frontend templates as nested stacks. It is best when you want a single infrastructure stack entrypoint, and the `deploy:full-stack` helper now also builds and publishes the frontend bundle unless you pass `--infrastructure-only` or `--frontend-infrastructure-only`. For a later frontend-only publish pass against an existing full-stack deployment, the helper also supports `--skip-infrastructure --publish-frontend-assets`, and that publish-only phase no longer depends on the bootstrap stack because it reuses the already-deployed full-stack outputs. It can also now publish from saved frontend/backend outputs files, or direct bucket/distribution values, when you do not want that later phase to read the full-stack CloudFormation outputs again. - -The full-stack outputs now surface not just frontend publishing values, but also the main backend operational values from the nested backend stack, including: - -- API function name and ARN -- migration function name -- socket function name, WebSocket API ID, and WebSocket endpoint -- scheduled worker function names -- database endpoint, reader endpoint, cluster ARN, port, name, module DB names, and secret ARN -- VPC ID, private subnet IDs, and Lambda security group ID - -Across the helper scripts, CLI flags can also be supplied via environment variables using uppercase underscore names. For example, `--backend-outputs-file` can come from `BACKEND_OUTPUTS_FILE`, and `--stack-name` can come from `STACK_NAME`. - -## Packaging The Real Api Repo - -If the backend source lives beside this repo, the simplest portable path is: - -1. Build and package the backend: - `yarn package:api-backend -- --api-repo-path= --environment=prod` -2. Upload the produced backend zip: - `yarn upload:backend-artifact -- --bootstrap-stack-name= --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/backend/api.zip` -3. Deploy the backend or full stack with the matching `LambdaCodeS3Key`. - -If your CI flow also builds a separate migration zip, you can attach it to the same manifest contract up front: - -- `yarn package:api-backend -- --api-repo-path= --environment=prod --migration-artifact-path=infrastructure/artifacts/api/api-prod-migrations.zip` - -All of the helpers that accept `--api-repo-path` also honor `API_REPO_PATH=` if that is easier for your local shell or CI environment. -If your CI pipeline already ran `package:api-backend`, the higher-level deploy helpers now also accept `--package-manifest-file=` so they can reuse the generated backend artifact and optional layer artifact without re-inspecting the Api repo checkout. That same manifest path also works with `validate:aws-deploy`, and its suggested `upload:backend-artifact` follow-up will reuse the manifest's saved artifact path directly. -The `package:api-backend -- --output=json` result and the written manifest now also include: - -- `recommendedBackendArtifactKey` -- `recommendedMigrationArtifactKey` -- manifest-driven `deploy:backend`, `deploy:aws`, and `deploy:full-stack` next-step hints -- an optional `migrationArtifactPath` when you want the same manifest contract to carry a separate migration zip too - -Artifact paths inside that manifest can now be relative to the manifest file itself, which makes the manifest portable across different local checkout paths and CI workspaces. A machine-readable sample of the direct `package:api-backend -- --output=json` result is included at [`examples/package-api-backend-output.sample.json`](./examples/package-api-backend-output.sample.json). The written manifest shape is also documented at [`examples/package-manifest.sample.json`](./examples/package-manifest.sample.json). The smoke suite contract-checks both samples against representative `package:api-backend -- --output=json` runs so they do not silently drift. - -The packaging helper supports two modes: - -- `self-contained`: - Builds a Lambda zip that includes `dist`, `config`, `lambda.js`, `package.json`, and `node_modules`. This is still useful for smaller backend builds, but the live June 24, 2026 staging rollout showed that the current Api package can exceed Lambda's 250 MB unzipped limit in this mode. -- `layered`: - Builds the Serverless-style main zip plus a separate `layer` zip. Use this when you want to stay closer to the Api repo's current packaging model. It is now the safer default for the AWS environment wrappers because it keeps the function zips below Lambda's unzipped size limit. The deploy wrappers publish the layer for you and pass its ARN through `DependenciesLayerArn`. - -If you want fewer manual steps, the higher-level deploy wrappers can now call that packaging helper for you when you point them at the Api repo: - -- `yarn deploy:backend -- --bootstrap-stack-name= --api-repo-path= --package-mode=self-contained` -- `yarn deploy:aws -- --api-repo-path= --package-mode=layered ...` -- `yarn deploy:full-stack -- --api-repo-path= --package-mode=layered ...` - -Or, if packaging already happened elsewhere: - -- `yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` -- `yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` -- `yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json ...` - -For the layered variant: - -- `yarn deploy:backend -- --bootstrap-stack-name= --api-repo-path= --package-mode=layered` -- `yarn deploy:aws -- --api-repo-path= --package-mode=layered ...` -- `yarn deploy:full-stack -- --api-repo-path= --package-mode=layered ...` - -When those wrappers upload a backend artifact for you and you do not pass an explicit key, they now default to: - -- backend artifact: `//backend/api.zip` -- migration artifact: `//backend/migrations.zip` - -The `--api-repo-path` wrapper flows still require the Api repo to have already run `corepack yarn install`, because the packaging helper builds from the real local checkout. The `--package-manifest-file` path does not have that requirement because it only reuses already-packaged artifacts. -If your machine can see the sibling Api checkout but local commands still fail with a permissions/readability error such as `Operation not permitted`, treat that the same as an unreadable local repo path: switch the local run to `--package-manifest-file=...` or `--backend-artifact-source-file=...`, or use the GitHub Actions `api-repo` path if that runner can read the backend repo. -When you are preparing the environment starter files, `prepare:environment-starter --write=true --write-secret-file=false` now lets you write the non-secret bootstrap/backend/frontend JSON first without materializing `app-config-secret.json` yet. The same helper also accepts `--mobile-app-url`, `--domain-cname-target`, `--domain-a-target`, `--default-stock-photo`, and `--google-analytics-tag` for the optional public runtime fields surfaced by the backend stack. -If you want a lower-risk first pass before packaging, `audit:api-repo-contract` checks the sibling Api repo for the expected `lambda.web`, `lambda.socket`, timer handlers, migration-module hints, build scripts, and package-layout readiness. - -### Local Api Repo Access Troubleshooting - -If a sibling checkout such as `../Api` is visible on disk but local commands still fail with a permissions/readability error, the quickest local fallbacks are: - -- reuse a manifest that was already produced elsewhere: - `yarn deploy:aws -- --package-manifest-file= ...` -- reuse a prepared backend zip directly: - `yarn deploy:aws -- --backend-artifact-source-file= ...` -- generate a full local fallback plan first: - `yarn plan:environment-deploy -- --environment=staging --deployment-source=package-manifest --package-manifest-file= --output=markdown` -- or: - `yarn plan:environment-deploy -- --environment=staging --deployment-source=backend-artifact --backend-artifact-source-file= --output=markdown` - -The same unreadable-repo guidance now appears in `plan:environment-deploy`, `validate:aws-deploy`, and the split-stack environment scripts, so any of those entrypoints should now point you back to the same manifest/artifact alternatives. - -For that layered path you can now either publish the layer manually: - -- `yarn publish:lambda-layer -- --region=us-east-1 --layer-name=b1admin-prod-dependencies --source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip` - -Or let the higher-level deploy wrappers publish it for you: - -- `yarn deploy:backend -- --api-repo-path= --package-mode=layered ...` -- `yarn deploy:aws -- --dependencies-layer-source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip ...` -- `yarn deploy:full-stack -- --dependencies-layer-source-file=infrastructure/artifacts/api/api-prod-dependencies-layer.zip ...` - -## Frontend Stack Inputs - -The CloudFormation template accepts these parameters: - -- `ProjectName`: naming/tagging prefix -- `EnvironmentName`: `dev`, `staging`, `prod`, or similar -- `BucketName`: optional explicit S3 bucket name -- `AlternateDomainName`: optional custom domain such as `admin.example.com` -- `AcmCertificateArn`: optional ACM cert in `us-east-1`, required with a custom domain -- `HostedZoneId`: optional Route53 zone ID for automatic alias record creation -- `PriceClass`: CloudFront price class - -## Bootstrap Stack Inputs - -The bootstrap template accepts: - -- `ProjectName` -- `EnvironmentName` -- `TemplateBucketName` -- `ArtifactBucketName` -- `EnableBucketVersioning` - -## Backend Stack Inputs - -The backend template accepts: - -- `ProjectName` -- `EnvironmentName` -- `LambdaCodeS3Bucket` -- `LambdaCodeS3Key` -- `LambdaHandler` -- `LambdaRuntime` -- `LambdaArchitecture` -- `LambdaMemorySize` -- `LambdaTimeout` -- `LambdaReservedConcurrency` -- `DependenciesLayerArn` -- `ObservabilityLayerArn` -- `LambdaNodeOptions` -- `EnableWebSocketApi` -- `SocketLambdaHandler` -- `SocketLambdaMemorySize` -- `SocketLambdaTimeout` -- `EnableScheduledWorkers` -- `Timer15MinLambdaHandler` -- `TimerMidnightLambdaHandler` -- `TimerScheduledTasksLambdaHandler` -- `TimerWebhooksLambdaHandler` -- `TimerLambdaMemorySize` -- `TimerLambdaTimeout` -- `RunMigrations` -- `MigrationCodeS3Bucket` -- `MigrationCodeS3Key` -- `MigrationHandler` -- `MigrationRuntime` -- `MigrationMemorySize` -- `MigrationTimeout` -- `MigrationTrigger` -- `DatabaseName` -- `MembershipDatabaseName` -- `AttendanceDatabaseName` -- `ContentDatabaseName` -- `GivingDatabaseName` -- `MessagingDatabaseName` -- `DoingDatabaseName` -- `ReportingDatabaseName` -- `DatabaseEngine` -- `DatabasePort` -- `DatabaseMasterUsername` -- `DatabaseMinCapacity` -- `DatabaseMaxCapacity` -- `ApiCustomDomainName` -- `ApiCertificateArn` -- `ApiHostedZoneId` -- `CreateNatGateway` -- `B1AdminRootUrl` -- `CorsOrigin` -- `FileStore` -- `ManageAssetBucket` -- `AssetBucketName` -- `AppConfigSecretArn` -- `MailSystem` -- `DeliveryProvider` -- `StoreApiUrl` -- `AiProvider` -- `EmailOnRegistration` -- `CaddyHost` -- `CaddyPort` -- VPC and subnet CIDRs -- Optional public/frontend-facing values such as `WebsiteBaseUrl`, `ContentRootUrl`, `TransferUrl`, `SupportEmail`, and related settings - -When `CreateNatGateway=false`, the stack now creates the minimum private AWS endpoints needed for this deployment path itself: - -- S3 gateway endpoint so the migration custom resource can return its CloudFormation response without public internet access -- Secrets Manager interface endpoint so Lambda can read the Aurora master secret without public internet access - -If your backend needs any other outbound internet access or private AWS APIs, you should either leave NAT enabled or add the extra VPC endpoints your application requires. - -For the real Api repo, the current backend stack now injects MySQL-style connection strings for all module databases. The membership database name defaults from `DatabaseName`, while the other module DB names default to `attendance`, `content`, `giving`, `messaging`, `doing`, and `reporting` unless you override them with the explicit `*DatabaseName` parameters. -It also injects the core non-database runtime settings the Api repo reads from `Environment.ts`, including `CONTENT_ROOT`, `B1ADMIN_ROOT`, `FILE_STORE`, `AWS_S3_BUCKET`, `MAIL_SYSTEM`, `DELIVERY_PROVIDER`, `STORE_API_URL`, `CORS_ORIGIN`, `SOCKET_URL`, and `WEBSOCKET_API_ID`. -If `FileStore=S3` and you leave both `AssetBucketName` and `ContentRootUrl` blank, the backend stack can now create a managed content bucket for you when `ManageAssetBucket=true` and infer `CONTENT_ROOT` from that bucket's regional S3 URL. -If you want to stay closer to the Api repo's current Serverless packaging model, you can also provide `DependenciesLayerArn`, `ObservabilityLayerArn`, and `LambdaNodeOptions` instead of forcing everything into one zip. Only set `LambdaNodeOptions` for imports such as `@sentry/aws-serverless/awslambda-auto` when the referenced package is actually present in the deployed zip or layer artifact. -The Lambda role now also includes the common AWS permissions this repo most clearly needs at runtime: WebSocket connection management, S3 access for the configured asset bucket, SES send actions when `MailSystem=SES`, and Polly speech synthesis. - -If you set `AppConfigSecretArn`, the backend Lambdas will also read additional non-database secrets/config from that Secrets Manager JSON document using dynamic references. The expected JSON keys currently include: - -- `jwtSecret` -- `encryptionKey` -- `hubspotKey` -- `mauticUrl` -- `mauticUser` -- `mauticPassword` -- `youTubeApiKey` -- `pexelsKey` -- `vimeoToken` -- `apiBibleKey` -- `youVersionApiKey` -- `praiseChartsConsumerKey` -- `praiseChartsConsumerSecret` -- `googleRecaptchaSecretKey` -- `openRouterApiKey` -- `openAiApiKey` -- `webPushPublicKey` -- `webPushPrivateKey` -- `webPushSubject` - -A sample JSON shape for that secret now lives at [`examples/app-config-secret.sample.json`](./examples/app-config-secret.sample.json). -At minimum, treat `jwtSecret` and `encryptionKey` as required non-empty values for a viable self-hosted backend boot path. -For a real environment, also replace the starter `webPushSubject` mailbox instead of leaving it at `mailto:support@example.com`. -If you want fewer manual steps, the deploy helpers can now sync that secret for you and resolve `AppConfigSecretArn` automatically: - -- `yarn deploy:backend -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` -- `yarn deploy:aws -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` -- `yarn deploy:full-stack -- --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json ...` - -## Migration Reality Check - -The CloudFormation templates support an optional migration Lambda/custom-resource flow, but the real Api repo currently ships CLI-oriented migration tooling in `tools/migrate.ts`, not a proven Lambda handler such as `dist/migrate.handler`. - -That means: - -- the migration fields in these templates are still useful as an integration point -- but for the current real Api repo, `RunMigrations=true` should currently be treated as a custom extension you still need to implement and verify -- the sample backend/full-stack parameter files now default `RunMigrations` to `false` to avoid implying that a ready-made Lambda migration handler already exists - -For the real Api repo, this repo now includes a helper to run those CLI migrations against a deployed AWS database by reading stack outputs plus the Aurora secret and exporting the expected `*_CONNECTION_STRING` env vars: - -```bash -yarn run:api-migrations -- \ - --api-repo-path= \ - --stack-name=b1admin-prod-backend \ - --action=up \ - --module=all \ - --region=us-east-1 -``` - -If you want to test the resolved migration command/env wiring locally without touching AWS, use the sample files: - -```bash -yarn run:api-migrations -- \ - --api-repo-path= \ - --outputs-file=infrastructure/examples/backend-stack-outputs.sample.json \ - --db-secret-file=infrastructure/examples/database-secret.sample.json \ - --action=status \ - --module=all \ - --dry-run=true \ - --output=json -``` - -You can preflight that standalone migration helper before running it: - -```bash -yarn validate:aws-deploy -- \ - --mode=api-migrations \ - --api-repo-path= \ - --outputs-file=infrastructure/examples/backend-stack-outputs.sample.json \ - --db-secret-file=infrastructure/examples/database-secret.sample.json \ - --action=status \ - --module=all \ - --dry-run=true \ - --output=json -``` - -## Bootstrapping The First Admin Login - -Fresh AWS environments do not get a default sign-in automatically from migrations alone. To make that repeatable without loading demo data, this repo now includes an Aurora Data API helper that seeds: - -- one admin user -- one church record -- the standard `Domain Admins` and `All Members` roles -- the linking `person`, `userChurch`, and `roleMembers` rows - -Start from [`examples/bootstrap-admin-secret.sample.json`](./examples/bootstrap-admin-secret.sample.json), copy it to a private file, and replace every placeholder value. - -Then run: - -```bash -yarn run:bootstrap-admin -- \ - --stack-name=b1admin-prod-backend \ - --region=us-east-1 \ - --bootstrap-admin-secret-file=/absolute/path/to/bootstrap-admin-secret.json -``` - -If you only want to verify the resolved target before touching AWS, add `--dry-run=true --output=json`. - -The helper is idempotent. On rerun it will: - -- reuse the existing church by subdomain -- reuse the existing user by email -- repair any missing role, permission, `person`, `userChurch`, or `roleMembers` rows -- reset the bootstrap user's password again by default - -If you do not want reruns to overwrite that password, add: - -```bash ---bootstrap-admin-reset-password=false -``` - -You can also have the main deploy helpers run this step immediately after a successful backend deploy and optional Api migrations: - -```bash -yarn deploy:aws -- \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --api-repo-path= \ - --run-api-migrations=true \ - --run-bootstrap-admin=true \ - --bootstrap-admin-secret-file=/absolute/path/to/bootstrap-admin-secret.json -``` - -The same flags are also supported by: - -- `yarn deploy:backend` -- `yarn deploy:full-stack` - -For GitHub Actions or other workflow-driven deploys, the workflow-side deploy role needs Aurora Data API transaction permissions plus `secretsmanager:GetSecretValue` if the bootstrap helper runs from the workflow host. - -That helper is a post-deploy operational step, not a CloudFormation custom resource, but it closes much of the gap between the current AWS infrastructure path and the real Api repo's migration model. - -The helper URL-encodes database usernames, passwords, and schema names when it builds `mysql://...` connection strings, so it is safer with real Secrets Manager values that contain reserved URI characters. -It also supports targeted module runs with only the outputs for that module, so `--module=attendance` does not require the full set of module database names. -For the current real Api repo specifically, `--module=all` follows the backend repo's own migration module list, which currently excludes reporting. -The helper and validator will also warn if you target a module that currently has no `tools/migrations/` directory in the Api repo, so unsupported direct runs are easier to spot before deployment. -When that happens, `validate:aws-deploy` now avoids suggesting a follow-up `run:api-migrations` command for that unsupported module. -Outside `--dry-run=true`, the helper now refuses direct runs for modules with no migration directory instead of succeeding with a silent skip. -The backend, split-stack, and full-stack deploy wrappers now fail before deployment for the same unsupported direct-migration targets, and they also fail early when the target Api repo is missing installed dependencies for a real non-dry-run migration. -The standalone `--mode=api-migrations` validator path follows the same rule and no longer suggests a follow-up command for unsupported non-dry-run module targets either. -The split-stack and full-stack wrappers also now reject impossible rollout combinations such as `--run-api-migrations=true` together with `--skip-backend` or `--skip-infrastructure`, instead of failing later for a less relevant reason. -They also now reject invalid `api-migration-action` and `api-migration-module` values before broader deploy preconditions like bucket or stack checks get involved. -The frontend publish helpers now do the same kind of early local validation for `--skip-build`: they fail immediately if `dist/` or `dist/sw.js` is missing, instead of reaching AWS stack or hosting operations first. - -If you want fewer manual steps, `deploy:backend` and `deploy:full-stack` can now invoke that helper for you as an optional post-deploy phase, and `deploy:aws` passes those flags through to the backend deploy: - -- `yarn deploy:backend -- --stack-name=b1admin-prod-backend --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` -- `yarn deploy:aws -- --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` -- `yarn deploy:full-stack -- --stack-name=b1admin-prod --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --run-api-migrations=true --api-migration-action=up --api-migration-module=all` - -You can add `--api-migration-dry-run=true` when you want the wrapper to resolve the connection-string wiring without actually executing migrations. - -## Frontend Runtime Inputs - -Build-time environment variables control where the deployed frontend points: - -- `REACT_APP_STAGE` -- `REACT_APP_API_BASE` -- `REACT_APP_CONTENT_ROOT` -- `REACT_APP_B1_WEBSITE_URL` -- `REACT_APP_LESSONS_API` -- `REACT_APP_GOOGLE_ANALYTICS` -- `REACT_APP_SENTRY_DSN` -- `REACT_APP_TRANSFER_URL` -- `REACT_APP_SUPPORT_EMAIL` -- `REACT_APP_SUPPORT_PHONE` -- `REACT_APP_SUPPORT_SITE_URL` -- `REACT_APP_MOBILE_APP_URL` -- `REACT_APP_DOMAIN_CNAME_TARGET` -- `REACT_APP_DOMAIN_A_TARGET` -- `REACT_APP_DEFAULT_STOCK_PHOTO` -- `REACT_APP_CHAT_MODE` - -For a portable deployment, the backend stack should output at least `REACT_APP_API_BASE` and `REACT_APP_CONTENT_ROOT`, and your deployment pipeline should inject them during the frontend build. - -## Deploying The Frontend - -Example: - -```bash -REACT_APP_API_BASE=https://api.example.com \ -REACT_APP_CONTENT_ROOT=https://content.example.com \ -REACT_APP_B1_WEBSITE_URL=https://{subdomain}.example.com \ -yarn deploy:frontend -- \ - --stack-name=b1admin-prod-frontend \ - --region=us-east-1 \ - --environment=prod \ - --project-name=b1admin \ - --alternate-domain-name=admin.example.com \ - --acm-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ - --hosted-zone-id=Z1234567890ABC -``` - -If your backend stack already exposes public outputs, you can let the deploy script import them directly: - -```bash -yarn deploy:frontend -- \ - --stack-name=b1admin-prod-frontend \ - --backend-stack-name=b1admin-prod-backend -``` - -You can also point at a saved JSON outputs file or raw `describe-stacks` output file: - -```bash -yarn deploy:frontend -- \ - --stack-name=b1admin-prod-frontend \ - --backend-outputs-file=deployment/backend-outputs.json -``` - -A sample backend outputs file shape is included at [`examples/backend-outputs.sample.json`](./examples/backend-outputs.sample.json). - -When the backend stack is deployed with `ApiCustomDomainName`, its `ApiBaseUrl`, `PublicApiBaseUrl`, and `LessonsApiUrl` outputs now all resolve to that custom domain so the frontend build does not accidentally mix custom-domain traffic with raw `execute-api` endpoints. - -You can also use a frontend parameter file: - -```bash -yarn deploy:frontend -- \ - --stack-name=b1admin-prod-frontend \ - --region=us-east-1 \ - --parameters-file=infrastructure/examples/frontend-parameters.sample.json -``` - -A sample parameter file is included at [`examples/frontend-parameters.sample.json`](./examples/frontend-parameters.sample.json). - -The deploy script will: - -1. Deploy or update the CloudFormation stack. -2. Resolve frontend build-time env vars from your shell and optional backend outputs. -3. Build the Vite app. -4. Sync `dist/` to the provisioned S3 bucket. -5. Upload `sw.js` with `no-cache`. -6. Invalidate the CloudFront distribution. - -After the stack deploy succeeds, the helper now reuses the freshly resolved bucket/distribution outputs directly for the publish step instead of re-reading the frontend stack through CloudFormation a second time. - -If you only want to provision the frontend hosting infrastructure and publish assets later, add `--infrastructure-only`. -Do not combine that with `--skip-build`, because no frontend publish happens in that phase. -If you already have a ready `dist/` bundle and use `--skip-build`, the direct frontend deploy helper now skips backend output resolution too, because no build-time `REACT_APP_*` injection happens in that phase. - -When you are ready to publish frontend assets into an existing frontend stack, use: - -```bash -yarn publish:frontend-assets -- \ - --stack-name=b1admin-prod-frontend \ - --region=us-east-1 \ - --backend-stack-name=b1admin-prod-backend -``` - -That helper will: - -1. Read the frontend stack outputs to find the S3 bucket and CloudFront distribution. -2. Resolve frontend build-time env vars from your shell and optional backend outputs. -3. Build the Vite app unless you pass `--skip-build`. -4. Sync `dist/` to the provisioned S3 bucket. -5. Upload `sw.js` with `no-cache`. -6. Invalidate the CloudFront distribution. - -If your publish environment does not have CloudFormation read access, you can use `--frontend-outputs-file=...` instead of `--stack-name`, or pass `--bucket=...` and `--distribution-id=...` directly. There is also a sample frontend outputs file at [`examples/frontend-outputs.sample.json`](./examples/frontend-outputs.sample.json) that shows the expected shape. -If you are reusing an existing `dist/` with `--skip-build`, the helper no longer needs backend outputs at all, because no build-time `REACT_APP_*` injection happens in that phase. -If you want a machine-readable example of the publish helper result itself, see [`examples/publish-frontend-output.sample.json`](./examples/publish-frontend-output.sample.json). - -This helper now understands both the standalone frontend stack outputs (`SiteBucketName`, `CloudFrontDistributionId`) and the nested full-stack outputs (`FrontendBucketName`, `FrontendDistributionId`), so `--stack-name` can point at either stack shape. - -## Bootstrap A Fresh AWS Account - -If the target AWS account does not already have S3 buckets for templates and artifacts, start here: - -```bash -yarn deploy:bootstrap -- \ - --stack-name=b1admin-prod-bootstrap \ - --region=us-east-1 \ - --project-name=b1admin \ - --environment=prod \ - --template-bucket-name=b1admin-prod-templates-123456789012 \ - --artifact-bucket-name=b1admin-prod-artifacts-123456789012 -``` - -A sample parameter file is included at [`examples/bootstrap-parameters.sample.json`](./examples/bootstrap-parameters.sample.json). - -You can also deploy bootstrap from that file directly: - -```bash -yarn deploy:bootstrap -- \ - --stack-name=b1admin-prod-bootstrap \ - --region=us-east-1 \ - --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json -``` - -Those outputs feed directly into the later deployment flows: - -- The template bucket is used by `deploy:full-stack` -- The artifact bucket is where your packaged backend Lambda zip should live - -After bootstrap, you can let later helpers consume those outputs automatically by passing `--bootstrap-stack-name`. - -If you want to capture the resolved bucket outputs programmatically, add `--output=json`. - -The main deploy helpers also support `--output=json` for machine-readable results: - -- `deploy:bootstrap` -- `deploy:frontend` -- `deploy:backend` -- `deploy:full-stack` -- `deploy:aws` -- `smoke:aws-tooling` - -Representative JSON output for `deploy:bootstrap -- --output=json` is included at [`examples/deploy-bootstrap-output.sample.json`](./examples/deploy-bootstrap-output.sample.json). -Representative JSON output for `deploy:frontend -- --infrastructure-only --output=json` is included at [`examples/deploy-frontend-output.sample.json`](./examples/deploy-frontend-output.sample.json). -Representative JSON output for the normal build-and-publish `deploy:frontend -- --output=json` path is included at [`examples/deploy-frontend-publish-output.sample.json`](./examples/deploy-frontend-publish-output.sample.json). -The backend artifact upload helper also supports `--output=json`. Representative JSON output for `upload:backend-artifact -- --output=json` is included at [`examples/upload-backend-artifact-output.sample.json`](./examples/upload-backend-artifact-output.sample.json). -Representative JSON output for `publish:lambda-layer -- --output=json` is included at [`examples/publish-lambda-layer-output.sample.json`](./examples/publish-lambda-layer-output.sample.json). -Representative JSON output for `publish:frontend-assets -- --output=json` is included at [`examples/publish-frontend-output.sample.json`](./examples/publish-frontend-output.sample.json). -Representative JSON output for `verify:split-stack -- --backend-outputs-file=... --frontend-outputs-file=... --check-aws=false --output=json` is included at [`examples/verify-split-stack-output.sample.json`](./examples/verify-split-stack-output.sample.json). -Representative JSON output for `run:api-migrations -- --dry-run=true --output=json` is included at [`examples/run-api-migrations-output.sample.json`](./examples/run-api-migrations-output.sample.json). -Representative JSON output for `sync:app-config-secret -- --output=json` is included at [`examples/sync-app-config-secret-output.sample.json`](./examples/sync-app-config-secret-output.sample.json). -Representative JSON output for `sync:legacy-ssm -- --output=json` is included at [`examples/sync-legacy-ssm-output.sample.json`](./examples/sync-legacy-ssm-output.sample.json). - -For manifest-driven backend flows, those deploy-helper JSON results now also surface the resolved local provenance fields that CI wrappers usually care about most: - -- `resolvedPackageManifestFile` -- `resolvedBackendArtifactSourceFile` -- `resolvedMigrationArtifactSourceFile` -- `resolvedDependenciesLayerSourceFile` - -On the higher-level wrappers, those values describe what the wrapper itself resolved before it handed work off to nested helpers. -Representative JSON output for `deploy:backend -- --output=json` is included at [`examples/deploy-backend-output.sample.json`](./examples/deploy-backend-output.sample.json). - -The top-level deploy helpers now also return a normal CLI error if a referenced parameters file is missing or unreadable, or if a referenced bootstrap/backend/frontend stack lookup fails, instead of crashing with a raw Node stack trace. That cleanup also applies when one top-level wrapper calls another helper under the hood, and to direct helper command failures such as packaging, S3 uploads, or frontend asset publication. The follow-up helper commands now do the same for referenced outputs JSON files, stack lookups, and AWS-side failures such as `deploy:frontend`, `publish:frontend-assets`, `publish:lambda-layer`, `upload:backend-artifact`, `sync:app-config-secret`, and `sync:legacy-ssm`. - -The AWS helper scripts accept both `--name=value` and `--name value` argument styles. - -All CloudFormation deploy helpers in this repo now pass `--no-fail-on-empty-changeset`, so a no-op re-run is treated as success. - -## Validate Before Deploy - -You can run a preflight check before backend or full-stack deployment: - -```bash -yarn validate:aws-deploy -- \ - --mode=full-stack \ - --region=us-east-1 \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ - --backend-artifact-source-file=../Api/dist/api.zip -``` - -Use `--mode=backend` with `--backend-parameters-file` if you want to validate just the backend path. - -Use `--mode=frontend` with `--frontend-parameters-file` if you want to validate only the frontend hosting path. - -Use `--mode=bootstrap` with `--parameters-file` if you want to validate the initial bucket/bootstrap stack inputs before running `deploy:bootstrap`. If you already know the target stack name, pass `--stack-name` there too so the validator's suggested follow-up command stays copy/pasteable. - -Use `--mode=split-stack` with both `--backend-parameters-file` and `--frontend-parameters-file` if you want to validate the same paired configuration that `deploy:aws` consumes. That validator path now also accepts `--backend-outputs-file=...` when you want the frontend half to build or publish from a saved backend outputs file instead of a live stack lookup. - -You can also pass `--frontend-infrastructure-only` to the validator when you want preflight feedback for the staged frontend-hosting-first flows used by `deploy:aws` and `deploy:full-stack`. - -Use `--mode=frontend-publish` when you want to validate the second phase of that staged flow before running `publish:frontend-assets`. - -For a quick local regression pass over the AWS tooling itself, you can also run: - -```bash -yarn smoke:aws-tooling -``` - -That smoke script checks the main AWS helper files with `node --check`, parses the CloudFormation YAML templates for syntax validity, validates the sample JSON files, runs representative `validate:aws-deploy` scenarios against the sample parameter files, and exercises a few deploy-wrapper guardrails that should fail locally before any AWS call is made, including missing-parameter-file handling for the top-level deploy entrypoints plus missing outputs-file and unreadable stack handling for the publish/upload/sync helper commands. It also contract-checks the checked-in machine-readable example files for `package:api-backend`, `deploy:bootstrap`, `deploy:frontend`, `deploy:backend`, multiple `validate:aws-deploy` modes, `publish:lambda-layer`, `publish:frontend-assets`, `run:api-migrations`, `sync:app-config-secret`, `sync:legacy-ssm`, the split-stack wrapper's hosting-only, publish-only, and normal end-to-end flows, and the full-stack wrapper's hosting-only, publish-only, and normal end-to-end flows against representative fake-AWS runs so those example outputs do not silently drift away from the real helper result shapes. The smoke suite also now fails if a parsed example JSON file is neither contract-checked nor explicitly classified as an input-only sample. If you also have the Api repo checked out locally and readable in the current environment, it will additionally compare the env var keys from that repo's `serverless.yml` against `backend-api.yaml` so the CloudFormation runtime contract does not drift silently. In more restricted environments, those Api-repo checks are skipped rather than failing the whole smoke run. Add `--output=json` if you want a machine-readable summary for CI or other automation. - -This repo also includes a GitHub Actions workflow at [`.github/workflows/aws-tooling-smoke.yml`](../.github/workflows/aws-tooling-smoke.yml) that runs the same smoke suite on pushes, pull requests, and manual dispatches. It now uses the repo's Yarn-first install path (`yarn install --immutable` plus `yarn smoke:aws-tooling`) instead of a separate Yarn-only flow, and it captures the smoke result as `aws-tooling-smoke.json` for upload as a build artifact. - -## Verify After Deploy - -If you are using the split-stack rollout path, you can verify the deployed outputs after `deploy:aws` completes: - -```bash -yarn verify:split-stack -- \ - --region=us-east-1 \ - --backend-stack-name=b1admin-prod-backend \ - --frontend-stack-name=b1admin-prod-frontend -``` - -That helper can: - -- read backend/frontend stack outputs directly from CloudFormation -- verify the frontend S3 bucket is reachable -- verify the CloudFront distribution is reachable -- print the resolved API base URL and frontend app URL - -If you prefer not to hit AWS again, you can also run it from saved outputs files: - -```bash -yarn verify:split-stack -- \ - --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json \ - --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json \ - --check-aws=false \ - --output=json -``` - -`--check-http=true` will also perform a frontend URL check when an app URL is available. If you want an API HTTP probe too, pass `--api-probe-url=...` explicitly so the helper knows which endpoint should answer cleanly. - -Add `--check-aws=true` if you want the validator to also verify live AWS prerequisites such as: - -- current AWS credentials/account access -- template bucket accessibility -- artifact bucket accessibility -- existence/accessibility of a referenced S3 artifact object -- accessibility of ACM certificates for frontend or API custom domains - -Add `--output=json` if you want the validator result in a machine-readable format for CI or wrapper scripts. Invalid or unreadable parameter files, and unreadable `--bootstrap-stack-name` lookups, now return normal validator errors in that output instead of crashing the script outright. For manifest-driven backend validation, the `resolved` object now also includes: - -- `packageManifestFile` -- `backendArtifactSource` -- `migrationArtifactSource` -- `dependenciesLayerSource` - -A representative validator result for that backend + manifest path is included at [`examples/validate-backend-output.sample.json`](./examples/validate-backend-output.sample.json). Single-helper validator examples are also checked in: [`examples/validate-bootstrap-output.sample.json`](./examples/validate-bootstrap-output.sample.json) covers bootstrap preflight, [`examples/validate-frontend-output.sample.json`](./examples/validate-frontend-output.sample.json) covers frontend deploy preflight, and [`examples/validate-api-migrations-output.sample.json`](./examples/validate-api-migrations-output.sample.json) covers standalone Api CLI migration preflight. Normal infrastructure-phase validator examples are also checked in for the main wrapper paths: [`examples/validate-split-stack-output.sample.json`](./examples/validate-split-stack-output.sample.json) covers the standard `deploy:aws` preflight, and [`examples/validate-full-stack-output.sample.json`](./examples/validate-full-stack-output.sample.json) covers the standard `deploy:full-stack` preflight with an explicit template bucket. The staged hosting-first validator path now has checked examples too: [`examples/validate-split-stack-frontend-infra-output.sample.json`](./examples/validate-split-stack-frontend-infra-output.sample.json) covers `deploy:aws --frontend-infrastructure-only`, and [`examples/validate-full-stack-frontend-infra-output.sample.json`](./examples/validate-full-stack-frontend-infra-output.sample.json) covers `deploy:full-stack --frontend-infrastructure-only`. Publish-phase validator examples are also checked in for the staged follow-up flows: [`examples/validate-frontend-publish-output.sample.json`](./examples/validate-frontend-publish-output.sample.json) covers the standalone `publish:frontend-assets` validation path, [`examples/validate-split-stack-publish-output.sample.json`](./examples/validate-split-stack-publish-output.sample.json) covers the split-stack `deploy:aws` follow-up that reuses saved frontend outputs, and [`examples/validate-full-stack-publish-output.sample.json`](./examples/validate-full-stack-publish-output.sample.json) covers the full-stack `deploy:full-stack` follow-up that reuses saved frontend/backend outputs. - -The validator checks things like: - -- bootstrap outputs -- bootstrap parameter file readability plus explicit bootstrap bucket-name sanity checks -- presence of the local backend artifact file -- presence of the local migration artifact file when you provide one -- required Lambda artifact bucket/key inputs -- frontend custom-domain certificate requirements -- backend API custom-domain requirements -- migration handler requirements when `RunMigrations=true` -- optional post-deploy Api CLI migration wiring, including repo availability, migration action/module validation, and DB secret file sanity checks when `--run-api-migrations=true` -- whether artifact keys will be derived automatically from the current project/environment when you use a local artifact or auto-packaging path -- whether the current backend stack is rich enough to support the real Api repo, including a note about the optional legacy SSM sync helper when an app config secret is present - -## Sync Legacy SSM Parameters - -If your AWS account still needs the Api repo's older `/${stage}/...` Parameter Store layout for `serverless.yml`, CLI tasks, or ad hoc ops scripts, you can mirror the current backend stack into that layout: - -```bash -yarn sync:legacy-ssm -- \ - --stack-name=b1admin-prod-backend \ - --environment=prod \ - --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json \ - --region=us-east-1 -``` - -The helper reads: - -- backend or full-stack CloudFormation outputs such as `DatabaseEndpoint`, `DatabaseSecretArn`, and the resolved module database names -- the app-config secret file, `--app-config-secret-arn`, or `AppConfigSecretArn` from the deployed stack outputs when available - -And writes SecureString parameters like: - -- `/prod/jwtSecret` -- `/prod/encryptionKey` -- `/prod/membershipApi/connectionString` -- `/prod/attendanceApi/connectionString` -- `/prod/contentApi/connectionString` -- `/prod/givingApi/connectionString` -- `/prod/messagingApi/connectionString` -- `/prod/doingApi/connectionString` -- `/prod/reportingApi/connectionString` -- provider key paths such as `/prod/openAiApiKey`, `/prod/openRouterApiKey`, `/prod/pexelsKey`, and `/prod/webPushSubject` - -Useful flags: - -- `--app-config-secret-arn=...` to read the non-database values from Secrets Manager instead of a local file -- `--prefix=/staging` to override the default `/${environment}` prefix -- `--dry-run=true` to print the planned parameter names without writing them -- `--include-empty=true` to write blank values instead of skipping them - -You can also fold that into the deployment wrappers: - -- `yarn deploy:backend -- --stack-name=b1admin-prod-backend --parameters-file=infrastructure/examples/backend-parameters.sample.json --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` -- `yarn deploy:aws -- --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` -- `yarn deploy:full-stack -- --stack-name=b1admin-prod --bootstrap-stack-name=b1admin-prod-bootstrap --api-repo-path= --sync-legacy-ssm=true --app-config-secret-file=infrastructure/examples/app-config-secret.sample.json` - -Those wrappers run the SSM sync after the backend or full stack finishes deploying. - -## Upload The Backend Artifact - -Once your API repo or CI pipeline has produced a Lambda zip, upload it to the artifact bucket: - -```bash -yarn upload:backend-artifact -- \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --source-file=../Api/dist/api.zip \ - --artifact-key=b1admin/backend/api.zip \ - --region=us-east-1 -``` - -You can also provide `--artifact-bucket` directly instead of `--bootstrap-stack-name`. - -If you want a machine-readable example of the upload helper result, see [`examples/upload-backend-artifact-output.sample.json`](./examples/upload-backend-artifact-output.sample.json). - -The resulting S3 key should match the `LambdaCodeS3Key` you pass into `deploy:backend`, `deploy:aws`, or `deploy:full-stack`. - -If you use `deploy:backend`, `deploy:aws`, or `deploy:full-stack` with `--backend-artifact-source-file=...`, `--api-repo-path=...`, or `--package-manifest-file=...`, you can omit the key and let the wrapper default it to `//backend/api.zip`. - -If you package migrations separately, upload that zip the same way with a different key: - -```bash -yarn upload:backend-artifact -- \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --source-file=../Api/dist/migrations.zip \ - --artifact-key=b1admin/backend/migrations.zip \ - --artifact-label="Migration artifact" \ - --region=us-east-1 -``` - -Then pass that key as `MigrationCodeS3Key`. If you omit `MigrationCodeS3Bucket`, the backend stack will reuse the main artifact bucket automatically. - -If you use `--migration-artifact-source-file=...` through the deploy wrappers and omit the key, they default it to `//backend/migrations.zip`. - -## Deploying The Backend - -Example using a parameter file: - -```bash -yarn deploy:backend -- \ - --stack-name=b1admin-prod-backend \ - --region=us-east-1 \ - --parameters-file=infrastructure/examples/backend-parameters.sample.json -``` - -You can also pass values directly: - -```bash -yarn deploy:backend -- \ - --stack-name=b1admin-prod-backend \ - --region=us-east-1 \ - --project-name=b1admin \ - --environment=prod \ - --lambda-code-s3-bucket=my-artifacts-bucket \ - --lambda-code-s3-key=b1admin/backend/api.zip \ - --website-base-url=https://{subdomain}.example.com \ - --content-root-url=https://content.example.com -``` - -The backend deploy script expects a Lambda zip to already be uploaded to S3. - -If you provide `--backend-artifact-source-file=...`, `--api-repo-path=...`, or `--package-manifest-file=...`, the backend deploy script will upload the artifact for you and can derive `LambdaCodeS3Key` automatically from `ProjectName` and `EnvironmentName`. - -The split-stack and full-stack wrappers now also honor `ProjectName` and `EnvironmentName` from their parameter files when deriving default stack-adjacent names like artifact keys, template prefixes, secret names, and layer names. - -If you want the backend API on a first-class domain like `api.example.com`, pass: - -- `ApiCustomDomainName` -- `ApiCertificateArn` -- `ApiHostedZoneId` - -When those values are set, the backend stack will create the API Gateway custom domain, map it to the HTTP API, and create Route53 alias records. `ApiBaseUrl` and `PublicApiBaseUrl` will then resolve to your custom domain instead of the raw `execute-api` hostname. - -If you want the stack to run schema/bootstrap work against Aurora, enable: - -- `RunMigrations=true` -- `MigrationHandler` - -Optional overrides are also available for: - -- `MigrationCodeS3Bucket` -- `MigrationCodeS3Key` -- `MigrationRuntime` -- `MigrationMemorySize` -- `MigrationTimeout` -- `MigrationTrigger` - -By default, the migration Lambda falls back to the main backend artifact bucket/key/runtime. The migration handler is expected to be idempotent and to implement the CloudFormation custom-resource response contract, since the stack invokes it as a custom resource during create/update. - -If you want to upload a separate migration zip as part of the wrapper flow, add: - -- `--migration-artifact-source-file=../Api/dist/migrations.zip` -- `--migration-code-s3-key=b1admin/backend/migrations.zip` - -If you omit those flags, the migration Lambda will continue to reuse the main backend artifact by default. - -If you do provide `--migration-artifact-source-file=...` and omit `--migration-code-s3-key`, the backend, split-stack, and full-stack wrappers will default the key from `ProjectName` and `EnvironmentName`. - -The backend and full-stack templates now also fail fast on a few invalid combinations: - -- `ApiCustomDomainName` without `ApiCertificateArn` -- `RunMigrations=true` without `MigrationHandler` -- `FrontendAlternateDomainName` without `FrontendAcmCertificateArn` in the full-stack template - -## Deploying The Full AWS Footprint - -Once you have a backend artifact in S3, you can deploy both stacks in sequence: - -```bash -yarn deploy:aws -- \ - --region=us-east-1 \ - --environment=prod \ - --project-name=b1admin \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json \ - --frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json \ - --backend-artifact-source-file=../Api/dist/api.zip \ - --migration-artifact-source-file=../Api/dist/migrations.zip \ - --run-migrations=true \ - --migration-handler=index.migrate \ - --api-custom-domain-name=api.example.com \ - --api-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy \ - --api-hosted-zone-id=Z1234567890ABC \ - --frontend-alternate-domain-name=admin.example.com \ - --frontend-acm-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ - --frontend-hosted-zone-id=Z1234567890ABC -``` - -The wrapper will: - -1. Optionally resolve the artifact bucket from the bootstrap stack. -2. Optionally upload a backend Lambda zip if `--backend-artifact-source-file` is provided, or reuse the packaged artifact referenced by `--package-manifest-file`. -3. Optionally upload a separate migration zip if `--migration-artifact-source-file` is provided. -4. Deploy the backend foundation, including an API custom domain if configured. -5. Read backend stack outputs. -6. Deploy the frontend stack. -7. Build the frontend with the backend outputs injected as `REACT_APP_*` values. -8. Upload frontend assets and invalidate CloudFront. - -The split-stack wrapper now supports `--frontend-parameters-file` too, so both halves of the deployment can be driven from parameter files instead of mixing file-based backend config with frontend-only CLI flags. -If you already have backend outputs saved from an earlier deploy or CI step, you can also pass `--backend-outputs-file=deployment/backend-outputs.json` so the frontend deploy/publish phases reuse that file instead of querying the backend stack. - -If you only want the split-stack wrapper to provision frontend hosting and publish assets later, add `--frontend-infrastructure-only`. -Do not combine that with `--skip-frontend`, because skipping the frontend step prevents the hosting stack from being created. -Do not add `--skip-build` there either, because no frontend publish is happening in that phase. -Do not combine it with `--publish-frontend-assets` either, because the publish flag is only for the later follow-up phase. - -When you are ready for that second phase, you can either use `publish:frontend-assets` directly or ask the split-stack wrapper to drive it for you: - -```bash -yarn deploy:aws -- \ - --region=us-east-1 \ - --project-name=b1admin \ - --environment=prod \ - --skip-backend \ - --skip-frontend \ - --publish-frontend-assets -``` - -In that publish-only follow-up path, `deploy:aws` now skips backend packaging, artifact upload, layer publication, and secret sync work instead of repeating it unnecessarily. -If you already have a ready `dist/` directory, you can add `--skip-build` there too. -`--publish-frontend-assets` is meant for that later staged follow-up shape, so use it with `--skip-frontend` after an earlier `--frontend-infrastructure-only` run. -When you do use `--skip-build` in that staged follow-up, the wrapper now also stops forwarding backend-stack lookup inputs into the publish helper, because no build-time `REACT_APP_*` injection happens in that phase. -If you do need a build in that later phase, `--backend-outputs-file=...` is also supported there, so the publish helper can inject `REACT_APP_*` values without re-reading the backend stack. -That same split-stack publish-only follow-up now also accepts `--frontend-outputs-file=...`, or direct `--bucket=... --distribution-id=...`, so it can publish without re-reading the frontend stack too. -If you want a machine-readable example of the normal end-to-end split-stack wrapper JSON result, see [`examples/deploy-aws-full-output.sample.json`](./examples/deploy-aws-full-output.sample.json). -If you want a machine-readable example of the earlier `--frontend-infrastructure-only` wrapper JSON result, see [`examples/deploy-aws-frontend-infra-output.sample.json`](./examples/deploy-aws-frontend-infra-output.sample.json). -If you want a machine-readable example of the split-stack wrapper's publish-only JSON result, see [`examples/deploy-aws-publish-output.sample.json`](./examples/deploy-aws-publish-output.sample.json). -If you want the build-driven variant that also carries resolved `frontendPublish.backendBuildEnv` values from a saved backend outputs file, see [`examples/deploy-aws-publish-build-output.sample.json`](./examples/deploy-aws-publish-build-output.sample.json). - -## Deploying With A Single CloudFormation Entry Point - -If you prefer a single CloudFormation stack that creates both nested stacks, first upload the child templates somewhere CloudFormation can reach, such as S3. Then deploy [`cloudformation/full-stack.yaml`](./cloudformation/full-stack.yaml) with a parameter file like [`examples/full-stack-parameters.sample.json`](./examples/full-stack-parameters.sample.json). - -The easiest path in this repo is the helper script: - -```bash -yarn deploy:full-stack -- \ - --stack-name=b1admin-prod \ - --region=us-east-1 \ - --project-name=b1admin \ - --environment=prod \ - --bootstrap-stack-name=b1admin-prod-bootstrap \ - --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ - --backend-artifact-source-file=../Api/dist/api.zip \ - --migration-artifact-source-file=../Api/dist/migrations.zip -``` - -That helper will: - -1. Resolve the template and artifact buckets from the bootstrap stack, if provided. -2. Upload `backend-api.yaml` to your template bucket. -3. Upload `frontend-site.yaml` to your template bucket. -4. Optionally upload a backend Lambda zip if `--backend-artifact-source-file` is provided, or reuse the packaged artifact referenced by `--package-manifest-file`. -5. Optionally upload a separate migration zip if `--migration-artifact-source-file` is provided. -6. Inject those template URLs into the full-stack deployment. -7. Deploy the nested-stack entrypoint. -8. Build the frontend with stack outputs injected as `REACT_APP_*` values. -9. Upload the frontend assets to the created S3 bucket. -10. Invalidate the created CloudFront distribution. - -During that publish step, the helper now passes the resolved frontend bucket/distribution values directly into `publish:frontend-assets` instead of making that helper rediscover them from a temporary frontend outputs file. A temporary backend-outputs manifest is only written when a real frontend build needs stack-driven `REACT_APP_*` injection. - -If you only want the infrastructure and plan to publish everything later, add `--infrastructure-only`. - -If you want the backend plus frontend hosting infrastructure now but plan to publish frontend assets later, add `--frontend-infrastructure-only`. -Do not combine that with `--skip-infrastructure`, because `--skip-infrastructure` is only for the later publish-only follow-up pass. -Do not combine that with `--publish-frontend-assets` either, because the publish flag is only for the later follow-up phase. - -When you are ready for that second phase, you can either use `publish:frontend-assets` directly or ask the full-stack wrapper to drive it for you. The full-stack wrapper now supports a real publish-only follow-up mode when you pass `--skip-infrastructure`: - -```bash -yarn deploy:full-stack -- \ - --stack-name=b1admin-prod \ - --region=us-east-1 \ - --parameters-file=infrastructure/examples/full-stack-parameters.sample.json \ - --skip-infrastructure \ - --publish-frontend-assets -``` - -If you prefer, `publish:frontend-assets` still works directly too. -As with the direct publish helper, you can add `--skip-build` when you want to reuse an existing `dist/` bundle. -If your later publish environment does not have CloudFormation read access, `deploy:full-stack -- --skip-infrastructure --publish-frontend-assets` now also accepts `--frontend-outputs-file=...`, or direct `--bucket=... --distribution-id=...`, and it can take `--backend-outputs-file=...` when a fresh build still needs stack-driven `REACT_APP_*` values. -If you want a machine-readable example of the full-stack wrapper's normal end-to-end JSON result, see [`examples/deploy-full-stack-full-output.sample.json`](./examples/deploy-full-stack-full-output.sample.json). -If you want a machine-readable example of the full-stack wrapper's hosting-only non-publish JSON result, see [`examples/deploy-full-stack-frontend-infra-output.sample.json`](./examples/deploy-full-stack-frontend-infra-output.sample.json). -If you want a machine-readable example of the full-stack wrapper's publish-only JSON result, see [`examples/deploy-full-stack-publish-output.sample.json`](./examples/deploy-full-stack-publish-output.sample.json). -If you want the build-driven variant that also carries resolved `frontendEnv` values from a saved backend outputs file, see [`examples/deploy-full-stack-publish-build-output.sample.json`](./examples/deploy-full-stack-publish-build-output.sample.json). -Do not combine `--skip-infrastructure` with `--infrastructure-only` or `--frontend-infrastructure-only`, because those modes describe different phases of the rollout. -Likewise, do not add `--publish-frontend-assets` to a normal full-stack deploy, because the regular full-stack path already publishes frontend assets. - -## Staging Starter Kit - -If you want a concrete environment to start from instead of editing the generic examples in place, use [`environments/staging`](./environments/staging). It includes: - -- bootstrap, backend, and frontend parameter files -- an app-config secret template -- a split-stack deployment script that validates inputs, deploys bootstrap, and then runs `deploy:aws` - -That starter kit defaults the first rollout to no custom domains so you can get a staging stack up before wiring ACM and Route53. See [`environments/staging/README.md`](./environments/staging/README.md) for the exact command sequence and the fields you still need to replace. -That staging path completed successfully on June 24, 2026 with backend stack `b1admin-staging-backend`, frontend stack `b1admin-staging-frontend`, API base URL `https://5wmx09abp3.execute-api.us-east-1.amazonaws.com`, and frontend app URL `https://d1niz7249zvl23.cloudfront.net`. - -## Prod Starter Kit - -There is now a matching production-oriented starter at [`environments/prod`](./environments/prod). It follows the same split-stack pattern as staging: - -- bootstrap, backend, and frontend parameter files -- an app-config secret template -- a split-stack deployment script that validates inputs, deploys bootstrap, and then runs `deploy:aws` - -Like the staging starter, it keeps custom domains blank on the first pass so you can stand up the base production stack before layering in ACM and Route53. See [`environments/prod/README.md`](./environments/prod/README.md) for the exact command sequence and the fields you still need to replace. - -If you want a quick index of both concrete environment starters in one place, see [`environments/README.md`](./environments/README.md). -There is also a shared first-rollout operator checklist at [`environments/first-rollout-checklist.md`](./environments/first-rollout-checklist.md). -For GitHub-driven rollouts, there is also a setup guide for the required repository environments, AWS auth secrets, and OIDC trust shape at [`environments/github-actions-setup.md`](./environments/github-actions-setup.md). -If this repository is public and you do not want the live AWS workflow running here, use the private-repo pattern in [`environments/private-deployment-repo.md`](./environments/private-deployment-repo.md) as the primary rollout model instead. -If you want reusable IAM role templates for the recommended GitHub-OIDC-role plus CloudFormation-execution-role model, use [`iam/README.md`](./iam/README.md). -For a field-by-field preparation pass against the checked-in parameter files, use [`environments/deployment-workbook.md`](./environments/deployment-workbook.md). -For a mechanical starter-file readiness check before a live deploy, run `yarn audit:environment-starter -- --environment=staging --output=json`. -For a tighter “what do I fix next?” view, run `yarn audit:environment-starter -- --environment=staging --only-blockers=true`. -For a copy-paste markdown checklist of those blockers, run `yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown`. -For a safe dry-run that proposes bucket replacements and secret generation before editing starter files, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=json`. -For the exact follow-up commands after that dry-run, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands`. -For a copy-paste markdown prep runbook, run `yarn prepare:environment-starter -- --environment=staging --account-id= --output=markdown`. -For a concrete first-run plan that maps the same starter files into both the local script path and the GitHub Actions workflow path, run `yarn plan:environment-deploy -- --environment=staging --output=markdown`. -If you want one compact “what is left?” snapshot across both checked-in environments, run `yarn show:rollout-status -- --output=markdown`. -If you want that same snapshot as copy-paste remediation commands, run `yarn show:rollout-status -- --output=commands`. -If you want the same rollout snapshot in a machine-readable shape for CI or wrapper scripts, run `yarn show:rollout-status -- --output=json`. A representative JSON result is checked in at [`examples/show-rollout-status-output.sample.json`](./examples/show-rollout-status-output.sample.json). -If you want that rollout snapshot to focus on the GitHub-to-AWS path only, run `yarn show:rollout-status -- --deployment-intent=github-actions --output=markdown` so local-only blockers like an unreadable `../Api` checkout do not dominate the summary. -That JSON output now also includes top-level `readyEnvironments`, `blockedEnvironments`, `commandSummary`, `blockerCategories`, `overallHighlightedBlockers`, and `recommendedNextSteps` fields so automation can read the cross-environment state and exact command order without scanning each environment block manually. -In GitHub-focused mode it also reports `deploymentIntent`, `ignoredBlockerCategories`, and `intentBlockerCategories`, and it trims local-only deploy commands out of the recommended command list. -That deploy-plan helper now separates starter/input blockers from local-only execution blockers, which makes it easier to tell whether the local shell path or the GitHub Actions path is actually runnable right now. -It now also recommends the safer execution path directly when the two paths differ in readiness. -It now also reports local GitHub CLI dispatch readiness separately, so the plan can tell you when the GitHub runner path is fine but the machine you are holding cannot actually call `gh workflow run` yet because `gh` is missing, `gh auth login -h github.com` still needs attention, or GitHub is not reachable from this shell. -When no execution path is runnable yet, it now also promotes the most concrete remediation command first, such as `gh auth login -h github.com` or `sync:github-app-config-secret`, instead of dropping back to a generic audit command. -When GitHub is the recommended execution path, the plan now prefers the checked-in `dispatch:github-aws-deploy` wrapper over a raw `gh workflow run` command, while still showing the low-level `gh` form for manual fallback or debugging. -That dispatch helper now also prints the exact `gh run list`, `gh run watch`, and `gh run view` follow-up commands for the latest `deploy-aws-self-hosted.yml` run so the operator can move straight from dispatch into live monitoring. -If the local `api-repo` path is unreadable, it now also emits concrete `package-manifest` and `backend-artifact` fallback commands so the operator can switch local deploy modes without reconstructing those commands manually. -When starter-file blockers still exist, it now recommends `prepare:environment-starter` first and includes the dry-run, markdown, and `--write=true` prep commands directly in the plan output. -The checked-in `deploy-split-stack.sh` wrappers now also support `PREVIEW_ONLY=true`, which runs the starter audit plus deploy-plan preflight and then stops before any AWS mutation. -The deploy planner now surfaces those local preview-only commands directly, alongside matching GitHub `preview_only=true` dispatch commands, so the safer dry-run path is visible in the same plan output as the live deploy path. -Its `--output=commands` mode now prints the recommended next command first and keeps alternate commands after it. -It now also includes the post-deploy `verify:split-stack` follow-up commands and a reminder to work through the shared rollout checklist. -It now also includes exact output-capture commands so the first rollout leaves behind reusable backend and frontend outputs JSON files. -Those output-capture commands now create the destination `deployment//` folder first so they are runnable on a fresh checkout. -It now also includes copy-paste follow-up commands that reuse those saved outputs for later verification and publish-only frontend asset runs, so a later shell or CI step does not need live CloudFormation reads. -If you want that evidence saved with one helper instead of two manual `describe-stacks` commands, run `yarn save:split-stack-outputs -- --environment=staging --region=`. -If you want to re-render the saved `deployment-summary.json` later as a readable checklist, run `yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown`. -If you want only the copy-paste follow-up commands from that saved summary, run `yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=commands`. -On that successful June 24, 2026 staging rollout, `yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend --check-http=true` passed, including HTTP reachability for the CloudFront app URL. -It now also spells out the GitHub post-deploy handoff directly in the plan output, including the expected deployment-evidence artifact on success, the fallback preflight-plan artifact on failure, and what the GitHub job summary should contain. -That audit helper now includes `nextSteps` and `suggestions` in its output so the unresolved staging work is easier to convert into concrete edits. -For manual CI-driven rollouts, there is now a GitHub Actions entrypoint at [`.github/workflows/deploy-aws-self-hosted.yml`](../.github/workflows/deploy-aws-self-hosted.yml). It targets the checked-in `staging` and `prod` environment starter kits and can deploy from a checked-out Api repo, a prebuilt package manifest, or direct backend artifact zip paths. The workflow supports either GitHub OIDC role assumption through `AWS_ROLE_TO_ASSUME` or the older static-key secret pair. -If B1Admin is public, treat that workflow as a template or bootstrap reference and move the live workflow, GitHub Environments, and live parameter files into a separate private deployment repository as described in [`environments/private-deployment-repo.md`](./environments/private-deployment-repo.md). -That workflow now also supports `preview_only=true`, which runs the same runner-side starter audit plus deploy-plan preflight and then stops before any AWS mutation. -If you intend to launch that workflow from this checkout instead of the GitHub UI, `yarn plan:environment-deploy -- --environment=staging --output=markdown` is now the fastest preflight because it shows both the runner-side GitHub Actions blockers and whether local `gh` auth is good enough to dispatch from this machine. -After a successful run, it now also uploads an `aws--deployment-evidence` artifact containing the saved backend outputs, frontend outputs, deployment summary, and any saved preflight plan from `deployment//`. -If the deploy step fails before that full bundle is created, the workflow still uploads an `aws--preflight-plan` artifact so the computed blocker list remains downloadable. -The workflow now also writes a GitHub job summary with the preflight plan, resolved stack names, key URLs, and saved-output follow-up commands so the operator does not need to open the artifact just to see the important results. -One remaining maintenance follow-up from the successful hosted-run path is updating the workflow action runtime mix away from Node 20-targeted actions, because GitHub currently emits a deprecation warning and shims those actions onto Node 24 on hosted runners. - -Example: - -```bash -aws cloudformation deploy \ - --stack-name b1admin-prod \ - --template-file infrastructure/cloudformation/full-stack.yaml \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides \ - $(jq -r 'to_entries[] | "\(.key)=\(.value)"' infrastructure/examples/full-stack-parameters.sample.json) -``` - -That stack will: - -1. Create the backend nested stack. -2. Create the frontend nested stack. -3. Surface the important outputs, including API base URL, frontend bucket name, and CloudFront distribution ID. - -If you use the helper script above, the frontend assets are published automatically. If you deploy `full-stack.yaml` manually with raw CloudFormation, you still need a separate asset publish step against the created frontend bucket. - -## Recommended Full-Stack Layout - -For a complete AWS self-hosting setup, use at least two stacks: - -1. `backend-core` -2. `frontend-site` - -`backend-core` should own: - -- API Gateway or Lambda Function URL entrypoints -- Lambda functions -- Aurora cluster -- VPC and subnets -- Secrets Manager parameters -- Optional custom domains for API/content services -- Packaging and deployment of the real API code artifact - -`frontend-site` should consume backend outputs through CI/CD variables, SSM parameters, or a deployment manifest. - -## Next Backend Step - -When you wire this to the real API repo, make sure the backend deployment produces: - -- `ApiBaseUrl` -- `ContentRootUrl` -- `WebsiteBaseUrl` -- `TransferUrl` -- `SupportEmail` -- `SupportPhone` -- `SupportSiteUrl` -- `MobileAppUrl` -- `DomainCnameTarget` -- `DomainATarget` -- Any other public, non-secret frontend endpoints - -That gives you an end-to-end account-agnostic deployment path even if the application source continues to live across multiple repositories. +- Stack names: `b1admin--bootstrap|backend|frontend`. +- All scripts accept `--output=json|markdown|text`, read defaults from `customer-values.json` via `--customer-file`, and are plain Node (no dependencies beyond the AWS/GitHub CLIs). +- The smoke suite (`yarn smoke:aws-tooling`) exercises every script against stubbed `aws`/`gh` binaries. Run it with invalid AWS credentials in the environment so the handful of live-CLI scenarios cannot touch a real account. diff --git a/infrastructure/cloudformation/full-stack.yaml b/infrastructure/cloudformation/full-stack.yaml deleted file mode 100644 index 6b715d62d..000000000 --- a/infrastructure/cloudformation/full-stack.yaml +++ /dev/null @@ -1,499 +0,0 @@ -AWSTemplateFormatVersion: "2010-09-09" -Description: B1Admin full AWS deployment entrypoint using nested backend and frontend stacks. - -Parameters: - ProjectName: - Type: String - Default: b1admin - AllowedPattern: "^[a-z0-9-]+$" - EnvironmentName: - Type: String - Default: prod - AllowedPattern: "^[a-z0-9-]+$" - BackendTemplateUrl: - Type: String - Description: S3 or HTTPS URL for backend-api.yaml uploaded template. - FrontendTemplateUrl: - Type: String - Description: S3 or HTTPS URL for frontend-site.yaml uploaded template. - LambdaCodeS3Bucket: - Type: String - LambdaCodeS3Key: - Type: String - LambdaHandler: - Type: String - Default: lambda.web - LambdaRuntime: - Type: String - Default: nodejs22.x - LambdaArchitecture: - Type: String - Default: arm64 - AllowedValues: [arm64, x86_64] - LambdaMemorySize: - Type: Number - Default: 1024 - LambdaTimeout: - Type: Number - Default: 30 - LambdaReservedConcurrency: - Type: Number - Default: 0 - DependenciesLayerArn: - Type: String - Default: "" - ObservabilityLayerArn: - Type: String - Default: "" - LambdaNodeOptions: - Type: String - Default: "" - EnableWebSocketApi: - Type: String - Default: "true" - AllowedValues: ["true", "false"] - SocketLambdaHandler: - Type: String - Default: lambda.socket - SocketLambdaMemorySize: - Type: Number - Default: 1024 - SocketLambdaTimeout: - Type: Number - Default: 30 - EnableScheduledWorkers: - Type: String - Default: "true" - AllowedValues: ["true", "false"] - Timer15MinLambdaHandler: - Type: String - Default: lambda.timer15Min - TimerMidnightLambdaHandler: - Type: String - Default: lambda.timerMidnight - TimerScheduledTasksLambdaHandler: - Type: String - Default: lambda.timerScheduledTasks - TimerWebhooksLambdaHandler: - Type: String - Default: lambda.timerWebhooks - TimerLambdaMemorySize: - Type: Number - Default: 256 - TimerLambdaTimeout: - Type: Number - Default: 300 - RunMigrations: - Type: String - Default: "false" - AllowedValues: ["true", "false"] - MigrationCodeS3Bucket: - Type: String - Default: "" - MigrationCodeS3Key: - Type: String - Default: "" - MigrationHandler: - Type: String - Default: "" - MigrationRuntime: - Type: String - Default: "" - MigrationMemorySize: - Type: Number - Default: 1024 - MigrationTimeout: - Type: Number - Default: 900 - MigrationTrigger: - Type: String - Default: "" - DatabaseName: - Type: String - Default: membership - MembershipDatabaseName: - Type: String - Default: "" - AttendanceDatabaseName: - Type: String - Default: "" - ContentDatabaseName: - Type: String - Default: "" - GivingDatabaseName: - Type: String - Default: "" - MessagingDatabaseName: - Type: String - Default: "" - DoingDatabaseName: - Type: String - Default: "" - ReportingDatabaseName: - Type: String - Default: "" - DatabaseEngine: - Type: String - Default: aurora-mysql - AllowedValues: - - aurora-mysql - - aurora-postgresql - DatabasePort: - Type: Number - Default: 3306 - DatabaseMasterUsername: - Type: String - Default: app_admin - DatabaseMinCapacity: - Type: Number - Default: 0.5 - DatabaseMaxCapacity: - Type: Number - Default: 2 - ApiCustomDomainName: - Type: String - Default: "" - ApiCertificateArn: - Type: String - Default: "" - ApiHostedZoneId: - Type: String - Default: "" - CreateNatGateway: - Type: String - Default: "true" - AllowedValues: ["true", "false"] - VpcCidr: - Type: String - Default: 10.30.0.0/16 - PublicSubnet1Cidr: - Type: String - Default: 10.30.0.0/24 - PublicSubnet2Cidr: - Type: String - Default: 10.30.1.0/24 - PrivateSubnet1Cidr: - Type: String - Default: 10.30.10.0/24 - PrivateSubnet2Cidr: - Type: String - Default: 10.30.11.0/24 - FrontendBucketName: - Type: String - Default: "" - FrontendAlternateDomainName: - Type: String - Default: "" - FrontendAcmCertificateArn: - Type: String - Default: "" - FrontendHostedZoneId: - Type: String - Default: "" - FrontendPriceClass: - Type: String - Default: PriceClass_100 - AllowedValues: - - PriceClass_100 - - PriceClass_200 - - PriceClass_All - WebsiteBaseUrl: - Type: String - Default: "" - ContentRootUrl: - Type: String - Default: "" - B1AdminRootUrl: - Type: String - Default: "" - CorsOrigin: - Type: String - Default: "*" - FileStore: - Type: String - Default: "S3" - ManageAssetBucket: - Type: String - Default: "true" - AllowedValues: ["true", "false"] - AssetBucketName: - Type: String - Default: "" - AppConfigSecretArn: - Type: String - Default: "" - MailSystem: - Type: String - Default: "SES" - DeliveryProvider: - Type: String - Default: "aws" - StoreApiUrl: - Type: String - Default: "" - AiProvider: - Type: String - Default: "" - EmailOnRegistration: - Type: String - Default: "" - AllowedValues: ["", "true", "false"] - CaddyHost: - Type: String - Default: "" - CaddyPort: - Type: String - Default: "" - TransferUrl: - Type: String - Default: "" - SupportEmail: - Type: String - Default: "" - SupportPhone: - Type: String - Default: "" - SupportSiteUrl: - Type: String - Default: "" - MobileAppUrl: - Type: String - Default: "" - DomainCnameTarget: - Type: String - Default: "" - DomainATarget: - Type: String - Default: "" - DefaultStockPhoto: - Type: String - Default: "" - GoogleAnalyticsTag: - Type: String - Default: "" - SentryDsn: - Type: String - Default: "" - -Rules: - ApiDomainRequiresCertificate: - Assertions: - - Assert: !Or - - !Equals [!Ref ApiCustomDomainName, ""] - - !Not [!Equals [!Ref ApiCertificateArn, ""]] - AssertDescription: ApiCertificateArn is required when ApiCustomDomainName is set. - MigrationRequiresHandler: - Assertions: - - Assert: !Or - - !Equals [!Ref RunMigrations, "false"] - - !Not [!Equals [!Ref MigrationHandler, ""]] - AssertDescription: MigrationHandler is required when RunMigrations is true. - FrontendDomainRequiresCertificate: - Assertions: - - Assert: !Or - - !Equals [!Ref FrontendAlternateDomainName, ""] - - !Not [!Equals [!Ref FrontendAcmCertificateArn, ""]] - AssertDescription: FrontendAcmCertificateArn is required when FrontendAlternateDomainName is set. - -Resources: - BackendStack: - Type: AWS::CloudFormation::Stack - Properties: - TemplateURL: !Ref BackendTemplateUrl - Parameters: - ProjectName: !Ref ProjectName - EnvironmentName: !Ref EnvironmentName - LambdaCodeS3Bucket: !Ref LambdaCodeS3Bucket - LambdaCodeS3Key: !Ref LambdaCodeS3Key - LambdaHandler: !Ref LambdaHandler - LambdaRuntime: !Ref LambdaRuntime - LambdaArchitecture: !Ref LambdaArchitecture - LambdaMemorySize: !Ref LambdaMemorySize - LambdaTimeout: !Ref LambdaTimeout - LambdaReservedConcurrency: !Ref LambdaReservedConcurrency - DependenciesLayerArn: !Ref DependenciesLayerArn - ObservabilityLayerArn: !Ref ObservabilityLayerArn - LambdaNodeOptions: !Ref LambdaNodeOptions - EnableWebSocketApi: !Ref EnableWebSocketApi - SocketLambdaHandler: !Ref SocketLambdaHandler - SocketLambdaMemorySize: !Ref SocketLambdaMemorySize - SocketLambdaTimeout: !Ref SocketLambdaTimeout - EnableScheduledWorkers: !Ref EnableScheduledWorkers - Timer15MinLambdaHandler: !Ref Timer15MinLambdaHandler - TimerMidnightLambdaHandler: !Ref TimerMidnightLambdaHandler - TimerScheduledTasksLambdaHandler: !Ref TimerScheduledTasksLambdaHandler - TimerWebhooksLambdaHandler: !Ref TimerWebhooksLambdaHandler - TimerLambdaMemorySize: !Ref TimerLambdaMemorySize - TimerLambdaTimeout: !Ref TimerLambdaTimeout - RunMigrations: !Ref RunMigrations - MigrationCodeS3Bucket: !Ref MigrationCodeS3Bucket - MigrationCodeS3Key: !Ref MigrationCodeS3Key - MigrationHandler: !Ref MigrationHandler - MigrationRuntime: !Ref MigrationRuntime - MigrationMemorySize: !Ref MigrationMemorySize - MigrationTimeout: !Ref MigrationTimeout - MigrationTrigger: !Ref MigrationTrigger - DatabaseName: !Ref DatabaseName - MembershipDatabaseName: !Ref MembershipDatabaseName - AttendanceDatabaseName: !Ref AttendanceDatabaseName - ContentDatabaseName: !Ref ContentDatabaseName - GivingDatabaseName: !Ref GivingDatabaseName - MessagingDatabaseName: !Ref MessagingDatabaseName - DoingDatabaseName: !Ref DoingDatabaseName - ReportingDatabaseName: !Ref ReportingDatabaseName - DatabaseEngine: !Ref DatabaseEngine - DatabasePort: !Ref DatabasePort - DatabaseMasterUsername: !Ref DatabaseMasterUsername - DatabaseMinCapacity: !Ref DatabaseMinCapacity - DatabaseMaxCapacity: !Ref DatabaseMaxCapacity - ApiCustomDomainName: !Ref ApiCustomDomainName - ApiCertificateArn: !Ref ApiCertificateArn - ApiHostedZoneId: !Ref ApiHostedZoneId - CreateNatGateway: !Ref CreateNatGateway - VpcCidr: !Ref VpcCidr - PublicSubnet1Cidr: !Ref PublicSubnet1Cidr - PublicSubnet2Cidr: !Ref PublicSubnet2Cidr - PrivateSubnet1Cidr: !Ref PrivateSubnet1Cidr - PrivateSubnet2Cidr: !Ref PrivateSubnet2Cidr - WebsiteBaseUrl: !Ref WebsiteBaseUrl - ContentRootUrl: !Ref ContentRootUrl - B1AdminRootUrl: !Ref B1AdminRootUrl - CorsOrigin: !Ref CorsOrigin - FileStore: !Ref FileStore - ManageAssetBucket: !Ref ManageAssetBucket - AssetBucketName: !Ref AssetBucketName - AppConfigSecretArn: !Ref AppConfigSecretArn - MailSystem: !Ref MailSystem - DeliveryProvider: !Ref DeliveryProvider - StoreApiUrl: !Ref StoreApiUrl - AiProvider: !Ref AiProvider - EmailOnRegistration: !Ref EmailOnRegistration - CaddyHost: !Ref CaddyHost - CaddyPort: !Ref CaddyPort - TransferUrl: !Ref TransferUrl - SupportEmail: !Ref SupportEmail - SupportPhone: !Ref SupportPhone - SupportSiteUrl: !Ref SupportSiteUrl - MobileAppUrl: !Ref MobileAppUrl - DomainCnameTarget: !Ref DomainCnameTarget - DomainATarget: !Ref DomainATarget - DefaultStockPhoto: !Ref DefaultStockPhoto - GoogleAnalyticsTag: !Ref GoogleAnalyticsTag - SentryDsn: !Ref SentryDsn - - FrontendStack: - Type: AWS::CloudFormation::Stack - Properties: - TemplateURL: !Ref FrontendTemplateUrl - Parameters: - ProjectName: !Ref ProjectName - EnvironmentName: !Ref EnvironmentName - BucketName: !Ref FrontendBucketName - AlternateDomainName: !Ref FrontendAlternateDomainName - AcmCertificateArn: !Ref FrontendAcmCertificateArn - HostedZoneId: !Ref FrontendHostedZoneId - PriceClass: !Ref FrontendPriceClass - -Outputs: - BackendStackId: - Value: !Ref BackendStack - FrontendStackId: - Value: !Ref FrontendStack - ApiBaseUrl: - Value: !GetAtt BackendStack.Outputs.ApiBaseUrl - PublicApiBaseUrl: - Value: !GetAtt BackendStack.Outputs.PublicApiBaseUrl - ApiFunctionName: - Value: !GetAtt BackendStack.Outputs.ApiFunctionName - ApiFunctionArn: - Value: !GetAtt BackendStack.Outputs.ApiFunctionArn - MigrationFunctionName: - Value: !GetAtt BackendStack.Outputs.MigrationFunctionName - SocketFunctionName: - Value: !GetAtt BackendStack.Outputs.SocketFunctionName - WebSocketApiId: - Value: !GetAtt BackendStack.Outputs.WebSocketApiId - WebSocketApiEndpoint: - Value: !GetAtt BackendStack.Outputs.WebSocketApiEndpoint - Timer15MinFunctionName: - Value: !GetAtt BackendStack.Outputs.Timer15MinFunctionName - TimerMidnightFunctionName: - Value: !GetAtt BackendStack.Outputs.TimerMidnightFunctionName - TimerScheduledTasksFunctionName: - Value: !GetAtt BackendStack.Outputs.TimerScheduledTasksFunctionName - TimerWebhooksFunctionName: - Value: !GetAtt BackendStack.Outputs.TimerWebhooksFunctionName - ApiCustomDomainName: - Value: !GetAtt BackendStack.Outputs.ApiCustomDomainName - DatabaseEndpoint: - Value: !GetAtt BackendStack.Outputs.DatabaseEndpoint - DatabaseReaderEndpoint: - Value: !GetAtt BackendStack.Outputs.DatabaseReaderEndpoint - DatabaseClusterArn: - Value: !GetAtt BackendStack.Outputs.DatabaseClusterArn - DatabasePort: - Value: !GetAtt BackendStack.Outputs.DatabasePort - DatabaseName: - Value: !GetAtt BackendStack.Outputs.DatabaseName - MembershipDatabaseName: - Value: !GetAtt BackendStack.Outputs.MembershipDatabaseName - AttendanceDatabaseName: - Value: !GetAtt BackendStack.Outputs.AttendanceDatabaseName - ContentDatabaseName: - Value: !GetAtt BackendStack.Outputs.ContentDatabaseName - GivingDatabaseName: - Value: !GetAtt BackendStack.Outputs.GivingDatabaseName - MessagingDatabaseName: - Value: !GetAtt BackendStack.Outputs.MessagingDatabaseName - DoingDatabaseName: - Value: !GetAtt BackendStack.Outputs.DoingDatabaseName - ReportingDatabaseName: - Value: !GetAtt BackendStack.Outputs.ReportingDatabaseName - DatabaseSecretArn: - Value: !GetAtt BackendStack.Outputs.DatabaseSecretArn - AppConfigSecretArn: - Value: !GetAtt BackendStack.Outputs.AppConfigSecretArn - VpcId: - Value: !GetAtt BackendStack.Outputs.VpcId - PrivateSubnet1Id: - Value: !GetAtt BackendStack.Outputs.PrivateSubnet1Id - PrivateSubnet2Id: - Value: !GetAtt BackendStack.Outputs.PrivateSubnet2Id - LambdaSecurityGroupId: - Value: !GetAtt BackendStack.Outputs.LambdaSecurityGroupId - ContentRootUrl: - Value: !GetAtt BackendStack.Outputs.ContentRootUrl - AssetBucketName: - Value: !GetAtt BackendStack.Outputs.AssetBucketName - WebsiteBaseUrl: - Value: !GetAtt BackendStack.Outputs.WebsiteBaseUrl - LessonsApiUrl: - Value: !GetAtt BackendStack.Outputs.LessonsApiUrl - TransferUrl: - Value: !GetAtt BackendStack.Outputs.TransferUrl - SupportEmail: - Value: !GetAtt BackendStack.Outputs.SupportEmail - SupportPhone: - Value: !GetAtt BackendStack.Outputs.SupportPhone - SupportSiteUrl: - Value: !GetAtt BackendStack.Outputs.SupportSiteUrl - MobileAppUrl: - Value: !GetAtt BackendStack.Outputs.MobileAppUrl - DomainCnameTarget: - Value: !GetAtt BackendStack.Outputs.DomainCnameTarget - DomainATarget: - Value: !GetAtt BackendStack.Outputs.DomainATarget - DefaultStockPhoto: - Value: !GetAtt BackendStack.Outputs.DefaultStockPhoto - GoogleAnalyticsTag: - Value: !GetAtt BackendStack.Outputs.GoogleAnalyticsTag - SentryDsn: - Value: !GetAtt BackendStack.Outputs.SentryDsn - FrontendBucketName: - Value: !GetAtt FrontendStack.Outputs.SiteBucketName - FrontendDistributionId: - Value: !GetAtt FrontendStack.Outputs.CloudFrontDistributionId - FrontendDistributionDomainName: - Value: !GetAtt FrontendStack.Outputs.CloudFrontDistributionDomainName - FrontendAppUrl: - Value: !GetAtt FrontendStack.Outputs.AppUrl diff --git a/infrastructure/environments/README.md b/infrastructure/environments/README.md index 64dc24ef6..030a94dbd 100644 --- a/infrastructure/environments/README.md +++ b/infrastructure/environments/README.md @@ -13,7 +13,6 @@ This folder is reference material for the installer. New installers should not s - [`staging/`](./staging): optional practice-environment starter files copied into the user's private repository. - [`private-deployment-repo.md`](./private-deployment-repo.md): reference guide for the user's private repository. - [`github-actions-setup.md`](./github-actions-setup.md): lower-level GitHub Actions reference. -- [`deployment-workbook.md`](./deployment-workbook.md): optional planning worksheet. - [`first-rollout-checklist.md`](./first-rollout-checklist.md): detailed verification checklist after a deploy. ## Normal Installer Path diff --git a/infrastructure/environments/deployment-workbook.md b/infrastructure/environments/deployment-workbook.md deleted file mode 100644 index 01a0047ea..000000000 --- a/infrastructure/environments/deployment-workbook.md +++ /dev/null @@ -1,289 +0,0 @@ -# Deployment Workbook - -Use this workbook only when you want a detailed planning worksheet. For a normal guided install, start with [`start-here.md`](./start-here.md) and answer the installer questions instead of filling this workbook by hand. - -This workbook can help prepare the real values for a first AWS rollout before you run either the local deploy scripts or the GitHub Actions workflow. - -The checked-in starter files already define the parameter shape: - -- [`staging/bootstrap-parameters.json`](./staging/bootstrap-parameters.json) -- [`staging/backend-parameters.json`](./staging/backend-parameters.json) -- [`staging/frontend-parameters.json`](./staging/frontend-parameters.json) -- [`staging/app-config-secret.template.json`](./staging/app-config-secret.template.json) -- [`prod/bootstrap-parameters.json`](./prod/bootstrap-parameters.json) -- [`prod/backend-parameters.json`](./prod/backend-parameters.json) -- [`prod/frontend-parameters.json`](./prod/frontend-parameters.json) -- [`prod/app-config-secret.template.json`](./prod/app-config-secret.template.json) - -The normal install can start with `prod`. Use `staging` only when you intentionally want an optional practice deployment before prod. - -## Rollout Choices - -Decide these first: - -1. Deployment path: - local script or GitHub Actions -2. Backend source: - `api-repo`, `package-manifest`, or `backend-artifact` -3. AWS auth mode for GitHub: - `AWS_ROLE_TO_ASSUME` or static access keys -4. First-pass domain strategy: - blank custom domains or fully wired ACM/Route53 -5. Migration strategy: - no migrations on first deploy or run Api migrations after deploy - -After you decide those, generate a concrete runbook for the chosen environment before the live deploy: - -- `yarn plan:environment-deploy -- --environment=prod --output=markdown` - -That plan now tells you: - -- whether the checked-in starter files are still blocking both local and GitHub paths -- whether only the local machine is blocked -- which deploy path is currently recommended -- which artifact name to expect from GitHub on success and on early failure - -## Bootstrap Values - -Fill these in for the target environment file: - -- `ProjectName` -- `EnvironmentName` -- `TemplateBucketName` -- `ArtifactBucketName` -- `EnableBucketVersioning` - -Recommended notes to capture beside those values: - -- AWS account ID -- AWS region -- whether the bucket names are globally unique already -- whether these buckets are dedicated to this environment or shared - -## Backend Values - -These fields usually need the most attention before a live deploy. - -### Packaging And Runtime - -Confirm or replace: - -- `LambdaCodeS3Bucket` -- `LambdaCodeS3Key` -- `DependenciesLayerArn` -- `ObservabilityLayerArn` -- `LambdaNodeOptions` -- `EnableWebSocketApi` -- `EnableScheduledWorkers` - -If you are using a manifest or direct backend artifact path, confirm that the S3 bucket/key values still match the artifact strategy you intend to run. - -### Database And Network - -Confirm these values are deliberate: - -- `DatabaseName` -- `MembershipDatabaseName` -- `AttendanceDatabaseName` -- `ContentDatabaseName` -- `GivingDatabaseName` -- `MessagingDatabaseName` -- `DoingDatabaseName` -- `ReportingDatabaseName` -- `DatabaseEngine` -- `DatabasePort` -- `DatabaseMasterUsername` -- `DatabaseMinCapacity` -- `DatabaseMaxCapacity` -- `CreateNatGateway` -- `VpcCidr` -- `PublicSubnet1Cidr` -- `PublicSubnet2Cidr` -- `PrivateSubnet1Cidr` -- `PrivateSubnet2Cidr` - -Capture one extra decision here: - -- whether the CIDR ranges overlap anything else in the target AWS account - -### URLs And Public App Settings - -These are the values most likely to need replacement on day one: - -- `WebsiteBaseUrl` -- `ContentRootUrl` -- `B1AdminRootUrl` -- `CorsOrigin` -- `StoreApiUrl` -- `TransferUrl` -- `SupportEmail` -- `SupportPhone` -- `SupportSiteUrl` -- `MobileAppUrl` - -If you are doing a domain-light first pass, decide which of these should point at temporary AWS-generated hostnames and which should stay on your real domains. - -### Domain And DNS - -Fill these only if you are enabling custom API domains on the first rollout: - -- `ApiCustomDomainName` -- `ApiCertificateArn` -- `ApiHostedZoneId` -- `DomainCnameTarget` -- `DomainATarget` - -### Optional Integrations - -Review whether these should stay blank or be configured now: - -- `AppConfigSecretArn` -- `MailSystem` -- `DeliveryProvider` -- `AiProvider` -- `EmailOnRegistration` -- `CaddyHost` -- `CaddyPort` -- `DefaultStockPhoto` -- `GoogleAnalyticsTag` -- `SentryDsn` - -## Frontend Values - -For the first pass, these are usually enough to review: - -- `BucketName` -- `AlternateDomainName` -- `AcmCertificateArn` -- `HostedZoneId` -- `PriceClass` - -If you are intentionally delaying custom-domain cutover, leave the domain and certificate values blank and keep a note that the first verification should use the CloudFront URL. - -## App Config Secret Values - -At minimum, replace these with real secrets: - -- `jwtSecret` -- `encryptionKey` - -Then decide which of the optional keys must be present before the first live run: - -- `hubspotKey` -- `mauticUrl` -- `mauticUser` -- `mauticPassword` -- `youTubeApiKey` -- `pexelsKey` -- `vimeoToken` -- `apiBibleKey` -- `youVersionApiKey` -- `praiseChartsConsumerKey` -- `praiseChartsConsumerSecret` -- `googleRecaptchaSecretKey` -- `openRouterApiKey` -- `openAiApiKey` -- `webPushPublicKey` -- `webPushPrivateKey` -- `webPushSubject` - -If GitHub Actions will manage the secret sync, mirror the same JSON into the `AWS_APP_CONFIG_SECRET_JSON` environment secret. - -## GitHub Actions Inputs - -If you are using [`.github/workflows/deploy-aws-self-hosted.yml`](../../.github/workflows/deploy-aws-self-hosted.yml), pre-decide these values before your first run: - -- `environment` -- `aws_region` -- `deployment_source` -- `api_repo` -- `api_ref` -- `package_manifest_file` -- `backend_artifact_source_file` -- `migration_artifact_source_file` -- `dependencies_layer_source_file` -- `sync_app_config_secret` -- `sync_bootstrap_admin_secret` -- `run_api_migrations` -- `run_bootstrap_admin` -- `api_migration_action` -- `api_migration_module` -- `verify_http_after_deploy` - -Recommended first-run defaults: - -- `environment=prod` -- `deployment_source=api-repo` if the workflow can check out the Api repo cleanly -- `sync_app_config_secret=false` until the secret JSON is final -- `run_api_migrations=false` until the base stack is healthy -- `verify_http_after_deploy=false` unless the public hostname is already expected to answer - -Recommended deployment-source choices: - -- `api-repo` when the runner can check out the Api repo and package it directly -- `package-manifest` when CI already produced a checked-in or attached manifest plus artifact set -- `backend-artifact` when you only want to push a prepared backend zip and optional layer/migration zips - -## Local Script Inputs - -If you are using the local environment script instead, pre-decide these env vars: - -- `AWS_REGION` -- `API_REPO_PATH` -- `PACKAGE_MANIFEST_FILE` -- `BACKEND_ARTIFACT_SOURCE_FILE` -- `MIGRATION_ARTIFACT_SOURCE_FILE` -- `DEPENDENCIES_LAYER_SOURCE_FILE` -- `BOOTSTRAP_STACK_NAME` -- `SYNC_APP_CONFIG_SECRET` -- `RUN_API_MIGRATIONS` -- `API_MIGRATION_ACTION` -- `API_MIGRATION_MODULE` -- `VERIFY_AFTER_DEPLOY` -- `VERIFY_HTTP_AFTER_DEPLOY` - -Recommended local-source choices: - -- leave `PACKAGE_MANIFEST_FILE` and `BACKEND_ARTIFACT_SOURCE_FILE` unset when `API_REPO_PATH` should drive packaging -- set `PACKAGE_MANIFEST_FILE` when you want to reuse an earlier `package:api-backend` result -- set `BACKEND_ARTIFACT_SOURCE_FILE` when the backend zip already exists outside the Api repo - -If the target machine can see `API_REPO_PATH` but cannot actually read that checkout or its `package.json`, prefer `PACKAGE_MANIFEST_FILE` or `BACKEND_ARTIFACT_SOURCE_FILE` for the local run instead of trying to force the script through the unreadable repo. - -## Evidence To Save After Deploy - -After the first rollout, save these somewhere durable: - -- bootstrap stack name -- backend stack name -- frontend stack name -- AWS region -- Secrets Manager secret names -- backend outputs JSON -- frontend outputs JSON -- final workflow inputs or local env vars used -- whether migrations ran -- whether the deploy used `api-repo`, `package-manifest`, or `backend-artifact` - -That record will make later updates or optional staging/prod comparisons much less error-prone. - -The quickest way to save that evidence into the repo workspace is: - -- `yarn save:split-stack-outputs -- --environment=prod --region=` - -That helper writes: - -- `deployment/prod/backend-outputs.json` -- `deployment/prod/frontend-outputs.json` -- `deployment/prod/deployment-summary.json` - -If `deployment/prod/preflight-plan.md` exists too, the saved summary will reference it so the preflight context stays attached to the final deployment evidence. - -For GitHub Actions runs, expect: - -- `aws-prod-deployment-evidence` after a successful deploy -- `aws-prod-preflight-plan` if the deploy fails before the full evidence bundle is created - -After you choose the deployment source and auth path, you can generate a concrete local and GitHub Actions run plan with: - -- `yarn plan:environment-deploy -- --environment=prod --output=markdown` diff --git a/infrastructure/environments/start-here.md b/infrastructure/environments/start-here.md index b30f42c87..4a7cd8e1f 100644 --- a/infrastructure/environments/start-here.md +++ b/infrastructure/environments/start-here.md @@ -789,5 +789,4 @@ Use these only when you need detail beyond the guided path: - [User's private repository guide](./private-deployment-repo.md) - [GitHub Actions setup guide](./github-actions-setup.md) -- [Deployment workbook](./deployment-workbook.md) - [IAM setup guide](../iam/README.md) diff --git a/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json b/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json deleted file mode 100644 index 839923615..000000000 --- a/infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "stackName": "example-full-stack", - "region": "us-east-1", - "environmentName": "prod", - "outputs": { - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", - "FrontendBucketName": "example-frontend-bucket", - "FrontendDistributionId": "EXAMPLE123", - "FrontendAppUrl": "https://admin.example.com", - "PublicApiBaseUrl": "https://api.example.com", - "ContentRootUrl": "https://content.example.com", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "LessonsApiUrl": "https://lessons-api.example.com", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" - }, - "infrastructureOnly": false, - "frontendInfrastructureOnly": true, - "publishFrontendAssets": false, - "skipInfrastructure": false, - "skipBuild": false, - "bootstrapStackName": "", - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "backendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/backend-api.yaml", - "frontendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/frontend-site.yaml", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", - "lambdaCodeS3Bucket": "my-artifacts-bucket", - "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", - "migrationCodeS3Bucket": "my-artifacts-bucket", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "frontendPublished": false, - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123", - "frontendAppUrl": "https://admin.example.com", - "frontendEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "runBootstrapAdmin": false, - "bootstrapAdmin": null -} diff --git a/infrastructure/examples/deploy-full-stack-full-output.sample.json b/infrastructure/examples/deploy-full-stack-full-output.sample.json deleted file mode 100644 index 5b0d171a0..000000000 --- a/infrastructure/examples/deploy-full-stack-full-output.sample.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "stackName": "example-full-stack", - "region": "us-east-1", - "environmentName": "prod", - "outputs": { - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", - "FrontendBucketName": "example-frontend-bucket", - "FrontendDistributionId": "EXAMPLE123", - "FrontendAppUrl": "https://admin.example.com", - "PublicApiBaseUrl": "https://api.example.com", - "ContentRootUrl": "https://content.example.com", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "LessonsApiUrl": "https://lessons-api.example.com", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" - }, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "publishFrontendAssets": false, - "skipInfrastructure": false, - "skipBuild": false, - "bootstrapStackName": "", - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "backendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/backend-api.yaml", - "frontendTemplateUrl": "https://my-template-bucket.s3.us-east-1.amazonaws.com/b1admin/prod/cloudformation/frontend-site.yaml", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example", - "lambdaCodeS3Bucket": "my-artifacts-bucket", - "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", - "migrationCodeS3Bucket": "my-artifacts-bucket", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "frontendPublished": true, - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123", - "frontendAppUrl": "https://admin.example.com", - "frontendEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "runBootstrapAdmin": false, - "bootstrapAdmin": null -} diff --git a/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json b/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json deleted file mode 100644 index 4c638d41f..000000000 --- a/infrastructure/examples/deploy-full-stack-publish-build-output.sample.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "stackName": "", - "region": "us-east-1", - "environmentName": "prod", - "outputs": {}, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "publishFrontendAssets": true, - "skipInfrastructure": true, - "skipBuild": false, - "bootstrapStackName": "", - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "backendTemplateUrl": "", - "frontendTemplateUrl": "", - "appConfigSecretArn": "", - "lambdaCodeS3Bucket": "", - "lambdaCodeS3Key": "", - "migrationCodeS3Bucket": "", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "frontendPublished": true, - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123", - "frontendAppUrl": "https://admin.example.com", - "frontendEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "runBootstrapAdmin": false, - "bootstrapAdmin": null -} diff --git a/infrastructure/examples/deploy-full-stack-publish-output.sample.json b/infrastructure/examples/deploy-full-stack-publish-output.sample.json deleted file mode 100644 index ae02c7aa1..000000000 --- a/infrastructure/examples/deploy-full-stack-publish-output.sample.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "stackName": "", - "region": "us-east-1", - "environmentName": "prod", - "outputs": {}, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "publishFrontendAssets": true, - "skipInfrastructure": true, - "skipBuild": true, - "bootstrapStackName": "", - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "backendTemplateUrl": "", - "frontendTemplateUrl": "", - "appConfigSecretArn": "", - "lambdaCodeS3Bucket": "", - "lambdaCodeS3Key": "", - "migrationCodeS3Bucket": "", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "frontendPublished": true, - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123", - "frontendAppUrl": "https://admin.example.com", - "frontendEnv": {}, - "runBootstrapAdmin": false, - "bootstrapAdmin": null -} diff --git a/infrastructure/examples/full-stack-parameters.sample.json b/infrastructure/examples/full-stack-parameters.sample.json deleted file mode 100644 index 71f932747..000000000 --- a/infrastructure/examples/full-stack-parameters.sample.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "ProjectName": "b1admin", - "EnvironmentName": "prod", - "BackendTemplateUrl": "https://my-template-bucket.s3.amazonaws.com/b1admin/backend-api.yaml", - "FrontendTemplateUrl": "https://my-template-bucket.s3.amazonaws.com/b1admin/frontend-site.yaml", - "LambdaCodeS3Bucket": "my-artifacts-bucket", - "LambdaCodeS3Key": "b1admin/backend/api.zip", - "LambdaHandler": "lambda.web", - "LambdaRuntime": "nodejs22.x", - "LambdaArchitecture": "arm64", - "LambdaMemorySize": "1024", - "LambdaTimeout": "30", - "LambdaReservedConcurrency": "0", - "DependenciesLayerArn": "", - "ObservabilityLayerArn": "", - "LambdaNodeOptions": "--import @sentry/aws-serverless/awslambda-auto", - "EnableWebSocketApi": "true", - "SocketLambdaHandler": "lambda.socket", - "SocketLambdaMemorySize": "1024", - "SocketLambdaTimeout": "30", - "EnableScheduledWorkers": "true", - "Timer15MinLambdaHandler": "lambda.timer15Min", - "TimerMidnightLambdaHandler": "lambda.timerMidnight", - "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", - "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", - "TimerLambdaMemorySize": "256", - "TimerLambdaTimeout": "300", - "RunMigrations": "false", - "MigrationCodeS3Bucket": "", - "MigrationCodeS3Key": "", - "MigrationHandler": "", - "MigrationRuntime": "", - "MigrationMemorySize": "1024", - "MigrationTimeout": "900", - "MigrationTrigger": "", - "DatabaseName": "membership", - "MembershipDatabaseName": "membership", - "AttendanceDatabaseName": "attendance", - "ContentDatabaseName": "content", - "GivingDatabaseName": "giving", - "MessagingDatabaseName": "messaging", - "DoingDatabaseName": "doing", - "ReportingDatabaseName": "reporting", - "DatabaseEngine": "aurora-mysql", - "DatabasePort": "3306", - "DatabaseMasterUsername": "app_admin", - "DatabaseMinCapacity": "0.5", - "DatabaseMaxCapacity": "2", - "ApiCustomDomainName": "api.example.com", - "ApiCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "ApiHostedZoneId": "Z1234567890ABC", - "CreateNatGateway": "true", - "VpcCidr": "10.30.0.0/16", - "PublicSubnet1Cidr": "10.30.0.0/24", - "PublicSubnet2Cidr": "10.30.1.0/24", - "PrivateSubnet1Cidr": "10.30.10.0/24", - "PrivateSubnet2Cidr": "10.30.11.0/24", - "FrontendBucketName": "", - "FrontendAlternateDomainName": "admin.example.com", - "FrontendAcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "FrontendHostedZoneId": "Z1234567890ABC", - "FrontendPriceClass": "PriceClass_100", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "ContentRootUrl": "", - "B1AdminRootUrl": "https://admin.example.com", - "CorsOrigin": "*", - "FileStore": "S3", - "ManageAssetBucket": "true", - "AssetBucketName": "", - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "MailSystem": "SES", - "DeliveryProvider": "aws", - "StoreApiUrl": "https://api.example-store.com", - "AiProvider": "openrouter", - "EmailOnRegistration": "false", - "CaddyHost": "", - "CaddyPort": "", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", - "GoogleAnalyticsTag": "", - "SentryDsn": "" -} diff --git a/infrastructure/examples/package-api-backend-output.sample.json b/infrastructure/examples/package-api-backend-output.sample.json index afee46c93..5987e9654 100644 --- a/infrastructure/examples/package-api-backend-output.sample.json +++ b/infrastructure/examples/package-api-backend-output.sample.json @@ -19,8 +19,7 @@ "publishDependenciesLayer": "", "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployFullStack": "yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployMode": "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + "deployMode": "Use the resulting backend zip directly with deploy:backend or deploy:aws." }, "includedBackendEntries": [ "config", diff --git a/infrastructure/examples/package-manifest.sample.json b/infrastructure/examples/package-manifest.sample.json index d836eadb5..3320c3456 100644 --- a/infrastructure/examples/package-manifest.sample.json +++ b/infrastructure/examples/package-manifest.sample.json @@ -19,8 +19,7 @@ "publishDependenciesLayer": "", "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployFullStack": "yarn deploy:full-stack -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployMode": "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + "deployMode": "Use the resulting backend zip directly with deploy:backend or deploy:aws." }, "includedBackendEntries": [ "config", diff --git a/infrastructure/examples/run-api-migrations-output.sample.json b/infrastructure/examples/run-api-migrations-output.sample.json deleted file mode 100644 index 85437d515..000000000 --- a/infrastructure/examples/run-api-migrations-output.sample.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "apiRepoPath": "", - "region": "us-east-1", - "stackName": "", - "outputsFile": "infrastructure/examples/backend-stack-outputs.sample.json", - "action": "status", - "module": "attendance", - "dryRun": true, - "command": " migrate --action=status --module=attendance", - "databaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", - "databasePort": "3306", - "resolvedDbSecretSource": "/abs/path/to/infrastructure/examples/database-secret.sample.json", - "apiRepoMigrationModules": [ - "attendance" - ], - "apiRepoMigrationDirectories": [ - "attendance" - ], - "effectiveModules": [ - "attendance" - ], - "skippedConfiguredModules": [], - "warnings": [], - "connectionStrings": { - "ATTENDANCE_CONNECTION_STRING": "mysql://churchapps:***@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance" - }, - "executed": false -} diff --git a/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json b/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json deleted file mode 100644 index 74d0342ca..000000000 --- a/infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "ok": true, - "mode": "full-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": true, - "stackName": "", - "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "my-template-bucket", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: full-stack", - "Region: us-east-1", - "Frontend infrastructure-only deploy requested.", - "Template bucket: my-template-bucket", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", - "Full-stack validation will assume backend plus frontend hosting infrastructure now, with frontend asset publishing deferred." - ], - "warnings": [], - "errors": [], - "nextSteps": [] -} diff --git a/infrastructure/examples/validate-full-stack-output.sample.json b/infrastructure/examples/validate-full-stack-output.sample.json deleted file mode 100644 index 13282300e..000000000 --- a/infrastructure/examples/validate-full-stack-output.sample.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "ok": true, - "mode": "full-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "my-template-bucket", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: full-stack", - "Region: us-east-1", - "Template bucket: my-template-bucket", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided." - ], - "warnings": [], - "errors": [], - "nextSteps": [] -} diff --git a/infrastructure/examples/validate-full-stack-publish-output.sample.json b/infrastructure/examples/validate-full-stack-publish-output.sample.json deleted file mode 100644 index 3888a72dc..000000000 --- a/infrastructure/examples/validate-full-stack-publish-output.sample.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "ok": true, - "mode": "full-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": true, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "infrastructure/examples/full-stack-parameters.sample.json", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: full-stack", - "Region: us-east-1", - "Full-stack publish-only follow-up: infrastructure changes will be skipped.", - "Infrastructure deploy step will be skipped.", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "Frontend outputs file: /abs/path/to/infrastructure/examples/frontend-outputs.sample.json", - "Backend outputs file: /abs/path/to/infrastructure/examples/backend-outputs.sample.json" - ], - "warnings": [ - "Full-stack publish-only follow-up will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." - ], - "errors": [], - "nextSteps": [ - "yarn deploy:full-stack -- --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json --parameters-file=infrastructure/examples/full-stack-parameters.sample.json --skip-infrastructure --publish-frontend-assets" - ] -} diff --git a/package.json b/package.json index dc5bce620..0c4d9fa57 100644 --- a/package.json +++ b/package.json @@ -110,8 +110,6 @@ "installer:github-readiness": "node scripts/installer-github-readiness.mjs", "installer:browser-smoke": "node scripts/installer-browser-smoke.mjs", "guide:environment-setup": "node scripts/show-environment-setup-guide.mjs", - "wizard:environment-setup": "node scripts/environment-setup-wizard.mjs", - "launch:staging": "node scripts/launch-staging.mjs", "reset:staging": "node scripts/reset-staging.mjs", "reset:prod": "node scripts/reset-prod.mjs", "discover:github-aws-roles": "node scripts/discover-github-aws-role-arns.mjs", @@ -120,7 +118,7 @@ "dispatch:github-aws-deploy": "node scripts/dispatch-github-aws-deploy.mjs", "save:split-stack-outputs": "node scripts/save-split-stack-outputs.mjs", "show:deployment-summary": "node scripts/show-deployment-summary.mjs", - "run:api-migrations": "node scripts/run-api-migrations.mjs", + "run:api-migrations": "node scripts/run-api-migrations-data-api.mjs", "run:bootstrap-admin": "node scripts/bootstrap-initial-admin.mjs", "publish:lambda-layer": "node scripts/publish-lambda-layer.mjs", "sync:app-config-secret": "node scripts/sync-app-config-secret.mjs", @@ -135,7 +133,6 @@ "deploy:backend": "node scripts/deploy-backend.mjs", "deploy:frontend": "node scripts/deploy-frontend.mjs", "deploy:aws": "node scripts/deploy-aws.mjs", - "deploy:full-stack": "node scripts/deploy-full-stack.mjs", "pretest": "node tests/setup/pretest.mjs", "test": "playwright test", "test:ui": "playwright test --ui", diff --git a/scripts/deploy-aws.mjs b/scripts/deploy-aws.mjs index 6586090e4..ee9b5f1ee 100644 --- a/scripts/deploy-aws.mjs +++ b/scripts/deploy-aws.mjs @@ -148,8 +148,11 @@ function validateApiMigrationArgs(action, moduleName) { } function validateApiMigrationRunner(runner) { - if (runner && !["direct", "data-api"].includes(runner)) { - fail(`Invalid api-migration-runner "${runner}". Use direct or data-api.`); + if (runner === "direct") { + fail('The "direct" migration runner has been removed; migrations run through the RDS Data API. Use --api-migration-runner=data-api (the default).'); + } + if (runner && !["data-api"].includes(runner)) { + fail(`Invalid api-migration-runner "${runner}". Use data-api.`); } } @@ -340,7 +343,7 @@ function main() { const runApiMigrations = getBooleanArgString("run-api-migrations"); const apiMigrationAction = getArg("api-migration-action"); const apiMigrationModule = getArg("api-migration-module"); - const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationRunner = getArg("api-migration-runner", "data-api"); const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path"); const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); @@ -440,12 +443,6 @@ function main() { if (!fs.existsSync(resolvedApiMigrationRepoPath)) { fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); } - if (apiMigrationRunner === "direct" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { - fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); - } - if (apiMigrationRunner === "direct" && apiMigrationDryRun !== "true" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { - fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); - } if (apiMigrationRunner === "data-api" && !fs.existsSync(path.join(rootDir, "node_modules", "typescript", "package.json"))) { fail(`B1Admin dependencies are not installed: ${path.join(rootDir, "node_modules", "typescript", "package.json")}`); } diff --git a/scripts/deploy-backend.mjs b/scripts/deploy-backend.mjs index ae91a0c66..45e302730 100644 --- a/scripts/deploy-backend.mjs +++ b/scripts/deploy-backend.mjs @@ -267,8 +267,11 @@ function validateApiMigrationArgs(action, moduleName) { } function validateApiMigrationRunner(runner) { - if (!["direct", "data-api"].includes(runner)) { - fail(`Invalid api-migration-runner "${runner}". Use direct or data-api.`); + if (runner === "direct") { + fail('The "direct" migration runner has been removed; migrations run through the RDS Data API. Use --api-migration-runner=data-api (the default).'); + } + if (!["data-api"].includes(runner)) { + fail(`Invalid api-migration-runner "${runner}". Use data-api.`); } } @@ -306,7 +309,7 @@ function main() { const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; const apiMigrationAction = getArg("api-migration-action", "up"); const apiMigrationModule = getArg("api-migration-module", "all"); - const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationRunner = getArg("api-migration-runner", "data-api"); const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path", apiRepoPath || "../Api"); const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); @@ -342,12 +345,6 @@ function main() { if (!fs.existsSync(resolvedApiMigrationRepoPath)) { fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); } - if (apiMigrationRunner === "direct" && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { - fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); - } - if (apiMigrationRunner === "direct" && !apiMigrationDryRun && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { - fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); - } if (apiMigrationRunner === "data-api" && !fs.existsSync(path.join(rootDir, "node_modules", "typescript", "package.json"))) { fail(`B1Admin dependencies are not installed: ${path.join(rootDir, "node_modules", "typescript", "package.json")}`); } @@ -634,9 +631,7 @@ function main() { const outputs = getStackOutputsSafe(stackName, region, "backend stack"); let apiMigrationsResult = null; if (runApiMigrations) { - const migrationScript = apiMigrationRunner === "data-api" - ? "scripts/run-api-migrations-data-api.mjs" - : "scripts/run-api-migrations.mjs"; + const migrationScript = "scripts/run-api-migrations-data-api.mjs"; const migrationArgs = [ `--stack-name=${stackName}`, `--region=${region}`, diff --git a/scripts/deploy-full-stack.mjs b/scripts/deploy-full-stack.mjs deleted file mode 100644 index caa4d0ae4..000000000 --- a/scripts/deploy-full-stack.mjs +++ /dev/null @@ -1,925 +0,0 @@ -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const fullStackTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "full-stack.yaml"); -const backendTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "backend-api.yaml"); -const frontendTemplatePath = path.join(rootDir, "infrastructure", "cloudformation", "frontend-site.yaml"); - -function getArg(name, fallback = "") { - const prefix = `--${name}=`; - const bareFlag = `--${name}`; - for (let index = 0; index < process.argv.length; index += 1) { - const arg = process.argv[index]; - if (arg.startsWith(prefix)) return arg.slice(prefix.length); - if (arg === bareFlag) { - const next = process.argv[index + 1]; - if (next !== undefined && !next.startsWith("--")) return next; - } - } - const envName = name.toUpperCase().replace(/-/g, "_"); - return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; -} - -function exitForCommandError(error, quiet = false) { - if (error && typeof error === "object") { - if (quiet && error.stdout) process.stderr.write(String(error.stdout)); - if (quiet && error.stderr) process.stderr.write(String(error.stderr)); - const status = typeof error.status === "number" ? error.status : 1; - process.exit(status); - } - - process.exit(1); -} - -function maskSensitiveArg(arg) { - return String(arg).replace(/^(--[^=]*(?:password|secret-string)[^=]*=).+/i, "$1"); -} - -function run(command, args, options = {}) { - const { quiet = false, ...execOptions } = options; - if (!quiet) console.log(`\n> ${command} ${args.map(maskSensitiveArg).join(" ")}`); - - try { - return execFileSync(command, args, { - cwd: rootDir, - stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit", - encoding: quiet ? "utf8" : undefined, - maxBuffer: quiet ? 20 * 1024 * 1024 : undefined, - ...execOptions, - }); - } catch (error) { - exitForCommandError(error, quiet); - } -} - -function runNodeJson(scriptPath, args) { - try { - return JSON.parse(execFileSync("node", [scriptPath, ...args], { - cwd: rootDir, - encoding: "utf8", - stdio: "pipe", - maxBuffer: 20 * 1024 * 1024, - })); - } catch (error) { - exitForCommandError(error, true); - } -} - -function runJson(command, args) { - return JSON.parse(execFileSync(command, args, { - cwd: rootDir, - encoding: "utf8", - stdio: "pipe", - maxBuffer: 20 * 1024 * 1024, - })); -} - -function addArg(args, name, value) { - if (value !== undefined && value !== null && value !== "") { - args.push(`--${name}=${value}`); - } -} - -function requireValue(name, value) { - if (!value) { - console.error(`Missing required value: ${name}`); - process.exit(1); - } -} - -function fail(message) { - console.error(message); - process.exit(1); -} - -function loadParamsFromFile(filePath) { - if (!filePath) return {}; - - try { - const resolved = path.resolve(rootDir, filePath); - const data = JSON.parse(fs.readFileSync(resolved, "utf8")); - - if (Array.isArray(data)) { - return Object.fromEntries(data.map((item) => [item.ParameterKey, item.ParameterValue])); - } - - return data; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not load parameters file "${filePath}": ${message}`); - } -} - -function toParameterOverrides(params) { - return Object.entries(params) - .filter(([, value]) => value !== undefined && value !== null) - .map(([key, value]) => `${key}=${value}`); -} - -function hasFlag(name) { - return process.argv.includes(`--${name}`); -} - -function normalizeOutputs(raw) { - if (!raw) return {}; - if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); - if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); - if (raw.Outputs) return normalizeOutputs(raw.Outputs); - return raw; -} - -function normalizeParameters(raw) { - if (!raw) return {}; - if (Array.isArray(raw)) return Object.fromEntries(raw.map((parameter) => [parameter.ParameterKey, parameter.ParameterValue])); - if (raw.Stacks?.[0]?.Parameters) return normalizeParameters(raw.Stacks[0].Parameters); - if (raw.Parameters) return normalizeParameters(raw.Parameters); - return raw; -} - -function describeStackSafe(stackName, region) { - try { - return runJson("aws", [ - "cloudformation", - "describe-stacks", - "--stack-name", - stackName, - "--region", - region, - "--output", - "json", - ]); - } catch (error) { - const stderr = String(error?.stderr || ""); - if (stderr.includes("does not exist")) return null; - throw error; - } -} - -function getStackOutputs(stackName, region) { - const response = runJson("aws", [ - "cloudformation", - "describe-stacks", - "--stack-name", - stackName, - "--region", - region, - "--output", - "json", - ]); - - return normalizeOutputs(response); -} - -function getStackOutputsSafe(stackName, region, label) { - if (!stackName) return {}; - - try { - return getStackOutputs(stackName, region); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not read ${label} "${stackName}": ${message}`); - } -} - -function getStackParametersSafe(stackName, region, label) { - if (!stackName) return {}; - - try { - return normalizeParameters(describeStackSafe(stackName, region)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not read ${label} "${stackName}" parameters: ${message}`); - } -} - -function loadJson(filePath, label = "JSON file") { - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not load ${label} "${filePath}": ${message}`); - } -} - -function resolveManifestArtifactPath(manifestFilePath, artifactPath) { - if (!artifactPath) return ""; - if (path.isAbsolute(artifactPath)) return artifactPath; - return path.resolve(path.dirname(manifestFilePath), artifactPath); -} - -function readOutputsFile(filePath, label) { - try { - const resolved = path.resolve(rootDir, filePath); - return normalizeOutputs(JSON.parse(fs.readFileSync(resolved, "utf8"))); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not load ${label} "${filePath}": ${message}`); - } -} - -function getOutputValue(outputs, keys) { - for (const key of keys) { - if (outputs[key]) return outputs[key]; - } - return ""; -} - -function compactObject(obj) { - return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== "")); -} - -function deriveArtifactKey(projectName, environmentName, fileName) { - return `${projectName}/${environmentName}/backend/${fileName}`; -} - -function ensureFrontendPublishPrerequisites(skipBuild) { - const distDir = path.join(rootDir, "dist"); - const serviceWorkerPath = path.join(distDir, "sw.js"); - - if (skipBuild) { - if (!fs.existsSync(distDir)) { - fail(`Build output not found: ${distDir}`); - } - if (!fs.existsSync(serviceWorkerPath)) { - fail(`Expected service worker not found: ${serviceWorkerPath}`); - } - return; - } - - const nodeModulesPath = path.join(rootDir, "node_modules"); - const viteCliPath = path.join(nodeModulesPath, "vite", "dist", "node", "cli.js"); - if (!fs.existsSync(nodeModulesPath) || !fs.existsSync(viteCliPath)) { - fail(`Frontend dependencies are not installed: ${nodeModulesPath}`); - } -} - -function loadApiRepoMigrationDirectories(apiRepoPath) { - const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); - if (!fs.existsSync(migrationsRoot)) return []; - - try { - return fs.readdirSync(migrationsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); - } -} - -function validateApiMigrationArgs(action, moduleName) { - const validActions = ["up", "down", "status"]; - const validModules = ["all", "membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; - - if (!validActions.includes(action)) { - fail(`Invalid api-migration-action "${action}". Use up, down, or status.`); - } - - if (!validModules.includes(moduleName)) { - fail(`Invalid api-migration-module "${moduleName}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); - } -} - -function buildFrontendEnvFromOutputs(outputs) { - return compactObject({ - REACT_APP_API_BASE: outputs.PublicApiBaseUrl || outputs.ApiBaseUrl || "", - REACT_APP_CONTENT_ROOT: outputs.ContentRootUrl || "", - REACT_APP_B1_WEBSITE_URL: outputs.WebsiteBaseUrl || "", - REACT_APP_LESSONS_API: outputs.LessonsApiUrl || outputs.PublicApiBaseUrl || outputs.ApiBaseUrl || "", - REACT_APP_GOOGLE_ANALYTICS: outputs.GoogleAnalyticsTag || "", - REACT_APP_SENTRY_DSN: outputs.SentryDsn || "", - REACT_APP_TRANSFER_URL: outputs.TransferUrl || "", - REACT_APP_SUPPORT_EMAIL: outputs.SupportEmail || "", - REACT_APP_SUPPORT_PHONE: outputs.SupportPhone || "", - REACT_APP_SUPPORT_SITE_URL: outputs.SupportSiteUrl || "", - REACT_APP_MOBILE_APP_URL: outputs.MobileAppUrl || "", - REACT_APP_DOMAIN_CNAME_TARGET: outputs.DomainCnameTarget || "", - REACT_APP_DOMAIN_A_TARGET: outputs.DomainATarget || "", - REACT_APP_DEFAULT_STOCK_PHOTO: outputs.DefaultStockPhoto || "", - }); -} - -function buildTemplateUrl(bucket, region, key) { - return `https://${bucket}.s3.${region}.amazonaws.com/${key}`; -} - -function uploadTemplate(bucket, region, localPath, s3Key, quiet = false) { - run("aws", [ - "s3", - "cp", - localPath, - `s3://${bucket}/${s3Key}`, - "--region", - region, - ], { quiet }); - return buildTemplateUrl(bucket, region, s3Key); -} - -function main() { - const stackName = getArg("stack-name"); - const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); - const cloudformationExecutionRoleArn = getArg("cloudformation-execution-role-arn", process.env.CLOUDFORMATION_EXECUTION_ROLE_ARN || ""); - const parametersFile = getArg("parameters-file"); - const fileParams = loadParamsFromFile(parametersFile); - const projectName = getArg("project-name", fileParams.ProjectName || "b1admin"); - const environmentName = getArg("environment", fileParams.EnvironmentName || "prod"); - const bootstrapStackName = getArg("bootstrap-stack-name"); - const templatePrefix = getArg("template-prefix", `${projectName}/${environmentName}/cloudformation`); - const outputMode = getArg("output", "text").toLowerCase(); - const jsonOutput = outputMode === "json"; - const infrastructureOnly = hasFlag("infrastructure-only"); - const frontendInfrastructureOnly = hasFlag("frontend-infrastructure-only"); - const publishFrontendAssets = hasFlag("publish-frontend-assets"); - const skipInfrastructure = hasFlag("skip-infrastructure"); - const skipBuild = hasFlag("skip-build"); - const frontendOutputsFile = getArg("frontend-outputs-file"); - const frontendPublishBucket = getArg("bucket"); - const frontendPublishDistributionId = getArg("distribution-id"); - const frontendPublishAppUrl = getArg("app-url"); - const backendOutputsFile = getArg("backend-outputs-file"); - const apiRepoPath = getArg("api-repo-path"); - const packageManifestFile = getArg("package-manifest-file"); - const runApiMigrations = getArg("run-api-migrations", "false").toLowerCase() === "true"; - const apiMigrationAction = getArg("api-migration-action", "up"); - const apiMigrationModule = getArg("api-migration-module", "all"); - const apiMigrationApiRepoPath = getArg("api-migration-api-repo-path", apiRepoPath || "../Api"); - const apiMigrationDbSecretArn = getArg("api-migration-db-secret-arn"); - const apiMigrationDbSecretFile = getArg("api-migration-db-secret-file"); - const apiMigrationDryRun = getArg("api-migration-dry-run", "false").toLowerCase() === "true"; - const runBootstrapAdmin = getArg("run-bootstrap-admin", "false").toLowerCase() === "true"; - const bootstrapAdminSecretFile = getArg("bootstrap-admin-secret-file"); - const bootstrapAdminSecretArn = getArg("bootstrap-admin-secret-arn"); - const bootstrapAdminEmail = getArg("bootstrap-admin-email"); - const bootstrapAdminPassword = getArg("bootstrap-admin-password"); - const bootstrapAdminFirstName = getArg("bootstrap-admin-first-name"); - const bootstrapAdminLastName = getArg("bootstrap-admin-last-name"); - const bootstrapAdminDisplayName = getArg("bootstrap-admin-display-name"); - const bootstrapChurchName = getArg("bootstrap-church-name"); - const bootstrapChurchSubdomain = getArg("bootstrap-church-subdomain"); - const bootstrapChurchAddress1 = getArg("bootstrap-church-address1"); - const bootstrapChurchAddress2 = getArg("bootstrap-church-address2"); - const bootstrapChurchCity = getArg("bootstrap-church-city"); - const bootstrapChurchState = getArg("bootstrap-church-state"); - const bootstrapChurchZip = getArg("bootstrap-church-zip"); - const bootstrapChurchCountry = getArg("bootstrap-church-country"); - const bootstrapMembershipStatus = getArg("bootstrap-membership-status"); - const bootstrapAdminResetPassword = getArg("bootstrap-admin-reset-password", "true"); - const packageApiBackend = !packageManifestFile && (apiRepoPath !== "" || getArg("package-api-backend", "false").toLowerCase() === "true"); - const packageMode = getArg("package-mode", "self-contained"); - const packageOutputDir = getArg("package-output-dir", "infrastructure/artifacts/api"); - const packageBuild = getArg("package-build", "true"); - const packageBuildLayer = getArg("package-build-layer", packageMode === "layered" ? "true" : "false"); - const backendArtifactSourceFile = getArg("backend-artifact-source-file"); - const migrationArtifactSourceFile = getArg("migration-artifact-source-file"); - const dependenciesLayerSourceFile = getArg("dependencies-layer-source-file"); - const bootstrapOutputs = !skipInfrastructure - ? getStackOutputsSafe(bootstrapStackName, region, "bootstrap stack") - : {}; - const templateBucket = getArg("template-bucket", fileParams.TemplateBucketName || bootstrapOutputs.TemplateBucketName || ""); - - const explicitFrontendPublishTarget = Boolean(frontendOutputsFile || (frontendPublishBucket && frontendPublishDistributionId)); - const stackOutputsRequired = !skipInfrastructure || !explicitFrontendPublishTarget || (!skipBuild && !backendOutputsFile); - - if (stackOutputsRequired) { - requireValue("stack-name", stackName); - } - if (skipInfrastructure && !explicitFrontendPublishTarget && !stackName) { - fail("Full-stack publish-only needs --stack-name, --frontend-outputs-file, or both --bucket and --distribution-id."); - } - if (skipInfrastructure && !skipBuild && !stackName && !backendOutputsFile) { - fail("Full-stack publish-only needs --stack-name or --backend-outputs-file when a frontend build is required."); - } - if (skipInfrastructure && !publishFrontendAssets) { - console.error("--skip-infrastructure is only supported together with --publish-frontend-assets."); - process.exit(1); - } - if (publishFrontendAssets && infrastructureOnly) { - console.error("--publish-frontend-assets cannot be combined with --infrastructure-only. Provision infrastructure first, then run a later publish phase with --skip-infrastructure --publish-frontend-assets."); - process.exit(1); - } - if (publishFrontendAssets && frontendInfrastructureOnly) { - console.error("--publish-frontend-assets cannot be combined with --frontend-infrastructure-only. Use --frontend-infrastructure-only for the hosting-only phase, then run a later publish phase with --skip-infrastructure --publish-frontend-assets."); - process.exit(1); - } - if (skipInfrastructure && infrastructureOnly) { - console.error("--skip-infrastructure and --infrastructure-only cannot be used together. Skip-infrastructure is for publish-only follow-up runs, while infrastructure-only skips frontend publishing."); - process.exit(1); - } - if (skipInfrastructure && frontendInfrastructureOnly) { - console.error("--skip-infrastructure and --frontend-infrastructure-only cannot be used together. The former reuses existing infrastructure, while the latter provisions frontend hosting without publishing assets."); - process.exit(1); - } - if (publishFrontendAssets && !skipInfrastructure) { - console.error("--publish-frontend-assets is only needed for the later publish-only phase. Omit it for a normal full-stack deploy, or pair it with --skip-infrastructure for the second phase."); - process.exit(1); - } - if (skipBuild && !publishFrontendAssets && !skipInfrastructure && !frontendInfrastructureOnly && !infrastructureOnly) { - console.error("--skip-build only applies when frontend assets are being published. Use it with a normal full-stack deploy that publishes assets, or with --skip-infrastructure --publish-frontend-assets."); - process.exit(1); - } - if (skipBuild && (infrastructureOnly || frontendInfrastructureOnly) && !publishFrontendAssets) { - console.error("--skip-build has no effect when frontend publishing is deferred. Remove it or use it later during the publish phase."); - process.exit(1); - } - - if (runApiMigrations && skipInfrastructure) { - console.error("--run-api-migrations=true is only supported during the infrastructure deploy phase. Remove --skip-infrastructure or run yarn run:api-migrations separately afterward."); - process.exit(1); - } - - if (runBootstrapAdmin && skipInfrastructure) { - console.error("--run-bootstrap-admin=true is only supported during the infrastructure deploy phase. Remove --skip-infrastructure or run yarn run:bootstrap-admin separately afterward."); - process.exit(1); - } - - if (runApiMigrations) { - validateApiMigrationArgs(apiMigrationAction, apiMigrationModule); - const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath); - if (!fs.existsSync(resolvedApiMigrationRepoPath)) { - fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); - } - if (!fs.existsSync(path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts"))) { - fail(`API migration repo is missing tools/migrate.ts: ${path.join(resolvedApiMigrationRepoPath, "tools", "migrate.ts")}`); - } - if (!apiMigrationDryRun && !fs.existsSync(path.join(resolvedApiMigrationRepoPath, "node_modules"))) { - fail(`API migration repo dependencies are not installed: ${path.join(resolvedApiMigrationRepoPath, "node_modules")}`); - } - if (!apiMigrationDryRun && apiMigrationModule !== "all") { - const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(resolvedApiMigrationRepoPath); - if (apiRepoMigrationDirectories.length > 0 && !apiRepoMigrationDirectories.includes(apiMigrationModule)) { - fail(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. Refusing to deploy with --run-api-migrations=true for an unsupported direct migration target.`); - } - } - } - const willPublishFrontendAssets = (!infrastructureOnly && !frontendInfrastructureOnly && !skipInfrastructure) || (skipInfrastructure && publishFrontendAssets); - if (willPublishFrontendAssets) { - ensureFrontendPublishPrerequisites(skipBuild); - } - - const currentStackParams = parametersFile - ? {} - : getStackParametersSafe(stackName, region, "full-stack"); - - let backendTemplateUrl = ""; - let frontendTemplateUrl = ""; - if (!skipInfrastructure) { - requireValue("template-bucket", templateBucket); - const backendTemplateKey = `${templatePrefix}/backend-api.yaml`; - const frontendTemplateKey = `${templatePrefix}/frontend-site.yaml`; - backendTemplateUrl = uploadTemplate(templateBucket, region, backendTemplatePath, backendTemplateKey, jsonOutput); - frontendTemplateUrl = uploadTemplate(templateBucket, region, frontendTemplatePath, frontendTemplateKey, jsonOutput); - } - const resolvedArtifactBucket = getArg("lambda-code-s3-bucket", fileParams.LambdaCodeS3Bucket || bootstrapOutputs.ArtifactBucketName || ""); - const resolvedMigrationBucket = getArg("migration-code-s3-bucket", fileParams.MigrationCodeS3Bucket || resolvedArtifactBucket); - let resolvedDependenciesLayerArn = getArg("dependencies-layer-arn", fileParams.DependenciesLayerArn); - let resolvedAppConfigSecretArn = getArg("app-config-secret-arn", fileParams.AppConfigSecretArn); - let resolvedLambdaCodeS3ObjectVersion = getArg("lambda-code-s3-object-version", fileParams.LambdaCodeS3ObjectVersion); - let resolvedMigrationCodeS3ObjectVersion = getArg("migration-code-s3-object-version", fileParams.MigrationCodeS3ObjectVersion); - let resolvedBackendArtifactSourceFile = backendArtifactSourceFile; - let resolvedMigrationArtifactSourceFile = migrationArtifactSourceFile; - let resolvedDependenciesLayerSourceFile = dependenciesLayerSourceFile; - let resolvedPackageManifestFile = ""; - let resolvedObservabilityLayerArn = getArg("observability-layer-arn", fileParams.ObservabilityLayerArn); - let resolvedLambdaCodeS3Key = getArg("lambda-code-s3-key", fileParams.LambdaCodeS3Key); - let resolvedMigrationCodeS3Key = getArg("migration-code-s3-key", fileParams.MigrationCodeS3Key); - const appConfigSecretFile = getArg("app-config-secret-file"); - const appConfigSecretName = getArg("app-config-secret-name", `${projectName}/${environmentName}/app-config`); - const appConfigSecretId = getArg("app-config-secret-id"); - const appConfigSecretDescription = getArg("app-config-secret-description", `${projectName} ${environmentName} backend app config`); - const appConfigSecretKmsKeyId = getArg("app-config-secret-kms-key-id"); - const syncLegacySsm = getArg("sync-legacy-ssm", "false").toLowerCase() === "true"; - const legacySsmPrefix = getArg("legacy-ssm-prefix"); - const legacySsmIncludeEmpty = getArg("legacy-ssm-include-empty"); - const legacySsmOverwrite = getArg("legacy-ssm-overwrite"); - const dependenciesLayerName = getArg("dependencies-layer-name", `${projectName}-${environmentName}-dependencies`); - const dependenciesLayerDescription = getArg("dependencies-layer-description", `${projectName} ${environmentName} backend dependencies`); - const dependenciesLayerLicenseInfo = getArg("dependencies-layer-license-info"); - const dependenciesLayerCompatibleRuntimes = getArg("dependencies-layer-compatible-runtimes", fileParams.DependenciesLayerCompatibleRuntimes || "nodejs22.x"); - const dependenciesLayerCompatibleArchitectures = getArg("dependencies-layer-compatible-architectures", fileParams.DependenciesLayerCompatibleArchitectures || "arm64"); - - if (!skipInfrastructure && packageManifestFile) { - const manifestFilePath = path.resolve(rootDir, packageManifestFile); - resolvedPackageManifestFile = manifestFilePath; - const packageResult = loadJson(manifestFilePath, "package manifest"); - resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.backendArtifactPath); - resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.migrationArtifactPath); - resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestFilePath, packageResult.dependenciesLayerArtifactPath); - } - - if (!skipInfrastructure && packageApiBackend) { - const manifestName = `api-${environmentName}-${packageMode}.manifest.json`; - const manifestPath = path.resolve(rootDir, packageOutputDir, manifestName); - resolvedPackageManifestFile = manifestPath; - const packageArgs = []; - addArg(packageArgs, "api-repo-path", apiRepoPath || "../Api"); - addArg(packageArgs, "environment", environmentName); - addArg(packageArgs, "package-mode", packageMode); - addArg(packageArgs, "output-dir", packageOutputDir); - addArg(packageArgs, "build", packageBuild); - addArg(packageArgs, "build-layer", packageBuildLayer); - addArg(packageArgs, "manifest-name", manifestName); - run("node", ["scripts/package-api-backend.mjs", ...packageArgs], { quiet: jsonOutput }); - const packageResult = loadJson(manifestPath, "package manifest"); - resolvedBackendArtifactSourceFile = resolvedBackendArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.backendArtifactPath); - resolvedMigrationArtifactSourceFile = resolvedMigrationArtifactSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.migrationArtifactPath); - resolvedDependenciesLayerSourceFile = resolvedDependenciesLayerSourceFile || resolveManifestArtifactPath(manifestPath, packageResult.dependenciesLayerArtifactPath); - } - - if (!skipInfrastructure && !resolvedLambdaCodeS3Key && resolvedBackendArtifactSourceFile) { - resolvedLambdaCodeS3Key = deriveArtifactKey(projectName, environmentName, "api.zip"); - } - - if (!skipInfrastructure && !resolvedMigrationCodeS3Key && resolvedMigrationArtifactSourceFile) { - resolvedMigrationCodeS3Key = deriveArtifactKey(projectName, environmentName, "migrations.zip"); - } - - if (!resolvedDependenciesLayerArn && !resolvedDependenciesLayerSourceFile) { - resolvedDependenciesLayerArn = currentStackParams.DependenciesLayerArn || ""; - } - - if (!resolvedObservabilityLayerArn) { - resolvedObservabilityLayerArn = currentStackParams.ObservabilityLayerArn || ""; - } - - if (!skipInfrastructure && resolvedDependenciesLayerSourceFile) { - const layerResponse = runNodeJson("scripts/publish-lambda-layer.mjs", [ - `--region=${region}`, - `--layer-name=${dependenciesLayerName}`, - `--source-file=${resolvedDependenciesLayerSourceFile}`, - `--description=${dependenciesLayerDescription}`, - `--compatible-runtimes=${dependenciesLayerCompatibleRuntimes}`, - `--compatible-architectures=${dependenciesLayerCompatibleArchitectures}`, - `--output=json`, - ...(dependenciesLayerLicenseInfo ? [`--license-info=${dependenciesLayerLicenseInfo}`] : []), - ]); - resolvedDependenciesLayerArn = layerResponse.LayerVersionArn || resolvedDependenciesLayerArn; - } - - if (!skipInfrastructure && appConfigSecretFile) { - const secretResponse = runNodeJson("scripts/sync-app-config-secret.mjs", [ - `--region=${region}`, - `--secret-file=${appConfigSecretFile}`, - `--secret-name=${appConfigSecretName}`, - ...(appConfigSecretId ? [`--secret-id=${appConfigSecretId}`] : []), - ...(appConfigSecretDescription ? [`--description=${appConfigSecretDescription}`] : []), - ...(appConfigSecretKmsKeyId ? [`--kms-key-id=${appConfigSecretKmsKeyId}`] : []), - "--output=json", - ]); - resolvedAppConfigSecretArn = secretResponse.arn || resolvedAppConfigSecretArn; - } - - if (!resolvedAppConfigSecretArn && !appConfigSecretFile) { - resolvedAppConfigSecretArn = currentStackParams.AppConfigSecretArn || ""; - } - - const params = { - ProjectName: getArg("project-name", fileParams.ProjectName || projectName), - EnvironmentName: getArg("environment", fileParams.EnvironmentName || environmentName), - BackendTemplateUrl: backendTemplateUrl, - FrontendTemplateUrl: frontendTemplateUrl, - LambdaCodeS3Bucket: resolvedArtifactBucket, - LambdaCodeS3Key: resolvedLambdaCodeS3Key, - LambdaCodeS3ObjectVersion: resolvedLambdaCodeS3ObjectVersion, - LambdaHandler: getArg("lambda-handler", fileParams.LambdaHandler), - LambdaRuntime: getArg("lambda-runtime", fileParams.LambdaRuntime), - LambdaArchitecture: getArg("lambda-architecture", fileParams.LambdaArchitecture), - LambdaMemorySize: getArg("lambda-memory-size", fileParams.LambdaMemorySize), - LambdaTimeout: getArg("lambda-timeout", fileParams.LambdaTimeout), - LambdaReservedConcurrency: getArg("lambda-reserved-concurrency", fileParams.LambdaReservedConcurrency), - DependenciesLayerArn: resolvedDependenciesLayerArn, - ObservabilityLayerArn: resolvedObservabilityLayerArn, - LambdaNodeOptions: getArg("lambda-node-options", fileParams.LambdaNodeOptions), - EnableWebSocketApi: getArg("enable-web-socket-api", fileParams.EnableWebSocketApi), - SocketLambdaHandler: getArg("socket-lambda-handler", fileParams.SocketLambdaHandler), - SocketLambdaMemorySize: getArg("socket-lambda-memory-size", fileParams.SocketLambdaMemorySize), - SocketLambdaTimeout: getArg("socket-lambda-timeout", fileParams.SocketLambdaTimeout), - EnableScheduledWorkers: getArg("enable-scheduled-workers", fileParams.EnableScheduledWorkers), - Timer15MinLambdaHandler: getArg("timer15-min-lambda-handler", fileParams.Timer15MinLambdaHandler), - TimerMidnightLambdaHandler: getArg("timer-midnight-lambda-handler", fileParams.TimerMidnightLambdaHandler), - TimerScheduledTasksLambdaHandler: getArg("timer-scheduled-tasks-lambda-handler", fileParams.TimerScheduledTasksLambdaHandler), - TimerWebhooksLambdaHandler: getArg("timer-webhooks-lambda-handler", fileParams.TimerWebhooksLambdaHandler), - TimerLambdaMemorySize: getArg("timer-lambda-memory-size", fileParams.TimerLambdaMemorySize), - TimerLambdaTimeout: getArg("timer-lambda-timeout", fileParams.TimerLambdaTimeout), - RunMigrations: getArg("run-migrations", fileParams.RunMigrations), - MigrationCodeS3Bucket: resolvedMigrationBucket, - MigrationCodeS3Key: resolvedMigrationCodeS3Key, - MigrationCodeS3ObjectVersion: resolvedMigrationCodeS3ObjectVersion, - MigrationHandler: getArg("migration-handler", fileParams.MigrationHandler), - MigrationRuntime: getArg("migration-runtime", fileParams.MigrationRuntime), - MigrationMemorySize: getArg("migration-memory-size", fileParams.MigrationMemorySize), - MigrationTimeout: getArg("migration-timeout", fileParams.MigrationTimeout), - MigrationTrigger: getArg("migration-trigger", fileParams.MigrationTrigger), - DatabaseName: getArg("database-name", fileParams.DatabaseName), - MembershipDatabaseName: getArg("membership-database-name", fileParams.MembershipDatabaseName), - AttendanceDatabaseName: getArg("attendance-database-name", fileParams.AttendanceDatabaseName), - ContentDatabaseName: getArg("content-database-name", fileParams.ContentDatabaseName), - GivingDatabaseName: getArg("giving-database-name", fileParams.GivingDatabaseName), - MessagingDatabaseName: getArg("messaging-database-name", fileParams.MessagingDatabaseName), - DoingDatabaseName: getArg("doing-database-name", fileParams.DoingDatabaseName), - ReportingDatabaseName: getArg("reporting-database-name", fileParams.ReportingDatabaseName), - DatabaseEngine: getArg("database-engine", fileParams.DatabaseEngine), - DatabasePort: getArg("database-port", fileParams.DatabasePort), - DatabaseMasterUsername: getArg("database-master-username", fileParams.DatabaseMasterUsername), - DatabaseMinCapacity: getArg("database-min-capacity", fileParams.DatabaseMinCapacity), - DatabaseMaxCapacity: getArg("database-max-capacity", fileParams.DatabaseMaxCapacity), - ApiCustomDomainName: getArg("api-custom-domain-name", fileParams.ApiCustomDomainName), - ApiCertificateArn: getArg("api-certificate-arn", fileParams.ApiCertificateArn), - ApiHostedZoneId: getArg("api-hosted-zone-id", fileParams.ApiHostedZoneId), - CreateNatGateway: getArg("create-nat-gateway", fileParams.CreateNatGateway), - VpcCidr: getArg("vpc-cidr", fileParams.VpcCidr), - PublicSubnet1Cidr: getArg("public-subnet-1-cidr", fileParams.PublicSubnet1Cidr), - PublicSubnet2Cidr: getArg("public-subnet-2-cidr", fileParams.PublicSubnet2Cidr), - PrivateSubnet1Cidr: getArg("private-subnet-1-cidr", fileParams.PrivateSubnet1Cidr), - PrivateSubnet2Cidr: getArg("private-subnet-2-cidr", fileParams.PrivateSubnet2Cidr), - FrontendBucketName: getArg("frontend-bucket-name", fileParams.FrontendBucketName), - FrontendAlternateDomainName: getArg("frontend-alternate-domain-name", fileParams.FrontendAlternateDomainName), - FrontendAcmCertificateArn: getArg("frontend-acm-certificate-arn", fileParams.FrontendAcmCertificateArn), - FrontendHostedZoneId: getArg("frontend-hosted-zone-id", fileParams.FrontendHostedZoneId), - FrontendPriceClass: getArg("frontend-price-class", fileParams.FrontendPriceClass), - WebsiteBaseUrl: getArg("website-base-url", fileParams.WebsiteBaseUrl), - ContentRootUrl: getArg("content-root-url", fileParams.ContentRootUrl), - B1AdminRootUrl: getArg("b1-admin-root-url", fileParams.B1AdminRootUrl), - CorsOrigin: getArg("cors-origin", fileParams.CorsOrigin), - FileStore: getArg("file-store", fileParams.FileStore), - ManageAssetBucket: getArg("manage-asset-bucket", fileParams.ManageAssetBucket), - AssetBucketName: getArg("asset-bucket-name", fileParams.AssetBucketName), - AppConfigSecretArn: resolvedAppConfigSecretArn, - MailSystem: getArg("mail-system", fileParams.MailSystem), - DeliveryProvider: getArg("delivery-provider", fileParams.DeliveryProvider), - StoreApiUrl: getArg("store-api-url", fileParams.StoreApiUrl), - AiProvider: getArg("ai-provider", fileParams.AiProvider), - EmailOnRegistration: getArg("email-on-registration", fileParams.EmailOnRegistration), - CaddyHost: getArg("caddy-host", fileParams.CaddyHost), - CaddyPort: getArg("caddy-port", fileParams.CaddyPort), - TransferUrl: getArg("transfer-url", fileParams.TransferUrl), - SupportEmail: getArg("support-email", fileParams.SupportEmail), - SupportPhone: getArg("support-phone", fileParams.SupportPhone), - SupportSiteUrl: getArg("support-site-url", fileParams.SupportSiteUrl), - MobileAppUrl: getArg("mobile-app-url", fileParams.MobileAppUrl), - DomainCnameTarget: getArg("domain-cname-target", fileParams.DomainCnameTarget), - DomainATarget: getArg("domain-a-target", fileParams.DomainATarget), - DefaultStockPhoto: getArg("default-stock-photo", fileParams.DefaultStockPhoto), - GoogleAnalyticsTag: getArg("google-analytics-tag", fileParams.GoogleAnalyticsTag), - SentryDsn: getArg("sentry-dsn", fileParams.SentryDsn), - }; - - if (!skipInfrastructure) { - requireValue("lambda-code-s3-bucket", params.LambdaCodeS3Bucket); - requireValue("lambda-code-s3-key", params.LambdaCodeS3Key); - } - - if (!skipInfrastructure && resolvedBackendArtifactSourceFile) { - const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ - "scripts/upload-backend-artifact.mjs", - `--region=${region}`, - `--bootstrap-stack-name=${bootstrapStackName}`, - `--artifact-bucket=${params.LambdaCodeS3Bucket}`, - `--source-file=${resolvedBackendArtifactSourceFile}`, - `--artifact-key=${params.LambdaCodeS3Key}`, - "--artifact-label=Backend artifact", - "--output=json", - ].filter((arg) => !arg.endsWith("="))); - resolvedLambdaCodeS3ObjectVersion = uploadResult.versionId || resolvedLambdaCodeS3ObjectVersion; - params.LambdaCodeS3ObjectVersion = resolvedLambdaCodeS3ObjectVersion; - if (!jsonOutput) { - console.log("\nBackend artifact upload complete."); - console.log(`Bucket: ${uploadResult.bucket}`); - console.log(`Key: ${uploadResult.key}`); - if (uploadResult.versionId) console.log(`VersionId: ${uploadResult.versionId}`); - console.log(`S3 URI: ${uploadResult.s3Uri}`); - } - } - - if (!skipInfrastructure && resolvedMigrationArtifactSourceFile) { - const uploadResult = runNodeJson("scripts/upload-backend-artifact.mjs", [ - "scripts/upload-backend-artifact.mjs", - `--region=${region}`, - `--bootstrap-stack-name=${bootstrapStackName}`, - `--artifact-bucket=${params.MigrationCodeS3Bucket}`, - `--source-file=${resolvedMigrationArtifactSourceFile}`, - `--artifact-key=${params.MigrationCodeS3Key}`, - "--artifact-label=Migration artifact", - "--output=json", - ].filter((arg) => !arg.endsWith("="))); - resolvedMigrationCodeS3ObjectVersion = uploadResult.versionId || resolvedMigrationCodeS3ObjectVersion; - params.MigrationCodeS3ObjectVersion = resolvedMigrationCodeS3ObjectVersion; - if (!jsonOutput) { - console.log("\nMigration artifact upload complete."); - console.log(`Bucket: ${uploadResult.bucket}`); - console.log(`Key: ${uploadResult.key}`); - if (uploadResult.versionId) console.log(`VersionId: ${uploadResult.versionId}`); - console.log(`S3 URI: ${uploadResult.s3Uri}`); - } - } - - if (!skipInfrastructure) { - const deployArgs = [ - "cloudformation", - "deploy", - "--stack-name", - stackName, - "--template-file", - fullStackTemplatePath, - "--region", - region, - "--no-fail-on-empty-changeset", - "--capabilities", - "CAPABILITY_NAMED_IAM", - "--parameter-overrides", - ...toParameterOverrides(params), - ]; - if (cloudformationExecutionRoleArn) { - deployArgs.push("--role-arn", cloudformationExecutionRoleArn); - } - run("aws", deployArgs, { quiet: jsonOutput }); - } - - if (!skipInfrastructure && syncLegacySsm) { - const legacySsmArgs = [ - `--stack-name=${stackName}`, - `--environment=${environmentName}`, - `--region=${region}`, - ]; - if (legacySsmPrefix) legacySsmArgs.push(`--prefix=${legacySsmPrefix}`); - if (legacySsmIncludeEmpty) legacySsmArgs.push(`--include-empty=${legacySsmIncludeEmpty}`); - if (legacySsmOverwrite) legacySsmArgs.push(`--overwrite=${legacySsmOverwrite}`); - if (appConfigSecretFile) legacySsmArgs.push(`--app-config-secret-file=${appConfigSecretFile}`); - else if (resolvedAppConfigSecretArn) legacySsmArgs.push(`--app-config-secret-arn=${resolvedAppConfigSecretArn}`); - run("node", ["scripts/sync-legacy-ssm-parameters.mjs", ...legacySsmArgs], { quiet: jsonOutput }); - } - const outputs = stackOutputsRequired ? getStackOutputsSafe(stackName, region, "full-stack") : {}; - const publishTargetOutputs = frontendOutputsFile ? readOutputsFile(frontendOutputsFile, "frontend outputs file") : outputs; - const buildEnvOutputs = backendOutputsFile ? readOutputsFile(backendOutputsFile, "backend outputs file") : outputs; - let apiMigrationsResult = null; - if (!skipInfrastructure && runApiMigrations) { - const migrationArgs = [ - `--stack-name=${stackName}`, - `--region=${region}`, - `--api-repo-path=${apiMigrationApiRepoPath}`, - `--action=${apiMigrationAction}`, - `--module=${apiMigrationModule}`, - ]; - if (apiMigrationDbSecretArn) migrationArgs.push(`--db-secret-arn=${apiMigrationDbSecretArn}`); - if (apiMigrationDbSecretFile) migrationArgs.push(`--db-secret-file=${apiMigrationDbSecretFile}`); - if (apiMigrationDryRun) migrationArgs.push("--dry-run=true"); - if (jsonOutput) migrationArgs.push("--output=json"); - apiMigrationsResult = jsonOutput - ? runNodeJson("scripts/run-api-migrations.mjs", migrationArgs) - : run("node", ["scripts/run-api-migrations.mjs", ...migrationArgs]); - } - let bootstrapAdminResult = null; - if (!skipInfrastructure && runBootstrapAdmin) { - const bootstrapArgs = [ - `--stack-name=${stackName}`, - `--region=${region}`, - `--bootstrap-admin-reset-password=${bootstrapAdminResetPassword}`, - ]; - if (bootstrapAdminSecretFile) bootstrapArgs.push(`--bootstrap-admin-secret-file=${bootstrapAdminSecretFile}`); - if (bootstrapAdminSecretArn) bootstrapArgs.push(`--bootstrap-admin-secret-arn=${bootstrapAdminSecretArn}`); - if (bootstrapAdminEmail) bootstrapArgs.push(`--bootstrap-admin-email=${bootstrapAdminEmail}`); - if (bootstrapAdminPassword) bootstrapArgs.push(`--bootstrap-admin-password=${bootstrapAdminPassword}`); - if (bootstrapAdminFirstName) bootstrapArgs.push(`--bootstrap-admin-first-name=${bootstrapAdminFirstName}`); - if (bootstrapAdminLastName) bootstrapArgs.push(`--bootstrap-admin-last-name=${bootstrapAdminLastName}`); - if (bootstrapAdminDisplayName) bootstrapArgs.push(`--bootstrap-admin-display-name=${bootstrapAdminDisplayName}`); - if (bootstrapChurchName) bootstrapArgs.push(`--bootstrap-church-name=${bootstrapChurchName}`); - if (bootstrapChurchSubdomain) bootstrapArgs.push(`--bootstrap-church-subdomain=${bootstrapChurchSubdomain}`); - if (bootstrapChurchAddress1) bootstrapArgs.push(`--bootstrap-church-address1=${bootstrapChurchAddress1}`); - if (bootstrapChurchAddress2) bootstrapArgs.push(`--bootstrap-church-address2=${bootstrapChurchAddress2}`); - if (bootstrapChurchCity) bootstrapArgs.push(`--bootstrap-church-city=${bootstrapChurchCity}`); - if (bootstrapChurchState) bootstrapArgs.push(`--bootstrap-church-state=${bootstrapChurchState}`); - if (bootstrapChurchZip) bootstrapArgs.push(`--bootstrap-church-zip=${bootstrapChurchZip}`); - if (bootstrapChurchCountry) bootstrapArgs.push(`--bootstrap-church-country=${bootstrapChurchCountry}`); - if (bootstrapMembershipStatus) bootstrapArgs.push(`--bootstrap-membership-status=${bootstrapMembershipStatus}`); - if (jsonOutput) bootstrapArgs.push("--output=json"); - bootstrapAdminResult = jsonOutput - ? runNodeJson("scripts/bootstrap-initial-admin.mjs", bootstrapArgs) - : run("node", ["scripts/bootstrap-initial-admin.mjs", ...bootstrapArgs]); - } - const result = { - stackName, - region, - environmentName, - outputs, - infrastructureOnly, - frontendInfrastructureOnly, - publishFrontendAssets, - skipInfrastructure, - skipBuild, - bootstrapStackName, - resolvedPackageManifestFile, - resolvedBackendArtifactSourceFile, - resolvedMigrationArtifactSourceFile, - resolvedDependenciesLayerSourceFile, - backendTemplateUrl, - frontendTemplateUrl, - appConfigSecretArn: resolvedAppConfigSecretArn || outputs.AppConfigSecretArn || "", - lambdaCodeS3Bucket: params.LambdaCodeS3Bucket, - lambdaCodeS3Key: params.LambdaCodeS3Key, - migrationCodeS3Bucket: params.MigrationCodeS3Bucket || "", - migrationCodeS3Key: params.MigrationCodeS3Key || "", - dependenciesLayerArn: resolvedDependenciesLayerArn || "", - syncLegacySsm, - runApiMigrations, - apiMigrations: apiMigrationsResult, - runBootstrapAdmin, - bootstrapAdmin: bootstrapAdminResult, - frontendPublished: false, - }; - - if (infrastructureOnly && !publishFrontendAssets) { - if (jsonOutput) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - - console.log("\nFull-stack infrastructure deployment complete."); - console.log(`Stack: ${stackName}`); - console.log(`Backend template URL: ${backendTemplateUrl}`); - console.log(`Frontend template URL: ${frontendTemplateUrl}`); - if (bootstrapStackName) console.log(`Bootstrap stack: ${bootstrapStackName}`); - return; - } - - const frontendEnv = buildFrontendEnvFromOutputs(buildEnvOutputs); - const bucketName = frontendPublishBucket || getOutputValue(publishTargetOutputs, ["FrontendBucketName", "SiteBucketName"]); - const distributionId = frontendPublishDistributionId || getOutputValue(publishTargetOutputs, ["FrontendDistributionId", "CloudFrontDistributionId"]); - const frontendAppUrl = frontendPublishAppUrl || getOutputValue(publishTargetOutputs, ["FrontendAppUrl", "AppUrl"]); - - requireValue("FrontendBucketName output", bucketName); - requireValue("FrontendDistributionId output", distributionId); - - if (frontendInfrastructureOnly && !publishFrontendAssets) { - result.frontendBucketName = bucketName; - result.frontendDistributionId = distributionId; - result.frontendAppUrl = frontendAppUrl || ""; - result.frontendEnv = frontendEnv; - - if (jsonOutput) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - - console.log("\nFull-stack infrastructure deployment complete."); - console.log(`Stack: ${stackName}`); - console.log(`Backend template URL: ${backendTemplateUrl}`); - console.log(`Frontend template URL: ${frontendTemplateUrl}`); - if (bootstrapStackName) console.log(`Bootstrap stack: ${bootstrapStackName}`); - console.log(`Frontend bucket: ${bucketName}`); - console.log(`Frontend distribution: ${distributionId}`); - if (frontendAppUrl) console.log(`URL: ${frontendAppUrl}`); - return; - } - - let publishResult = null; - const publishArgs = [ - `--bucket=${bucketName}`, - `--distribution-id=${distributionId}`, - `--region=${region}`, - `--environment=${environmentName}`, - ]; - if (frontendAppUrl) publishArgs.push(`--app-url=${frontendAppUrl}`); - if (skipBuild) publishArgs.push("--skip-build"); - - let tempDir = ""; - try { - if (!skipBuild) { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "b1admin-full-stack-")); - const backendOutputsPath = path.join(tempDir, "backend-outputs.json"); - fs.writeFileSync(backendOutputsPath, `${JSON.stringify(buildEnvOutputs, null, 2)}\n`); - publishArgs.push(`--backend-outputs-file=${backendOutputsPath}`); - } - - publishResult = jsonOutput - ? runNodeJson("scripts/publish-frontend-assets.mjs", [...publishArgs, "--output=json"]) - : run("node", ["scripts/publish-frontend-assets.mjs", ...publishArgs]); - } finally { - if (tempDir) { - fs.rmSync(tempDir, { force: true, recursive: true }); - } - } - - result.frontendPublished = jsonOutput ? publishResult.frontendPublished : true; - result.frontendBucketName = bucketName; - result.frontendDistributionId = distributionId; - result.frontendAppUrl = frontendAppUrl || ""; - result.frontendEnv = jsonOutput ? (publishResult.backendBuildEnv || frontendEnv) : frontendEnv; - - if (jsonOutput) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - - if (skipInfrastructure) { - console.log("\nFull-stack frontend asset publish complete."); - } else { - console.log("\nFull-stack application deployment complete."); - } - if (frontendAppUrl) console.log(`URL: ${frontendAppUrl}`); -} - -main(); diff --git a/scripts/environment-setup-wizard.mjs b/scripts/environment-setup-wizard.mjs deleted file mode 100644 index e78456c36..000000000 --- a/scripts/environment-setup-wizard.mjs +++ /dev/null @@ -1,360 +0,0 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import readline from "node:readline/promises"; -import { fileURLToPath } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - -function getArg(name, fallback = "") { - const prefix = `--${name}=`; - const bareFlag = `--${name}`; - for (let index = 0; index < process.argv.length; index += 1) { - const arg = process.argv[index]; - if (arg.startsWith(prefix)) return arg.slice(prefix.length); - if (arg === bareFlag) { - const next = process.argv[index + 1]; - if (next !== undefined && !next.startsWith("--")) return next; - } - } - const envName = name.toUpperCase().replace(/-/g, "_"); - return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; -} - -function resolveEnvironmentDir(environment, explicitDir = "") { - if (explicitDir) { - return path.resolve(rootDir, explicitDir); - } - return path.join(rootDir, "infrastructure", "environments", environment); -} - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function deriveRootDomain(environment, backend) { - const adminUrl = backend.B1AdminRootUrl || ""; - const supportEmail = backend.SupportEmail || ""; - - const adminMatch = adminUrl.match(/^https:\/\/admin(?:-[^.]+)?\.(.+)$/); - if (adminMatch) return adminMatch[1]; - - const supportParts = supportEmail.split("@"); - if (supportParts.length === 2) return supportParts[1]; - - return environment === "prod" ? "yourdomain.com" : ""; -} - -function stripProtocol(value) { - return typeof value === "string" ? value.replace(/^https?:\/\//, "") : value; -} - -function parseAccountId(bootstrap) { - const match = String(bootstrap.TemplateBucketName || "").match(/(\d{12})$/); - return match ? match[1] : ""; -} - -async function promptText(rl, label, defaultValue = "", options = {}) { - const suffix = defaultValue !== "" ? ` [${defaultValue}]` : ""; - const raw = await rl.question(`${label}${suffix}: `); - const value = raw.trim(); - if (value === "") return defaultValue; - if (options.allowBlankToken && value.toLowerCase() === "blank") return ""; - return value; -} - -async function promptYesNo(rl, label, defaultValue = true) { - const suffix = defaultValue ? " [Y/n]" : " [y/N]"; - const raw = (await rl.question(`${label}${suffix}: `)).trim().toLowerCase(); - if (raw === "") return defaultValue; - if (["y", "yes"].includes(raw)) return true; - if (["n", "no"].includes(raw)) return false; - return promptYesNo(rl, label, defaultValue); -} - -function buildPrepareArgs(config, writeChanges) { - const args = [ - path.join(rootDir, "scripts", "prepare-environment-starter.mjs"), - `--environment=${config.environment}`, - `--project-name=${config.projectName}`, - `--write=${writeChanges ? "true" : "false"}`, - "--output=markdown", - `--generate-secrets=${config.generateSecrets ? "true" : "false"}`, - `--write-secret-file=${config.writeSecretFile ? "true" : "false"}`, - ]; - - if (config.accountId) args.push(`--account-id=${config.accountId}`); - if (config.force) args.push("--force=true"); - if (config.rootDomain) args.push(`--root-domain=${config.rootDomain}`); - - const keyedArgs = { - websiteBaseUrl: "website-base-url", - contentRootUrl: "content-root-url", - adminRootUrl: "admin-root-url", - corsOrigin: "cors-origin", - frontendDomain: "frontend-domain", - frontendCertificateArn: "frontend-certificate-arn", - frontendHostedZoneId: "frontend-hosted-zone-id", - apiDomain: "api-domain", - apiCertificateArn: "api-certificate-arn", - apiHostedZoneId: "api-hosted-zone-id", - storeApiUrl: "store-api-url", - transferUrl: "transfer-url", - supportEmail: "support-email", - supportPhone: "support-phone", - supportSiteUrl: "support-site-url", - mobileAppUrl: "mobile-app-url", - domainCnameTarget: "domain-cname-target", - domainATarget: "domain-a-target", - defaultStockPhoto: "default-stock-photo", - googleAnalyticsTag: "google-analytics-tag", - }; - - Object.entries(keyedArgs).forEach(([key, argName]) => { - if (config[key] !== undefined && config[key] !== null && config[key] !== "") { - args.push(`--${argName}=${config[key]}`); - } - }); - - return args; -} - -function runPrepare(config, writeChanges) { - return spawnSync(process.execPath, buildPrepareArgs(config, writeChanges), { - cwd: rootDir, - encoding: "utf8", - stdio: ["inherit", "pipe", "pipe"], - }); -} - -function runGuide(environment) { - return spawnSync( - process.execPath, - [ - path.join(rootDir, "scripts", "show-environment-setup-guide.mjs"), - `--environment=${environment}`, - "--output=markdown", - ], - { - cwd: rootDir, - encoding: "utf8", - stdio: ["inherit", "pipe", "pipe"], - }, - ); -} - -function buildIamRoleDiscoveryCommand(environment, projectName) { - return `yarn discover:github-aws-roles -- --environment=${environment} --project-name=${projectName} --output=markdown`; -} - -async function main() { - const requestedEnvironment = getArg("environment", "staging"); - const environmentDirArg = getArg("environment-dir"); - const environmentDir = resolveEnvironmentDir(requestedEnvironment, environmentDirArg); - - if (!fs.existsSync(environmentDir)) { - console.error(`Unknown environment starter "${requestedEnvironment}".`); - process.exit(1); - } - - const bootstrap = readJson(path.join(environmentDir, "bootstrap-parameters.json")); - const backend = readJson(path.join(environmentDir, "backend-parameters.json")); - const frontend = readJson(path.join(environmentDir, "frontend-parameters.json")); - const templateSecret = readJson(path.join(environmentDir, "app-config-secret.template.json")); - const secretPath = path.join(environmentDir, "app-config-secret.json"); - const existingSecret = fs.existsSync(secretPath) ? readJson(secretPath) : null; - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - console.log(`Environment setup wizard: ${requestedEnvironment}`); - console.log("Note: this is an advanced tool for editing parameter files directly."); - console.log("For the normal guided install, use `yarn installer:run` instead.\n"); - console.log("This will walk through first-deploy values first, then optional custom-domain and integration values."); - console.log("Press Enter to keep the suggested default. Type blank only where the wizard says it is allowed.\n"); - - const environment = await promptText(rl, "Environment", requestedEnvironment); - const projectName = await promptText(rl, "Project name", bootstrap.ProjectName || "b1admin"); - const accountId = await promptText(rl, "AWS account ID", parseAccountId(bootstrap)); - - console.log("\nPhase 1: First AWS deploy"); - const useRootDomain = await promptYesNo( - rl, - "Derive admin/content/store/transfer/support values from a shared root domain", - true, - ); - - const rootDomainDefault = deriveRootDomain(environment, backend); - let rootDomain = ""; - let websiteBaseUrl = ""; - let contentRootUrl = ""; - let adminRootUrl = ""; - let corsOrigin = ""; - let storeApiUrl = ""; - let transferUrl = ""; - let supportEmail = backend.SupportEmail || ""; - - if (useRootDomain) { - rootDomain = await promptText(rl, "Root domain", rootDomainDefault); - supportEmail = await promptText(rl, "Support email", backend.SupportEmail || `support@${rootDomain}`); - } else { - websiteBaseUrl = await promptText(rl, "Website base URL pattern", backend.WebsiteBaseUrl || "https://{subdomain}.yourdomain.com"); - contentRootUrl = await promptText(rl, "Content root URL", backend.ContentRootUrl || ""); - adminRootUrl = await promptText(rl, "Admin root URL", backend.B1AdminRootUrl || ""); - corsOrigin = await promptText(rl, "CORS origin", backend.CorsOrigin || adminRootUrl); - storeApiUrl = await promptText(rl, "Store API URL", backend.StoreApiUrl || ""); - transferUrl = await promptText(rl, "Transfer URL", backend.TransferUrl || ""); - supportEmail = await promptText(rl, "Support email", backend.SupportEmail || ""); - } - - const supportPhone = await promptText(rl, "Support phone", backend.SupportPhone || ""); - const supportSiteUrl = await promptText(rl, "Support site URL", backend.SupportSiteUrl || ""); - - const writeSecretFile = await promptYesNo( - rl, - existingSecret - ? "Keep using and updating app-config-secret.json in this environment folder" - : "Create app-config-secret.json now", - true, - ); - const generateSecrets = writeSecretFile - ? await promptYesNo( - rl, - existingSecret - ? "Regenerate jwtSecret and encryptionKey" - : "Generate jwtSecret and encryptionKey", - existingSecret ? false : true, - ) - : false; - const force = Boolean(existingSecret && generateSecrets); - - console.log("\nPhase 2: Optional custom domains"); - const configureCustomDomains = await promptYesNo( - rl, - "Fill the ACM and Route53 custom-domain fields now", - false, - ); - - let frontendDomain = ""; - let frontendCertificateArn = ""; - let frontendHostedZoneId = ""; - let apiDomain = ""; - let apiCertificateArn = ""; - let apiHostedZoneId = ""; - - if (configureCustomDomains) { - const frontendDomainDefault = frontend.AlternateDomainName || stripProtocol(backend.B1AdminRootUrl); - frontendDomain = await promptText(rl, "Frontend domain", frontendDomainDefault); - frontendCertificateArn = await promptText(rl, "Frontend ACM certificate ARN", frontend.AcmCertificateArn || ""); - frontendHostedZoneId = await promptText(rl, "Frontend Route53 hosted zone ID", frontend.HostedZoneId || ""); - apiDomain = await promptText(rl, "API custom domain", backend.ApiCustomDomainName || ""); - apiCertificateArn = await promptText(rl, "API ACM certificate ARN", backend.ApiCertificateArn || ""); - apiHostedZoneId = await promptText(rl, "API Route53 hosted zone ID", backend.ApiHostedZoneId || ""); - } - - console.log("\nPhase 3: Optional integrations and metadata"); - const reviewOptionalMetadata = await promptYesNo( - rl, - "Review optional runtime metadata now", - false, - ); - - let mobileAppUrl = ""; - let domainCnameTarget = ""; - let domainATarget = ""; - let defaultStockPhoto = ""; - let googleAnalyticsTag = ""; - - if (reviewOptionalMetadata) { - mobileAppUrl = await promptText(rl, "Mobile app URL", backend.MobileAppUrl || "", { allowBlankToken: true }); - domainCnameTarget = await promptText(rl, "Legacy CNAME target", backend.DomainCnameTarget || "", { allowBlankToken: true }); - domainATarget = await promptText(rl, "Legacy A-record target", backend.DomainATarget || "", { allowBlankToken: true }); - defaultStockPhoto = await promptText(rl, "Default stock photo URL", backend.DefaultStockPhoto || "", { allowBlankToken: true }); - googleAnalyticsTag = await promptText(rl, "Google Analytics tag", backend.GoogleAnalyticsTag || "", { allowBlankToken: true }); - } - - const config = { - environment, - projectName, - accountId, - generateSecrets, - writeSecretFile, - force, - rootDomain, - websiteBaseUrl, - contentRootUrl, - adminRootUrl, - corsOrigin, - frontendDomain, - frontendCertificateArn, - frontendHostedZoneId, - apiDomain, - apiCertificateArn, - apiHostedZoneId, - storeApiUrl, - transferUrl, - supportEmail, - supportPhone, - supportSiteUrl, - mobileAppUrl, - domainCnameTarget, - domainATarget, - defaultStockPhoto, - googleAnalyticsTag, - }; - - console.log("\nPreviewing the exact starter-file changes...\n"); - const preview = runPrepare(config, false); - if (preview.status !== 0) { - process.stdout.write(preview.stdout || ""); - process.stderr.write(preview.stderr || ""); - process.exit(preview.status ?? 1); - } - process.stdout.write(preview.stdout); - if (preview.stderr) process.stderr.write(preview.stderr); - - if ((preview.stdout || "").includes("- No changes proposed.")) { - console.log("\nThe starter files already match the answers you gave."); - console.log("\nCurrent readiness snapshot:\n"); - const guide = runGuide(environment); - process.stdout.write(guide.stdout || ""); - if (guide.stderr) process.stderr.write(guide.stderr); - process.exit(guide.status ?? 0); - } - - const applyChanges = await promptYesNo(rl, "\nWrite these changes to the environment files now", true); - if (!applyChanges) { - console.log("No files were changed."); - process.exit(0); - } - - console.log("\nApplying changes...\n"); - const applied = runPrepare(config, true); - if (applied.status !== 0) { - process.stdout.write(applied.stdout || ""); - process.stderr.write(applied.stderr || ""); - process.exit(applied.status ?? 1); - } - process.stdout.write(applied.stdout); - if (applied.stderr) process.stderr.write(applied.stderr); - - console.log("\nUpdated readiness snapshot:\n"); - const guide = runGuide(environment); - process.stdout.write(guide.stdout || ""); - if (guide.stderr) process.stderr.write(guide.stderr); - console.log("\nGitHub AWS role discovery:"); - console.log(buildIamRoleDiscoveryCommand(environment, projectName)); - process.exit(guide.status ?? 0); - } finally { - rl.close(); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/launch-staging.mjs b/scripts/launch-staging.mjs deleted file mode 100644 index 1682fd621..000000000 --- a/scripts/launch-staging.mjs +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { getArg, getBooleanArg } from "./lib/arg-utils.mjs"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const deployScriptPath = path.join(rootDir, "infrastructure", "environments", "staging", "deploy-split-stack.sh"); - -function runNodeScript(scriptPath, args) { - const result = spawnSync(process.execPath, [scriptPath, ...args], { - cwd: rootDir, - stdio: "inherit", - env: process.env, - }); - if (result.status !== 0) process.exit(result.status ?? 1); -} - -function runShellScript(scriptPath, envOverrides = {}) { - const result = spawnSync("bash", [scriptPath], { - cwd: rootDir, - stdio: "inherit", - env: { ...process.env, ...envOverrides }, - }); - if (result.status !== 0) process.exit(result.status ?? 1); -} - -function buildPlanArgs(options) { - const args = [ - path.join(rootDir, "scripts", "plan-environment-deploy.mjs"), - "--environment=staging", - `--region=${options.region}`, - `--deployment-source=${options.deploymentSource}`, - `--api-repo-path=${options.apiRepoPath}`, - `--sync-app-config-secret=${options.syncAppConfigSecret}`, - `--sync-bootstrap-admin-secret=${options.syncBootstrapAdminSecret}`, - `--run-api-migrations=${options.runApiMigrations}`, - `--run-bootstrap-admin=${options.runBootstrapAdmin}`, - `--api-migration-action=${options.apiMigrationAction}`, - `--api-migration-module=${options.apiMigrationModule}`, - `--verify-http-after-deploy=${options.verifyHttpAfterDeploy}`, - "--output=json", - ]; - - if (options.packageManifestFile) args.push(`--package-manifest-file=${options.packageManifestFile}`); - if (options.backendArtifactSourceFile) args.push(`--backend-artifact-source-file=${options.backendArtifactSourceFile}`); - if (options.migrationArtifactSourceFile) args.push(`--migration-artifact-source-file=${options.migrationArtifactSourceFile}`); - if (options.dependenciesLayerSourceFile) args.push(`--dependencies-layer-source-file=${options.dependenciesLayerSourceFile}`); - - return args; -} - -function getPlan(options) { - const result = spawnSync(process.execPath, buildPlanArgs(options), { - cwd: rootDir, - encoding: "utf8", - env: process.env, - }); - - let parsed; - try { - parsed = JSON.parse(result.stdout || "{}"); - } catch { - console.error("Could not parse staging deploy plan output."); - console.error(result.stdout); - console.error(result.stderr); - process.exit(result.status ?? 1); - } - - if (result.status !== 0) { - console.error("Staging deploy plan found blockers."); - if (parsed?.recommendedCommands?.primary) { - console.error(`Primary next command: ${parsed.recommendedCommands.primary}`); - } - process.exit(result.status); - } - - return parsed; -} - -function buildDispatchArgs(options) { - const args = [ - path.join(rootDir, "scripts", "dispatch-github-aws-deploy.mjs"), - "--environment=staging", - `--deployment-source=${options.deploymentSource}`, - `--region=${options.region}`, - `--sync-app-config-secret=${options.syncAppConfigSecret}`, - `--sync-bootstrap-admin-secret=${options.syncBootstrapAdminSecret}`, - `--run-api-migrations=${options.runApiMigrations}`, - `--run-bootstrap-admin=${options.runBootstrapAdmin}`, - `--api-migration-action=${options.apiMigrationAction}`, - `--api-migration-module=${options.apiMigrationModule}`, - `--verify-http-after-deploy=${options.verifyHttpAfterDeploy}`, - `--dry-run=${options.dryRun}`, - `--preview-only=${options.previewOnly}`, - ]; - - if (options.githubAuthMode !== "oidc") args.push(`--github-auth-mode=${options.githubAuthMode}`); - if (options.repo) args.push(`--repo=${options.repo}`); - if (options.apiRepo !== "ChurchApps/Api") args.push(`--api-repo=${options.apiRepo}`); - if (options.apiRef !== "main") args.push(`--api-ref=${options.apiRef}`); - if (options.packageManifestFile) args.push(`--package-manifest-file=${options.packageManifestFile}`); - if (options.backendArtifactSourceFile) args.push(`--backend-artifact-source-file=${options.backendArtifactSourceFile}`); - if (options.migrationArtifactSourceFile) args.push(`--migration-artifact-source-file=${options.migrationArtifactSourceFile}`); - if (options.dependenciesLayerSourceFile) args.push(`--dependencies-layer-source-file=${options.dependenciesLayerSourceFile}`); - - return args; -} - -function getLocalEnvOverrides(options) { - return { - AWS_REGION: options.region, - API_REPO_PATH: options.apiRepoPath, - PACKAGE_MODE: "layered", - PACKAGE_BUILD_LAYER: "true", - PACKAGE_MANIFEST_FILE: options.packageManifestFile, - BACKEND_ARTIFACT_SOURCE_FILE: options.backendArtifactSourceFile, - MIGRATION_ARTIFACT_SOURCE_FILE: options.migrationArtifactSourceFile, - DEPENDENCIES_LAYER_SOURCE_FILE: options.dependenciesLayerSourceFile, - SYNC_APP_CONFIG_SECRET: String(options.syncAppConfigSecret), - SYNC_BOOTSTRAP_ADMIN_SECRET: String(options.syncBootstrapAdminSecret), - RUN_API_MIGRATIONS: String(options.runApiMigrations), - RUN_BOOTSTRAP_ADMIN: String(options.runBootstrapAdmin), - API_MIGRATION_ACTION: options.apiMigrationAction, - API_MIGRATION_MODULE: options.apiMigrationModule, - VERIFY_HTTP_AFTER_DEPLOY: String(options.verifyHttpAfterDeploy), - PREVIEW_ONLY: String(options.previewOnly), - }; -} - -function main() { - const options = { - mode: getArg("mode", "auto"), - region: getArg("region", "us-east-1"), - deploymentSource: getArg("deployment-source", "api-repo"), - apiRepoPath: getArg("api-repo-path", "../Api"), - apiRepo: getArg("api-repo", "ChurchApps/Api"), - apiRef: getArg("api-ref", "main"), - packageManifestFile: getArg("package-manifest-file", ""), - backendArtifactSourceFile: getArg("backend-artifact-source-file", ""), - migrationArtifactSourceFile: getArg("migration-artifact-source-file", ""), - dependenciesLayerSourceFile: getArg("dependencies-layer-source-file", ""), - repo: getArg("repo", ""), - githubAuthMode: getArg("github-auth-mode", "oidc"), - syncAppConfigSecret: getBooleanArg("sync-app-config-secret", true), - syncBootstrapAdminSecret: getBooleanArg("sync-bootstrap-admin-secret", false), - runApiMigrations: getBooleanArg("run-api-migrations", false), - runBootstrapAdmin: getBooleanArg("run-bootstrap-admin", false), - apiMigrationAction: getArg("api-migration-action", "up"), - apiMigrationModule: getArg("api-migration-module", "all"), - verifyHttpAfterDeploy: getBooleanArg("verify-http-after-deploy", false), - previewOnly: getBooleanArg("preview-only", false), - dryRun: getBooleanArg("dry-run", false), - }; - - if (!options.previewOnly && !options.dryRun && options.deploymentSource === "api-repo") { - console.log("Staging launch note: the Api TypeScript compile step can take 10+ minutes on a full local build."); - console.log("During that step, repeated [WAIT] messages are expected while the deploy continues."); - } - - const plan = getPlan(options); - const recommendedPath = plan?.recommendedExecution?.path ?? "none"; - - let selectedMode = options.mode; - if (selectedMode === "auto") { - selectedMode = recommendedPath === "github-actions" ? "github" : "local"; - } - - if (selectedMode === "github") { - runNodeScript(buildDispatchArgs(options)[0], buildDispatchArgs(options).slice(1)); - return; - } - - if (selectedMode === "local") { - if (options.dryRun) { - console.log("Staging launch dry-run complete."); - console.log(`Recommended path: ${recommendedPath}`); - console.log(`Using local deploy wrapper: ${deployScriptPath}`); - return; - } - runShellScript(deployScriptPath, getLocalEnvOverrides(options)); - return; - } - - console.error(`Unsupported launch mode: ${options.mode}`); - process.exit(1); -} - -main(); diff --git a/scripts/package-api-backend.mjs b/scripts/package-api-backend.mjs index def65e956..40ac875b9 100644 --- a/scripts/package-api-backend.mjs +++ b/scripts/package-api-backend.mjs @@ -224,10 +224,9 @@ function main() { : "", deployBackend: `yarn deploy:backend -- --package-manifest-file=${relativeManifestPath}`, deployAws: `yarn deploy:aws -- --package-manifest-file=${relativeManifestPath}`, - deployFullStack: `yarn deploy:full-stack -- --package-manifest-file=${relativeManifestPath}`, deployMode: packageMode === "self-contained" - ? "Use the resulting backend zip directly with deploy:backend, deploy:aws, or deploy:full-stack." + ? "Use the resulting backend zip directly with deploy:backend or deploy:aws." : "Upload the backend zip and dependencies layer zip separately. Then publish the layer and pass its ARN through DependenciesLayerArn.", }, includedBackendEntries: backendEntries, diff --git a/scripts/run-api-migrations.mjs b/scripts/run-api-migrations.mjs deleted file mode 100644 index 3ecd695c4..000000000 --- a/scripts/run-api-migrations.mjs +++ /dev/null @@ -1,378 +0,0 @@ -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const modules = ["membership", "attendance", "content", "giving", "messaging", "doing", "reporting"]; - -function getArg(name, fallback = "") { - const prefix = `--${name}=`; - const bareFlag = `--${name}`; - for (let index = 0; index < process.argv.length; index += 1) { - const arg = process.argv[index]; - if (arg.startsWith(prefix)) return arg.slice(prefix.length); - if (arg === bareFlag) { - const next = process.argv[index + 1]; - if (next !== undefined && !next.startsWith("--")) return next; - } - } - const envName = name.toUpperCase().replace(/-/g, "_"); - return process.env[envName] ?? process.env[name.toUpperCase()] ?? fallback; -} - -function fail(message) { - console.error(message); - process.exit(1); -} - -function parseBoolean(value, fallback) { - if (value === "") return fallback; - return value.toLowerCase() === "true"; -} - -function normalizeOutputs(raw) { - if (!raw) return {}; - if (Array.isArray(raw)) return Object.fromEntries(raw.map((output) => [output.OutputKey, output.OutputValue])); - if (raw.Stacks?.[0]?.Outputs) return normalizeOutputs(raw.Stacks[0].Outputs); - if (raw.Outputs) return normalizeOutputs(raw.Outputs); - return raw; -} - -function loadJsonFile(filePath, label) { - try { - const resolved = path.resolve(rootDir, filePath); - return JSON.parse(fs.readFileSync(resolved, "utf8")); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not load ${label} "${filePath}": ${message}`); - } -} - -function runAwsJson(args, failureLabel) { - try { - return JSON.parse(execFileSync("aws", args, { - cwd: rootDir, - encoding: "utf8", - stdio: "pipe", - maxBuffer: 20 * 1024 * 1024, - })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`${failureLabel}: ${message}`); - } -} - -function getStackOutputs(stackName, region) { - const response = runAwsJson([ - "cloudformation", - "describe-stacks", - "--stack-name", - stackName, - "--region", - region, - "--output", - "json", - ], `Could not read stack "${stackName}"`); - - return normalizeOutputs(response); -} - -function getSecretJson(secretId, region) { - const result = runAwsJson([ - "secretsmanager", - "get-secret-value", - "--secret-id", - secretId, - "--region", - region, - "--output", - "json", - ], `Could not read Secrets Manager secret "${secretId}"`); - - if (!result.SecretString) fail(`Secret does not contain SecretString: ${secretId}`); - - try { - return JSON.parse(result.SecretString); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`SecretString for "${secretId}" is not valid JSON: ${message}`); - } -} - -function resolveYarnCommand() { - try { - execFileSync("corepack", ["--version"], { stdio: "ignore" }); - return { command: "corepack", argsPrefix: ["yarn"] }; - } catch (_error) { - return { command: "yarn", argsPrefix: [] }; - } -} - -function runChild(command, args, cwd, env, captureOutput) { - try { - if (captureOutput) { - return { - stdout: execFileSync(command, args, { - cwd, - env, - encoding: "utf8", - stdio: "pipe", - maxBuffer: 20 * 1024 * 1024, - }), - stderr: "", - }; - } - - execFileSync(command, args, { - cwd, - stdio: "inherit", - env, - }); - return { stdout: "", stderr: "" }; - } catch (error) { - if (captureOutput && error && typeof error === "object") { - if (error.stdout) process.stderr.write(String(error.stdout)); - if (error.stderr) process.stderr.write(String(error.stderr)); - const status = typeof error.status === "number" ? error.status : 1; - process.exit(status); - } - - if (error && typeof error === "object") { - const status = typeof error.status === "number" ? error.status : 1; - process.exit(status); - } - - process.exit(1); - } -} - -function ensurePathExists(label, targetPath) { - if (!fs.existsSync(targetPath)) { - fail(`${label} not found: ${targetPath}`); - } -} - -function loadApiRepoMigrationModules(apiRepoPath) { - const kyselyConfigPath = path.join(apiRepoPath, "tools", "kysely-config.ts"); - if (!fs.existsSync(kyselyConfigPath)) return modules; - - try { - const source = fs.readFileSync(kyselyConfigPath, "utf8"); - const match = source.match(/const\s+MODULES\s*=\s*\[(.*?)\]\s+as const/s); - if (!match) return modules; - - const values = Array.from(match[1].matchAll(/"([^"]+)"/g)).map((item) => item[1]); - return values.length > 0 ? values : modules; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not read API migration module config from "${kyselyConfigPath}": ${message}`); - } -} - -function loadApiRepoMigrationDirectories(apiRepoPath) { - const migrationsRoot = path.join(apiRepoPath, "tools", "migrations"); - if (!fs.existsSync(migrationsRoot)) return []; - - try { - return fs.readdirSync(migrationsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fail(`Could not read API migration directories from "${migrationsRoot}": ${message}`); - } -} - -function buildMysqlConnectionString({ username, password, host, port, database }) { - const encodedUsername = encodeURIComponent(String(username)); - const encodedPassword = encodeURIComponent(String(password)); - const encodedDatabase = encodeURIComponent(String(database)); - return `mysql://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`; -} - -function getDatabaseNameForModule(outputs, moduleName) { - const outputKeys = { - membership: "MembershipDatabaseName", - attendance: "AttendanceDatabaseName", - content: "ContentDatabaseName", - giving: "GivingDatabaseName", - messaging: "MessagingDatabaseName", - doing: "DoingDatabaseName", - reporting: "ReportingDatabaseName", - }; - - return outputs[outputKeys[moduleName]]; -} - -function getTargetModules(moduleName) { - return moduleName === "all" ? modules : [moduleName]; -} - -function requireOutput(outputs, key) { - if (!outputs[key]) fail(`Missing required stack output: ${key}`); - return outputs[key]; -} - -function main() { - const region = getArg("region", process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"); - const stackName = getArg("stack-name"); - const outputsFile = getArg("outputs-file"); - const dbSecretArn = getArg("db-secret-arn"); - const dbSecretFile = getArg("db-secret-file"); - const apiRepoPath = path.resolve(rootDir, getArg("api-repo-path", "../Api")); - const action = getArg("action", "up"); - const moduleName = getArg("module", "all"); - const dryRun = parseBoolean(getArg("dry-run", "false"), false); - const outputMode = getArg("output", "text").toLowerCase(); - - if (!["up", "down", "status"].includes(action)) { - fail(`Invalid action "${action}". Use up, down, or status.`); - } - - if (moduleName !== "all" && !modules.includes(moduleName)) { - fail(`Invalid module "${moduleName}". Use one of ${modules.join(", ")} or all.`); - } - - if (!stackName && !outputsFile) { - fail("Provide --stack-name or --outputs-file."); - } - - ensurePathExists("API repo", apiRepoPath); - ensurePathExists("API package.json", path.join(apiRepoPath, "package.json")); - ensurePathExists("API migrate tool", path.join(apiRepoPath, "tools", "migrate.ts")); - const apiRepoMigrationModules = loadApiRepoMigrationModules(apiRepoPath); - const apiRepoMigrationDirectories = loadApiRepoMigrationDirectories(apiRepoPath); - - const outputs = stackName - ? getStackOutputs(stackName, region) - : normalizeOutputs(loadJsonFile(outputsFile, "stack outputs file")); - - const databaseEndpoint = requireOutput(outputs, "DatabaseEndpoint"); - const databasePort = requireOutput(outputs, "DatabasePort"); - const resolvedDbSecretArn = dbSecretArn || outputs.DatabaseSecretArn || ""; - if (!dbSecretFile && !resolvedDbSecretArn) { - fail("Provide --db-secret-file, --db-secret-arn, or stack outputs with DatabaseSecretArn."); - } - - const dbSecret = dbSecretFile - ? loadJsonFile(dbSecretFile, "database secret file") - : getSecretJson(resolvedDbSecretArn, region); - - if (!dbSecret.username) fail("Database secret is missing username."); - if (!dbSecret.password) fail("Database secret is missing password."); - - const targetModules = moduleName === "all" ? apiRepoMigrationModules : getTargetModules(moduleName); - const connectionStrings = {}; - targetModules.forEach((name) => { - const databaseName = getDatabaseNameForModule(outputs, name); - if (!databaseName) fail(`Could not resolve ${name} database name from stack outputs.`); - - connectionStrings[`${name.toUpperCase()}_CONNECTION_STRING`] = buildMysqlConnectionString({ - username: dbSecret.username, - password: dbSecret.password, - host: databaseEndpoint, - port: databasePort, - database: databaseName, - }); - }); - - if (connectionStrings.MEMBERSHIP_CONNECTION_STRING) { - connectionStrings.DOING_MEMBERSHIP_CONNECTION_STRING = connectionStrings.MEMBERSHIP_CONNECTION_STRING; - } - - const yarnCommand = resolveYarnCommand(); - const command = yarnCommand.command; - const args = [...yarnCommand.argsPrefix, "migrate", `--action=${action}`, `--module=${moduleName}`]; - - const redactedConnectionStrings = Object.fromEntries(Object.entries(connectionStrings).map(([key, value]) => { - const redacted = String(value).replace(/:\/\/([^:]+):([^@]+)@/, "://$1:***@"); - return [key, redacted]; - })); - - const result = { - apiRepoPath, - region, - stackName, - outputsFile, - action, - module: moduleName, - dryRun, - command: `${command} ${args.join(" ")}`, - databaseEndpoint, - databasePort, - resolvedDbSecretSource: dbSecretFile ? path.resolve(rootDir, dbSecretFile) : resolvedDbSecretArn, - apiRepoMigrationModules, - apiRepoMigrationDirectories, - effectiveModules: targetModules, - skippedConfiguredModules: moduleName === "all" - ? modules.filter((name) => !apiRepoMigrationModules.includes(name)) - : [], - warnings: [], - connectionStrings: redactedConnectionStrings, - executed: false, - }; - - if (moduleName !== "all" && !apiRepoMigrationModules.includes(moduleName)) { - result.warnings.push(`The current Api repo's --module=all migration set does not include ${moduleName}.`); - } - - const modulesWithoutMigrationDirectories = targetModules.filter((name) => !apiRepoMigrationDirectories.includes(name)); - if (modulesWithoutMigrationDirectories.length > 0) { - result.warnings.push(`No migration directory exists in the Api repo for: ${modulesWithoutMigrationDirectories.join(", ")}.`); - } - - if (!dryRun && moduleName !== "all" && modulesWithoutMigrationDirectories.length > 0) { - fail(`The current Api repo has no tools/migrations/${moduleName} directory. Refusing to run a direct ${moduleName} migration outside dry-run mode.`); - } - - if (outputMode === "json") { - if (dryRun) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - } else { - console.log("\nAPI migration helper ready."); - console.log(`API repo: ${apiRepoPath}`); - console.log(`Action: ${action}`); - console.log(`Module: ${moduleName}`); - if (moduleName === "all") { - console.log(`Effective modules: ${targetModules.join(", ")}`); - if (result.skippedConfiguredModules.length > 0) { - console.warn(`Warning: --module=all in the current Api repo does not include: ${result.skippedConfiguredModules.join(", ")}`); - } - } - result.warnings.forEach((warning) => console.warn(`Warning: ${warning}`)); - console.log(`Database host: ${databaseEndpoint}:${databasePort}`); - if (dryRun) { - console.log("Dry run only. No migrations executed."); - Object.keys(redactedConnectionStrings).forEach((key) => console.log(`- ${key}=${redactedConnectionStrings[key]}`)); - return; - } - } - - if (!fs.existsSync(path.join(apiRepoPath, "node_modules"))) { - fail(`API repo dependencies are not installed: ${path.join(apiRepoPath, "node_modules")}`); - } - - const childResult = runChild( - command, - args, - apiRepoPath, - { - ...process.env, - ...connectionStrings, - }, - outputMode === "json", - ); - - result.executed = true; - if (outputMode === "json") { - result.stdout = childResult.stdout; - result.stderr = childResult.stderr; - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } -} - -main(); diff --git a/scripts/smoke-aws-tooling.mjs b/scripts/smoke-aws-tooling.mjs index 37a2dab5f..62c1aba6b 100644 --- a/scripts/smoke-aws-tooling.mjs +++ b/scripts/smoke-aws-tooling.mjs @@ -97,7 +97,6 @@ function expectJsonExampleContractCoverage(jsonFilesToParse) { "infrastructure/examples/database-secret.sample.json", "infrastructure/examples/frontend-outputs.sample.json", "infrastructure/examples/frontend-parameters.sample.json", - "infrastructure/examples/full-stack-parameters.sample.json", ].sort(); const parsedExampleSamples = jsonFilesToParse @@ -3604,30 +3603,6 @@ function expectPlanEnvironmentDeployBackendArtifactInputBlockerWorks() { } } -function expectDeployFullStackPackageManifestMissingArtifact() { - withFakePackageManifest((manifestPath) => { - withFakeAwsAllowingS3Cp((env) => { - const result = runScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--template-bucket=my-template-bucket", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--infrastructure-only", - ], env); - - if (result.status === 0) { - throw new Error(`deploy-full-stack package manifest file without api repo unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("Source file not found:")) { - throw new Error(`deploy-full-stack package manifest file without api repo did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); - }, { missingBackendArtifact: true }); -} - function expectDeployBackendPackageManifestMissingMigrationArtifact() { withFakePackageManifest((manifestPath) => { withFakeAwsAllowingS3Cp((env) => { @@ -3676,32 +3651,6 @@ function expectDeployAwsPackageManifestMissingMigrationArtifact() { }, { missingMigrationArtifact: true }); } -function expectDeployFullStackPackageManifestMissingMigrationArtifact() { - withFakePackageManifest((manifestPath) => { - withFakeAwsAllowingS3Cp((env) => { - const result = runScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--template-bucket=my-template-bucket", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--run-migrations=true", - "--migration-handler=index.migrate", - "--infrastructure-only", - ], env); - - if (result.status === 0) { - throw new Error(`deploy-full-stack package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("Source file not found:")) { - throw new Error(`deploy-full-stack package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); - }, { missingMigrationArtifact: true }); -} - function expectDeployBackendJsonIncludesManifestProvenance() { withFakePackageManifest((manifestPath) => { withFakeAwsForBackendDeploy((env) => { @@ -3762,35 +3711,6 @@ function expectDeployAwsJsonIncludesManifestProvenance() { }); } -function expectDeployFullStackJsonIncludesManifestProvenance() { - withFakePackageManifest((manifestPath) => { - withFakeAwsForFullStackDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - `--package-manifest-file=${manifestPath}`, - "--template-bucket=my-template-bucket", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--infrastructure-only", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.resolvedPackageManifestFile !== manifestPath) { - throw new Error(`deploy-full-stack json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); - } - - const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); - if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { - throw new Error(`deploy-full-stack json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - function expectDeployBackendOutputSampleMatchesContract() { const sample = readJsonFile("infrastructure/examples/deploy-backend-output.sample.json"); @@ -4498,44 +4418,6 @@ function expectValidatorBootstrapRespectsEnvironmentName() { } } -function expectValidatorFullStackRespectsEnvironmentName() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-full-stack-env-")); - const paramsPath = path.join(tempDir, "full-stack-parameters.json"); - - try { - fs.writeFileSync(paramsPath, `${JSON.stringify({ - ProjectName: "b1admin", - EnvironmentName: "staging", - BackendTemplateUrl: "https://example-bucket.s3.amazonaws.com/b1admin/backend-api.yaml", - FrontendTemplateUrl: "https://example-bucket.s3.amazonaws.com/b1admin/frontend-site.yaml", - LambdaCodeS3Bucket: "full-stack-staging-artifacts-123456789012", - LambdaCodeS3Key: "b1admin/staging/backend/api.zip", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=full-stack", - "--region=us-east-1", - "--template-bucket=example-template-bucket", - `--parameters-file=${paramsPath}`, - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`full-stack EnvironmentName validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed?.environmentName !== "staging") { - throw new Error(`full-stack validator did not preserve EnvironmentName from the parameters file.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed?.resolved?.artifactKey !== "b1admin/staging/backend/api.zip") { - throw new Error(`full-stack validator did not derive the staging artifact key from EnvironmentName.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - function expectValidateApiMigrationsOutputSampleMatchesContract() { const sample = readJsonFile("infrastructure/examples/validate-api-migrations-output.sample.json"); const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-api-migrations-sample-")); @@ -4731,124 +4613,6 @@ function expectValidateSplitStackFrontendInfraOutputSampleMatchesContract() { } } -function expectValidateFullStackOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-full-stack-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--template-bucket=my-template-bucket", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-full-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-full-stack output sample", actual, sample); - expectObjectContainsKeys("validate-full-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "full-stack" || sample.fullStackPublishOnly !== false) { - throw new Error(`validate-full-stack output sample should document an ok non-publish full-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parametersFile !== "infrastructure/examples/full-stack-parameters.sample.json") { - throw new Error(`validate-full-stack output sample should point to the sample full-stack parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.templateBucket !== "my-template-bucket") { - throw new Error(`validate-full-stack output sample should document templateBucket=my-template-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { - throw new Error(`validate-full-stack output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Template bucket: my-template-bucket")) { - throw new Error(`validate-full-stack output sample should document the resolved template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.errors) || sample.errors.length !== 0 || !Array.isArray(sample.warnings) || sample.warnings.length !== 0) { - throw new Error(`validate-full-stack output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidateFullStackFrontendInfraOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--template-bucket=my-template-bucket", - "--frontend-infrastructure-only", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-full-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-full-stack frontend-infrastructure output sample", actual, sample); - expectObjectContainsKeys("validate-full-stack frontend-infrastructure output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "full-stack" || sample.frontendInfrastructureOnly !== true) { - throw new Error(`validate-full-stack frontend-infrastructure output sample should document an ok hosting-only full-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Frontend infrastructure-only deploy requested.")) { - throw new Error(`validate-full-stack frontend-infrastructure output sample should document the frontend infrastructure-only mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("frontend asset publishing deferred"))) { - throw new Error(`validate-full-stack frontend-infrastructure output sample should document the deferred frontend publish phase.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { - throw new Error(`validate-full-stack frontend-infrastructure output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidateFullStackPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-full-stack-publish-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--skip-infrastructure", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-full-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-full-stack publish output sample", actual, sample); - expectObjectContainsKeys("validate-full-stack publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "full-stack" || sample.fullStackPublishOnly !== true) { - throw new Error(`validate-full-stack publish output sample should document an ok full-stack publish-only validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parametersFile !== "infrastructure/examples/full-stack-parameters.sample.json") { - throw new Error(`validate-full-stack publish output sample should point to the sample full-stack parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { - throw new Error(`validate-full-stack publish output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { - throw new Error(`validate-full-stack publish output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend outputs file: /abs/path/to/"))) { - throw new Error(`validate-full-stack publish output sample should show the frontend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Backend outputs file: /abs/path/to/"))) { - throw new Error(`validate-full-stack publish output sample should show the backend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { - throw new Error(`validate-full-stack publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:full-stack"))) { - throw new Error(`validate-full-stack publish output sample should include a deploy:full-stack next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - function expectValidateSplitStackPublishOutputSampleMatchesContract() { const sample = readJsonFile("infrastructure/examples/validate-split-stack-publish-output.sample.json"); @@ -5243,63 +5007,6 @@ function expectDispatchGithubAwsDeployOutputSampleMatchesContract() { } } -function expectRunApiMigrationsOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/run-api-migrations-output.sample.json"); - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-run-api-migrations-sample-")); - - try { - fs.mkdirSync(path.join(tempDir, "tools", "migrations", "attendance"), { recursive: true }); - fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.writeFileSync(path.join(tempDir, "tools", "migrate.ts"), "export {};\n"); - fs.writeFileSync(path.join(tempDir, "tools", "kysely-config.ts"), "const MODULES = [\"attendance\"] as const;\nexport { MODULES };\n"); - - const result = runJsonScript("scripts/run-api-migrations.mjs", [ - `--api-repo-path=${tempDir}`, - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--module=attendance", - "--action=status", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`run-api-migrations output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("run-api-migrations output sample", actual, sample); - expectObjectContainsKeys("run-api-migrations output sample", actual.connectionStrings || {}, sample.connectionStrings || {}, "connectionStrings"); - - if (sample.apiRepoPath !== "") { - throw new Error(`run-api-migrations output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.command !== " migrate --action=status --module=attendance") { - throw new Error(`run-api-migrations output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.outputsFile !== "infrastructure/examples/backend-stack-outputs.sample.json" || sample.module !== "attendance" || sample.action !== "status") { - throw new Error(`run-api-migrations output sample should document the checked sample invocation.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.resolvedDbSecretSource).includes("/abs/path/to/")) { - throw new Error(`run-api-migrations output sample should show a db-secret placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(actual.command || "").includes("migrate --action=status --module=attendance")) { - throw new Error(`run-api-migrations contract run did not expose the expected migration command.\nSTDOUT:\n${result.stdout}`); - } - if (sample.connectionStrings?.ATTENDANCE_CONNECTION_STRING !== "mysql://churchapps:***@b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com:3306/attendance") { - throw new Error(`run-api-migrations output sample should document the redacted attendance connection string.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.executed !== false || sample.dryRun !== true) { - throw new Error(`run-api-migrations output sample should document the dry-run non-executed state.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - function expectPublishFrontendOutputSampleMatchesContract() { const sample = readJsonFile("infrastructure/examples/publish-frontend-output.sample.json"); @@ -5686,142 +5393,6 @@ function expectDeployAwsFrontendInfraOutputSampleMatchesContract() { }); } -function expectDeployFullStackFrontendInfraOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json"); - - withFakeAwsForFullStackDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--template-bucket=my-template-bucket", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--lambda-code-s3-key=b1admin/prod/backend/api.zip", - "--frontend-infrastructure-only", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual, sample); - expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - expectObjectContainsKeys("deploy-full-stack frontend-infrastructure output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); - - if (sample.stackName !== "example-full-stack" || sample.region !== "us-east-1" || sample.environmentName !== "prod") { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the default region/environment/stack identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendInfrastructureOnly !== true || sample.infrastructureOnly !== false || sample.publishFrontendAssets !== false) { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the hosting-only non-publish flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket" || sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved frontend hosting target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document REACT_APP_API_BASE from full-stack outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublished !== false) { - throw new Error(`deploy-full-stack frontend-infrastructure output sample should document that frontend publishing is deferred.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectDeployFullStackFullOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-full-stack-full-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFullStackDeploy((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--template-bucket=my-template-bucket", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--lambda-code-s3-key=b1admin/prod/backend/api.zip", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack full output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-full-stack full output sample", actual, sample); - expectObjectContainsKeys("deploy-full-stack full output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - expectObjectContainsKeys("deploy-full-stack full output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.stackName !== "example-full-stack" || sample.region !== "us-east-1" || sample.environmentName !== "prod") { - throw new Error(`deploy-full-stack full output sample should document the default region/environment/stack identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipInfrastructure !== false || sample.publishFrontendAssets !== false || sample.frontendInfrastructureOnly !== false || sample.infrastructureOnly !== false) { - throw new Error(`deploy-full-stack full output sample should document the standard end-to-end wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket" || sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { - throw new Error(`deploy-full-stack full output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublished !== true || sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`deploy-full-stack full output sample should document the published frontend target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`deploy-full-stack full output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-full-stack full output sample should document REACT_APP_API_BASE from full-stack outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-full-stack full output sample contract run did not receive the expected frontend build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectDeployFullStackPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-full-stack-publish-output.sample.json"); - - withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendPublish((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--skip-infrastructure", - "--publish-frontend-assets", - "--skip-build", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-full-stack publish output sample", actual, sample); - - if (sample.region !== "us-east-1" || sample.environmentName !== "prod") { - throw new Error(`deploy-full-stack publish output sample should document the default region/environment identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.publishFrontendAssets !== true || sample.skipInfrastructure !== true || sample.skipBuild !== true) { - throw new Error(`deploy-full-stack publish output sample should document the publish-only skip-build flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`deploy-full-stack publish output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`deploy-full-stack publish output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublished !== true) { - throw new Error(`deploy-full-stack publish output sample should document a successful publish-only result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - }); -} - function expectDeployAwsPublishBuildOutputSampleMatchesContract() { const sample = readJsonFile("infrastructure/examples/deploy-aws-publish-build-output.sample.json"); @@ -5870,53 +5441,6 @@ function expectDeployAwsPublishBuildOutputSampleMatchesContract() { }); } -function expectDeployFullStackPublishBuildOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-full-stack-publish-build-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--skip-infrastructure", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack publish build output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-full-stack publish build output sample", actual, sample); - expectObjectContainsKeys("deploy-full-stack publish build output sample", actual.frontendEnv || {}, sample.frontendEnv || {}, "frontendEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.skipBuild !== false || sample.publishFrontendAssets !== true || sample.skipInfrastructure !== true) { - throw new Error(`deploy-full-stack publish build output sample should document the build-driven publish-only flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendBucketName !== "example-frontend-bucket" || sample.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`deploy-full-stack publish build output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`deploy-full-stack publish build output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-full-stack publish build output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`deploy-full-stack publish build output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-full-stack publish build output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - function expectError(name, invocation, expectedMessage) { const result = runJsonScript("scripts/validate-aws-deploy.mjs", invocation); if (result.status === 0) { @@ -5965,21 +5489,6 @@ function expectScriptOk(name, scriptPath, invocation) { } } -function withFakeApiRepoWithoutNodeModules(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-api-repo-")); - try { - fs.mkdirSync(path.join(tempDir, "tools"), { recursive: true }); - fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.writeFileSync(path.join(tempDir, "tools", "migrate.ts"), "export {};\n"); - callback(tempDir); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - function withFakePackagableApiRepo(callback, options = {}) { const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-packagable-api-repo-")); @@ -6600,67 +6109,6 @@ process.exit(1); } } -function withFakeAwsForFullStackDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-full-stack-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "s3" && (args[1] === "cp" || args[1] === "sync")) { - process.exit(0); -} -if (args[0] === "cloudfront" && args[1] === "create-invalidation") { - process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "deploy") { - process.exit(0); -} - if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-full-stack") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" }, - { OutputKey: "FrontendBucketName", OutputValue: "example-frontend-bucket" }, - { OutputKey: "FrontendDistributionId", OutputValue: "EXAMPLE123" }, - { OutputKey: "FrontendAppUrl", OutputValue: "https://admin.example.com" }, - { OutputKey: "PublicApiBaseUrl", OutputValue: "https://api.example.com" }, - { OutputKey: "ContentRootUrl", OutputValue: "https://content.example.com" }, - { OutputKey: "WebsiteBaseUrl", OutputValue: "https://{subdomain}.example.com" }, - { OutputKey: "LessonsApiUrl", OutputValue: "https://lessons-api.example.com" }, - { OutputKey: "TransferUrl", OutputValue: "https://transfer.example.com" }, - { OutputKey: "SupportEmail", OutputValue: "support@example.com" }, - { OutputKey: "SupportPhone", OutputValue: "555-555-5555" }, - { OutputKey: "SupportSiteUrl", OutputValue: "https://support.example.com" }, - { OutputKey: "MobileAppUrl", OutputValue: "https://example.com/app" }, - { OutputKey: "DomainCnameTarget", OutputValue: "proxy.example.com" }, - { OutputKey: "DomainATarget", OutputValue: "203.0.113.10" }, - { OutputKey: "DefaultStockPhoto", OutputValue: "https://content.example.com/stockPhotos/default.jpg" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - function withFakeAwsForFrontendDeploy(callback) { const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-frontend-")); const awsPath = path.join(tempDir, "aws"); @@ -7214,124 +6662,6 @@ function expectDeployFrontendSkipBuildIgnoresBackendStack() { }); } -function expectApiMigrationConnectionStringEncoding() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-api-migration-encoding-")); - try { - const secretPath = path.join(tempDir, "database-secret.json"); - fs.writeFileSync(secretPath, `${JSON.stringify({ - username: "church@apps", - password: "p@ss word/with:symbols", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - `--db-secret-file=${secretPath}`, - "--module=all", - "--action=status", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`run-api-migrations dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const membershipConnectionString = result.parsed?.connectionStrings?.MEMBERSHIP_CONNECTION_STRING || ""; - if (!membershipConnectionString.includes("mysql://church%40apps:***@")) { - throw new Error(`Encoded connection string was not redacted/encoded as expected.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectApiMigrationSingleModuleMinimalOutputs() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-api-migration-single-module-")); - try { - const outputsPath = path.join(tempDir, "outputs.json"); - const secretPath = path.join(tempDir, "database-secret.json"); - - fs.writeFileSync(outputsPath, `${JSON.stringify({ - DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", - DatabasePort: "3306", - AttendanceDatabaseName: "attendance", - }, null, 2)}\n`); - fs.writeFileSync(secretPath, `${JSON.stringify({ - username: "churchapps", - password: "replace-me", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - `--outputs-file=${outputsPath}`, - `--db-secret-file=${secretPath}`, - "--module=attendance", - "--action=status", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`single-module migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const keys = Object.keys(result.parsed?.connectionStrings || {}); - const expectedKeys = ["ATTENDANCE_CONNECTION_STRING"]; - if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) { - throw new Error(`single-module migration generated unexpected connection strings: ${keys.join(", ")}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectApiMigrationAllModuleRepoSupportSignal() { - const result = runJsonScript("scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--module=all", - "--action=status", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`all-module migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const effectiveModules = result.parsed?.effectiveModules || []; - const skippedConfiguredModules = result.parsed?.skippedConfiguredModules || []; - if (!Array.isArray(effectiveModules) || effectiveModules.length === 0) { - throw new Error(`all-module migration dry run did not report effective modules.\nSTDOUT:\n${result.stdout}`); - } - if (!skippedConfiguredModules.includes("reporting")) { - throw new Error(`all-module migration dry run did not surface reporting as skipped.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectApiMigrationReportingSupportSignal() { - const result = runJsonScript("scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--module=reporting", - "--action=status", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`reporting migration dry run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const warnings = result.parsed?.warnings || []; - if (!warnings.some((warning) => String(warning).includes("No migration directory exists"))) { - throw new Error(`reporting migration dry run did not surface missing migration directory.\nSTDOUT:\n${result.stdout}`); - } -} - function expectValidatorReportingMigrationNoNextStep() { const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ "--mode=backend", @@ -7374,25 +6704,6 @@ function expectStandaloneValidatorReportingMigrationNoNextStep() { } } -function expectApiMigrationReportingFailsOutsideDryRun() { - const result = runScript("scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--module=reporting", - "--action=status", - ]); - - if (result.status === 0) { - throw new Error(`reporting migration unexpectedly succeeded outside dry-run mode.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("has no tools/migrations/reporting directory")) { - throw new Error(`reporting migration failure did not mention missing migration directory.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } -} - function runCase(name, fn, results) { try { fn(); @@ -7413,7 +6724,6 @@ function main() { "scripts/deploy-frontend.mjs", "scripts/deploy-backend.mjs", "scripts/deploy-aws.mjs", - "scripts/deploy-full-stack.mjs", "scripts/upload-backend-artifact.mjs", "scripts/publish-frontend-assets.mjs", "scripts/package-api-backend.mjs", @@ -7448,7 +6758,7 @@ function main() { "scripts/save-split-stack-outputs.mjs", "scripts/show-deployment-summary.mjs", "scripts/verify-split-stack.mjs", - "scripts/run-api-migrations.mjs", + "scripts/run-api-migrations-data-api.mjs", "scripts/publish-lambda-layer.mjs", "scripts/sync-app-config-secret.mjs", "scripts/sync-github-app-config-secret.mjs", @@ -7464,7 +6774,6 @@ function main() { "infrastructure/cloudformation/bootstrap.yaml", "infrastructure/cloudformation/frontend-site.yaml", "infrastructure/cloudformation/backend-api.yaml", - "infrastructure/cloudformation/full-stack.yaml", ]; const workflowsToParse = [ ".github/workflows/deploy-aws-self-hosted.yml", @@ -7492,18 +6801,12 @@ function main() { "infrastructure/examples/deploy-frontend-output.sample.json", "infrastructure/examples/deploy-frontend-publish-output.sample.json", "infrastructure/examples/dispatch-github-aws-deploy-output.sample.json", - "infrastructure/examples/deploy-full-stack-frontend-infra-output.sample.json", - "infrastructure/examples/deploy-full-stack-full-output.sample.json", - "infrastructure/examples/deploy-full-stack-publish-build-output.sample.json", - "infrastructure/examples/deploy-full-stack-publish-output.sample.json", "infrastructure/examples/frontend-outputs.sample.json", "infrastructure/examples/frontend-parameters.sample.json", - "infrastructure/examples/full-stack-parameters.sample.json", "infrastructure/examples/package-api-backend-output.sample.json", "infrastructure/examples/package-manifest.sample.json", "infrastructure/examples/publish-lambda-layer-output.sample.json", "infrastructure/examples/publish-frontend-output.sample.json", - "infrastructure/examples/run-api-migrations-output.sample.json", "infrastructure/examples/save-split-stack-outputs-output.sample.json", "infrastructure/examples/show-rollout-status-output.sample.json", "infrastructure/examples/sync-app-config-secret-output.sample.json", @@ -7516,9 +6819,6 @@ function main() { "infrastructure/examples/validate-bootstrap-output.sample.json", "infrastructure/examples/validate-frontend-output.sample.json", "infrastructure/examples/validate-frontend-publish-output.sample.json", - "infrastructure/examples/validate-full-stack-frontend-infra-output.sample.json", - "infrastructure/examples/validate-full-stack-output.sample.json", - "infrastructure/examples/validate-full-stack-publish-output.sample.json", "infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json", "infrastructure/examples/validate-split-stack-output.sample.json", "infrastructure/examples/validate-split-stack-publish-output.sample.json", @@ -7554,32 +6854,12 @@ function main() { "--api-repo-path=../Api", "--build-command=definitely-not-a-real-build-command", ], "definitely-not-a-real-build-command"), results); - runCase("run-api-migrations dry run", () => expectScriptOk("run-api-migrations dry run", "scripts/run-api-migrations.mjs", [ - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--module=all", - "--action=status", - "--dry-run=true", - "--output=json", - ]), results); - runCase("run-api-migrations connection string encoding", () => expectApiMigrationConnectionStringEncoding(), results); - runCase("run-api-migrations single-module minimal outputs", () => expectApiMigrationSingleModuleMinimalOutputs(), results); - runCase("run-api-migrations all-module repo support signal", () => expectApiMigrationAllModuleRepoSupportSignal(), results); - runCase("run-api-migrations reporting support signal", () => expectApiMigrationReportingSupportSignal(), results); - runCase("run-api-migrations reporting fails outside dry run", () => expectApiMigrationReportingFailsOutsideDryRun(), results); runCase("validator reporting migration no next step", () => expectValidatorReportingMigrationNoNextStep(), results); runCase("standalone validator reporting migration no next step", () => expectStandaloneValidatorReportingMigrationNoNextStep(), results); } else { addSkippedResults(results, [ "api repo serverless env key coverage", "package-api-backend child failure is clean", - "run-api-migrations dry run", - "run-api-migrations connection string encoding", - "run-api-migrations single-module minimal outputs", - "run-api-migrations all-module repo support signal", - "run-api-migrations reporting support signal", - "run-api-migrations reporting fails outside dry run", "validator reporting migration no next step", "standalone validator reporting migration no next step", ]); @@ -7651,13 +6931,8 @@ function main() { runCase("deploy-aws full output sample matches contract", () => expectDeployAwsFullOutputSampleMatchesContract(), results); runCase("deploy-aws publish output sample matches contract", () => expectDeployAwsPublishOutputSampleMatchesContract(), results); runCase("deploy-aws publish build output sample matches contract", () => expectDeployAwsPublishBuildOutputSampleMatchesContract(), results); - runCase("deploy-full-stack frontend-infrastructure output sample matches contract", () => expectDeployFullStackFrontendInfraOutputSampleMatchesContract(), results); - runCase("deploy-full-stack full output sample matches contract", () => expectDeployFullStackFullOutputSampleMatchesContract(), results); - runCase("deploy-full-stack publish output sample matches contract", () => expectDeployFullStackPublishOutputSampleMatchesContract(), results); - runCase("deploy-full-stack publish build output sample matches contract", () => expectDeployFullStackPublishBuildOutputSampleMatchesContract(), results); runCase("publish-lambda-layer output sample matches contract", () => expectPublishLambdaLayerOutputSampleMatchesContract(), results); runCase("dispatch-github-aws-deploy output sample matches contract", () => expectDispatchGithubAwsDeployOutputSampleMatchesContract(), results); - runCase("run-api-migrations output sample matches contract", () => expectRunApiMigrationsOutputSampleMatchesContract(), results); runCase("sync-app-config-secret output sample matches contract", () => expectSyncAppConfigSecretOutputSampleMatchesContract(), results); runCase("sync-github-app-config-secret output sample matches contract", () => expectSyncGithubAppConfigSecretOutputSampleMatchesContract(), results); runCase("sync-legacy-ssm output sample matches contract", () => expectSyncLegacySsmOutputSampleMatchesContract(), results); @@ -7678,9 +6953,6 @@ function main() { runCase("staging deploy script stops on unreadable api repo", () => expectStagingDeployScriptStopsOnUnreadableApiRepo(), results); runCase("validate-frontend output sample matches contract", () => expectValidateFrontendOutputSampleMatchesContract(), results); runCase("validate-frontend publish output sample matches contract", () => expectValidateFrontendPublishOutputSampleMatchesContract(), results); - runCase("validate-full-stack frontend-infrastructure output sample matches contract", () => expectValidateFullStackFrontendInfraOutputSampleMatchesContract(), results); - runCase("validate-full-stack output sample matches contract", () => expectValidateFullStackOutputSampleMatchesContract(), results); - runCase("validate-full-stack publish output sample matches contract", () => expectValidateFullStackPublishOutputSampleMatchesContract(), results); runCase("validate-split-stack frontend-infrastructure output sample matches contract", () => expectValidateSplitStackFrontendInfraOutputSampleMatchesContract(), results); runCase("validate-split-stack output sample matches contract", () => expectValidateSplitStackOutputSampleMatchesContract(), results); runCase("validate-split-stack publish output sample matches contract", () => expectValidateSplitStackPublishOutputSampleMatchesContract(), results); @@ -7704,7 +6976,6 @@ function main() { ]), results); runCase("validator bootstrap mode respects EnvironmentName", () => expectValidatorBootstrapRespectsEnvironmentName(), results); - runCase("validator full-stack mode respects EnvironmentName", () => expectValidatorFullStackRespectsEnvironmentName(), results); runCase("validator bootstrap mode", () => expectOk("bootstrap mode", [ "--mode=bootstrap", @@ -7723,12 +6994,9 @@ function main() { "--output=json", ]), results); - runCase("validator full-stack mode", () => expectOk("full-stack mode", [ + runCase("validator full-stack mode is removed", () => expectScriptError("validator full-stack mode is removed", "scripts/validate-aws-deploy.mjs", [ "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--template-bucket=my-template-bucket", - "--output=json", - ]), results); + ], "The full-stack deployment mode has been removed"), results); runCase("validator frontend publish mode", () => expectOk("frontend publish mode", [ "--mode=frontend-publish", @@ -7798,15 +7066,6 @@ function main() { "--output=json", ], "cannot be combined with --frontend-infrastructure-only"), results); - runCase("validator full-stack invalid publish combo", () => expectError("full-stack invalid publish combo", [ - "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--template-bucket=my-template-bucket", - "--infrastructure-only", - "--publish-frontend-assets", - "--output=json", - ], "cannot be combined with --infrastructure-only"), results); - runCase("validator frontend invalid skip-build combo", () => expectError("frontend invalid skip-build combo", [ "--mode=frontend", "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", @@ -7875,32 +7134,13 @@ function main() { } runCase("validator unreadable bootstrap stack", () => expectError("unreadable bootstrap stack", [ - "--mode=full-stack", + "--mode=split-stack", "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", "--output=json", ], 'Bootstrap stack "definitely-not-a-real-bootstrap-stack" could not be read'), results); - runCase("validator full-stack publish-only ignores bootstrap stack", () => expectOk("full-stack publish-only ignores bootstrap stack", [ - "--mode=full-stack", - "--stack-name=example-full-stack", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--skip-infrastructure", - "--publish-frontend-assets", - "--output=json", - ]), results); - - runCase("validator full-stack publish-only with outputs files", () => expectOk("full-stack publish-only with outputs files", [ - "--mode=full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - "--skip-infrastructure", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ]), results); - runCase("validator split-stack publish-only ignores bootstrap stack", () => expectOk("split-stack publish-only ignores bootstrap stack", [ "--mode=split-stack", "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", @@ -8023,138 +7263,12 @@ function main() { "--api-migration-dry-run=true", ], 'Invalid api-migration-action "nope"'), results); - runCase("deploy-backend missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { - expectScriptError("deploy-backend missing migration repo dependencies", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--run-api-migrations=true", - `--api-migration-api-repo-path=${fakeApiRepoPath}`, - ], "API migration repo dependencies are not installed"); - }), results); - - runCase("deploy-full-stack missing parameters file", () => expectScriptError("deploy-full-stack missing parameters file", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--parameters-file=does-not-exist.json", - ], 'Could not load parameters file "does-not-exist.json"'), results); - - runCase("deploy-full-stack package manifest file without api repo", () => expectDeployFullStackPackageManifestMissingArtifact(), results); - runCase("deploy-full-stack json includes manifest provenance", () => expectDeployFullStackJsonIncludesManifestProvenance(), results); - runCase("deploy-full-stack package manifest missing migration artifact", () => expectDeployFullStackPackageManifestMissingMigrationArtifact(), results); - - runCase("deploy-full-stack publish-only ignores bootstrap stack", () => withFakeFrontendBuildOutput(() => { - expectScriptError("deploy-full-stack publish-only ignores bootstrap stack", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--skip-infrastructure", - "--publish-frontend-assets", - "--skip-build", - ], 'Could not read full-stack "example-full-stack"'); - }), results); - - runCase("deploy-full-stack publish-only accepts frontend outputs file without stack", () => withFakeFrontendBuildOutput(() => { - expectScriptErrorClean("deploy-full-stack publish-only accepts frontend outputs file without stack", "scripts/deploy-full-stack.mjs", [ - "--skip-infrastructure", - "--publish-frontend-assets", - "--skip-build", - "--frontend-outputs-file=does-not-exist.json", - ], 'Could not load frontend outputs file "does-not-exist.json"'); - }), results); - - runCase("deploy-full-stack publish-only with frontend outputs file works", () => withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendPublish((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--skip-infrastructure", - "--publish-frontend-assets", - "--skip-build", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack publish-only with frontend outputs file failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.frontendBucketName !== "example-frontend-bucket") { - throw new Error(`deploy-full-stack publish-only did not reuse the saved bucket from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`deploy-full-stack publish-only did not reuse the saved distribution from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`deploy-full-stack publish-only did not reuse the saved app URL from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (!parsed.frontendPublished || parsed.frontendEnv === undefined) { - throw new Error(`deploy-full-stack publish-only did not complete the outputs-driven publish follow-up cleanly.\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - runCase("deploy-full-stack publish-only backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-full-stack.mjs", [ - "--skip-infrastructure", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-full-stack publish-only backend outputs build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (parsed.frontendEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-full-stack publish-only did not expose REACT_APP_API_BASE from saved backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { - throw new Error(`deploy-full-stack publish-only did not expose REACT_APP_SUPPORT_EMAIL from saved backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-full-stack publish-only did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - if (siblingApiRepoReadable) { - runCase("deploy-full-stack unsupported reporting migration target", () => expectScriptError("deploy-full-stack unsupported reporting migration target", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--run-api-migrations=true", - "--api-migration-module=reporting", - ], "Refusing to deploy with --run-api-migrations=true for an unsupported direct migration target"), results); - } else { - addSkippedResults(results, ["deploy-full-stack unsupported reporting migration target"]); - } - - runCase("deploy-full-stack invalid migration action", () => expectScriptError("deploy-full-stack invalid migration action", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", + runCase("deploy-backend direct migration runner is removed", () => expectScriptError("deploy-backend direct migration runner is removed", "scripts/deploy-backend.mjs", [ + "--stack-name=example-backend", "--run-api-migrations=true", - "--api-migration-action=nope", + "--api-migration-runner=direct", "--api-migration-dry-run=true", - ], 'Invalid api-migration-action "nope"'), results); - - runCase("deploy-full-stack missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { - expectScriptError("deploy-full-stack missing migration repo dependencies", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--run-api-migrations=true", - `--api-migration-api-repo-path=${fakeApiRepoPath}`, - ], "API migration repo dependencies are not installed"); - }), results); - - runCase("deploy-full-stack missing frontend dependencies", () => withMissingFrontendNodeModules(() => expectScriptError("deploy-full-stack missing frontend dependencies", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--parameters-file=infrastructure/examples/full-stack-parameters.sample.json", - ], "Frontend dependencies are not installed:")), results); + ], 'The "direct" migration runner has been removed'), results); runCase("deploy-aws missing backend parameters file", () => expectScriptError("deploy-aws missing backend parameters file", "scripts/deploy-aws.mjs", [ "--backend-parameters-file=does-not-exist.json", @@ -8324,14 +7438,13 @@ function main() { "--skip-frontend", ], "--run-api-migrations=true requires the backend deploy step"), results); - runCase("deploy-aws missing migration repo dependencies", () => withFakeApiRepoWithoutNodeModules((fakeApiRepoPath) => { - expectScriptError("deploy-aws missing migration repo dependencies", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--run-api-migrations=true", - `--api-migration-api-repo-path=${fakeApiRepoPath}`, - "--skip-frontend", - ], "API migration repo dependencies are not installed"); - }), results); + runCase("deploy-aws direct migration runner is removed", () => expectScriptError("deploy-aws direct migration runner is removed", "scripts/deploy-aws.mjs", [ + "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", + "--run-api-migrations=true", + "--api-migration-runner=direct", + "--api-migration-dry-run=true", + "--skip-frontend", + ], 'The "direct" migration runner has been removed'), results); runCase("deploy-aws missing frontend dependencies", () => withMissingFrontendNodeModules(() => expectScriptError("deploy-aws missing frontend dependencies", "scripts/deploy-aws.mjs", [ "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", @@ -8339,18 +7452,6 @@ function main() { "--skip-backend", ], "Frontend dependencies are not installed:")), results); - runCase("deploy-full-stack invalid publish combo", () => expectScriptError("deploy-full-stack invalid publish combo", "scripts/deploy-full-stack.mjs", [ - "--stack-name=b1admin-prod", - "--publish-frontend-assets", - ], "--publish-frontend-assets is only needed for the later publish-only phase."), results); - - runCase("deploy-full-stack run-api-migrations requires infrastructure phase", () => expectScriptError("deploy-full-stack run-api-migrations requires infrastructure phase", "scripts/deploy-full-stack.mjs", [ - "--stack-name=example-full-stack", - "--run-api-migrations=true", - "--skip-infrastructure", - "--publish-frontend-assets", - ], "--run-api-migrations=true is only supported during the infrastructure deploy phase"), results); - runCase("deploy-aws publish-only missing frontend dependencies is clean", () => withMissingFrontendNodeModules(() => expectScriptErrorClean("deploy-aws publish-only missing frontend dependencies is clean", "scripts/deploy-aws.mjs", [ "--skip-backend", "--skip-frontend", diff --git a/scripts/validate-aws-deploy.mjs b/scripts/validate-aws-deploy.mjs index 431d955a2..75fbc43eb 100644 --- a/scripts/validate-aws-deploy.mjs +++ b/scripts/validate-aws-deploy.mjs @@ -301,7 +301,11 @@ function main() { const frontendParametersFile = getArg("frontend-parameters-file"); const parametersFile = getArg("parameters-file"); const fullStackParametersFile = parametersFile; - const mode = getArg("mode", "full-stack"); + const mode = getArg("mode", "split-stack"); + if (mode === "full-stack") { + console.error("The full-stack deployment mode has been removed from this distribution. Use --mode=split-stack (the guided installer path)."); + process.exit(1); + } const splitStackMode = mode === "split-stack" || mode === "aws"; const frontendPublishMode = mode === "frontend-publish" || mode === "publish-frontend"; const bootstrapMode = mode === "bootstrap"; @@ -982,7 +986,7 @@ function main() { warnings.push("RunMigrations is enabled, but the real Api repo currently exposes CLI migration tooling under tools/migrate.ts rather than a proven Lambda migration handler. Treat MigrationHandler as a custom integration you still need to supply and validate."); } if (backendRelevant && !runApiMigrations) { - info.push("You can also run the real Api repo's CLI migrations after deploy with yarn run:api-migrations or by adding --run-api-migrations=true to deploy:backend / deploy:aws / deploy:full-stack."); + info.push("You can also run the Api migrations after deploy with yarn run:api-migrations or by adding --run-api-migrations=true to deploy:backend / deploy:aws."); } if (backendRelevant && !lambdaNodeOptions) { info.push("No LambdaNodeOptions value is set. That is fine unless you intentionally package observability auto-instrumentation such as Sentry."); From 1e7f0f762f1d086b44d7318be86308b0edc82263 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 16:40:59 -0500 Subject: [PATCH 8/9] Move the smoke harness and example fixtures to a follow-up PR scripts/smoke-aws-tooling.mjs (7,943 lines) and the 40 example output fixtures it checks against now live on the codex/aws-tooling-smoke-harness branch, to be opened as a follow-up PR once the installer PR lands. This keeps the reviewable surface of the main PR to the product itself. Co-Authored-By: Claude Fable 5 --- infrastructure/README.md | 2 +- .../examples/app-config-secret.sample.json | 21 - ...dit-environment-starter-output.sample.json | 341 - .../examples/backend-outputs.sample.json | 14 - .../examples/backend-parameters.sample.json | 81 - .../backend-stack-outputs.sample.json | 13 - .../bootstrap-admin-secret.sample.json | 16 - .../examples/bootstrap-parameters.sample.json | 7 - .../examples/database-secret.sample.json | 4 - ...ploy-aws-frontend-infra-output.sample.json | 62 - .../deploy-aws-full-output.sample.json | 108 - ...eploy-aws-publish-build-output.sample.json | 61 - .../deploy-aws-publish-output.sample.json | 48 - .../deploy-backend-output.sample.json | 36 - .../deploy-bootstrap-output.sample.json | 15 - .../deploy-frontend-output.sample.json | 17 - ...deploy-frontend-publish-output.sample.json | 30 - ...patch-github-aws-deploy-output.sample.json | 38 - .../examples/frontend-outputs.sample.json | 5 - .../examples/frontend-parameters.sample.json | 9 - .../package-api-backend-output.sample.json | 31 - .../examples/package-manifest.sample.json | 31 - ...plan-environment-deploy-output.sample.json | 190 - ...are-environment-starter-output.sample.json | 61 - .../publish-frontend-output.sample.json | 29 - .../publish-lambda-layer-output.sample.json | 18 - ...ave-split-stack-outputs-output.sample.json | 32 - .../show-rollout-status-output.sample.json | 223 - .../sync-app-config-secret-output.sample.json | 6 - ...ithub-app-config-secret-output.sample.json | 12 - .../sync-legacy-ssm-output.sample.json | 41 - ...upload-backend-artifact-output.sample.json | 10 - ...validate-api-migrations-output.sample.json | 69 - .../validate-backend-output.sample.json | 58 - .../validate-bootstrap-output.sample.json | 55 - .../validate-frontend-output.sample.json | 51 - ...lidate-frontend-publish-output.sample.json | 59 - ...it-stack-frontend-infra-output.sample.json | 59 - .../validate-split-stack-output.sample.json | 57 - ...ate-split-stack-publish-output.sample.json | 64 - .../verify-split-stack-output.sample.json | 76 - package.json | 1 - scripts/smoke-aws-tooling.mjs | 7943 ----------------- 43 files changed, 1 insertion(+), 10103 deletions(-) delete mode 100644 infrastructure/examples/app-config-secret.sample.json delete mode 100644 infrastructure/examples/audit-environment-starter-output.sample.json delete mode 100644 infrastructure/examples/backend-outputs.sample.json delete mode 100644 infrastructure/examples/backend-parameters.sample.json delete mode 100644 infrastructure/examples/backend-stack-outputs.sample.json delete mode 100644 infrastructure/examples/bootstrap-admin-secret.sample.json delete mode 100644 infrastructure/examples/bootstrap-parameters.sample.json delete mode 100644 infrastructure/examples/database-secret.sample.json delete mode 100644 infrastructure/examples/deploy-aws-frontend-infra-output.sample.json delete mode 100644 infrastructure/examples/deploy-aws-full-output.sample.json delete mode 100644 infrastructure/examples/deploy-aws-publish-build-output.sample.json delete mode 100644 infrastructure/examples/deploy-aws-publish-output.sample.json delete mode 100644 infrastructure/examples/deploy-backend-output.sample.json delete mode 100644 infrastructure/examples/deploy-bootstrap-output.sample.json delete mode 100644 infrastructure/examples/deploy-frontend-output.sample.json delete mode 100644 infrastructure/examples/deploy-frontend-publish-output.sample.json delete mode 100644 infrastructure/examples/dispatch-github-aws-deploy-output.sample.json delete mode 100644 infrastructure/examples/frontend-outputs.sample.json delete mode 100644 infrastructure/examples/frontend-parameters.sample.json delete mode 100644 infrastructure/examples/package-api-backend-output.sample.json delete mode 100644 infrastructure/examples/package-manifest.sample.json delete mode 100644 infrastructure/examples/plan-environment-deploy-output.sample.json delete mode 100644 infrastructure/examples/prepare-environment-starter-output.sample.json delete mode 100644 infrastructure/examples/publish-frontend-output.sample.json delete mode 100644 infrastructure/examples/publish-lambda-layer-output.sample.json delete mode 100644 infrastructure/examples/save-split-stack-outputs-output.sample.json delete mode 100644 infrastructure/examples/show-rollout-status-output.sample.json delete mode 100644 infrastructure/examples/sync-app-config-secret-output.sample.json delete mode 100644 infrastructure/examples/sync-github-app-config-secret-output.sample.json delete mode 100644 infrastructure/examples/sync-legacy-ssm-output.sample.json delete mode 100644 infrastructure/examples/upload-backend-artifact-output.sample.json delete mode 100644 infrastructure/examples/validate-api-migrations-output.sample.json delete mode 100644 infrastructure/examples/validate-backend-output.sample.json delete mode 100644 infrastructure/examples/validate-bootstrap-output.sample.json delete mode 100644 infrastructure/examples/validate-frontend-output.sample.json delete mode 100644 infrastructure/examples/validate-frontend-publish-output.sample.json delete mode 100644 infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json delete mode 100644 infrastructure/examples/validate-split-stack-output.sample.json delete mode 100644 infrastructure/examples/validate-split-stack-publish-output.sample.json delete mode 100644 infrastructure/examples/verify-split-stack-output.sample.json delete mode 100644 scripts/smoke-aws-tooling.mjs diff --git a/infrastructure/README.md b/infrastructure/README.md index c87197d35..44eea2145 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -44,4 +44,4 @@ See [What Costs Money?](./environments/start-here.md#what-costs-money) for the m - Stack names: `b1admin--bootstrap|backend|frontend`. - All scripts accept `--output=json|markdown|text`, read defaults from `customer-values.json` via `--customer-file`, and are plain Node (no dependencies beyond the AWS/GitHub CLIs). -- The smoke suite (`yarn smoke:aws-tooling`) exercises every script against stubbed `aws`/`gh` binaries. Run it with invalid AWS credentials in the environment so the handful of live-CLI scenarios cannot touch a real account. +- A smoke suite exercising every script against stubbed `aws`/`gh` binaries ships separately on the `codex/aws-tooling-smoke-harness` branch (follow-up PR), together with the example output fixtures it checks against. diff --git a/infrastructure/examples/app-config-secret.sample.json b/infrastructure/examples/app-config-secret.sample.json deleted file mode 100644 index e005932e8..000000000 --- a/infrastructure/examples/app-config-secret.sample.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "jwtSecret": "replace-me", - "encryptionKey": "replace-me", - "hubspotKey": "", - "mauticUrl": "", - "mauticUser": "", - "mauticPassword": "", - "youTubeApiKey": "", - "pexelsKey": "", - "vimeoToken": "", - "apiBibleKey": "", - "youVersionApiKey": "", - "praiseChartsConsumerKey": "", - "praiseChartsConsumerSecret": "", - "googleRecaptchaSecretKey": "", - "openRouterApiKey": "", - "openAiApiKey": "", - "webPushPublicKey": "", - "webPushPrivateKey": "", - "webPushSubject": "mailto:support@example.com" -} diff --git a/infrastructure/examples/audit-environment-starter-output.sample.json b/infrastructure/examples/audit-environment-starter-output.sample.json deleted file mode 100644 index 12d9f87ae..000000000 --- a/infrastructure/examples/audit-environment-starter-output.sample.json +++ /dev/null @@ -1,341 +0,0 @@ -{ - "ok": false, - "environment": "staging", - "onlyBlockers": false, - "environmentDir": "infrastructure/environments/staging", - "summary": { - "placeholderCount": 5, - "unsafeDefaultCount": 10, - "requiredBlankCount": 0, - "optionalBlankCount": 41 - }, - "blockerSummary": { - "placeholderCount": 5, - "unsafeDefaultCount": 10, - "requiredBlankCount": 0, - "blockerCount": 15 - }, - "nextSteps": [ - { - "file": "infrastructure/environments/staging/bootstrap-parameters.json", - "action": "Replace or fill 2 blocker values.", - "keys": [ - "TemplateBucketName", - "ArtifactBucketName" - ] - }, - { - "file": "infrastructure/environments/staging/backend-parameters.json", - "action": "Replace or fill 10 blocker values.", - "keys": [ - "LambdaCodeS3Bucket", - "WebsiteBaseUrl", - "ContentRootUrl", - "B1AdminRootUrl", - "CorsOrigin", - "StoreApiUrl", - "TransferUrl", - "SupportEmail", - "SupportPhone", - "SupportSiteUrl" - ] - }, - { - "file": "infrastructure/environments/staging/app-config-secret.template.json", - "action": "Replace or fill 3 blocker values.", - "keys": [ - "jwtSecret", - "encryptionKey", - "webPushSubject" - ] - } - ], - "suggestions": [ - { - "file": "infrastructure/environments/staging/bootstrap-parameters.json", - "recommendation": "Choose globally unique S3 bucket names for the template and artifact buckets.", - "example": "b1admin-staging-templates- and b1admin-staging-artifacts-" - }, - { - "file": "infrastructure/environments/staging/backend-parameters.json", - "recommendation": "Point LambdaCodeS3Bucket at the same artifact bucket chosen in bootstrap-parameters.json.", - "example": "b1admin-staging-artifacts-" - }, - { - "file": "infrastructure/environments/staging/backend-parameters.json", - "recommendation": "Replace the checked-in starter hostnames and support values with real environment URLs and contact details before the first deploy.", - "example": "B1AdminRootUrl=https://admin-staging.yourdomain.com, CorsOrigin=https://admin-staging.yourdomain.com, SupportEmail=support@yourdomain.com" - }, - { - "file": "infrastructure/environments/staging/app-config-secret.template.json", - "recommendation": "Copy the template to app-config-secret.json, replace jwtSecret and encryptionKey with long random values, and set webPushSubject to the real support mailbox before syncing the secret.", - "example": "cp infrastructure/environments/staging/app-config-secret.template.json infrastructure/environments/staging/app-config-secret.json" - } - ], - "files": [ - { - "fileName": "bootstrap-parameters.json", - "relativePath": "infrastructure/environments/staging/bootstrap-parameters.json", - "placeholders": [ - { - "key": "TemplateBucketName", - "value": "replace-me-b1admin-staging-templates-123456789012" - }, - { - "key": "ArtifactBucketName", - "value": "replace-me-b1admin-staging-artifacts-123456789012" - } - ], - "unsafeDefaults": [], - "requiredBlankValues": [], - "optionalBlankValues": [], - "resolvedBySecretFile": [] - }, - { - "fileName": "backend-parameters.json", - "relativePath": "infrastructure/environments/staging/backend-parameters.json", - "placeholders": [ - { - "key": "LambdaCodeS3Bucket", - "value": "replace-me-b1admin-staging-artifacts-123456789012" - } - ], - "unsafeDefaults": [ - { - "key": "WebsiteBaseUrl", - "value": "https://{subdomain}.example.com" - }, - { - "key": "ContentRootUrl", - "value": "https://content-staging.example.com" - }, - { - "key": "B1AdminRootUrl", - "value": "https://admin-staging.example.com" - }, - { - "key": "CorsOrigin", - "value": "https://admin-staging.example.com" - }, - { - "key": "StoreApiUrl", - "value": "https://store-staging.example.com" - }, - { - "key": "TransferUrl", - "value": "https://transfer-staging.example.com" - }, - { - "key": "SupportEmail", - "value": "support@example.com" - }, - { - "key": "SupportPhone", - "value": "555-555-5555" - }, - { - "key": "SupportSiteUrl", - "value": "https://support.example.com" - } - ], - "requiredBlankValues": [], - "optionalBlankValues": [ - { - "key": "DependenciesLayerArn", - "value": "" - }, - { - "key": "ObservabilityLayerArn", - "value": "" - }, - { - "key": "MigrationCodeS3Bucket", - "value": "" - }, - { - "key": "MigrationCodeS3Key", - "value": "" - }, - { - "key": "MigrationHandler", - "value": "" - }, - { - "key": "MigrationRuntime", - "value": "" - }, - { - "key": "MigrationTrigger", - "value": "" - }, - { - "key": "ApiCustomDomainName", - "value": "" - }, - { - "key": "ApiCertificateArn", - "value": "" - }, - { - "key": "ApiHostedZoneId", - "value": "" - }, - { - "key": "AssetBucketName", - "value": "" - }, - { - "key": "AppConfigSecretArn", - "value": "" - }, - { - "key": "CaddyHost", - "value": "" - }, - { - "key": "CaddyPort", - "value": "" - }, - { - "key": "MobileAppUrl", - "value": "" - }, - { - "key": "DomainCnameTarget", - "value": "" - }, - { - "key": "DomainATarget", - "value": "" - }, - { - "key": "DefaultStockPhoto", - "value": "" - }, - { - "key": "GoogleAnalyticsTag", - "value": "" - }, - { - "key": "SentryDsn", - "value": "" - } - ], - "resolvedBySecretFile": [] - }, - { - "fileName": "frontend-parameters.json", - "relativePath": "infrastructure/environments/staging/frontend-parameters.json", - "placeholders": [], - "unsafeDefaults": [], - "requiredBlankValues": [], - "optionalBlankValues": [ - { - "key": "BucketName", - "value": "" - }, - { - "key": "AlternateDomainName", - "value": "" - }, - { - "key": "AcmCertificateArn", - "value": "" - }, - { - "key": "HostedZoneId", - "value": "" - } - ], - "resolvedBySecretFile": [] - }, - { - "fileName": "app-config-secret.template.json", - "relativePath": "infrastructure/environments/staging/app-config-secret.template.json", - "placeholders": [ - { - "key": "jwtSecret", - "value": "replace-me-long-random-jwt-secret" - }, - { - "key": "encryptionKey", - "value": "replace-me-long-random-encryption-key" - } - ], - "unsafeDefaults": [ - { - "key": "webPushSubject", - "value": "mailto:support@example.com" - } - ], - "requiredBlankValues": [], - "optionalBlankValues": [ - { - "key": "hubspotKey", - "value": "" - }, - { - "key": "mauticUrl", - "value": "" - }, - { - "key": "mauticUser", - "value": "" - }, - { - "key": "mauticPassword", - "value": "" - }, - { - "key": "youTubeApiKey", - "value": "" - }, - { - "key": "pexelsKey", - "value": "" - }, - { - "key": "vimeoToken", - "value": "" - }, - { - "key": "apiBibleKey", - "value": "" - }, - { - "key": "youVersionApiKey", - "value": "" - }, - { - "key": "praiseChartsConsumerKey", - "value": "" - }, - { - "key": "praiseChartsConsumerSecret", - "value": "" - }, - { - "key": "googleRecaptchaSecretKey", - "value": "" - }, - { - "key": "openRouterApiKey", - "value": "" - }, - { - "key": "openAiApiKey", - "value": "" - }, - { - "key": "webPushPublicKey", - "value": "" - }, - { - "key": "webPushPrivateKey", - "value": "" - } - ], - "resolvedBySecretFile": [] - } - ] -} diff --git a/infrastructure/examples/backend-outputs.sample.json b/infrastructure/examples/backend-outputs.sample.json deleted file mode 100644 index 0730ae397..000000000 --- a/infrastructure/examples/backend-outputs.sample.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "ApiBaseUrl": "https://api.example.com", - "ContentRootUrl": "https://content.example.com", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "LessonsApiUrl": "https://lessons-api.example.com", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg" -} diff --git a/infrastructure/examples/backend-parameters.sample.json b/infrastructure/examples/backend-parameters.sample.json deleted file mode 100644 index 5e5b141ac..000000000 --- a/infrastructure/examples/backend-parameters.sample.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "ProjectName": "b1admin", - "EnvironmentName": "prod", - "LambdaCodeS3Bucket": "my-artifacts-bucket", - "LambdaCodeS3Key": "b1admin/backend/api.zip", - "LambdaHandler": "lambda.web", - "LambdaRuntime": "nodejs22.x", - "LambdaArchitecture": "arm64", - "LambdaMemorySize": "1024", - "LambdaTimeout": "30", - "LambdaReservedConcurrency": "0", - "DependenciesLayerArn": "", - "ObservabilityLayerArn": "", - "LambdaNodeOptions": "--import @sentry/aws-serverless/awslambda-auto", - "EnableWebSocketApi": "true", - "SocketLambdaHandler": "lambda.socket", - "SocketLambdaMemorySize": "1024", - "SocketLambdaTimeout": "30", - "EnableScheduledWorkers": "true", - "Timer15MinLambdaHandler": "lambda.timer15Min", - "TimerMidnightLambdaHandler": "lambda.timerMidnight", - "TimerScheduledTasksLambdaHandler": "lambda.timerScheduledTasks", - "TimerWebhooksLambdaHandler": "lambda.timerWebhooks", - "TimerLambdaMemorySize": "256", - "TimerLambdaTimeout": "300", - "RunMigrations": "false", - "MigrationCodeS3Bucket": "", - "MigrationCodeS3Key": "", - "MigrationHandler": "", - "MigrationRuntime": "", - "MigrationMemorySize": "1024", - "MigrationTimeout": "900", - "MigrationTrigger": "", - "DatabaseName": "membership", - "MembershipDatabaseName": "membership", - "AttendanceDatabaseName": "attendance", - "ContentDatabaseName": "content", - "GivingDatabaseName": "giving", - "MessagingDatabaseName": "messaging", - "DoingDatabaseName": "doing", - "ReportingDatabaseName": "reporting", - "DatabaseEngine": "aurora-mysql", - "DatabasePort": "3306", - "DatabaseMasterUsername": "app_admin", - "DatabaseMinCapacity": "0.5", - "DatabaseMaxCapacity": "2", - "ApiCustomDomainName": "api.example.com", - "ApiCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "ApiHostedZoneId": "Z1234567890ABC", - "CreateNatGateway": "true", - "VpcCidr": "10.30.0.0/16", - "PublicSubnet1Cidr": "10.30.0.0/24", - "PublicSubnet2Cidr": "10.30.1.0/24", - "PrivateSubnet1Cidr": "10.30.10.0/24", - "PrivateSubnet2Cidr": "10.30.11.0/24", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "ContentRootUrl": "", - "B1AdminRootUrl": "https://admin.example.com", - "CorsOrigin": "*", - "FileStore": "S3", - "ManageAssetBucket": "true", - "AssetBucketName": "", - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "MailSystem": "SES", - "DeliveryProvider": "aws", - "StoreApiUrl": "https://api.example-store.com", - "AiProvider": "openrouter", - "EmailOnRegistration": "false", - "CaddyHost": "", - "CaddyPort": "", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", - "GoogleAnalyticsTag": "", - "SentryDsn": "" -} diff --git a/infrastructure/examples/backend-stack-outputs.sample.json b/infrastructure/examples/backend-stack-outputs.sample.json deleted file mode 100644 index 47ac2514e..000000000 --- a/infrastructure/examples/backend-stack-outputs.sample.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", - "DatabaseClusterArn": "arn:aws:rds:us-east-1:123456789012:cluster:b1admin-prod-cluster", - "DatabasePort": "3306", - "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", - "MembershipDatabaseName": "membership", - "AttendanceDatabaseName": "attendance", - "ContentDatabaseName": "content", - "GivingDatabaseName": "giving", - "MessagingDatabaseName": "messaging", - "DoingDatabaseName": "doing", - "ReportingDatabaseName": "reporting" -} diff --git a/infrastructure/examples/bootstrap-admin-secret.sample.json b/infrastructure/examples/bootstrap-admin-secret.sample.json deleted file mode 100644 index fc3ba6e90..000000000 --- a/infrastructure/examples/bootstrap-admin-secret.sample.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "email": "admin@example.com", - "password": "ChangeMe123!", - "firstName": "Admin", - "lastName": "User", - "displayName": "Admin User", - "churchName": "Example Church", - "churchSubdomain": "examplechurch", - "address1": "123 Main St", - "address2": "", - "city": "Springfield", - "state": "IL", - "zip": "62701", - "country": "USA", - "membershipStatus": "Staff" -} diff --git a/infrastructure/examples/bootstrap-parameters.sample.json b/infrastructure/examples/bootstrap-parameters.sample.json deleted file mode 100644 index 61aab3f41..000000000 --- a/infrastructure/examples/bootstrap-parameters.sample.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "ProjectName": "b1admin", - "EnvironmentName": "prod", - "TemplateBucketName": "b1admin-prod-templates-123456789012", - "ArtifactBucketName": "b1admin-prod-artifacts-123456789012", - "EnableBucketVersioning": "true" -} diff --git a/infrastructure/examples/database-secret.sample.json b/infrastructure/examples/database-secret.sample.json deleted file mode 100644 index 8c17b2217..000000000 --- a/infrastructure/examples/database-secret.sample.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "username": "churchapps", - "password": "replace-me" -} diff --git a/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json b/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json deleted file mode 100644 index 52902ac06..000000000 --- a/infrastructure/examples/deploy-aws-frontend-infra-output.sample.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "region": "us-east-1", - "environment": "prod", - "projectName": "b1admin", - "bootstrapStackName": "", - "backendStackName": "b1admin-prod-backend", - "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", - "frontendStackName": "example-frontend", - "frontendOutputsFile": "", - "frontendPublishBucket": "", - "frontendPublishDistributionId": "", - "frontendPublishAppUrl": "", - "frontendInfrastructureOnly": true, - "publishFrontendAssets": false, - "skipBackend": true, - "skipFrontend": false, - "skipBuild": false, - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "resolvedArtifactBucket": "", - "resolvedLambdaCodeS3Key": "", - "resolvedMigrationBucket": "", - "resolvedMigrationCodeS3Key": "", - "resolvedDependenciesLayerArn": "", - "resolvedAppConfigSecretArn": "", - "backendArtifactUpload": null, - "migrationArtifactUpload": null, - "backend": null, - "frontend": { - "stackName": "example-frontend", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "skipBuild": false, - "infrastructureOnly": true, - "frontendPublished": false - }, - "frontendPublish": null -} diff --git a/infrastructure/examples/deploy-aws-full-output.sample.json b/infrastructure/examples/deploy-aws-full-output.sample.json deleted file mode 100644 index 7311c1ec0..000000000 --- a/infrastructure/examples/deploy-aws-full-output.sample.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "region": "us-east-1", - "environment": "prod", - "projectName": "b1admin", - "bootstrapStackName": "", - "backendStackName": "example-backend", - "backendOutputsFile": "", - "frontendStackName": "example-frontend", - "frontendOutputsFile": "", - "frontendPublishBucket": "", - "frontendPublishDistributionId": "", - "frontendPublishAppUrl": "", - "frontendInfrastructureOnly": false, - "publishFrontendAssets": false, - "skipBackend": false, - "skipFrontend": false, - "skipBuild": false, - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "resolvedArtifactBucket": "my-artifacts-bucket", - "resolvedLambdaCodeS3Key": "b1admin/prod/backend/api.zip", - "resolvedMigrationBucket": "my-artifacts-bucket", - "resolvedMigrationCodeS3Key": "", - "resolvedDependenciesLayerArn": "", - "resolvedAppConfigSecretArn": "", - "backendArtifactUpload": null, - "migrationArtifactUpload": null, - "backend": { - "stackName": "example-backend", - "region": "us-east-1", - "environmentName": "prod", - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "outputs": { - "ApiBaseUrl": "https://api.example.com", - "ContentRootUrl": "https://content.example.com", - "WebsiteBaseUrl": "https://{subdomain}.example.com", - "LessonsApiUrl": "https://lessons-api.example.com", - "TransferUrl": "https://transfer.example.com", - "SupportEmail": "support@example.com", - "SupportPhone": "555-555-5555", - "SupportSiteUrl": "https://support.example.com", - "MobileAppUrl": "https://example.com/app", - "DomainCnameTarget": "proxy.example.com", - "DomainATarget": "203.0.113.10", - "DefaultStockPhoto": "https://content.example.com/stockPhotos/default.jpg", - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", - "DatabasePort": "3306", - "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", - "MembershipDatabaseName": "membership", - "AttendanceDatabaseName": "attendance", - "ContentDatabaseName": "content", - "GivingDatabaseName": "giving", - "MessagingDatabaseName": "messaging", - "DoingDatabaseName": "doing", - "ReportingDatabaseName": "reporting" - }, - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "lambdaCodeS3Bucket": "my-artifacts-bucket", - "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", - "migrationCodeS3Bucket": "", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "templateBucket": "", - "apiMigrationRunner": "direct", - "runBootstrapAdmin": false, - "bootstrapAdmin": null - }, - "frontend": { - "stackName": "example-frontend", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "skipBuild": false, - "infrastructureOnly": false, - "frontendPublished": true - }, - "frontendPublish": null -} diff --git a/infrastructure/examples/deploy-aws-publish-build-output.sample.json b/infrastructure/examples/deploy-aws-publish-build-output.sample.json deleted file mode 100644 index cdb6548c7..000000000 --- a/infrastructure/examples/deploy-aws-publish-build-output.sample.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "region": "us-east-1", - "environment": "prod", - "projectName": "b1admin", - "bootstrapStackName": "", - "backendStackName": "b1admin-prod-backend", - "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", - "frontendStackName": "b1admin-prod-frontend", - "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", - "frontendPublishBucket": "", - "frontendPublishDistributionId": "", - "frontendPublishAppUrl": "", - "frontendInfrastructureOnly": false, - "publishFrontendAssets": true, - "skipBackend": true, - "skipFrontend": true, - "skipBuild": false, - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "resolvedArtifactBucket": "", - "resolvedLambdaCodeS3Key": "", - "resolvedMigrationBucket": "", - "resolvedMigrationCodeS3Key": "", - "resolvedDependenciesLayerArn": "", - "resolvedAppConfigSecretArn": "", - "backendArtifactUpload": null, - "migrationArtifactUpload": null, - "backend": null, - "frontend": null, - "frontendPublish": { - "stackName": "", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "skipBuild": false, - "frontendPublished": true - } -} diff --git a/infrastructure/examples/deploy-aws-publish-output.sample.json b/infrastructure/examples/deploy-aws-publish-output.sample.json deleted file mode 100644 index 708d59009..000000000 --- a/infrastructure/examples/deploy-aws-publish-output.sample.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "region": "us-east-1", - "environment": "prod", - "projectName": "b1admin", - "bootstrapStackName": "", - "backendStackName": "b1admin-prod-backend", - "backendOutputsFile": "", - "frontendStackName": "b1admin-prod-frontend", - "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", - "frontendPublishBucket": "", - "frontendPublishDistributionId": "", - "frontendPublishAppUrl": "", - "frontendInfrastructureOnly": false, - "publishFrontendAssets": true, - "skipBackend": true, - "skipFrontend": true, - "skipBuild": true, - "resolvedPackageManifestFile": "", - "resolvedBackendArtifactSourceFile": "", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "resolvedArtifactBucket": "", - "resolvedLambdaCodeS3Key": "", - "resolvedMigrationBucket": "", - "resolvedMigrationCodeS3Key": "", - "resolvedDependenciesLayerArn": "", - "resolvedAppConfigSecretArn": "", - "backendArtifactUpload": null, - "migrationArtifactUpload": null, - "backend": null, - "frontend": null, - "frontendPublish": { - "stackName": "", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": {}, - "skipBuild": true, - "frontendPublished": true - } -} diff --git a/infrastructure/examples/deploy-backend-output.sample.json b/infrastructure/examples/deploy-backend-output.sample.json deleted file mode 100644 index 1b9ff65e9..000000000 --- a/infrastructure/examples/deploy-backend-output.sample.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "stackName": "example-backend", - "region": "us-east-1", - "environmentName": "prod", - "resolvedPackageManifestFile": "infrastructure/examples/package-manifest.sample.json", - "resolvedBackendArtifactSourceFile": "/api-prod-self-contained.zip", - "resolvedMigrationArtifactSourceFile": "", - "resolvedDependenciesLayerSourceFile": "", - "outputs": { - "ApiBaseUrl": "https://api.example.com", - "AppConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "DatabaseEndpoint": "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com", - "DatabasePort": "3306", - "DatabaseSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123", - "MembershipDatabaseName": "membership", - "AttendanceDatabaseName": "attendance", - "ContentDatabaseName": "content", - "GivingDatabaseName": "giving", - "MessagingDatabaseName": "messaging", - "DoingDatabaseName": "doing", - "ReportingDatabaseName": "reporting" - }, - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "lambdaCodeS3Bucket": "my-artifacts-bucket", - "lambdaCodeS3Key": "b1admin/prod/backend/api.zip", - "migrationCodeS3Bucket": "", - "migrationCodeS3Key": "", - "dependenciesLayerArn": "", - "syncLegacySsm": false, - "runApiMigrations": false, - "apiMigrations": null, - "templateBucket": "", - "apiMigrationRunner": "direct", - "runBootstrapAdmin": false, - "bootstrapAdmin": null -} diff --git a/infrastructure/examples/deploy-bootstrap-output.sample.json b/infrastructure/examples/deploy-bootstrap-output.sample.json deleted file mode 100644 index 042be26ae..000000000 --- a/infrastructure/examples/deploy-bootstrap-output.sample.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "stackName": "example-bootstrap", - "region": "us-east-1", - "parameters": { - "ProjectName": "b1admin", - "EnvironmentName": "prod", - "TemplateBucketName": "b1admin-prod-templates-123456789012", - "ArtifactBucketName": "b1admin-prod-artifacts-123456789012", - "EnableBucketVersioning": "true" - }, - "outputs": { - "TemplateBucketName": "b1admin-prod-templates-123456789012", - "ArtifactBucketName": "b1admin-prod-artifacts-123456789012" - } -} diff --git a/infrastructure/examples/deploy-frontend-output.sample.json b/infrastructure/examples/deploy-frontend-output.sample.json deleted file mode 100644 index 6b2c55e6e..000000000 --- a/infrastructure/examples/deploy-frontend-output.sample.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "stackName": "example-frontend", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": {}, - "skipBuild": false, - "infrastructureOnly": true, - "frontendPublished": false -} diff --git a/infrastructure/examples/deploy-frontend-publish-output.sample.json b/infrastructure/examples/deploy-frontend-publish-output.sample.json deleted file mode 100644 index 6ed684c16..000000000 --- a/infrastructure/examples/deploy-frontend-publish-output.sample.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "stackName": "example-frontend", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "skipBuild": false, - "infrastructureOnly": false, - "frontendPublished": true -} diff --git a/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json b/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json deleted file mode 100644 index d4be393c1..000000000 --- a/infrastructure/examples/dispatch-github-aws-deploy-output.sample.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "ok": true, - "action": "validated", - "environment": "staging", - "workflowEnvironmentName": "aws-staging", - "deploymentSource": "package-manifest", - "previewOnly": false, - "syncAppConfigSecret": true, - "secretSync": { - "attempted": true, - "performed": false, - "command": "yarn sync:github-app-config-secret -- --environment=staging --secret-file=.tmp-dispatch-github-deploy-env/app-config-secret.json" - }, - "dispatchCommand": "gh workflow run deploy-aws-self-hosted.yml --repo 'ChurchApps/B1Admin' -f environment='staging' -f aws_region='us-east-1' -f deployment_source='package-manifest' -f api_repo='' -f api_ref='' -f package_manifest_file='.tmp-dispatch-github-deploy-env/package-manifest.json' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='true' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='false'", - "followUpCommands": { - "listRuns": "gh run list --workflow deploy-aws-self-hosted.yml --limit 5 --repo 'ChurchApps/B1Admin'", - "watchLatestRun": "gh run watch $(gh run list --workflow deploy-aws-self-hosted.yml --limit 1 --json databaseId --jq '.[0].databaseId' --repo 'ChurchApps/B1Admin') --compact --exit-status --repo 'ChurchApps/B1Admin'", - "viewLatestRun": "gh run view $(gh run list --workflow deploy-aws-self-hosted.yml --limit 1 --json databaseId --jq '.[0].databaseId' --repo 'ChurchApps/B1Admin') --repo 'ChurchApps/B1Admin'" - }, - "workflowInputs": { - "environment": "staging", - "aws_region": "us-east-1", - "deployment_source": "package-manifest", - "api_repo": "", - "api_ref": "", - "package_manifest_file": ".tmp-dispatch-github-deploy-env/package-manifest.json", - "backend_artifact_source_file": "", - "migration_artifact_source_file": "", - "dependencies_layer_source_file": "", - "sync_app_config_secret": "true", - "run_api_migrations": "false", - "api_migration_action": "up", - "api_migration_module": "all", - "verify_http_after_deploy": "false", - "preview_only": "false" - }, - "blockers": [] -} diff --git a/infrastructure/examples/frontend-outputs.sample.json b/infrastructure/examples/frontend-outputs.sample.json deleted file mode 100644 index 07640c9a4..000000000 --- a/infrastructure/examples/frontend-outputs.sample.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" -} diff --git a/infrastructure/examples/frontend-parameters.sample.json b/infrastructure/examples/frontend-parameters.sample.json deleted file mode 100644 index 5efdffcd5..000000000 --- a/infrastructure/examples/frontend-parameters.sample.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ProjectName": "b1admin", - "EnvironmentName": "prod", - "BucketName": "", - "AlternateDomainName": "admin.example.com", - "AcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "HostedZoneId": "Z1234567890ABC", - "PriceClass": "PriceClass_100" -} diff --git a/infrastructure/examples/package-api-backend-output.sample.json b/infrastructure/examples/package-api-backend-output.sample.json deleted file mode 100644 index 5987e9654..000000000 --- a/infrastructure/examples/package-api-backend-output.sample.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "apiRepoPath": "", - "projectName": "b1admin", - "packageMode": "self-contained", - "environment": "prod", - "build": false, - "buildCommand": "build:prod", - "buildLayer": false, - "buildLayerCommand": "", - "backendArtifactPath": "api-prod-self-contained.zip", - "migrationArtifactPath": "", - "dependenciesLayerArtifactPath": "", - "manifestPath": "infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "recommendedBackendArtifactKey": "b1admin/prod/backend/api.zip", - "recommendedMigrationArtifactKey": "b1admin/prod/backend/migrations.zip", - "recommendedNextSteps": { - "uploadBackendArtifact": "yarn upload:backend-artifact -- --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", - "uploadMigrationArtifact": "", - "publishDependenciesLayer": "", - "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployMode": "Use the resulting backend zip directly with deploy:backend or deploy:aws." - }, - "includedBackendEntries": [ - "config", - "dist", - "lambda.js", - "package.json", - "node_modules" - ] -} diff --git a/infrastructure/examples/package-manifest.sample.json b/infrastructure/examples/package-manifest.sample.json deleted file mode 100644 index 3320c3456..000000000 --- a/infrastructure/examples/package-manifest.sample.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "apiRepoPath": "", - "projectName": "b1admin", - "packageMode": "self-contained", - "environment": "prod", - "build": true, - "buildCommand": "build:prod", - "buildLayer": false, - "buildLayerCommand": "", - "backendArtifactPath": "api-prod-self-contained.zip", - "migrationArtifactPath": "", - "dependenciesLayerArtifactPath": "", - "manifestPath": "package-manifest.sample.json", - "recommendedBackendArtifactKey": "b1admin/prod/backend/api.zip", - "recommendedMigrationArtifactKey": "b1admin/prod/backend/migrations.zip", - "recommendedNextSteps": { - "uploadBackendArtifact": "yarn upload:backend-artifact -- --source-file=infrastructure/artifacts/api/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", - "uploadMigrationArtifact": "", - "publishDependenciesLayer": "", - "deployBackend": "yarn deploy:backend -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployAws": "yarn deploy:aws -- --package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json", - "deployMode": "Use the resulting backend zip directly with deploy:backend or deploy:aws." - }, - "includedBackendEntries": [ - "config", - "dist", - "lambda.js", - "package.json", - "node_modules" - ] -} diff --git a/infrastructure/examples/plan-environment-deploy-output.sample.json b/infrastructure/examples/plan-environment-deploy-output.sample.json deleted file mode 100644 index ff97a9f55..000000000 --- a/infrastructure/examples/plan-environment-deploy-output.sample.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "ok": false, - "region": "us-east-1", - "environment": "staging", - "projectName": "b1admin", - "environmentDir": "infrastructure/environments/staging", - "environmentDirArg": "", - "deploymentSource": "api-repo", - "githubAuthMode": "oidc", - "workflowEnvironmentName": "aws-staging", - "appConfigSecretFilePresent": false, - "stackNames": { - "bootstrap": "b1admin-staging-bootstrap", - "backend": "b1admin-staging-backend", - "frontend": "b1admin-staging-frontend" - }, - "starterSummary": { - "placeholderCount": 5, - "unsafeDefaultCount": 10, - "requiredBlankCount": 0, - "blockerCount": 15 - }, - "inputBlockers": [], - "warnings": [ - "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", - "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." - ], - "blockers": [ - { - "type": "starter-file", - "file": "infrastructure/environments/staging/bootstrap-parameters.json", - "keys": [ - "TemplateBucketName", - "ArtifactBucketName" - ], - "summary": "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName" - }, - { - "type": "starter-file", - "file": "infrastructure/environments/staging/backend-parameters.json", - "keys": [ - "LambdaCodeS3Bucket", - "WebsiteBaseUrl", - "ContentRootUrl", - "B1AdminRootUrl", - "CorsOrigin", - "StoreApiUrl", - "TransferUrl", - "SupportEmail", - "SupportPhone", - "SupportSiteUrl" - ], - "summary": "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl" - }, - { - "type": "starter-file", - "file": "infrastructure/environments/staging/app-config-secret.template.json", - "keys": [ - "jwtSecret", - "encryptionKey", - "webPushSubject" - ], - "summary": "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" - } - ], - "localExecution": { - "ok": false, - "blockerCount": 3, - "blockers": [ - "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", - "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", - "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" - ] - }, - "githubActionsExecution": { - "ok": false, - "blockerCount": 3, - "blockers": [ - "Resolve 2 blocker values in infrastructure/environments/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", - "Resolve 10 blocker values in infrastructure/environments/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", - "Resolve 3 blocker values in infrastructure/environments/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" - ] - }, - "localGithubDispatch": { - "ok": false, - "blockerCount": 1, - "blockers": [ - "GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again." - ] - }, - "localEnv": { - "AWS_REGION": "us-east-1", - "SYNC_APP_CONFIG_SECRET": "false", - "RUN_API_MIGRATIONS": "false", - "VERIFY_HTTP_AFTER_DEPLOY": "false", - "API_REPO_PATH": "." - }, - "workflowInputs": { - "environment": "staging", - "aws_region": "us-east-1", - "deployment_source": "api-repo", - "api_repo": "ChurchApps/Api", - "api_ref": "main", - "package_manifest_file": "", - "backend_artifact_source_file": "", - "migration_artifact_source_file": "", - "dependencies_layer_source_file": "", - "sync_app_config_secret": "false", - "run_api_migrations": "false", - "api_migration_action": "up", - "api_migration_module": "all", - "verify_http_after_deploy": "false" - }, - "requiredGithubSecrets": [ - "AWS_ROLE_TO_ASSUME" - ], - "optionalGithubSecrets": [ - "API_REPO_CHECKOUT_TOKEN" - ], - "commands": { - "audit": "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "localPreview": "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", - "local": "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' ./infrastructure/environments/staging/deploy-split-stack.sh", - "githubActionsWrapperPreview": "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --preview-only=true", - "githubActionsPreview": "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='true'", - "githubActions": "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" - }, - "starterPrepCommands": { - "dryRun": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "commands": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", - "markdown": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=markdown", - "write": "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true" - }, - "localFallbackCommands": null, - "nextSteps": [ - "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", - "Save the backend and frontend outputs after staging so prod can reuse the proven values.", - "Decide whether app-config-secret should be synced on the first live run or introduced later." - ], - "recommendedExecution": { - "path": "none", - "reason": "Shared starter or input blockers still exist, so neither the local path nor the GitHub Actions path is ready yet." - }, - "recommendedCommands": { - "primary": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "alternates": [ - "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", - "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", - "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --preview-only=true", - "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false' -f preview_only='true'", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='.' ./infrastructure/environments/staging/deploy-split-stack.sh", - "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" - ] - }, - "preflightCommands": { - "auditStarter": "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "auditApiRepoContract": "yarn audit:api-repo-contract -- --api-repo-path=. --output=markdown" - }, - "postDeployCommands": { - "verify": "yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend", - "verifyWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend --check-http=true", - "saveOutputsWithHelper": "yarn save:split-stack-outputs -- --environment=staging --region=us-east-1", - "showSavedSummary": "yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown", - "ensureOutputsDir": "mkdir -p deployment/staging", - "saveBackendOutputs": "mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-backend --region us-east-1 --output json > deployment/staging/backend-outputs.json", - "saveFrontendOutputs": "mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-frontend --region us-east-1 --output json > deployment/staging/frontend-outputs.json", - "verifyFromSavedOutputs": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json", - "verifyFromSavedOutputsWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json --check-http=true", - "publishFromSavedOutputs": "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json --skip-backend --skip-frontend --publish-frontend-assets", - "publishFrontendAssetsFromSavedOutputs": "yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json", - "checklist": "Open infrastructure/environments/first-rollout-checklist.md and work through the post-deploy checks." - }, - "githubPostDeploy": { - "artifactName": "aws-staging-deployment-evidence", - "artifactPath": "deployment/staging/", - "failureArtifactName": "aws-staging-preflight-plan", - "failureArtifactPath": "deployment/staging/preflight-plan.md", - "summaryIncludes": [ - "preflight deploy plan", - "resolved stack names", - "API base URL", - "frontend app URL", - "saved-output follow-up commands" - ], - "note": "After a successful GitHub Actions run, review the job summary for the preflight plan plus resolved values and download the deployment-evidence artifact if you need the saved output files outside the runner. If the deploy step fails earlier, GitHub still uploads the preflight-plan artifact so the computed blocker list is recoverable." - }, - "githubSecretSyncCommand": "" -} diff --git a/infrastructure/examples/prepare-environment-starter-output.sample.json b/infrastructure/examples/prepare-environment-starter-output.sample.json deleted file mode 100644 index 67e0d46fd..000000000 --- a/infrastructure/examples/prepare-environment-starter-output.sample.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "ok": true, - "environment": "staging", - "write": false, - "writeSecretFile": true, - "accountId": "123456789012", - "generatedSecrets": true, - "usedExistingSecretFile": false, - "recommendedCommands": [ - "yarn prepare:environment-starter -- --environment=staging --account-id=123456789012 --write=true", - "yarn audit:environment-starter -- --environment=staging --only-blockers=true", - "yarn validate:aws-deploy -- --mode=bootstrap --region=us-east-1 --stack-name=b1admin-staging-bootstrap --parameters-file=infrastructure/environments/staging/bootstrap-parameters.json", - "yarn validate:aws-deploy -- --mode=split-stack --region=us-east-1 --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json", - "./infrastructure/environments/staging/deploy-split-stack.sh" - ], - "changes": [ - { - "file": "infrastructure/environments/staging/bootstrap-parameters.json", - "key": "TemplateBucketName", - "currentValue": "replace-me-b1admin-staging-templates-123456789012", - "nextValue": "b1admin-staging-templates-123456789012" - }, - { - "file": "infrastructure/environments/staging/bootstrap-parameters.json", - "key": "ArtifactBucketName", - "currentValue": "replace-me-b1admin-staging-artifacts-123456789012", - "nextValue": "b1admin-staging-artifacts-123456789012" - }, - { - "file": "infrastructure/environments/staging/backend-parameters.json", - "key": "LambdaCodeS3Bucket", - "currentValue": "replace-me-b1admin-staging-artifacts-123456789012", - "nextValue": "b1admin-staging-artifacts-123456789012" - }, - { - "file": "infrastructure/environments/staging/app-config-secret.json", - "key": "app-config-secret.json", - "currentValue": "", - "nextValue": "will be created from template" - }, - { - "file": "infrastructure/environments/staging/app-config-secret.json", - "key": "jwtSecret", - "currentValue": "replace-me-long-random-jwt-secret", - "nextValue": "" - }, - { - "file": "infrastructure/environments/staging/app-config-secret.json", - "key": "encryptionKey", - "currentValue": "replace-me-long-random-encryption-key", - "nextValue": "" - } - ], - "nextSteps": [ - "Review the proposed bucket values written to infrastructure/environments/staging/bootstrap-parameters.json and infrastructure/environments/staging/backend-parameters.json.", - "If your backend URLs mostly follow a shared DNS pattern, re-run with --root-domain= to derive the common admin/content/store/transfer values automatically.", - "If you want to prep optional custom-domain fields too, re-run with --frontend-domain/--frontend-certificate-arn/--frontend-hosted-zone-id and --api-domain/--api-certificate-arn/--api-hosted-zone-id.", - "If you already know the real staging/prod URLs and support contact values, re-run with --admin-root-url, --cors-origin, --content-root-url, --store-api-url, --transfer-url, --support-email, --support-phone, and --support-site-url to clear those starter defaults too.", - "Re-run with --write=true to apply these starter-file changes and create infrastructure/environments/staging/app-config-secret.json." - ] -} diff --git a/infrastructure/examples/publish-frontend-output.sample.json b/infrastructure/examples/publish-frontend-output.sample.json deleted file mode 100644 index d9128ff7a..000000000 --- a/infrastructure/examples/publish-frontend-output.sample.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "stackName": "", - "region": "us-east-1", - "environmentName": "prod", - "bucket": "example-frontend-bucket", - "distributionId": "EXAMPLE123", - "appUrl": "https://admin.example.com", - "outputs": { - "SiteBucketName": "example-frontend-bucket", - "CloudFrontDistributionId": "EXAMPLE123", - "AppUrl": "https://admin.example.com" - }, - "backendBuildEnv": { - "REACT_APP_API_BASE": "https://api.example.com", - "REACT_APP_CONTENT_ROOT": "https://content.example.com", - "REACT_APP_B1_WEBSITE_URL": "https://{subdomain}.example.com", - "REACT_APP_LESSONS_API": "https://lessons-api.example.com", - "REACT_APP_TRANSFER_URL": "https://transfer.example.com", - "REACT_APP_SUPPORT_EMAIL": "support@example.com", - "REACT_APP_SUPPORT_PHONE": "555-555-5555", - "REACT_APP_SUPPORT_SITE_URL": "https://support.example.com", - "REACT_APP_MOBILE_APP_URL": "https://example.com/app", - "REACT_APP_DOMAIN_CNAME_TARGET": "proxy.example.com", - "REACT_APP_DOMAIN_A_TARGET": "203.0.113.10", - "REACT_APP_DEFAULT_STOCK_PHOTO": "https://content.example.com/stockPhotos/default.jpg" - }, - "skipBuild": false, - "frontendPublished": true -} diff --git a/infrastructure/examples/publish-lambda-layer-output.sample.json b/infrastructure/examples/publish-lambda-layer-output.sample.json deleted file mode 100644 index a6dfc3a8e..000000000 --- a/infrastructure/examples/publish-lambda-layer-output.sample.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "Content": { - "Location": "https://lambda.us-east-1.amazonaws.com/2018-10-31/layers/b1admin-prod-dependencies/versions/3", - "CodeSha256": "examplecodesha256value=", - "CodeSize": 12345 - }, - "LayerArn": "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies", - "LayerVersionArn": "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3", - "Description": "Published by B1Admin AWS deployment tooling", - "CreatedDate": "2026-01-15T12:34:56.000+0000", - "Version": 3, - "CompatibleRuntimes": [ - "nodejs22.x" - ], - "CompatibleArchitectures": [ - "arm64" - ] -} diff --git a/infrastructure/examples/save-split-stack-outputs-output.sample.json b/infrastructure/examples/save-split-stack-outputs-output.sample.json deleted file mode 100644 index 1b9c1b922..000000000 --- a/infrastructure/examples/save-split-stack-outputs-output.sample.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "ok": true, - "environment": "staging", - "projectName": "b1admin", - "environmentName": "staging", - "region": "us-east-1", - "stackNames": { - "backend": "b1admin-staging-backend", - "frontend": "b1admin-staging-frontend" - }, - "outputDir": ".tmp-save-split-stack-contract", - "files": { - "backendOutputsFile": ".tmp-save-split-stack-contract/backend-outputs.json", - "frontendOutputsFile": ".tmp-save-split-stack-contract/frontend-outputs.json", - "summaryFile": ".tmp-save-split-stack-contract/deployment-summary.json", - "preflightPlanFile": ".tmp-save-split-stack-contract/preflight-plan.md" - }, - "resolved": { - "apiBaseUrl": "https://api.example.com", - "frontendAppUrl": "https://admin.example.com", - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" - }, - "followUpCommands": { - "showDeploymentSummary": "yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown", - "verifyFromSavedOutputs": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json", - "verifyFromSavedOutputsWithHttp": "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --check-http=true", - "publishFrontendAssetsFromSavedOutputs": "yarn publish:frontend-assets -- --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json", - "publishFromSavedOutputs": "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json --backend-parameters-file=infrastructure/environments/staging/backend-parameters.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --skip-backend --skip-frontend --publish-frontend-assets" - } -} diff --git a/infrastructure/examples/show-rollout-status-output.sample.json b/infrastructure/examples/show-rollout-status-output.sample.json deleted file mode 100644 index 1bf5883a0..000000000 --- a/infrastructure/examples/show-rollout-status-output.sample.json +++ /dev/null @@ -1,223 +0,0 @@ -{ - "ok": false, - "deploymentIntent": "all", - "ignoredBlockerCategories": [], - "environmentCount": 2, - "readyEnvironmentCount": 0, - "blockedEnvironmentCount": 2, - "readyEnvironments": [], - "blockedEnvironments": [ - "staging", - "prod" - ], - "recommendedNextCommand": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "commandSummary": { - "global": [ - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json" - ], - "all": [ - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", - "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", - "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", - "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", - "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" - ], - "byEnvironment": { - "staging": [ - "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", - "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", - "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" - ], - "prod": [ - "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", - "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" - ] - } - }, - "blockerCategories": { - "starterOrInput": { - "environmentCount": 1, - "environments": [ - "staging" - ] - }, - "localExecution": { - "environmentCount": 2, - "environments": [ - "staging", - "prod" - ] - }, - "githubActionsExecution": { - "environmentCount": 2, - "environments": [ - "staging", - "prod" - ] - }, - "localGithubDispatch": { - "environmentCount": 0, - "environments": [] - } - }, - "intentBlockerCategories": { - "starterOrInput": { - "environmentCount": 1, - "environments": [ - "staging" - ] - }, - "localExecution": { - "environmentCount": 2, - "environments": [ - "staging", - "prod" - ] - }, - "githubActionsExecution": { - "environmentCount": 2, - "environments": [ - "staging", - "prod" - ] - }, - "localGithubDispatch": { - "environmentCount": 0, - "environments": [] - } - }, - "overallHighlightedBlockers": [ - "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", - "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", - "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject", - "Local api-repo path is not readable from this workspace: ../Api", - "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact.", - "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." - ], - "recommendedNextSteps": [ - "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", - "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper." - ], - "environments": [ - { - "ok": true, - "environment": "staging", - "status": "blocked", - "githubFocusedStatus": "blocked", - "recommendedPath": "none", - "recommendedReason": "Shared starter or input blockers still exist, so neither the local path nor the GitHub Actions path is ready yet.", - "primaryCommand": "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "alternateCommands": [ - "yarn audit:environment-starter -- --environment=staging --only-blockers=true --output=markdown", - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=commands", - "yarn prepare:environment-starter -- --environment=staging --account-id= --write=true", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/staging/deploy-split-stack.sh", - "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='staging' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'" - ], - "starterBlockerCount": 15, - "inputBlockerCount": 0, - "starterAndInputBlockerCount": 15, - "localExecutionOk": false, - "localExecutionBlockerCount": 5, - "githubActionsExecutionOk": false, - "githubActionsExecutionBlockerCount": 3, - "localGithubDispatchOk": true, - "localGithubDispatchBlockerCount": 0, - "appConfigSecretFilePresent": false, - "warnings": [ - "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", - "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." - ], - "highlightedBlockers": [ - "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", - "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", - "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject", - "Local api-repo path is not readable from this workspace: ../Api", - "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact." - ], - "githubFocusedHighlightedBlockers": [ - "Resolve 2 blocker values in .tmp-rollout-status-sample-env/staging/bootstrap-parameters.json: TemplateBucketName, ArtifactBucketName", - "Resolve 10 blocker values in .tmp-rollout-status-sample-env/staging/backend-parameters.json: LambdaCodeS3Bucket, WebsiteBaseUrl, ContentRootUrl, B1AdminRootUrl, CorsOrigin, StoreApiUrl, TransferUrl, SupportEmail, SupportPhone, SupportSiteUrl", - "Resolve 3 blocker values in .tmp-rollout-status-sample-env/staging/app-config-secret.template.json: jwtSecret, encryptionKey, webPushSubject" - ], - "nextSteps": [ - "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", - "If the local Api repo is unreadable here, switch the local run to package-manifest or backend-artifact mode with the fallback commands below.", - "Save the backend and frontend outputs after staging so prod can reuse the proven values.", - "Decide whether app-config-secret should be synced on the first live run or introduced later." - ], - "githubFocusedNextSteps": [ - "Run the starter prep helper, review the proposed file changes, and then clear any remaining blocker values before the first live deployment.", - "Save the backend and frontend outputs after staging so prod can reuse the proven values.", - "Decide whether app-config-secret should be synced on the first live run or introduced later." - ] - }, - { - "ok": true, - "environment": "prod", - "status": "blocked", - "githubFocusedStatus": "blocked", - "recommendedPath": "none", - "recommendedReason": "Execution-specific blockers still need to be cleared before running a deploy.", - "primaryCommand": "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json", - "alternateCommands": [ - "yarn dispatch:github-aws-deploy -- --environment=prod --deployment-source=api-repo --region=us-east-1", - "gh workflow run deploy-aws-self-hosted.yml -f environment='prod' -f aws_region='us-east-1' -f deployment_source='api-repo' -f api_repo='ChurchApps/Api' -f api_ref='main' -f package_manifest_file='' -f backend_artifact_source_file='' -f migration_artifact_source_file='' -f dependencies_layer_source_file='' -f sync_app_config_secret='false' -f run_api_migrations='false' -f api_migration_action='up' -f api_migration_module='all' -f verify_http_after_deploy='false'", - "AWS_REGION='us-east-1' SYNC_APP_CONFIG_SECRET='false' RUN_API_MIGRATIONS='false' VERIFY_HTTP_AFTER_DEPLOY='false' API_REPO_PATH='../Api' ./infrastructure/environments/prod/deploy-split-stack.sh", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=package-manifest --package-manifest-file= --region=us-east-1 --output=markdown", - "yarn plan:environment-deploy -- --environment=prod --deployment-source=backend-artifact --backend-artifact-source-file= --region=us-east-1 --output=markdown" - ], - "starterBlockerCount": 0, - "inputBlockerCount": 0, - "starterAndInputBlockerCount": 0, - "localExecutionOk": false, - "localExecutionBlockerCount": 2, - "githubActionsExecutionOk": false, - "githubActionsExecutionBlockerCount": 1, - "localGithubDispatchOk": true, - "localGithubDispatchBlockerCount": 0, - "appConfigSecretFilePresent": true, - "warnings": [ - "HTTP verification after deploy is disabled. The stack and output checks still run, but public URL reachability will not be probed automatically.", - "API migrations are disabled for this plan. If the target database is empty, schedule a migration pass after the first infrastructure deploy." - ], - "highlightedBlockers": [ - "Local api-repo path is not readable from this workspace: ../Api", - "Use the GitHub Actions api-repo path, or switch this local run to deployment-source=package-manifest or deployment-source=backend-artifact.", - "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." - ], - "githubFocusedHighlightedBlockers": [ - "GitHub Actions will not receive the local app-config-secret.json file from this workspace. Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment, or use a local deploy/package-manifest path instead." - ], - "nextSteps": [ - "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper.", - "If the local Api repo is unreadable here, switch the local run to package-manifest or backend-artifact mode with the fallback commands below.", - "If you want the GitHub Actions path, run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` to populate `AWS_APP_CONFIG_SECRET_JSON` from this checkout.", - "Save the backend and frontend outputs after staging so prod can reuse the proven values.", - "If you want the GitHub Actions path, enable sync-app-config-secret and set AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment so the runner can recreate app-config-secret.json." - ], - "githubFocusedNextSteps": [ - "Run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` first if you want the GitHub Actions path, then re-run the deploy plan or dispatch helper.", - "If you want the GitHub Actions path, run `yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json` to populate `AWS_APP_CONFIG_SECRET_JSON` from this checkout.", - "Save the backend and frontend outputs after staging so prod can reuse the proven values.", - "If you want the GitHub Actions path, enable sync-app-config-secret and set AWS_APP_CONFIG_SECRET_JSON in the GitHub Environment so the runner can recreate app-config-secret.json." - ] - } - ] -} diff --git a/infrastructure/examples/sync-app-config-secret-output.sample.json b/infrastructure/examples/sync-app-config-secret-output.sample.json deleted file mode 100644 index f9320a7c8..000000000 --- a/infrastructure/examples/sync-app-config-secret-output.sample.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "action": "created", - "arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "name": "b1admin-prod-app-config", - "versionId": "11111111-2222-3333-4444-555555555555" -} diff --git a/infrastructure/examples/sync-github-app-config-secret-output.sample.json b/infrastructure/examples/sync-github-app-config-secret-output.sample.json deleted file mode 100644 index eaa6546b4..000000000 --- a/infrastructure/examples/sync-github-app-config-secret-output.sample.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "action": "stored", - "secretName": "AWS_APP_CONFIG_SECRET_JSON", - "githubEnvironment": "aws-staging", - "repo": "ChurchApps/B1Admin", - "scope": "environment", - "app": "actions", - "sourceFile": "infrastructure/examples/app-config-secret.sample.json", - "keyCount": 19, - "normalizedJsonLength": 433, - "commandPreview": "gh secret set 'AWS_APP_CONFIG_SECRET_JSON' --env 'aws-staging' --app 'actions' --repo 'ChurchApps/B1Admin' < 'infrastructure/examples/app-config-secret.sample.json'" -} diff --git a/infrastructure/examples/sync-legacy-ssm-output.sample.json b/infrastructure/examples/sync-legacy-ssm-output.sample.json deleted file mode 100644 index 541a15b26..000000000 --- a/infrastructure/examples/sync-legacy-ssm-output.sample.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "stackName": "example-backend", - "region": "us-east-1", - "environment": "prod", - "prefix": "/prod", - "overwrite": true, - "dryRun": true, - "parameterCount": 10, - "parameters": [ - { - "name": "/prod/jwtSecret" - }, - { - "name": "/prod/encryptionKey" - }, - { - "name": "/prod/membershipApi/connectionString" - }, - { - "name": "/prod/attendanceApi/connectionString" - }, - { - "name": "/prod/contentApi/connectionString" - }, - { - "name": "/prod/givingApi/connectionString" - }, - { - "name": "/prod/messagingApi/connectionString" - }, - { - "name": "/prod/doingApi/connectionString" - }, - { - "name": "/prod/reportingApi/connectionString" - }, - { - "name": "/prod/webPushSubject" - } - ] -} diff --git a/infrastructure/examples/upload-backend-artifact-output.sample.json b/infrastructure/examples/upload-backend-artifact-output.sample.json deleted file mode 100644 index 4ae25c00e..000000000 --- a/infrastructure/examples/upload-backend-artifact-output.sample.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "artifactLabel": "Backend artifact", - "region": "us-east-1", - "bucket": "my-artifacts-bucket", - "key": "b1admin/backend/api.zip", - "sourceFile": "/abs/path/to/api.zip", - "s3Uri": "s3://my-artifacts-bucket/b1admin/backend/api.zip", - "versionId": "3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY+MTRCxf3vjVBH40Nr8X8gdRQBpUMLUo", - "bootstrapStackName": "example-bootstrap" -} diff --git a/infrastructure/examples/validate-api-migrations-output.sample.json b/infrastructure/examples/validate-api-migrations-output.sample.json deleted file mode 100644 index 903374296..000000000 --- a/infrastructure/examples/validate-api-migrations-output.sample.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "ok": true, - "mode": "api-migrations", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": true, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "", - "artifactKey": "", - "migrationBucket": "", - "migrationKey": "", - "frontendDomain": "", - "frontendCert": "", - "frontendZone": "", - "apiDomain": "", - "apiCert": "", - "apiZone": "", - "appConfigSecretArn": "", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [ - "membership", - "attendance" - ], - "apiRepoMigrationDirectories": [ - "attendance" - ] - }, - "info": [ - "Mode: api-migrations", - "Region: us-east-1", - "Standalone Api CLI migration validation", - "API repo path: ", - "API migration DB secret file: /abs/path/to/database-secret.json", - "API migration action: status", - "API migration module: attendance", - "API migration repo path: ", - "API migration outputs file: /abs/path/to/outputs.json", - "API migration DB secret file: /abs/path/to/database-secret.json", - "Standalone API migration helper is in dry-run mode.", - "API repo migration modules: membership, attendance", - "API repo migration directories: attendance" - ], - "warnings": [], - "errors": [], - "nextSteps": [ - "yarn run:api-migrations -- --api-repo-path= --action=status --module=attendance --region=us-east-1 --outputs-file=/abs/path/to/outputs.json --db-secret-file=/abs/path/to/database-secret.json --dry-run=true" - ] -} diff --git a/infrastructure/examples/validate-backend-output.sample.json b/infrastructure/examples/validate-backend-output.sample.json deleted file mode 100644 index 22efea0a0..000000000 --- a/infrastructure/examples/validate-backend-output.sample.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "ok": true, - "mode": "backend", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "example-backend", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", - "frontendParametersFile": "", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "infrastructure/examples/package-manifest.sample.json", - "backendArtifactSource": "/api-prod-self-contained.zip", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/prod/backend/api.zip", - "migrationBucket": "", - "migrationKey": "", - "frontendDomain": "", - "frontendCert": "", - "frontendZone": "", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: backend", - "Region: us-east-1", - "Backend parameters file: /abs/path/to/infrastructure/examples/backend-parameters.sample.json", - "Package manifest file: /abs/path/to/infrastructure/examples/package-manifest.sample.json", - "Lambda artifact bucket: my-artifacts-bucket", - "Lambda artifact key: b1admin/prod/backend/api.zip" - ], - "warnings": [], - "errors": [], - "nextSteps": [ - "yarn upload:backend-artifact -- --source-file=/api-prod-self-contained.zip --artifact-key=b1admin/prod/backend/api.zip", - "yarn deploy:backend -- --stack-name=example-backend --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json --lambda-code-s3-bucket=my-artifacts-bucket --lambda-code-s3-key=b1admin/prod/backend/api.zip" - ] -} diff --git a/infrastructure/examples/validate-bootstrap-output.sample.json b/infrastructure/examples/validate-bootstrap-output.sample.json deleted file mode 100644 index b12a44c72..000000000 --- a/infrastructure/examples/validate-bootstrap-output.sample.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "ok": true, - "mode": "bootstrap", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": true, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "infrastructure/examples/bootstrap-parameters.sample.json", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "infrastructure/examples/bootstrap-parameters.sample.json", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "b1admin-prod-templates-123456789012", - "artifactBucket": "b1admin-prod-artifacts-123456789012", - "artifactKey": "", - "migrationBucket": "b1admin-prod-artifacts-123456789012", - "migrationKey": "", - "frontendDomain": "", - "frontendCert": "", - "frontendZone": "", - "apiDomain": "", - "apiCert": "", - "apiZone": "", - "appConfigSecretArn": "", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: bootstrap", - "Region: us-east-1", - "Template bucket: b1admin-prod-templates-123456789012", - "Artifact bucket: b1admin-prod-artifacts-123456789012" - ], - "warnings": [], - "errors": [], - "nextSteps": [ - "yarn deploy:bootstrap -- --region=us-east-1 --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json --stack-name=" - ] -} diff --git a/infrastructure/examples/validate-frontend-output.sample.json b/infrastructure/examples/validate-frontend-output.sample.json deleted file mode 100644 index 1e5e3cbb0..000000000 --- a/infrastructure/examples/validate-frontend-output.sample.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "ok": true, - "mode": "frontend", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "", - "artifactKey": "", - "migrationBucket": "", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "", - "apiCert": "", - "apiZone": "", - "appConfigSecretArn": "", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: frontend", - "Region: us-east-1" - ], - "warnings": [], - "errors": [], - "nextSteps": [] -} diff --git a/infrastructure/examples/validate-frontend-publish-output.sample.json b/infrastructure/examples/validate-frontend-publish-output.sample.json deleted file mode 100644 index 8ca7312dd..000000000 --- a/infrastructure/examples/validate-frontend-publish-output.sample.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "ok": true, - "mode": "frontend-publish", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": false, - "splitStackPublishOnly": false, - "frontendPublishMode": true, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "", - "frontendParametersFile": "", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "", - "artifactKey": "", - "migrationBucket": "", - "migrationKey": "", - "frontendDomain": "", - "frontendCert": "", - "frontendZone": "", - "apiDomain": "", - "apiCert": "", - "apiZone": "", - "appConfigSecretArn": "", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: frontend-publish", - "Region: us-east-1", - "Frontend asset publish validation", - "Backend outputs file: /abs/path/to/infrastructure/examples/backend-outputs.sample.json", - "Frontend publish bucket: example-frontend-bucket", - "Frontend distribution ID: EXAMPLE123" - ], - "warnings": [ - "Frontend publish will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." - ], - "errors": [], - "nextSteps": [ - "yarn publish:frontend-assets -- --bucket=example-frontend-bucket --distribution-id=EXAMPLE123 --backend-outputs-file=infrastructure/examples/backend-outputs.sample.json" - ] -} diff --git a/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json b/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json deleted file mode 100644 index 503721b6a..000000000 --- a/infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "ok": true, - "mode": "split-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": true, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": true, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", - "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: split-stack", - "Region: us-east-1", - "Split-stack validation: backend + frontend", - "Frontend infrastructure-only deploy requested.", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", - "Split-stack validation will assume backend deploy now, with frontend hosting provisioned but frontend asset publishing deferred.", - "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" - ], - "warnings": [], - "errors": [], - "nextSteps": [] -} diff --git a/infrastructure/examples/validate-split-stack-output.sample.json b/infrastructure/examples/validate-split-stack-output.sample.json deleted file mode 100644 index 1bcad7aed..000000000 --- a/infrastructure/examples/validate-split-stack-output.sample.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "ok": true, - "mode": "split-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": true, - "splitStackPublishOnly": false, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", - "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: split-stack", - "Region: us-east-1", - "Split-stack validation: backend + frontend", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "ContentRootUrl will be inferred from the managed asset bucket because FileStore=S3, ManageAssetBucket=true, and no explicit ContentRootUrl was provided.", - "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" - ], - "warnings": [], - "errors": [], - "nextSteps": [] -} diff --git a/infrastructure/examples/validate-split-stack-publish-output.sample.json b/infrastructure/examples/validate-split-stack-publish-output.sample.json deleted file mode 100644 index f4641976c..000000000 --- a/infrastructure/examples/validate-split-stack-publish-output.sample.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "ok": true, - "mode": "split-stack", - "region": "us-east-1", - "projectName": "b1admin", - "environmentName": "prod", - "bootstrapMode": false, - "apiMigrationsMode": false, - "splitStackMode": true, - "splitStackPublishOnly": true, - "frontendPublishMode": false, - "fullStackPublishOnly": false, - "checkAws": false, - "infrastructureOnly": false, - "frontendInfrastructureOnly": false, - "stackName": "", - "parametersFile": "", - "bootstrapStackName": "", - "backendParametersFile": "infrastructure/examples/backend-parameters.sample.json", - "frontendParametersFile": "infrastructure/examples/frontend-parameters.sample.json", - "fullStackParametersFile": "", - "resolved": { - "packageManifestFile": "", - "backendArtifactSource": "", - "migrationArtifactSource": "", - "dependenciesLayerSource": "", - "templateBucket": "", - "artifactBucket": "my-artifacts-bucket", - "artifactKey": "b1admin/backend/api.zip", - "migrationBucket": "my-artifacts-bucket", - "migrationKey": "", - "frontendDomain": "admin.example.com", - "frontendCert": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "frontendZone": "Z1234567890ABC", - "apiDomain": "api.example.com", - "apiCert": "arn:aws:acm:us-east-1:123456789012:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", - "apiZone": "Z1234567890ABC", - "appConfigSecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "dependenciesLayerArn": "", - "observabilityLayerArn": "", - "apiRepoMigrationModules": [], - "apiRepoMigrationDirectories": [] - }, - "info": [ - "Mode: split-stack", - "Region: us-east-1", - "Split-stack validation: backend + frontend", - "Split-stack publish-only follow-up: backend and frontend deploy steps will be skipped.", - "Artifact bucket: my-artifacts-bucket", - "Artifact key: b1admin/backend/api.zip", - "App config secret ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - "Frontend outputs file: /abs/path/to/infrastructure/examples/frontend-outputs.sample.json", - "Backend deploy step will be skipped.", - "Frontend deploy step will be skipped.", - "Frontend parameters file: /abs/path/to/infrastructure/examples/frontend-parameters.sample.json" - ], - "warnings": [ - "Split-stack publish-only follow-up will build the app, but node_modules was not found at /abs/path/to/B1Admin/node_modules. Run yarn install first or use --skip-build with an existing dist/." - ], - "errors": [], - "nextSteps": [ - "yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=prod --frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json --backend-parameters-file=infrastructure/examples/backend-parameters.sample.json --frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json --skip-backend --skip-frontend --publish-frontend-assets" - ] -} diff --git a/infrastructure/examples/verify-split-stack-output.sample.json b/infrastructure/examples/verify-split-stack-output.sample.json deleted file mode 100644 index 7f4dd3c86..000000000 --- a/infrastructure/examples/verify-split-stack-output.sample.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "ok": true, - "mode": "split-stack", - "region": "us-east-1", - "backendStackName": "", - "frontendStackName": "", - "backendOutputsFile": "infrastructure/examples/backend-outputs.sample.json", - "frontendOutputsFile": "infrastructure/examples/frontend-outputs.sample.json", - "checkAws": false, - "checkHttp": false, - "resolved": { - "apiBaseUrl": "https://api.example.com", - "contentRootUrl": "https://content.example.com", - "websiteBaseUrl": "https://{subdomain}.example.com", - "frontendAppUrl": "https://admin.example.com", - "frontendBucketName": "example-frontend-bucket", - "frontendDistributionId": "EXAMPLE123" - }, - "checks": [ - { - "name": "backend outputs source", - "ok": true, - "detail": "Loaded backend outputs file /abs/path/to/B1Admin/infrastructure/examples/backend-outputs.sample.json" - }, - { - "name": "frontend outputs source", - "ok": true, - "detail": "Loaded frontend outputs file /abs/path/to/B1Admin/infrastructure/examples/frontend-outputs.sample.json" - }, - { - "name": "api base url output", - "ok": true, - "detail": "Resolved from ApiBaseUrl/PublicApiBaseUrl: https://api.example.com" - }, - { - "name": "frontend app url output", - "ok": true, - "detail": "Resolved from AppUrl/FrontendAppUrl: https://admin.example.com" - }, - { - "name": "frontend bucket output", - "ok": true, - "detail": "Resolved from SiteBucketName/FrontendBucketName: example-frontend-bucket" - }, - { - "name": "frontend distribution output", - "ok": true, - "detail": "Resolved from CloudFrontDistributionId/FrontendDistributionId: EXAMPLE123" - }, - { - "name": "frontend bucket aws reachability", - "ok": true, - "skipped": true, - "detail": "Skipped because --check-aws=false." - }, - { - "name": "frontend distribution aws reachability", - "ok": true, - "skipped": true, - "detail": "Skipped because --check-aws=false." - }, - { - "name": "frontend app http reachability", - "ok": true, - "skipped": true, - "detail": "Skipped because --check-http=false." - }, - { - "name": "api probe http reachability", - "ok": true, - "skipped": true, - "detail": "Skipped because --check-http=false." - } - ], - "errors": [] -} diff --git a/package.json b/package.json index 0c4d9fa57..3fd6694f3 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,6 @@ "verify:split-stack": "node scripts/verify-split-stack.mjs", "audit:environment-starter": "node scripts/audit-environment-starter.mjs", "validate:aws-deploy": "node scripts/validate-aws-deploy.mjs", - "smoke:aws-tooling": "node scripts/smoke-aws-tooling.mjs", "deploy:backend": "node scripts/deploy-backend.mjs", "deploy:frontend": "node scripts/deploy-frontend.mjs", "deploy:aws": "node scripts/deploy-aws.mjs", diff --git a/scripts/smoke-aws-tooling.mjs b/scripts/smoke-aws-tooling.mjs deleted file mode 100644 index 62c1aba6b..000000000 --- a/scripts/smoke-aws-tooling.mjs +++ /dev/null @@ -1,7943 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const jsonOutput = process.argv.includes("--output=json") || (process.argv.includes("--output") && process.argv[process.argv.indexOf("--output") + 1] === "json"); -const childProcessTimeoutMs = Number(process.env.SMOKE_CHILD_TIMEOUT_MS || 30000); - -function spawnNode(scriptPath, args, env = {}) { - const result = spawnSync("node", [scriptPath, ...args], { - cwd: rootDir, - encoding: "utf8", - timeout: childProcessTimeoutMs, - env: { - ...process.env, - ...env, - }, - }); - - return { - status: result.status ?? 1, - stdout: result.stdout || "", - stderr: `${result.stderr || ""}${result.error ? `\n${result.error.message}` : ""}`, - }; -} - -function runCheck(scriptPath) { - execFileSync("node", ["--check", scriptPath], { - cwd: rootDir, - stdio: "pipe", - encoding: "utf8", - }); -} - -function runShellCheck(scriptPath) { - execFileSync("bash", ["-n", scriptPath], { - cwd: rootDir, - stdio: "pipe", - encoding: "utf8", - }); -} - -function runYamlParse(filePath) { - execFileSync("ruby", ["-e", 'require "psych"; Psych.parse_stream(File.read(ARGV[0]))', filePath], { - cwd: rootDir, - stdio: "pipe", - encoding: "utf8", - }); -} - -function sleepMs(milliseconds) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); -} - -function parseJsonFileWithRetry(filePath, { attempts = 4, retryDelayMs = 50 } = {}) { - let lastError = null; - - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - return JSON.parse(fs.readFileSync(path.join(rootDir, filePath), "utf8")); - } catch (error) { - lastError = error; - const isMissingFile = error && typeof error === "object" && "code" in error && error.code === "ENOENT"; - const isTransientParseFailure = error instanceof SyntaxError; - const shouldRetry = attempt < attempts && (isMissingFile || isTransientParseFailure); - if (!shouldRetry) break; - sleepMs(retryDelayMs); - } - } - - throw lastError; -} - -function runJsonParse(filePath) { - parseJsonFileWithRetry(filePath); -} - -function readJsonFile(filePath) { - return parseJsonFileWithRetry(filePath); -} - -function listContractCheckedJsonSamples() { - const smokeSource = fs.readFileSync(fileURLToPath(import.meta.url), "utf8"); - return [...new Set( - [...smokeSource.matchAll(/readJsonFile\("([^"]+\.sample\.json)"\)/g)].map((match) => match[1]), - )].sort(); -} - -function expectJsonExampleContractCoverage(jsonFilesToParse) { - const inputOnlySamples = [ - "infrastructure/examples/app-config-secret.sample.json", - "infrastructure/examples/backend-outputs.sample.json", - "infrastructure/examples/backend-parameters.sample.json", - "infrastructure/examples/backend-stack-outputs.sample.json", - "infrastructure/examples/bootstrap-parameters.sample.json", - "infrastructure/examples/database-secret.sample.json", - "infrastructure/examples/frontend-outputs.sample.json", - "infrastructure/examples/frontend-parameters.sample.json", - ].sort(); - - const parsedExampleSamples = jsonFilesToParse - .filter((filePath) => filePath.startsWith("infrastructure/examples/") && filePath.endsWith(".sample.json")) - .sort(); - const contractCheckedSamples = listContractCheckedJsonSamples(); - - const missingContractCoverage = parsedExampleSamples.filter((filePath) => ( - !contractCheckedSamples.includes(filePath) && !inputOnlySamples.includes(filePath) - )); - const staleInputOnlyEntries = inputOnlySamples.filter((filePath) => !parsedExampleSamples.includes(filePath)); - const contractCheckedButUnparsed = contractCheckedSamples.filter((filePath) => !parsedExampleSamples.includes(filePath)); - - if (missingContractCoverage.length > 0 || staleInputOnlyEntries.length > 0 || contractCheckedButUnparsed.length > 0) { - const lines = []; - if (missingContractCoverage.length > 0) { - lines.push(`Samples missing contract coverage or input-only classification: ${missingContractCoverage.join(", ")}`); - } - if (staleInputOnlyEntries.length > 0) { - lines.push(`Input-only sample allowlist contains files that are no longer parsed: ${staleInputOnlyEntries.join(", ")}`); - } - if (contractCheckedButUnparsed.length > 0) { - lines.push(`Contract-checked samples are no longer parsed by the smoke suite: ${contractCheckedButUnparsed.join(", ")}`); - } - throw new Error(lines.join("\n")); - } -} - -function expectEnvironmentStarterParity() { - const environmentsRoot = path.join(rootDir, "infrastructure", "environments"); - const environmentNames = fs.readdirSync(environmentsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - - const requiredEnvironments = ["prod", "staging"]; - const missingEnvironments = requiredEnvironments.filter((name) => !environmentNames.includes(name)); - if (missingEnvironments.length > 0) { - throw new Error(`Missing expected environment starter directories: ${missingEnvironments.join(", ")}`); - } - - const fileSets = Object.fromEntries(environmentNames.map((name) => { - const envDir = path.join(environmentsRoot, name); - const files = fs.readdirSync(envDir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .filter((entry) => [ - "app-config-secret.template.json", - "backend-parameters.json", - "bootstrap-parameters.json", - "deploy-split-stack.sh", - "frontend-parameters.json", - ].includes(entry.name)) - .map((entry) => entry.name) - .sort(); - return [name, files]; - })); - - const baseline = fileSets[requiredEnvironments[0]]; - for (const environmentName of requiredEnvironments.slice(1)) { - const current = fileSets[environmentName]; - const missingFromCurrent = baseline.filter((fileName) => !current.includes(fileName)); - const extraInCurrent = current.filter((fileName) => !baseline.includes(fileName)); - - if (missingFromCurrent.length > 0 || extraInCurrent.length > 0) { - const lines = [`Environment starter kits are out of sync between ${requiredEnvironments[0]} and ${environmentName}.`]; - if (missingFromCurrent.length > 0) { - lines.push(`${environmentName} is missing: ${missingFromCurrent.join(", ")}`); - } - if (extraInCurrent.length > 0) { - lines.push(`${environmentName} has extra files: ${extraInCurrent.join(", ")}`); - } - throw new Error(lines.join("\n")); - } - } -} - -function expectDeployAwsWorkflowUploadsEvidenceArtifact() { - const workflowPath = path.join(rootDir, ".github", "workflows", "deploy-aws-self-hosted.yml"); - const workflowText = fs.readFileSync(workflowPath, "utf8"); - - const expectedSnippets = [ - "name: Write preflight plan summary", - "## Preflight Plan", - "yarn plan:environment-deploy --", - "preflight-plan.md", - "preview_only:", - "PREVIEW_ONLY:", - "name: Upload deployment evidence", - "uses: actions/upload-artifact@v4", - "name: aws-${{ inputs.environment }}-deployment-evidence", - "path: deployment/${{ inputs.environment }}/", - "name: Save source metadata", - "source-metadata.json", - "name: Upload preflight plan for preview-only run", - "name: Upload preflight plan on failure", - "name: aws-${{ inputs.environment }}-preflight-plan", - "path: deployment/${{ inputs.environment }}/preflight-plan.md", - "name: Write deployment summary", - "name: Write preview-only summary", - "## Preview-Only Result", - "GITHUB_STEP_SUMMARY", - "deployment-summary.json", - "yarn show:deployment-summary -- --summary-file=\"${SUMMARY_FILE}\" --output=markdown", - ]; - - const missing = expectedSnippets.filter((snippet) => !workflowText.includes(snippet)); - if (missing.length > 0) { - throw new Error(`deploy-aws-self-hosted workflow is missing expected deployment-evidence upload content: ${missing.join(", ")}`); - } - - const privateWorkflowTemplatePath = path.join(rootDir, "infrastructure", "environments", "private-deployment-workflow.sample.yml"); - const privateWorkflowTemplateText = fs.readFileSync(privateWorkflowTemplatePath, "utf8"); - if (!privateWorkflowTemplateText.includes("ARGS+=(--run-api-migrations=true)")) { - throw new Error("private deployment workflow template must pass --run-api-migrations=true explicitly so deploy-aws forwards migrations to deploy-backend."); - } - if (!privateWorkflowTemplateText.includes("name: Save source metadata") - || !privateWorkflowTemplateText.includes("source-metadata.json")) { - throw new Error("private deployment workflow template must save source-metadata.json into deployment evidence."); - } -} - -function expectObjectContainsKeys(name, actual, sample, objectPath = "") { - if (!actual || typeof actual !== "object" || Array.isArray(actual)) { - throw new Error(`${name} expected an object at ${objectPath}.`); - } - if (!sample || typeof sample !== "object" || Array.isArray(sample)) { - throw new Error(`${name} sample did not contain an object at ${objectPath}.`); - } - - const missing = Object.keys(actual).filter((key) => !(key in sample)); - if (missing.length > 0) { - throw new Error(`${name} sample is missing keys at ${objectPath}: ${missing.join(", ")}`); - } -} - -function canReadFile(filePath) { - try { - fs.accessSync(filePath, fs.constants.R_OK); - return true; - } catch { - return false; - } -} - -function canReadDirectory(filePath) { - try { - fs.readdirSync(filePath); - return true; - } catch { - return false; - } -} - -function addSkippedResults(results, names) { - names.forEach((name) => { - results.push({ - name, - ok: true, - skipped: true, - }); - }); -} - -function parseApiRepoServerlessEnvKeys(filePath) { - const ruby = ` - require "yaml" - require "json" - data = YAML.load_file(ARGV[0]) - provider_env = (data.dig("provider", "environment") || {}).keys - function_env = (data["functions"] || {}).values.flat_map { |fn| (fn["environment"] || {}).keys } - puts JSON.generate((provider_env + function_env).uniq.sort) - `; - - return JSON.parse(execFileSync("ruby", ["-e", ruby, filePath], { - cwd: rootDir, - stdio: "pipe", - encoding: "utf8", - })); -} - -function checkBackendTemplateContainsApiRepoEnvKeys(apiRepoPath) { - const serverlessPath = path.join(apiRepoPath, "serverless.yml"); - const templatePath = path.join(rootDir, "infrastructure", "cloudformation", "backend-api.yaml"); - const envKeys = parseApiRepoServerlessEnvKeys(serverlessPath); - const templateText = fs.readFileSync(templatePath, "utf8"); - const missing = envKeys.filter((key) => !templateText.includes(`${key}:`)); - - if (missing.length > 0) { - throw new Error(`backend-api.yaml is missing env keys required by Api/serverless.yml: ${missing.join(", ")}`); - } -} - -function runJsonScript(scriptPath, args) { - const result = spawnNode(scriptPath, args); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - let parsed = null; - - if (stdout.trim() !== "") { - parsed = JSON.parse(stdout); - } - - return { - status: result.status ?? 1, - stdout, - stderr, - parsed, - }; -} - -function runJsonScriptWithEnv(scriptPath, args, env) { - const result = spawnNode(scriptPath, args, env); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - let parsed = null; - - if (stdout.trim() !== "") { - parsed = JSON.parse(stdout); - } - - return { - status: result.status ?? 1, - stdout, - stderr, - parsed, - }; -} - -function runScript(scriptPath, args) { - return spawnNode(scriptPath, args); -} - -function runScriptWithEnv(scriptPath, args, env) { - return spawnNode(scriptPath, args, env); -} - -function expectOk(name, invocation) { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", invocation); - if (result.status !== 0) { - throw new Error(`${name} failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - if (!result.parsed?.ok) { - throw new Error(`${name} returned ok=false unexpectedly.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectBootstrapValidatorNextStep() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=bootstrap", - "--stack-name=example-bootstrap", - "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`bootstrap validator next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.stackName !== "example-bootstrap") { - throw new Error(`bootstrap validator did not preserve stack-name.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.parametersFile !== "infrastructure/examples/bootstrap-parameters.sample.json") { - throw new Error(`bootstrap validator did not expose parametersFile cleanly.\nSTDOUT:\n${result.stdout}`); - } - - const nextStep = result.parsed.nextSteps?.[0] || ""; - const expected = "yarn deploy:bootstrap -- --region=us-east-1 --parameters-file=infrastructure/examples/bootstrap-parameters.sample.json --stack-name=example-bootstrap"; - if (nextStep !== expected) { - throw new Error(`bootstrap validator next step was not exact.\nExpected:\n${expected}\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); - } -} - -function expectPackageManifestValidatorNextStep() { - withFakePackageManifest((manifestPath) => { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`package manifest validator next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.resolved?.packageManifestFile !== manifestPath) { - throw new Error(`package manifest validator did not expose the resolved manifest path.\nSTDOUT:\n${result.stdout}`); - } - - const nextStep = result.parsed.nextSteps?.find((step) => String(step).includes("upload:backend-artifact")) || ""; - if (!nextStep.includes(`--source-file=${path.join(path.dirname(manifestPath), "api-test-self-contained.zip")}`)) { - throw new Error(`package manifest validator next step did not reuse the manifest backend artifact path.\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); - } - }); -} - -function expectPackageManifestValidatorMigrationNextStep() { - withFakePackageManifest((manifestPath) => { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--run-migrations=true", - "--migration-handler=index.migrate", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`package manifest validator migration next step failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.resolved?.migrationArtifactSource !== path.join(path.dirname(manifestPath), "api-test-migrations.zip")) { - throw new Error(`package manifest validator did not expose the resolved migration artifact path.\nSTDOUT:\n${result.stdout}`); - } - - const nextStep = result.parsed.nextSteps?.find((step) => String(step).includes('Migration artifact')) || ""; - if (!nextStep.includes(`--source-file=${path.join(path.dirname(manifestPath), "api-test-migrations.zip")}`)) { - throw new Error(`package manifest validator migration next step did not reuse the manifest migration artifact path.\nActual:\n${nextStep}\nSTDOUT:\n${result.stdout}`); - } - }, { includeMigrationArtifact: true }); -} - -function expectPackageApiBackendJsonIncludesManifestDeployHints() { - withFakePackagableApiRepo((fakeApiRepoPath) => { - const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-output-")); - const migrationArtifactPath = path.join(outputDir, "api-stage-migrations.zip"); - - try { - fs.writeFileSync(migrationArtifactPath, "fake migration artifact\n"); - - const result = runJsonScript("scripts/package-api-backend.mjs", [ - `--api-repo-path=${fakeApiRepoPath}`, - `--output-dir=${outputDir}`, - "--project-name=testproj", - "--environment=stage", - `--migration-artifact-path=${migrationArtifactPath}`, - "--build=false", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`package-api-backend json hints failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.backendArtifactPath !== "api-stage-self-contained.zip") { - throw new Error(`package-api-backend did not emit a manifest-relative backend artifact path.\nSTDOUT:\n${result.stdout}`); - } - - if (parsed.recommendedBackendArtifactKey !== "testproj/stage/backend/api.zip") { - throw new Error(`package-api-backend did not expose the derived backend artifact key.\nSTDOUT:\n${result.stdout}`); - } - - if (parsed.migrationArtifactPath !== "api-stage-migrations.zip") { - throw new Error(`package-api-backend did not emit a manifest-relative migration artifact path.\nSTDOUT:\n${result.stdout}`); - } - - const deployBackend = parsed.recommendedNextSteps?.deployBackend || ""; - if (!deployBackend.includes("--package-manifest-file=")) { - throw new Error(`package-api-backend did not emit a manifest-driven deploy:backend hint.\nSTDOUT:\n${result.stdout}`); - } - - const uploadHint = parsed.recommendedNextSteps?.uploadBackendArtifact || ""; - if (!uploadHint.includes("--artifact-key=testproj/stage/backend/api.zip")) { - throw new Error(`package-api-backend upload hint did not include the derived artifact key.\nSTDOUT:\n${result.stdout}`); - } - - const uploadMigrationHint = parsed.recommendedNextSteps?.uploadMigrationArtifact || ""; - if (!uploadMigrationHint.includes("--artifact-key=testproj/stage/backend/migrations.zip")) { - throw new Error(`package-api-backend migration upload hint did not include the derived migration artifact key.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(outputDir, { recursive: true, force: true }); - } - }); -} - -function expectPackageManifestSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/package-manifest.sample.json"); - - withFakePackagableApiRepo((fakeApiRepoPath) => { - const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-manifest-contract-")); - - try { - const result = runJsonScript("scripts/package-api-backend.mjs", [ - `--api-repo-path=${fakeApiRepoPath}`, - `--output-dir=${outputDir}`, - "--project-name=b1admin", - "--environment=prod", - "--build=false", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`package manifest sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("package manifest sample", actual, sample); - expectObjectContainsKeys("package manifest sample", actual.recommendedNextSteps || {}, sample.recommendedNextSteps || {}, "recommendedNextSteps"); - - if (sample.apiRepoPath !== "") { - throw new Error(`package manifest sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendArtifactPath !== "api-prod-self-contained.zip") { - throw new Error(`package manifest sample should document the manifest-relative backend artifact path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.manifestPath !== "package-manifest.sample.json") { - throw new Error(`package manifest sample should point at the checked sample manifest filename.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedBackendArtifactKey !== "b1admin/prod/backend/api.zip") { - throw new Error(`package manifest sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedMigrationArtifactKey !== "b1admin/prod/backend/migrations.zip") { - throw new Error(`package manifest sample should document the derived migration artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.recommendedNextSteps?.deployBackend || "").includes("--package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json")) { - throw new Error(`package manifest sample should document the manifest-driven deploy:backend hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.recommendedNextSteps?.uploadBackendArtifact || "").includes("--artifact-key=b1admin/prod/backend/api.zip")) { - throw new Error(`package manifest sample should document the upload helper artifact key hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - } finally { - fs.rmSync(outputDir, { recursive: true, force: true }); - } - }); -} - -function expectPackageApiBackendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/package-api-backend-output.sample.json"); - - withFakePackagableApiRepo((fakeApiRepoPath) => { - const outputDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-output-contract-")); - - try { - const result = runJsonScript("scripts/package-api-backend.mjs", [ - `--api-repo-path=${fakeApiRepoPath}`, - `--output-dir=${outputDir}`, - "--project-name=b1admin", - "--environment=prod", - "--build=false", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`package-api-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("package-api-backend output sample", actual, sample); - expectObjectContainsKeys( - "package-api-backend output sample", - actual.recommendedNextSteps || {}, - sample.recommendedNextSteps || {}, - "recommendedNextSteps", - ); - - if (sample.apiRepoPath !== "") { - throw new Error(`package-api-backend output sample should use the placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendArtifactPath !== "api-prod-self-contained.zip") { - throw new Error(`package-api-backend output sample should document the manifest-relative backend artifact path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.manifestPath !== "infrastructure/artifacts/api/api-prod-self-contained.manifest.json") { - throw new Error(`package-api-backend output sample should point at the generated manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedBackendArtifactKey !== "b1admin/prod/backend/api.zip") { - throw new Error(`package-api-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedMigrationArtifactKey !== "b1admin/prod/backend/migrations.zip") { - throw new Error(`package-api-backend output sample should document the derived migration artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.recommendedNextSteps?.deployBackend || "").includes("--package-manifest-file=infrastructure/artifacts/api/api-prod-self-contained.manifest.json")) { - throw new Error(`package-api-backend output sample should document the manifest-driven deploy:backend hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.recommendedNextSteps?.uploadBackendArtifact || "").includes("--artifact-key=b1admin/prod/backend/api.zip")) { - throw new Error(`package-api-backend output sample should document the upload helper artifact key hint.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - } finally { - fs.rmSync(outputDir, { recursive: true, force: true }); - } - }); -} - -function expectAuditEnvironmentStarterOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/audit-environment-starter-output.sample.json"); - let result; - withRawStarterEnvironment("staging", (tempDir) => { - result = runJsonScript("scripts/audit-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--output=json", - ]); - }); - - if (result.status !== 1) { - throw new Error(`audit-environment-starter sample contract run should fail while placeholders remain in staging.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("audit-environment-starter output sample", actual, sample); - - if (sample.ok !== false || sample.environment !== "staging") { - throw new Error(`audit-environment-starter output sample should document a non-ready staging starter.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.summary?.placeholderCount !== 5 - || sample.summary?.unsafeDefaultCount !== 10 - || sample.summary?.requiredBlankCount !== 0 - || sample.summary?.optionalBlankCount !== 41) { - throw new Error(`audit-environment-starter output sample should document the current staging placeholder, starter-default, and optional-blank counts.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectAuditEnvironmentStarterMarkdownOutputWorks() { - let result; - withRawStarterEnvironment("staging", (tempDir) => { - result = spawnSync("node", ["scripts/audit-environment-starter.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--only-blockers=true", "--output=markdown"], { - cwd: rootDir, - encoding: "utf8", - }); - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - - if (result.status !== 1) { - throw new Error(`audit-environment-starter markdown mode should fail while blockers remain in staging.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - if (!stdout.includes("# Environment Starter Audit: staging")) { - throw new Error(`audit-environment-starter markdown output is missing the expected title.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("## Next Steps") || !stdout.includes("## Suggestions") || !stdout.includes("## Findings")) { - throw new Error(`audit-environment-starter markdown output is missing one or more expected sections.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("jwtSecret") - || !stdout.includes("encryptionKey")) { - throw new Error(`audit-environment-starter markdown output should include the current staging blocker keys.\nSTDOUT:\n${stdout}`); - } -} - -function expectPrepareEnvironmentStarterOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/prepare-environment-starter-output.sample.json"); - const result = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--account-id=123456789012", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`prepare-environment-starter output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("prepare-environment-starter output sample", actual, sample); - - if (sample.ok !== true || sample.environment !== "staging" || sample.write !== false) { - throw new Error(`prepare-environment-starter output sample should document a staging dry-run result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.accountId !== "123456789012" || sample.generatedSecrets !== true || sample.usedExistingSecretFile !== false) { - throw new Error(`prepare-environment-starter output sample should document the expected input identity and secret-generation path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectPrepareEnvironmentStarterCommandsOutputWorks() { - const result = spawnSync("node", ["scripts/prepare-environment-starter.mjs", "--environment=staging", "--account-id=123456789012", "--output=commands"], { - cwd: rootDir, - encoding: "utf8", - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - - if (result.status !== 0) { - throw new Error(`prepare-environment-starter commands mode failed unexpectedly.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - const expectedSnippets = [ - "yarn prepare:environment-starter -- --environment=staging --account-id=123456789012 --write=true", - "yarn audit:environment-starter -- --environment=staging --only-blockers=true", - "./infrastructure/environments/staging/deploy-split-stack.sh", - ]; - - for (const snippet of expectedSnippets) { - if (!stdout.includes(snippet)) { - throw new Error(`prepare-environment-starter commands output is missing expected command: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectPrepareEnvironmentStarterMarkdownOutputWorks() { - let result; - withRawStarterEnvironment("staging", (tempDir) => { - result = spawnSync("node", ["scripts/prepare-environment-starter.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--account-id=123456789012", "--output=markdown"], { - cwd: rootDir, - encoding: "utf8", - }); - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - - if (result.status !== 0) { - throw new Error(`prepare-environment-starter markdown mode failed unexpectedly.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - const expectedSnippets = [ - "# Prepare Environment Starter: staging", - "## Proposed Changes", - "## Next Steps", - "## Recommended Commands", - "app-config-secret.json", - "jwtSecret", - ]; - - for (const snippet of expectedSnippets) { - if (!stdout.includes(snippet)) { - throw new Error(`prepare-environment-starter markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectPrepareEnvironmentStarterWriteModeClearsGeneratedBlockers() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-starter-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - if (!fs.existsSync(path.join(tempDir, "app-config-secret.json"))) { - throw new Error("prepare-environment-starter write mode did not create app-config-secret.json in the target environment directory."); - } - - const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--only-blockers=true", - "--output=json", - ]); - - if (auditResult.status !== 1) { - throw new Error(`audit-environment-starter should still report starter-default blockers after prepare write mode updates the copied environment.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); - } - - if (auditResult.parsed?.summary?.placeholderCount !== 0) { - throw new Error(`prepare-environment-starter write mode should clear placeholder blockers in the copied environment.\nSTDOUT:\n${auditResult.stdout}`); - } - - if (auditResult.parsed?.blockerSummary?.unsafeDefaultCount !== 9 || auditResult.parsed?.blockerSummary?.blockerCount !== 9) { - throw new Error(`audit-environment-starter should leave only the known starter-default blockers after prepare write mode.\nSTDOUT:\n${auditResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPrepareEnvironmentStarterWriteModeCanClearStarterDefaults() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-complete-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--admin-root-url=https://admin-staging.b1test.org", - "--cors-origin=https://admin-staging.b1test.org", - "--content-root-url=https://content-staging.b1test.org", - "--store-api-url=https://store-staging.b1test.org", - "--transfer-url=https://transfer-staging.b1test.org", - "--support-email=support@b1test.org", - "--support-phone=800-555-0199", - "--support-site-url=https://support.b1test.org", - "--website-base-url=https://{subdomain}.staging.b1test.org", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter full write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--only-blockers=true", - "--output=json", - ]); - - if (auditResult.status !== 0) { - throw new Error(`audit-environment-starter should report no blockers after prepare write mode receives explicit backend values.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); - } - - if (auditResult.parsed?.blockerSummary?.blockerCount !== 0) { - throw new Error(`prepare-environment-starter should be able to clear all starter blockers when explicit backend values are provided.\nSTDOUT:\n${auditResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPrepareEnvironmentStarterRootDomainShortcutWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-root-domain-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--root-domain=b1test.org", - "--support-phone=800-555-0199", - "--support-site-url=https://support.b1test.org", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter root-domain write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); - const expected = { - WebsiteBaseUrl: "https://{subdomain}.b1test.org", - ContentRootUrl: "https://content-staging.b1test.org", - B1AdminRootUrl: "https://admin-staging.b1test.org", - CorsOrigin: "https://admin-staging.b1test.org", - StoreApiUrl: "https://store-staging.b1test.org", - TransferUrl: "https://transfer-staging.b1test.org", - SupportEmail: "support@b1test.org", - }; - - for (const [key, value] of Object.entries(expected)) { - if (backend[key] !== value) { - throw new Error(`prepare-environment-starter root-domain shortcut did not derive ${key} correctly.\nBackend:\n${JSON.stringify(backend, null, 2)}`); - } - } - - const secret = JSON.parse(fs.readFileSync(path.join(tempDir, "app-config-secret.json"), "utf8")); - if (secret.webPushSubject !== "mailto:support@b1test.org") { - throw new Error(`prepare-environment-starter root-domain shortcut did not derive webPushSubject correctly.\nSecret:\n${JSON.stringify(secret, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPrepareEnvironmentStarterCustomDomainInputsWork() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-domains-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--frontend-domain=admin-staging.b1test.org", - "--frontend-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/frontend", - "--frontend-hosted-zone-id=ZFRONTEND123", - "--api-domain=api-staging.b1test.org", - "--api-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/api", - "--api-hosted-zone-id=ZAPI123", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter custom-domain write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); - const frontend = JSON.parse(fs.readFileSync(path.join(tempDir, "frontend-parameters.json"), "utf8")); - - if (frontend.AlternateDomainName !== "admin-staging.b1test.org" - || frontend.AcmCertificateArn !== "arn:aws:acm:us-east-1:123456789012:certificate/frontend" - || frontend.HostedZoneId !== "ZFRONTEND123") { - throw new Error(`prepare-environment-starter did not write the expected frontend custom-domain fields.\nFrontend:\n${JSON.stringify(frontend, null, 2)}`); - } - - if (backend.ApiCustomDomainName !== "api-staging.b1test.org" - || backend.ApiCertificateArn !== "arn:aws:acm:us-east-1:123456789012:certificate/api" - || backend.ApiHostedZoneId !== "ZAPI123" - || backend.B1AdminRootUrl !== "https://admin-staging.b1test.org" - || backend.CorsOrigin !== "https://admin-staging.b1test.org") { - throw new Error(`prepare-environment-starter did not write the expected backend custom-domain fields.\nBackend:\n${JSON.stringify(backend, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPrepareEnvironmentStarterWriteModeCanSkipSecretFile() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-no-secret-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--admin-root-url=https://admin-staging.customer.test", - "--cors-origin=https://admin-staging.customer.test", - "--content-root-url=https://content-staging.customer.test", - "--transfer-url=https://transfer-staging.customer.test", - "--support-email=support@customer.test", - "--support-phone=918-994-2638", - "--support-site-url=https://support-staging.customer.test", - "--website-base-url=https://{subdomain}.customer.test", - "--write=true", - "--write-secret-file=false", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter no-secret write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - if (prepareResult.parsed?.writeSecretFile !== false) { - throw new Error(`prepare-environment-starter should report writeSecretFile=false when asked to skip secret materialization.\nSTDOUT:\n${prepareResult.stdout}`); - } - - if (fs.existsSync(path.join(tempDir, "app-config-secret.json"))) { - throw new Error("prepare-environment-starter should not create app-config-secret.json when --write-secret-file=false is set."); - } - - const auditResult = runJsonScript("scripts/audit-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--only-blockers=true", - "--output=json", - ]); - - if (auditResult.status !== 1) { - throw new Error(`audit-environment-starter should still report the unresolved secret-template blockers when no secret file is written.\nSTDOUT:\n${auditResult.stdout}\nSTDERR:\n${auditResult.stderr}`); - } - - const secretTemplate = JSON.parse(fs.readFileSync(path.join(tempDir, "app-config-secret.template.json"), "utf8")); - if (secretTemplate.webPushSubject !== "mailto:support@customer.test") { - throw new Error(`prepare-environment-starter should update the template webPushSubject when secret materialization is skipped.\nTemplate:\n${JSON.stringify(secretTemplate, null, 2)}`); - } - - if (auditResult.parsed?.blockerSummary?.blockerCount !== 3) { - throw new Error(`prepare-environment-starter no-secret write mode should leave only the unresolved store URL plus the two secret placeholders.\nSTDOUT:\n${auditResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPrepareEnvironmentStarterOptionalPublicFieldsWork() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-prepare-environment-public-fields-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "prod", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "prod"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=prod", - `--environment-dir=${tempDir}`, - "--mobile-app-url=https://customer.test/app", - "--domain-cname-target=proxy.customer.test", - "--domain-a-target=3.23.251.61", - "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", - "--google-analytics-tag=G-47N4XQJQJ5", - "--write=true", - "--write-secret-file=false", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter optional public fields write mode failed unexpectedly.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - const backend = JSON.parse(fs.readFileSync(path.join(tempDir, "backend-parameters.json"), "utf8")); - const expected = { - MobileAppUrl: "https://customer.test/app", - DomainCnameTarget: "proxy.customer.test", - DomainATarget: "3.23.251.61", - DefaultStockPhoto: "https://content.customer.test/stockPhotos/default.png", - GoogleAnalyticsTag: "G-47N4XQJQJ5", - }; - - for (const [key, value] of Object.entries(expected)) { - if (backend[key] !== value) { - throw new Error(`prepare-environment-starter did not write ${key} as expected.\nBackend:\n${JSON.stringify(backend, null, 2)}`); - } - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/plan-environment-deploy-output.sample.json"); - let result; - withFailingGhForDispatchGithubAwsDeploy((env) => withRawStarterEnvironment("staging", (tempDir) => { - result = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--api-repo-path=.", - "--output=json", - ], env); - })); - - if (sample.localGithubDispatch?.ok !== false - || sample.localGithubDispatch?.blockerCount !== 1 - || !sample.localGithubDispatch?.blockers?.some((entry) => String(entry).includes("gh auth login -h github.com"))) { - throw new Error(`plan-environment-deploy output sample should document the local gh auth blocker cleanly.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - - if (result.status !== 1) { - throw new Error(`plan-environment-deploy output sample contract run should be blocked while placeholders remain in staging.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("plan-environment-deploy output sample", actual, sample); - - if (sample.ok !== false || sample.environment !== "staging" || sample.deploymentSource !== "api-repo") { - throw new Error(`plan-environment-deploy output sample should document a blocked staging api-repo plan.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.requiredGithubSecrets) || sample.requiredGithubSecrets[0] !== "AWS_ROLE_TO_ASSUME") { - throw new Error(`plan-environment-deploy output sample should document the default OIDC secret requirement.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.localExecution?.blockerCount !== 3 || sample.githubActionsExecution?.blockerCount !== 3) { - throw new Error(`plan-environment-deploy output sample should document the expected shared execution blocker counts.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.starterSummary?.unsafeDefaultCount !== 10 || sample.starterSummary?.blockerCount !== 15) { - throw new Error(`plan-environment-deploy output sample should document the expected starter blocker totals.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedExecution?.path !== "none") { - throw new Error(`plan-environment-deploy output sample should recommend no execution path while shared blockers remain.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedCommands?.primary !== sample.starterPrepCommands?.dryRun) { - throw new Error(`plan-environment-deploy output sample should recommend the starter prep dry-run first while shared starter blockers remain.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.commands?.localPreview || "").includes("PREVIEW_ONLY='true'") - || !String(sample.commands?.githubActionsWrapperPreview || "").includes("--preview-only=true") - || !String(sample.commands?.githubActionsPreview || "").includes("preview_only='true'")) { - throw new Error(`plan-environment-deploy output sample should expose local and GitHub preview commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.recommendedCommands?.alternates) - || !sample.recommendedCommands.alternates.some((command) => String(command).includes("PREVIEW_ONLY='true'")) - || !sample.recommendedCommands.alternates.some((command) => String(command).includes("--preview-only=true")) - || !sample.recommendedCommands.alternates.some((command) => String(command).includes("preview_only='true'"))) { - throw new Error(`plan-environment-deploy output sample should include preview-mode alternates alongside the live commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.starterPrepCommands?.commands || "").includes("prepare:environment-starter") - || !String(sample.starterPrepCommands?.write || "").includes("--write=true")) { - throw new Error(`plan-environment-deploy output sample should include the starter prep follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.preflightCommands?.auditStarter || "").includes("audit:environment-starter") - || !String(sample.preflightCommands?.auditApiRepoContract || "").includes("audit:api-repo-contract")) { - throw new Error(`plan-environment-deploy output sample should document the expected preflight audit commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.postDeployCommands?.verify || "").includes("verify:split-stack") || !String(sample.postDeployCommands?.checklist || "").includes("first-rollout-checklist.md")) { - throw new Error(`plan-environment-deploy output sample should document the expected post-deploy follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.postDeployCommands?.ensureOutputsDir || "").includes("mkdir -p deployment/staging") - || !String(sample.postDeployCommands?.saveBackendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks") - || !String(sample.postDeployCommands?.saveFrontendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks")) { - throw new Error(`plan-environment-deploy output sample should document the expected output-capture commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.postDeployCommands?.saveOutputsWithHelper || "").includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { - throw new Error(`plan-environment-deploy output sample should document the helper-based output capture command.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.postDeployCommands?.showSavedSummary || "").includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { - throw new Error(`plan-environment-deploy output sample should document the saved-summary render command.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.postDeployCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=deployment/staging/backend-outputs.json") - || !String(sample.postDeployCommands?.verifyFromSavedOutputsWithHttp || "").includes("--check-http=true") - || !String(sample.postDeployCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets") - || !String(sample.postDeployCommands?.publishFrontendAssetsFromSavedOutputs || "").includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json")) { - throw new Error(`plan-environment-deploy output sample should document the expected saved-output reuse commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.githubPostDeploy?.artifactName !== "aws-staging-deployment-evidence" - || sample.githubPostDeploy?.artifactPath !== "deployment/staging/" - || sample.githubPostDeploy?.failureArtifactName !== "aws-staging-preflight-plan" - || sample.githubPostDeploy?.failureArtifactPath !== "deployment/staging/preflight-plan.md" - || !Array.isArray(sample.githubPostDeploy?.summaryIncludes) - || !sample.githubPostDeploy.summaryIncludes.includes("preflight deploy plan") - || !sample.githubPostDeploy.summaryIncludes.includes("saved-output follow-up commands")) { - throw new Error(`plan-environment-deploy output sample should document the expected GitHub post-deploy handoff.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectPlanEnvironmentDeployCommandsOutputWorks() { - let result; - withRawStarterEnvironment("staging", (tempDir) => { - result = spawnSync("node", ["scripts/plan-environment-deploy.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--api-repo-path=.", "--output=commands"], { - cwd: rootDir, - encoding: "utf8", - }); - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - - if (result.status !== 1) { - throw new Error(`plan-environment-deploy commands mode should be blocked while staging placeholders remain.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - const expectedSnippets = [ - "yarn prepare:environment-starter -- --environment=staging --environment-dir=", - "--account-id= --output=json", - "--account-id= --output=commands", - "--account-id= --write=true", - "PREVIEW_ONLY='true' ./infrastructure/environments/staging/deploy-split-stack.sh", - "yarn dispatch:github-aws-deploy -- --environment=staging --deployment-source=api-repo --region=us-east-1 --environment-dir=", - "--preview-only=true", - "preview_only='true'", - "./infrastructure/environments/staging/deploy-split-stack.sh", - "gh workflow run deploy-aws-self-hosted.yml", - ]; - - const lines = stdout.trim().split("\n"); - if (!lines[0]?.startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") - || !lines[0]?.endsWith("--account-id= --output=json")) { - throw new Error(`plan-environment-deploy commands output should recommend the starter prep dry-run first while shared starter blockers remain.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("yarn verify:split-stack -- --region=us-east-1 --backend-stack-name=b1admin-staging-backend --frontend-stack-name=b1admin-staging-frontend")) { - throw new Error(`plan-environment-deploy commands output should include the post-deploy verification command.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { - throw new Error(`plan-environment-deploy commands output should include the helper-based output capture command.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("yarn audit:api-repo-contract -- --api-repo-path=. --output=markdown")) { - throw new Error(`plan-environment-deploy commands output should include the Api repo contract preflight command.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { - throw new Error(`plan-environment-deploy commands output should include the saved-summary render command.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("mkdir -p deployment/staging") - || !stdout.includes("mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-backend --region us-east-1 --output json > deployment/staging/backend-outputs.json") - || !stdout.includes("mkdir -p deployment/staging && aws cloudformation describe-stacks --stack-name b1admin-staging-frontend --region us-east-1 --output json > deployment/staging/frontend-outputs.json")) { - throw new Error(`plan-environment-deploy commands output should include the output-capture commands.\nSTDOUT:\n${stdout}`); - } - const publishFromSavedOutputsPattern = new RegExp( - String.raw`yarn deploy:aws -- --region=us-east-1 --project-name=b1admin --environment=staging --frontend-parameters-file=.*frontend-parameters\.json --backend-parameters-file=.*backend-parameters\.json --frontend-outputs-file=deployment/staging/frontend-outputs\.json --backend-outputs-file=deployment/staging/backend-outputs\.json --skip-backend --skip-frontend --publish-frontend-assets`, - ); - - if (!stdout.includes("yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=deployment/staging/backend-outputs.json --frontend-outputs-file=deployment/staging/frontend-outputs.json") - || !publishFromSavedOutputsPattern.test(stdout) - || !stdout.includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json --backend-outputs-file=deployment/staging/backend-outputs.json")) { - throw new Error(`plan-environment-deploy commands output should include the saved-output reuse commands.\nSTDOUT:\n${stdout}`); - } - - for (const snippet of expectedSnippets) { - if (!stdout.includes(snippet)) { - throw new Error(`plan-environment-deploy commands output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectInstallerSetupScaffoldsPrivateDeploymentRepo() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-setup-")); - - try { - const dryRun = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--output=json", - ]); - - if (dryRun.status !== 0 || dryRun.parsed?.write !== false || dryRun.parsed?.writtenCount !== 0 || dryRun.parsed?.plannedCount !== 14) { - throw new Error(`installer setup dry-run did not report the expected scaffold plan.\nSTDOUT:\n${dryRun.stdout}\nSTDERR:\n${dryRun.stderr}`); - } - - const writeRun = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - - if (writeRun.status !== 0 || writeRun.parsed?.write !== true || writeRun.parsed?.writtenCount !== 14) { - throw new Error(`installer setup write mode did not write the expected scaffold.\nSTDOUT:\n${writeRun.stdout}\nSTDERR:\n${writeRun.stderr}`); - } - const safeCommitCommands = writeRun.parsed?.safeCommitCommands || []; - if (!safeCommitCommands.some((command) => String(command).includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments")) - || safeCommitCommands.some((command) => String(command).includes("customer-values.json ")) - || safeCommitCommands.some((command) => String(command).includes("deployment/"))) { - throw new Error(`installer setup should output safe private repo commit commands that do not stage local secrets or evidence.\nSTDOUT:\n${writeRun.stdout}`); - } - - const expectedFiles = [ - ".github/workflows/deploy-aws-self-hosted.yml", - ".gitignore", - "README.md", - "customer-values.sample.json", - "environments/staging/bootstrap-parameters.json", - "environments/staging/backend-parameters.json", - "environments/staging/frontend-parameters.json", - "environments/staging/app-config-secret.template.json", - "environments/staging/deploy-split-stack.sh", - "environments/prod/bootstrap-parameters.json", - "environments/prod/backend-parameters.json", - "environments/prod/frontend-parameters.json", - "environments/prod/app-config-secret.template.json", - "environments/prod/deploy-split-stack.sh", - ]; - - const missing = expectedFiles.filter((fileName) => !fs.existsSync(path.join(tempDir, fileName))); - if (missing.length > 0) { - throw new Error(`installer setup scaffold is missing expected files: ${missing.join(", ")}`); - } - - for (const forbiddenFile of [ - "environments/staging/app-config-secret.json", - "environments/staging/bootstrap-admin-secret.json", - "environments/prod/app-config-secret.json", - "environments/prod/bootstrap-admin-secret.json", - ]) { - if (fs.existsSync(path.join(tempDir, forbiddenFile))) { - throw new Error(`installer setup should not copy local runtime secret files: ${forbiddenFile}`); - } - } - - const workflowText = fs.readFileSync(path.join(tempDir, ".github/workflows/deploy-aws-self-hosted.yml"), "utf8"); - if (!workflowText.includes("b1admin_repo:") || !workflowText.includes("b1admin_ref:")) { - throw new Error("installer setup should copy the private workflow with explicit B1Admin source inputs."); - } - if (!workflowText.includes("name: Save deployment evidence") - || !workflowText.includes("yarn save:split-stack-outputs --") - || !workflowText.includes("name: Write deployment summary") - || !workflowText.includes("deployment-summary.json")) { - throw new Error("installer setup should copy a private workflow that saves and summarizes deployment evidence before uploading artifacts."); - } - - const gitignoreText = fs.readFileSync(path.join(tempDir, ".gitignore"), "utf8"); - if (!gitignoreText.includes("environments/*/app-config-secret.json") - || !gitignoreText.includes("environments/*/bootstrap-admin-secret.json") - || !gitignoreText.includes("customer-values.json")) { - throw new Error("installer setup should create a private repo .gitignore that protects runtime secret files."); - } - - const readmeText = fs.readFileSync(path.join(tempDir, "README.md"), "utf8"); - if (!readmeText.includes("pauses before approval steps") - || !readmeText.includes(`yarn installer:init -- --deploy-repo-dir=${tempDir} --output=markdown`) - || !readmeText.includes("yarn installer:customer-values") - || !readmeText.includes("yarn installer:run") - || !readmeText.includes(`--deploy-env-dir=${path.join(tempDir, "environments")}`) - || !readmeText.includes(`--deployment-root=${path.join(tempDir, "deployment")}`) - || !readmeText.includes("Smallest AWS footprint: deploy prod first and skip staging") - || !readmeText.includes("--environment=prod") - || !readmeText.includes("Optional practice deployment") - || !readmeText.includes("Do not commit `customer-values.json`, `app-config-secret.json`, `bootstrap-admin-secret.json`, or `deployment/`") - || !readmeText.includes("installer stores downloaded workflow evidence, browser-smoke results, and the final report") - || !readmeText.includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments") - || !readmeText.includes("Use `yarn installer:doctor")) { - throw new Error(`installer setup private README should keep the operator on the guided path.\n${readmeText}`); - } - - const customerFilePath = path.join(tempDir, "customer-values.json"); - fs.copyFileSync(path.join(tempDir, "customer-values.sample.json"), customerFilePath); - const customerValues = JSON.parse(fs.readFileSync(customerFilePath, "utf8")); - fs.writeFileSync(customerFilePath, `${JSON.stringify({ - ...customerValues, - accountId: "123456789012", - repo: "example/b1admin-deploy", - rootDomain: "customer.test", - supportEmail: "support@customer.test", - supportPhone: "111-222-3333", - }, null, 2)}\n`); - - const audit = runJsonScript("scripts/audit-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--only-blockers=true", - "--output=json", - ]); - - if (audit.status !== 1 || audit.parsed?.blockerSummary?.blockerCount !== 15) { - throw new Error(`installer setup should scaffold raw starters with expected first-run blockers.\nSTDOUT:\n${audit.stdout}\nSTDERR:\n${audit.stderr}`); - } - - const configured = runJsonScript("scripts/installer-configure.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--write=true", - "--output=json", - ]); - - if (configured.status !== 0 || configured.parsed?.auditBlockerCount !== 0) { - throw new Error(`installer configure should clear generated staging blockers.\nSTDOUT:\n${configured.stdout}\nSTDERR:\n${configured.stderr}`); - } - - const appConfigPreview = runJsonScript("scripts/installer-app-config-secret.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--output=json", - ]); - - if (appConfigPreview.status !== 0 || !["preview", "reuse-existing"].includes(appConfigPreview.parsed?.fileAction)) { - throw new Error(`installer app-config secret preview should report whether it would create or reuse the local secret file.\nSTDOUT:\n${appConfigPreview.stdout}\nSTDERR:\n${appConfigPreview.stderr}`); - } - - const appConfigWrite = runJsonScript("scripts/installer-app-config-secret.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--write=true", - "--output=json", - ]); - - const appConfigSecretPath = path.join(tempDir, "environments", "staging", "app-config-secret.json"); - const appConfigSecret = JSON.parse(fs.readFileSync(appConfigSecretPath, "utf8")); - if (appConfigWrite.status !== 0 - || !["created", "kept-existing"].includes(appConfigWrite.parsed?.fileAction) - || String(appConfigSecret.jwtSecret).startsWith("replace-me") - || String(appConfigSecret.encryptionKey).startsWith("replace-me") - || appConfigSecret.webPushSubject !== "mailto:support@customer.test") { - throw new Error(`installer app-config secret write should create a usable gitignored secret JSON.\nSTDOUT:\n${appConfigWrite.stdout}\nSTDERR:\n${appConfigWrite.stderr}`); - } - - const appConfigGithubPreview = runJsonScript("scripts/installer-app-config-secret.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--repo=example/b1admin-deploy", - "--sync-github-secret=true", - "--skip-gh-auth-check=true", - "--output=json", - ]); - - if (appConfigGithubPreview.status !== 0 - || appConfigGithubPreview.parsed?.githubSync?.action !== "validated" - || appConfigGithubPreview.parsed?.githubSync?.secretName !== "AWS_APP_CONFIG_SECRET_JSON" - || !String(appConfigGithubPreview.parsed?.githubSync?.commandPreview || "").includes("gh secret set")) { - throw new Error(`installer app-config secret should dry-run GitHub secret sync without touching GitHub.\nSTDOUT:\n${appConfigGithubPreview.stdout}\nSTDERR:\n${appConfigGithubPreview.stderr}`); - } - - const awsPreflightSkipped = runJsonScript("scripts/installer-aws-preflight.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--skip-aws-check=true", - "--output=json", - ]); - - if (awsPreflightSkipped.status !== 0 || awsPreflightSkipped.parsed?.ok !== true || awsPreflightSkipped.parsed?.skipped !== true) { - throw new Error(`installer aws preflight should support an explicit offline skip mode.\nSTDOUT:\n${awsPreflightSkipped.stdout}\nSTDERR:\n${awsPreflightSkipped.stderr}`); - } - - const frontendParamsPath = path.join(tempDir, "environments", "staging", "frontend-parameters.json"); - const frontendParams = JSON.parse(fs.readFileSync(frontendParamsPath, "utf8")); - fs.writeFileSync(frontendParamsPath, `${JSON.stringify({ - ...frontendParams, - AlternateDomainName: "admin-staging.customer.test", - AcmCertificateArn: "arn:aws:acm:us-west-2:123456789012:certificate/example", - HostedZoneId: "Z1234567890", - }, null, 2)}\n`); - - const badCertificate = runJsonScript("scripts/installer-aws-preflight.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--skip-aws-identity-check=true", - "--skip-aws-resource-lookups=true", - "--output=json", - ]); - - if (badCertificate.status === 0 || badCertificate.parsed?.ok !== false || !String(JSON.stringify(badCertificate.parsed)).includes("CloudFront requires the frontend ACM certificate in us-east-1")) { - throw new Error(`installer aws preflight should block frontend certs outside us-east-1.\nSTDOUT:\n${badCertificate.stdout}\nSTDERR:\n${badCertificate.stderr}`); - } - - fs.writeFileSync(frontendParamsPath, `${JSON.stringify(frontendParams, null, 2)}\n`); - - const preflight = runJsonScript("scripts/installer-preflight.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--repo=example/b1admin-deploy", - "--skip-github-repo-check=true", - "--skip-aws-check=true", - "--output=json", - ]); - - if (preflight.status !== 0 || preflight.parsed?.ok !== true || preflight.parsed?.starterBlockers !== 0) { - throw new Error(`installer preflight should pass for a configured private staging starter when repo lookup is skipped.\nSTDOUT:\n${preflight.stdout}\nSTDERR:\n${preflight.stderr}`); - } - - const deployDryRun = runJsonScript("scripts/installer-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - "--repo=example/b1admin-deploy", - "--skip-github-repo-check=true", - "--skip-gh-auth-check=true", - "--skip-aws-check=true", - "--dry-run=true", - "--output=json", - ]); - - if (deployDryRun.status !== 0 || deployDryRun.parsed?.action !== "validated" || deployDryRun.parsed?.dispatch?.previewOnly !== true) { - throw new Error(`installer deploy dry-run should validate a preview workflow dispatch without touching GitHub.\nSTDOUT:\n${deployDryRun.stdout}\nSTDERR:\n${deployDryRun.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerInitCreatesGuidedStartingPoint() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-init-")); - - try { - const result = runJsonScript("scripts/installer-init.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--output=json", - ]); - - if (result.status !== 0 - || result.parsed?.ok !== true - || !fs.existsSync(path.join(tempDir, ".github", "workflows", "deploy-aws-self-hosted.yml")) - || !fs.existsSync(path.join(tempDir, "customer-values.json")) - || !String(result.parsed?.nextCommand || "").includes("installer:customer-values") - || !String(result.parsed?.nextCommand || "").includes("installer:run") - || !String(result.parsed?.nextCommand || "").includes(`--deploy-repo-dir=${tempDir}`) - || !String(result.parsed?.nextCommand || "").includes(`--deploy-env-dir=${path.join(tempDir, "environments")}`) - || !String(result.parsed?.nextCommand || "").includes(`--deployment-root=${path.join(tempDir, "deployment")}`) - || !String(result.parsed?.nextCommand || "").includes("--environment=prod") - || !String(result.parsed?.nextCommand || "").includes("Optional practice deployment") - || !result.parsed?.safeCommitCommands?.some((command) => String(command).includes("git add README.md .gitignore .github/workflows/deploy-aws-self-hosted.yml customer-values.sample.json environments")) - || result.parsed?.safeCommitCommands?.some((command) => String(command).includes("customer-values.json ")) - || result.parsed?.safeCommitCommands?.some((command) => String(command).includes("deployment/"))) { - throw new Error(`installer init should scaffold the private repo, create customer-values.json, and recommend installer:run.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const customerFile = path.join(tempDir, "customer-values.json"); - fs.writeFileSync(customerFile, `${JSON.stringify({ sentinel: "keep-me" }, null, 2)}\n`); - - const rerun = runJsonScript("scripts/installer-init.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--output=json", - ]); - const customerValues = JSON.parse(fs.readFileSync(customerFile, "utf8")); - - if (rerun.status !== 0 - || customerValues.sentinel !== "keep-me" - || !String(rerun.parsed?.actions?.find((action) => action.label === "Customer values file")?.detail || "").includes("not overwritten")) { - throw new Error(`installer init should preserve an existing customer-values.json.\nSTDOUT:\n${rerun.stdout}\nSTDERR:\n${rerun.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerCustomerValuesWritesGuidedAnswers() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-customer-values-")); - const customerFile = path.join(tempDir, "customer-values.json"); - - try { - const result = runJsonScript("scripts/installer-customer-values.mjs", [ - `--customer-file=${customerFile}`, - "--interactive=false", - "--write=true", - "--aws-region=us-east-1", - "--account-id=123456789012", - "--repo=example/b1admin-deploy", - "--root-domain=customer.test", - "--support-email=support@customer.test", - "--support-phone=111-222-3333", - "--first-admin-email=admin@customer.test", - "--first-admin-password=Use-Once-2638!", - "--first-church-name=Customer Church", - "--prod-frontend-domain=admin.customer.test", - "--prod-frontend-certificate-arn=arn:aws:acm:us-east-1:123456789012:certificate/example", - "--prod-frontend-hosted-zone-id=Z123EXAMPLE", - "--output=json", - ]); - - const values = JSON.parse(fs.readFileSync(customerFile, "utf8")); - if (result.status !== 0 - || result.parsed?.ok !== true - || values.accountId !== "123456789012" - || values.repo !== "example/b1admin-deploy" - || values.firstChurchName !== "Customer Church" - || values.environments?.prod?.frontendDomain !== "admin.customer.test" - || values.environments?.staging?.frontendDomain !== "") { - throw new Error(`installer customer-values should write answers into the local customer file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerRunExecutesGuidedStep() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-run-")); - - try { - const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - if (setup.status !== 0) { - throw new Error(`installer run fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); - } - - const result = runJsonScript("scripts/installer-run.mjs", [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--customer-file=${path.join(tempDir, "customer-values.json")}`, - "--yes=true", - "--max-steps=1", - "--output=json", - ]); - - if (result.status !== 0 - || result.parsed?.complete !== false - || result.parsed?.history?.[0]?.action !== "run" - || !fs.existsSync(path.join(tempDir, "customer-values.json"))) { - throw new Error(`installer run should execute the first safe guided step.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerUpdateDryRun() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-update-")); - - try { - const result = runJsonScript("scripts/installer-update.mjs", [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--deployment-root=${path.join(tempDir, "deployment")}`, - `--customer-file=${path.join(tempDir, "customer-values.json")}`, - "--environment=prod", - "--dry-run=true", - "--skip-pull=true", - "--skip-private-commit=true", - "--output=json", - ]); - - if (result.status !== 0 - || result.parsed?.ok !== true - || result.parsed?.history?.[0]?.action !== "installer-init" - || result.parsed?.history?.[1]?.action !== "installer-run" - || result.parsed?.history?.some((entry) => entry.action === "git-pull")) { - throw new Error(`installer update dry-run should plan scaffold refresh and guided deploy without pulling source.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function gitCommitAllFixture(repoDir) { - const runGit = (args) => { - const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8", timeout: childProcessTimeoutMs }); - return { status: result.status ?? 1, stdout: result.stdout || "", stderr: result.stderr || "" }; - }; - const requireGit = (args) => { - const result = runGit(args); - if (result.status !== 0) throw new Error(`fixture git ${args.join(" ")} failed: ${result.stderr}`); - return result; - }; - - if (!fs.existsSync(path.join(repoDir, ".git"))) { - requireGit(["init", "-b", "main"]); - requireGit(["config", "user.email", "smoke@example.com"]); - requireGit(["config", "user.name", "Smoke Fixture"]); - const remoteDir = path.join(repoDir, ".remote.git"); - const bare = spawnSync("git", ["init", "--bare", remoteDir], { encoding: "utf8", timeout: childProcessTimeoutMs }); - if ((bare.status ?? 1) !== 0) throw new Error(`fixture bare git init failed: ${bare.stderr}`); - fs.appendFileSync(path.join(repoDir, ".gitignore"), "\n/.remote.git/\n"); - requireGit(["remote", "add", "origin", remoteDir]); - } - - requireGit(["add", "-A"]); - const commit = runGit(["commit", "-m", "fixture commit"]); - if (commit.status !== 0 && !/nothing to commit/.test(`${commit.stdout}${commit.stderr}`)) { - throw new Error(`fixture git commit failed: ${commit.stderr || commit.stdout}`); - } - requireGit(["push", "-u", "origin", "HEAD"]); -} - -function expectInstallerStartRecommendsNextStep() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-start-")); - const nodeModulesDir = path.join(rootDir, "node_modules"); - const viteCliPath = path.join(nodeModulesDir, "vite", "dist", "node", "cli.js"); - const hadNodeModules = fs.existsSync(nodeModulesDir); - const hadViteCli = fs.existsSync(viteCliPath); - - try { - const noScaffoldDir = path.join(tempDir, "empty-deploy-repo"); - const noScaffold = runJsonScript("scripts/installer-start.mjs", [ - `--deploy-repo-dir=${noScaffoldDir}`, - `--deploy-env-dir=${path.join(noScaffoldDir, "environments")}`, - `--customer-file=${path.join(noScaffoldDir, "customer-values.json")}`, - "--environment=staging", - "--output=json", - ]); - if (noScaffold.status !== 0 || !String(noScaffold.parsed?.nextCommand || "").includes("installer:init")) { - throw new Error(`installer start should recommend scaffolding before copying a missing customer sample.\nSTDOUT:\n${noScaffold.stdout}\nSTDERR:\n${noScaffold.stderr}`); - } - - const defaultEnvironment = runJsonScript("scripts/installer-start.mjs", [ - `--deploy-repo-dir=${noScaffoldDir}`, - `--deploy-env-dir=${path.join(noScaffoldDir, "environments")}`, - `--customer-file=${path.join(noScaffoldDir, "customer-values.json")}`, - "--output=json", - ]); - if (defaultEnvironment.status !== 0 || defaultEnvironment.parsed?.environment !== "prod") { - throw new Error(`installer start should default to prod for the smaller AWS footprint path.\nSTDOUT:\n${defaultEnvironment.stdout}\nSTDERR:\n${defaultEnvironment.stderr}`); - } - - const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - if (setup.status !== 0) { - throw new Error(`installer start fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); - } - - const missingCustomer = runJsonScript("scripts/installer-start.mjs", [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--customer-file=${path.join(tempDir, "customer-values.json")}`, - "--environment=staging", - "--output=json", - ]); - - if (missingCustomer.status !== 0 || !String(missingCustomer.parsed?.nextCommand || "").startsWith("cp ")) { - throw new Error(`installer start should recommend creating customer-values.json first.\nSTDOUT:\n${missingCustomer.stdout}\nSTDERR:\n${missingCustomer.stderr}`); - } - - const customerFilePath = path.join(tempDir, "customer-values.json"); - const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); - fs.writeFileSync(customerFilePath, `${JSON.stringify(sample, null, 2)}\n`); - - const blankCustomer = runJsonScript("scripts/installer-start.mjs", [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--customer-file=${customerFilePath}`, - "--environment=staging", - "--output=json", - ]); - - if (blankCustomer.status !== 0 - || !String(blankCustomer.parsed?.nextCommand || "").includes("installer:customer-values") - || blankCustomer.parsed?.checks?.find((check) => check.label === "Core customer values")?.ok !== false) { - throw new Error(`installer start should not treat blank/sample customer values as ready.\nSTDOUT:\n${blankCustomer.stdout}\nSTDERR:\n${blankCustomer.stderr}`); - } - - fs.writeFileSync(customerFilePath, `${JSON.stringify({ - ...sample, - accountId: "999888777666", - repo: "acme-church/b1admin-deploy", - rootDomain: "acmechurch.org", - supportEmail: "support@acmechurch.org", - supportPhone: "918-555-2638", - firstAdminEmail: "", - firstAdminPassword: "", - firstChurchName: "", - }, null, 2)}\n`); - - const withCustomer = runJsonScript("scripts/installer-start.mjs", [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--customer-file=${customerFilePath}`, - "--environment=staging", - "--output=json", - ]); - - if (withCustomer.status !== 0 - || !String(withCustomer.parsed?.nextCommand || "").includes("installer:aws-handoff") - || withCustomer.parsed?.deploymentRoot !== path.relative(rootDir, path.join(tempDir, "deployment")) - || !withCustomer.parsed?.checks?.some((check) => check.label === "Core customer values" && check.ok === true)) { - throw new Error(`installer start should use customer-values.json and recommend the IAM handoff next.\nSTDOUT:\n${withCustomer.stdout}\nSTDERR:\n${withCustomer.stderr}`); - } - - const deploymentRoot = path.join(tempDir, "deployment"); - const startArgs = [ - `--deploy-repo-dir=${tempDir}`, - `--deploy-env-dir=${path.join(tempDir, "environments")}`, - `--deployment-root=${deploymentRoot}`, - `--customer-file=${customerFilePath}`, - "--environment=staging", - "--output=json", - ]; - - const handoff = runJsonScript("scripts/installer-aws-handoff.mjs", [ - `--customer-file=${customerFilePath}`, - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - if (handoff.status !== 0) { - throw new Error(`installer start fixture handoff failed.\nSTDOUT:\n${handoff.stdout}\nSTDERR:\n${handoff.stderr}`); - } - - const needsIamApply = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsIamApply.status !== 0 || !String(needsIamApply.parsed?.nextCommand || "").includes("--apply=true")) { - throw new Error(`installer start should recommend creating the IAM roles after the handoff files exist.\nSTDOUT:\n${needsIamApply.stdout}\nSTDERR:\n${needsIamApply.stderr}`); - } - - fs.mkdirSync(path.join(tempDir, "iam", "staging"), { recursive: true }); - fs.writeFileSync(path.join(tempDir, "iam", "staging", "apply-result.json"), JSON.stringify({ ok: true }, null, 2)); - - const configure = runJsonScript("scripts/installer-configure.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--write=true", - "--output=json", - ]); - if (configure.status !== 0) { - throw new Error(`installer start fixture configure failed.\nSTDOUT:\n${configure.stdout}\nSTDERR:\n${configure.stderr}`); - } - - const appConfig = runJsonScript("scripts/installer-app-config-secret.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--write=true", - "--output=json", - ]); - if (appConfig.status !== 0) { - throw new Error(`installer start fixture app-config failed.\nSTDOUT:\n${appConfig.stdout}\nSTDERR:\n${appConfig.stderr}`); - } - - const needsCommit = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsCommit.status !== 0 || !String(needsCommit.parsed?.nextCommand || "").includes("installer:commit")) { - throw new Error(`installer start should recommend syncing the private repository after local files change.\nSTDOUT:\n${needsCommit.stdout}\nSTDERR:\n${needsCommit.stderr}`); - } - gitCommitAllFixture(tempDir); - - const needsGithubReadiness = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsGithubReadiness.status !== 0 || !String(needsGithubReadiness.parsed?.nextCommand || "").includes("installer:github-readiness")) { - throw new Error(`installer start should recommend GitHub readiness after local setup is complete.\nSTDOUT:\n${needsGithubReadiness.stdout}\nSTDERR:\n${needsGithubReadiness.stderr}`); - } - - const stagingEvidenceDir = path.join(deploymentRoot, "staging"); - fs.mkdirSync(stagingEvidenceDir, { recursive: true }); - fs.writeFileSync(path.join(stagingEvidenceDir, "github-readiness.json"), JSON.stringify({ ok: false }, null, 2)); - const failedGithubReadiness = runJsonScript("scripts/installer-start.mjs", startArgs); - if (failedGithubReadiness.status !== 0 - || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("installer:github-setup") - || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("--write=true") - || !String(failedGithubReadiness.parsed?.nextCommand || "").includes("--write-secrets=true")) { - throw new Error(`installer start should recommend GitHub setup when readiness evidence is not clean.\nSTDOUT:\n${failedGithubReadiness.stdout}\nSTDERR:\n${failedGithubReadiness.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "github-readiness.json"), JSON.stringify({ ok: true }, null, 2)); - - const needsPreflight = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsPreflight.status !== 0 || !String(needsPreflight.parsed?.nextCommand || "").includes("installer:preflight")) { - throw new Error(`installer start should recommend preflight after GitHub readiness evidence exists.\nSTDOUT:\n${needsPreflight.stdout}\nSTDERR:\n${needsPreflight.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "preflight-readiness.json"), JSON.stringify({ ok: true }, null, 2)); - const needsPreview = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsPreview.status !== 0 || !String(needsPreview.parsed?.nextCommand || "").includes("--preview-only=true")) { - throw new Error(`installer start should recommend preview dispatch after preflight evidence exists.\nSTDOUT:\n${needsPreview.stdout}\nSTDERR:\n${needsPreview.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "last-preview-dispatch.json"), JSON.stringify({ ok: true, runId: 123 }, null, 2)); - const needsObservePreview = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsObservePreview.status !== 0 || !String(needsObservePreview.parsed?.nextCommand || "").includes("installer:observe")) { - throw new Error(`installer start should recommend observing a dispatched preview.\nSTDOUT:\n${needsObservePreview.stdout}\nSTDERR:\n${needsObservePreview.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "preflight-plan.md"), "# Preview plan\n"); - const needsDeploy = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsDeploy.status !== 0 || !String(needsDeploy.parsed?.nextCommand || "").includes("--confirm=true")) { - throw new Error(`installer start should recommend real deploy after preview evidence exists.\nSTDOUT:\n${needsDeploy.stdout}\nSTDERR:\n${needsDeploy.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "last-deploy-dispatch.json"), JSON.stringify({ ok: true, runId: 456 }, null, 2)); - const needsObserveDeploy = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsObserveDeploy.status !== 0 || !String(needsObserveDeploy.parsed?.nextCommand || "").includes("--verify=true")) { - throw new Error(`installer start should recommend observing a dispatched deploy.\nSTDOUT:\n${needsObserveDeploy.stdout}\nSTDERR:\n${needsObserveDeploy.stderr}`); - } - - writeReportEvidenceFixture(deploymentRoot, "staging"); - const needsFrontendOrigin = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsFrontendOrigin.status !== 0 - || !String(needsFrontendOrigin.parsed?.nextCommand || "").includes("installer:adopt-frontend-origin") - || !needsFrontendOrigin.parsed?.checks?.some((check) => check.label === "Frontend origin accepted by backend" && check.ok === false)) { - throw new Error(`installer start should recommend adopting the deployed frontend origin before browser login steps.\nSTDOUT:\n${needsFrontendOrigin.stdout}\nSTDERR:\n${needsFrontendOrigin.stderr}`); - } - - const adoptFrontendOrigin = runJsonScript("scripts/installer-adopt-frontend-origin.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--deployment-root=${deploymentRoot}`, - "--write=true", - "--output=json", - ]); - if (adoptFrontendOrigin.status !== 0 || !adoptFrontendOrigin.parsed?.ok) { - throw new Error(`installer adopt frontend origin should update backend parameters from deployment evidence.\nSTDOUT:\n${adoptFrontendOrigin.stdout}\nSTDERR:\n${adoptFrontendOrigin.stderr}`); - } - - const needsPostAdoptCommit = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsPostAdoptCommit.status !== 0 || !String(needsPostAdoptCommit.parsed?.nextCommand || "").includes("installer:commit")) { - throw new Error(`installer start should recommend committing the adopted frontend origin.\nSTDOUT:\n${needsPostAdoptCommit.stdout}\nSTDERR:\n${needsPostAdoptCommit.stderr}`); - } - gitCommitAllFixture(tempDir); - - const needsRedeploy = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsRedeploy.status !== 0 || !String(needsRedeploy.parsed?.nextCommand || "").includes("--confirm=true")) { - throw new Error(`installer start should recommend rerunning the real deploy after adopting the frontend origin.\nSTDOUT:\n${needsRedeploy.stdout}\nSTDERR:\n${needsRedeploy.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "last-deploy-dispatch.json"), JSON.stringify({ ok: true, runId: 789 }, null, 2)); - const needsObserveRedeploy = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsObserveRedeploy.status !== 0 || !String(needsObserveRedeploy.parsed?.nextCommand || "").includes("--verify=true")) { - throw new Error(`installer start should recommend observing the post-adopt redeploy.\nSTDOUT:\n${needsObserveRedeploy.stdout}\nSTDERR:\n${needsObserveRedeploy.stderr}`); - } - writeReportEvidenceFixture(deploymentRoot, "staging"); - - const needsFirstAdminValues = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsFirstAdminValues.status !== 0 - || !String(needsFirstAdminValues.parsed?.nextCommand || "").includes("installer:customer-values") - || !needsFirstAdminValues.parsed?.checks?.some((check) => check.label === "First admin values" && check.ok === false)) { - throw new Error(`installer start should ask for first-admin values after deployment evidence exists.\nSTDOUT:\n${needsFirstAdminValues.stdout}\nSTDERR:\n${needsFirstAdminValues.stderr}`); - } - - const customerValues = JSON.parse(fs.readFileSync(customerFilePath, "utf8")); - fs.writeFileSync(customerFilePath, `${JSON.stringify({ - ...customerValues, - firstAdminEmail: "admin@customer.test", - firstAdminPassword: "Use-Once-2638!", - firstChurchName: "Customer Church", - }, null, 2)}\n`); - - let needsBootstrapAdmin = runJsonScript("scripts/installer-start.mjs", startArgs); - if (!hadViteCli) { - if (needsBootstrapAdmin.status !== 0 || !String(needsBootstrapAdmin.parsed?.nextCommand || "").includes("yarn install")) { - throw new Error(`installer start should ask for yarn install only when local bootstrap/browser work is next.\nSTDOUT:\n${needsBootstrapAdmin.stdout}\nSTDERR:\n${needsBootstrapAdmin.stderr}`); - } - fs.mkdirSync(path.dirname(viteCliPath), { recursive: true }); - fs.writeFileSync(viteCliPath, "export {};\n"); - needsBootstrapAdmin = runJsonScript("scripts/installer-start.mjs", startArgs); - } - if (needsBootstrapAdmin.status !== 0 || !String(needsBootstrapAdmin.parsed?.nextCommand || "").includes("installer:bootstrap-admin")) { - throw new Error(`installer start should recommend first-admin bootstrap after deployment evidence exists.\nSTDOUT:\n${needsBootstrapAdmin.stdout}\nSTDERR:\n${needsBootstrapAdmin.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "bootstrap-admin.json"), JSON.stringify({ ok: true, dryRun: false }, null, 2)); - const needsBrowserSmoke = runJsonScript("scripts/installer-start.mjs", startArgs); - if (needsBrowserSmoke.status !== 0 || !String(needsBrowserSmoke.parsed?.nextCommand || "").includes("installer:browser-smoke")) { - throw new Error(`installer start should recommend browser smoke after first-admin evidence exists.\nSTDOUT:\n${needsBrowserSmoke.stdout}\nSTDERR:\n${needsBrowserSmoke.stderr}`); - } - - fs.writeFileSync(path.join(stagingEvidenceDir, "browser-smoke.json"), JSON.stringify({ ok: true }, null, 2)); - const stagingComplete = runJsonScript("scripts/installer-start.mjs", startArgs); - if (stagingComplete.status !== 0 || !String(stagingComplete.parsed?.nextCommand || "").includes("--environment=prod")) { - throw new Error(`installer start should send the operator to prod after staging is complete.\nSTDOUT:\n${stagingComplete.stdout}\nSTDERR:\n${stagingComplete.stderr}`); - } - - const markdownShort = spawnNode("scripts/installer-start.mjs", [ - ...startArgs.filter((arg) => arg !== "--output=json"), - "--output=markdown", - ]); - if (markdownShort.status !== 0 - || !markdownShort.stdout.includes("## Next Command") - || markdownShort.stdout.includes("## Command Reference") - || markdownShort.stdout.includes("## Useful Commands")) { - throw new Error(`installer start markdown should focus on one next command by default.\nSTDOUT:\n${markdownShort.stdout}\nSTDERR:\n${markdownShort.stderr}`); - } - - const markdownReference = spawnNode("scripts/installer-start.mjs", [ - ...startArgs.filter((arg) => arg !== "--output=json"), - "--output=markdown", - "--show-all-commands=true", - ]); - if (markdownReference.status !== 0 || !markdownReference.stdout.includes("## Command Reference")) { - throw new Error(`installer start markdown should expose the command reference when requested.\nSTDOUT:\n${markdownReference.stdout}\nSTDERR:\n${markdownReference.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - if (!hadNodeModules) { - fs.rmSync(nodeModulesDir, { recursive: true, force: true }); - } else if (!hadViteCli) { - fs.rmSync(path.join(nodeModulesDir, "vite"), { recursive: true, force: true }); - } - } -} - -function expectCustomerFileAwsRegionAliasWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-region-alias-")); - - try { - const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - if (setup.status !== 0) { - throw new Error(`region alias fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); - } - - const customerFilePath = path.join(tempDir, "customer-values.json"); - const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); - fs.writeFileSync(customerFilePath, `${JSON.stringify({ - ...sample, - awsRegion: "us-west-2", - accountId: "123456789012", - repo: "example/b1admin-deploy", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/installer-aws-preflight.mjs", [ - "--environment=staging", - `--environment-dir=${path.join(tempDir, "environments", "staging")}`, - `--customer-file=${customerFilePath}`, - "--skip-aws-check=true", - "--output=json", - ]); - - if (result.status !== 0 || result.parsed?.region !== "us-west-2") { - throw new Error(`customer-values awsRegion should be accepted as the installer region.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerAwsRolesGeneratesPolicyFiles() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-aws-roles-")); - - try { - const result = runJsonScript("scripts/installer-aws-roles.mjs", [ - "--environment=staging", - "--account-id=123456789012", - "--repo=example/b1admin-deploy", - `--output-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - - if (result.status !== 0 || result.parsed?.files?.length !== 4 || result.parsed?.roleArns?.deployRoleArn !== "arn:aws:iam::123456789012:role/b1admin-staging-github-deploy") { - throw new Error(`installer aws roles should render the expected IAM file set and role ARNs.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const expectedFiles = [ - "b1admin-staging-github-deploy-trust.json", - "b1admin-staging-github-deploy-policy.json", - "b1admin-staging-cfn-exec-trust.json", - "b1admin-staging-cfn-exec-policy.json", - ]; - expectedFiles.forEach((fileName) => { - const filePath = path.join(tempDir, fileName); - if (!fs.existsSync(filePath)) { - throw new Error(`installer aws roles did not write ${fileName}.`); - } - const text = fs.readFileSync(filePath, "utf8"); - JSON.parse(text); - if (text.includes("") || text.includes("") || text.includes("")) { - throw new Error(`installer aws roles left placeholders in ${fileName}.\n${text}`); - } - }); - - const trust = JSON.parse(fs.readFileSync(path.join(tempDir, "b1admin-staging-github-deploy-trust.json"), "utf8")); - const subject = trust.Statement?.[0]?.Condition?.StringLike?.["token.actions.githubusercontent.com:sub"]; - if (subject !== "repo:example/b1admin-deploy:environment:aws-staging") { - throw new Error(`installer aws roles rendered the wrong GitHub OIDC subject: ${subject}`); - } - - const deployPolicy = JSON.parse(fs.readFileSync(path.join(tempDir, "b1admin-staging-github-deploy-policy.json"), "utf8")); - const passRole = deployPolicy.Statement.find((statement) => statement.Sid === "PassCloudFormationExecutionRole"); - if (passRole?.Resource !== "arn:aws:iam::123456789012:role/b1admin-staging-cfn-exec") { - throw new Error(`installer aws roles rendered the wrong iam:PassRole resource.\n${JSON.stringify(passRole, null, 2)}`); - } - - if (!result.parsed.awsCommands.some((command) => command.includes("create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com")) - || result.parsed.awsCommands.some((command) => command.includes("--thumbprint-list")) - || !result.parsed.githubSecretCommands.some((command) => command.includes("AWS_ROLE_TO_ASSUME")) - || !result.parsed.githubSecretCommands.some((command) => command.includes("AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN"))) { - throw new Error(`installer aws roles should output OIDC setup and GitHub secret commands.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerAwsHandoffWritesAdminDocument() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-aws-handoff-")); - - try { - const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - if (setup.status !== 0) { - throw new Error(`installer aws handoff fixture setup failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); - } - - const customerFilePath = path.join(tempDir, "customer-values.json"); - const sample = JSON.parse(fs.readFileSync(path.join(tempDir, "customer-values.sample.json"), "utf8")); - fs.writeFileSync(customerFilePath, `${JSON.stringify({ - ...sample, - accountId: "123456789012", - repo: "example/b1admin-deploy", - rootDomain: "customer.test", - supportEmail: "support@customer.test", - supportPhone: "111-222-3333", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/installer-aws-handoff.mjs", [ - `--customer-file=${customerFilePath}`, - `--deploy-repo-dir=${tempDir}`, - "--write=true", - "--output=json", - ]); - - const handoffPath = path.join(tempDir, "aws-admin-handoff.md"); - if (result.status !== 0 - || result.parsed?.environments?.length !== 2 - || !fs.existsSync(handoffPath) - || !fs.existsSync(path.join(tempDir, "iam", "staging", "b1admin-staging-github-deploy-trust.json")) - || !fs.existsSync(path.join(tempDir, "iam", "prod", "b1admin-prod-github-deploy-trust.json"))) { - throw new Error(`installer aws handoff should write a two-environment admin bundle.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const body = fs.readFileSync(handoffPath, "utf8"); - if (!body.includes("# B1Admin AWS Admin Handoff") - || !body.includes("aws iam create-role --role-name b1admin-staging-github-deploy") - || !body.includes("aws iam create-role --role-name b1admin-prod-github-deploy") - || !body.includes("arn:aws:iam::123456789012:role/b1admin-prod-cfn-exec") - || !body.includes("gh secret set AWS_ROLE_TO_ASSUME --repo example/b1admin-deploy --env aws-prod") - || !body.includes("Smallest AWS footprint: continue with prod first") - || !body.includes("--environment=prod")) { - throw new Error(`installer aws handoff document is missing expected admin/operator content.\n${body}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectInstallerGithubSetupPlansAndWritesSecrets() { - const deployRepoDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-setup-repo-")); - const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-setup-gh-")); - const ghPath = path.join(fakeGhDir, "gh"); - const capturePath = path.join(fakeGhDir, "capture.jsonl"); - const ghScript = `#!/usr/bin/env node -import fs from "node:fs"; -const args = process.argv.slice(2); -let stdin = ""; -process.stdin.on("data", (chunk) => { stdin += chunk; }); -process.stdin.on("end", () => { - if (args[0] === "api" && args[1] === "-X" && args[2] === "PUT") { - fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ kind: "environment", args }) + "\\n"); - process.exit(0); - } - if (args[0] === "secret" && args[1] === "set") { - const bodyIndex = args.indexOf("--body"); - fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ - kind: "secret", - args, - body: bodyIndex >= 0 ? args[bodyIndex + 1] : stdin, - }) + "\\n"); - process.exit(0); - } - process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); - process.exit(1); -}); -`; - - try { - const setup = runJsonScript("scripts/setup-private-deployment-repo.mjs", [ - `--deploy-repo-dir=${deployRepoDir}`, - "--write=true", - "--output=json", - ]); - if (setup.status !== 0) { - throw new Error(`installer github setup fixture scaffold failed.\nSTDOUT:\n${setup.stdout}\nSTDERR:\n${setup.stderr}`); - } - - for (const environment of ["staging", "prod"]) { - const appConfig = runJsonScript("scripts/installer-app-config-secret.mjs", [ - `--environment=${environment}`, - `--environment-dir=${path.join(deployRepoDir, "environments", environment)}`, - "--support-email=support@customer.test", - "--write=true", - "--output=json", - ]); - if (appConfig.status !== 0) { - throw new Error(`installer github setup fixture app-config generation failed for ${environment}.\nSTDOUT:\n${appConfig.stdout}\nSTDERR:\n${appConfig.stderr}`); - } - } - - const preview = runJsonScript("scripts/installer-github-setup.mjs", [ - "--repo=example/b1admin-deploy", - "--account-id=123456789012", - `--deploy-env-dir=${path.join(deployRepoDir, "environments")}`, - "--include-checkout-token-commands=false", - "--output=json", - ]); - - if (preview.status !== 0 - || preview.parsed?.secretPlans?.length !== 6 - || !preview.parsed.secretPlans.every((secret) => secret.ready === true) - || !preview.parsed.secretCommands.some((command) => command.includes("arn:aws:iam::123456789012:role/b1admin-staging-github-deploy")) - || !preview.parsed.secretCommands.some((command) => command.includes("app-config-secret.json"))) { - throw new Error(`installer github setup should produce ready concrete secret commands from generated IAM/app-config values.\nSTDOUT:\n${preview.stdout}\nSTDERR:\n${preview.stderr}`); - } - - fs.writeFileSync(ghPath, ghScript); - fs.chmodSync(ghPath, 0o755); - const write = runJsonScriptWithEnv("scripts/installer-github-setup.mjs", [ - "--repo=example/b1admin-deploy", - "--account-id=123456789012", - `--deploy-env-dir=${path.join(deployRepoDir, "environments")}`, - "--include-checkout-token-commands=false", - "--write=true", - "--write-secrets=true", - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (write.status !== 0 || write.parsed?.secretResults?.length !== 6 || !write.parsed.secretResults.every((secret) => secret.ok === true)) { - throw new Error(`installer github setup should create environments and write all ready required secrets through gh.\nSTDOUT:\n${write.stdout}\nSTDERR:\n${write.stderr}`); - } - - const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); - const environmentCreates = captures.filter((capture) => capture.kind === "environment"); - const secretWrites = captures.filter((capture) => capture.kind === "secret"); - if (environmentCreates.length !== 2 || secretWrites.length !== 6) { - throw new Error(`installer github setup did not call gh for both environments and all required secrets.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - if (!secretWrites.some((capture) => capture.args[2] === "AWS_APP_CONFIG_SECRET_JSON" && String(capture.body || "").includes("jwtSecret")) - || !secretWrites.some((capture) => capture.args[2] === "AWS_ROLE_TO_ASSUME" && String(capture.body || "").includes("b1admin-staging-github-deploy"))) { - throw new Error(`installer github setup did not send expected secret bodies.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - } finally { - fs.rmSync(deployRepoDir, { recursive: true, force: true }); - fs.rmSync(fakeGhDir, { recursive: true, force: true }); - } -} - -function expectInstallerGithubReadinessChecksEnvironmentSecrets() { - const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-github-readiness-gh-")); - const ghPath = path.join(fakeGhDir, "gh"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -const endpoint = args[1] === "api" ? args[2] : args[1]; -if (args[0] !== "api") { - process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); - process.exit(1); -} -if (endpoint.endsWith("/environments/aws-staging")) { - process.stdout.write(JSON.stringify({ name: "aws-staging" })); - process.exit(0); -} -if (endpoint.endsWith("/environments/aws-staging/secrets")) { - process.stdout.write(JSON.stringify({ secrets: [ - { name: "AWS_ROLE_TO_ASSUME" }, - { name: "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN" }, - { name: "AWS_APP_CONFIG_SECRET_JSON" } - ] })); - process.exit(0); -} -if (endpoint.endsWith("/environments/aws-prod")) { - process.stdout.write(JSON.stringify({ name: "aws-prod" })); - process.exit(0); -} -if (endpoint.endsWith("/environments/aws-prod/secrets")) { - process.stdout.write(JSON.stringify({ secrets: [ - { name: "AWS_ROLE_TO_ASSUME" }, - { name: "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN" } - ] })); - process.exit(0); -} -process.stderr.write("Unexpected gh endpoint: " + endpoint + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - - const staging = runJsonScriptWithEnv("scripts/installer-github-readiness.mjs", [ - "--environment=staging", - "--repo=example/b1admin-deploy", - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (staging.status !== 0 - || staging.parsed?.ok !== true - || staging.parsed?.environments?.[0]?.missingSecrets?.length !== 0) { - throw new Error(`installer github readiness should pass when all required environment secrets exist.\nSTDOUT:\n${staging.stdout}\nSTDERR:\n${staging.stderr}`); - } - - const all = runJsonScriptWithEnv("scripts/installer-github-readiness.mjs", [ - "--environment=all", - "--repo=example/b1admin-deploy", - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (all.status === 0 - || all.parsed?.ok !== false - || !all.parsed?.environments?.find((environment) => environment.githubEnvironment === "aws-prod")?.missingSecrets?.includes("AWS_APP_CONFIG_SECRET_JSON")) { - throw new Error(`installer github readiness should fail clearly when a required environment secret is missing.\nSTDOUT:\n${all.stdout}\nSTDERR:\n${all.stderr}`); - } - } finally { - fs.rmSync(fakeGhDir, { recursive: true, force: true }); - } -} - -function expectInstallerObserveSummarizesDownloadedEvidence() { - const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-gh-")); - const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-evidence-")); - const ghPath = path.join(fakeGhDir, "gh"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -const args = process.argv.slice(2); -if (args[0] === "run" && args[1] === "list") { - process.stdout.write(JSON.stringify([{ - databaseId: 12345, - status: "completed", - conclusion: "success", - url: "https://github.com/example/b1admin-deploy/actions/runs/12345", - headSha: "abc123", - createdAt: "2026-07-22T12:00:00Z", - updatedAt: "2026-07-22T12:05:00Z", - displayTitle: "Deploy AWS From Private Repo", - workflowName: "Deploy AWS From Private Repo" - }])); - process.exit(0); -} -if (args[0] === "run" && args[1] === "view") { - process.stdout.write(JSON.stringify({ - databaseId: Number(args[2]), - status: "completed", - conclusion: "success", - url: "https://github.com/example/b1admin-deploy/actions/runs/" + args[2], - headSha: "abc123", - createdAt: "2026-07-22T12:00:00Z", - updatedAt: "2026-07-22T12:05:00Z", - displayTitle: "Deploy AWS From Private Repo", - workflowName: "Deploy AWS From Private Repo", - event: "workflow_dispatch" - })); - process.exit(0); -} -if (args[0] === "run" && args[1] === "download") { - const dir = args[args.indexOf("--dir") + 1]; - fs.mkdirSync(dir, { recursive: true }); - fs.copyFileSync(path.join(${JSON.stringify(rootDir)}, "infrastructure", "examples", "backend-outputs.sample.json"), path.join(dir, "backend-outputs.json")); - fs.copyFileSync(path.join(${JSON.stringify(rootDir)}, "infrastructure", "examples", "frontend-outputs.sample.json"), path.join(dir, "frontend-outputs.json")); - fs.writeFileSync(path.join(dir, "deployment-summary.json"), JSON.stringify({ - environment: "staging", - region: "us-east-1", - stackNames: { - backend: "b1admin-staging-backend", - frontend: "b1admin-staging-frontend" - }, - resolved: { - apiBaseUrl: "https://api.example.com", - frontendAppUrl: "https://admin.example.com", - frontendBucketName: "example-frontend-bucket", - frontendDistributionId: "EXAMPLE123" - }, - files: { - backendOutputsFile: "deployment/staging/backend-outputs.json", - frontendOutputsFile: "deployment/staging/frontend-outputs.json", - summaryFile: "deployment/staging/deployment-summary.json" - }, - followUpCommands: {} - }, null, 2) + "\\n"); - process.exit(0); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - - const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ - "--environment=staging", - "--repo=example/b1admin-deploy", - `--evidence-dir=${evidenceDir}`, - "--check-http=false", - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (result.status !== 0 - || result.parsed?.runId !== 12345 - || result.parsed?.summary?.resolved?.frontendAppUrl !== "https://admin.example.com" - || result.parsed?.verification?.ok !== true - || result.parsed?.warnings?.length !== 0) { - throw new Error(`installer observe should summarize a completed run from downloaded evidence.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(fakeGhDir, { recursive: true, force: true }); - fs.rmSync(evidenceDir, { recursive: true, force: true }); - } -} - -function expectInstallerObserveDownloadsPreviewArtifactFallback() { - const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-preview-gh-")); - const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-preview-evidence-")); - const ghPath = path.join(fakeGhDir, "gh"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -const args = process.argv.slice(2); -if (args[0] === "run" && args[1] === "list") { - process.stdout.write(JSON.stringify([{ - databaseId: 67890, - status: "completed", - conclusion: "success", - url: "https://github.com/example/b1admin-deploy/actions/runs/67890", - headSha: "def456", - createdAt: "2026-07-22T12:00:00Z", - updatedAt: "2026-07-22T12:05:00Z", - displayTitle: "Deploy AWS From Private Repo", - workflowName: "Deploy AWS From Private Repo" - }])); - process.exit(0); -} -if (args[0] === "run" && args[1] === "download") { - const name = args[args.indexOf("--name") + 1]; - if (name.endsWith("deployment-evidence")) { - process.stderr.write("artifact not found\\n"); - process.exit(1); - } - const dir = args[args.indexOf("--dir") + 1]; - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "preflight-plan.md"), "# Preview plan\\n"); - process.exit(0); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - - const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ - "--environment=staging", - "--repo=example/b1admin-deploy", - `--evidence-dir=${evidenceDir}`, - "--verify=false", - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (result.status !== 0 - || result.parsed?.downloadedArtifact !== "aws-staging-preflight-plan" - || !fs.existsSync(path.join(evidenceDir, "preflight-plan.md")) - || result.parsed?.warnings?.length !== 0) { - throw new Error(`installer observe should fall back to preview preflight artifacts.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(fakeGhDir, { recursive: true, force: true }); - fs.rmSync(evidenceDir, { recursive: true, force: true }); - } -} - -function expectInstallerObserveWarnsOnIncompleteDeploymentArtifact() { - const fakeGhDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-incomplete-gh-")); - const evidenceDir = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-observe-incomplete-evidence-")); - const ghPath = path.join(fakeGhDir, "gh"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -const args = process.argv.slice(2); -if (args[0] === "run" && args[1] === "list") { - process.stdout.write(JSON.stringify([{ - databaseId: 24680, - status: "completed", - conclusion: "success", - url: "https://github.com/example/b1admin-deploy/actions/runs/24680", - headSha: "abc123", - createdAt: "2026-07-22T12:00:00Z", - updatedAt: "2026-07-22T12:05:00Z", - displayTitle: "Deploy AWS From Private Repo", - workflowName: "Deploy AWS From Private Repo" - }])); - process.exit(0); -} -if (args[0] === "run" && args[1] === "download") { - const dir = args[args.indexOf("--dir") + 1]; - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "preflight-plan.md"), "# Preview plan\\n"); - process.exit(0); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - - const result = runJsonScriptWithEnv("scripts/installer-observe.mjs", [ - "--environment=staging", - "--repo=example/b1admin-deploy", - `--evidence-dir=${evidenceDir}`, - "--output=json", - ], { - PATH: `${fakeGhDir}${path.delimiter}${process.env.PATH || ""}`, - }); - - if (result.status === 0 - || result.parsed?.ok !== false - || !String(result.parsed?.warnings?.[0] || "").includes("deployment-summary.json")) { - throw new Error(`installer observe should warn when a deployment artifact lacks saved deployment evidence.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(fakeGhDir, { recursive: true, force: true }); - fs.rmSync(evidenceDir, { recursive: true, force: true }); - } -} - -function writeReportEvidenceFixture(deploymentRoot, environment) { - const environmentDir = path.join(deploymentRoot, environment); - fs.mkdirSync(environmentDir, { recursive: true }); - fs.copyFileSync(path.join(rootDir, "infrastructure", "examples", "backend-outputs.sample.json"), path.join(environmentDir, "backend-outputs.json")); - fs.copyFileSync(path.join(rootDir, "infrastructure", "examples", "frontend-outputs.sample.json"), path.join(environmentDir, "frontend-outputs.json")); - fs.writeFileSync(path.join(environmentDir, "deployment-summary.json"), JSON.stringify({ - environment, - region: "us-east-1", - stackNames: { - backend: `b1admin-${environment}-backend`, - frontend: `b1admin-${environment}-frontend`, - }, - resolved: { - apiBaseUrl: "https://api.example.com", - frontendAppUrl: "https://d123example.cloudfront.net", - frontendBucketName: "example-frontend-bucket", - frontendDistributionId: "EXAMPLE123", - }, - files: { - backendOutputsFile: path.join(environmentDir, "backend-outputs.json"), - frontendOutputsFile: path.join(environmentDir, "frontend-outputs.json"), - summaryFile: path.join(environmentDir, "deployment-summary.json"), - }, - }, null, 2)); - fs.writeFileSync(path.join(environmentDir, "last-deploy-dispatch.json"), JSON.stringify({ - ok: true, - runId: environment === "staging" ? 111 : 222, - }, null, 2)); - fs.writeFileSync(path.join(environmentDir, "source-metadata.json"), JSON.stringify({ - ok: true, - environment, - githubActions: { - runId: environment === "staging" ? 111 : 222, - privateRepoSha: `${environment}-deploy-repo`, - }, - b1admin: { - repo: "ChurchApps/B1Admin", - ref: "main", - sha: `${environment}-b1`, - }, - api: { - repo: "ChurchApps/Api", - ref: "main", - sha: `${environment}-api`, - }, - }, null, 2)); -} - -function expectInstallerReportGeneratesRolloutRecord() { - const deploymentRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-installer-report-")); - - try { - writeReportEvidenceFixture(deploymentRoot, "staging"); - writeReportEvidenceFixture(deploymentRoot, "prod"); - - const browserSmoke = runJsonScript("scripts/installer-browser-smoke.mjs", [ - `--deployment-root=${deploymentRoot}`, - "--environment=staging", - "--app-url=https://admin.example.com", - "--email=admin@example.com", - "--password=temporary-password", - "--church-name=Example Church", - "--dry-run=true", - "--output=json", - ]); - - if (browserSmoke.status !== 0 - || browserSmoke.parsed?.ok !== true - || !fs.existsSync(path.join(deploymentRoot, "staging", "browser-smoke.json"))) { - throw new Error(`installer browser smoke dry-run should write browser evidence without launching a browser.\nSTDOUT:\n${browserSmoke.stdout}\nSTDERR:\n${browserSmoke.stderr}`); - } - - const incomplete = runJsonScript("scripts/installer-report.mjs", [ - `--deployment-root=${deploymentRoot}`, - "--environment=all", - "--output=json", - ]); - - if (incomplete.status !== 0 - || incomplete.parsed?.ok !== false - || !String(incomplete.stdout).includes("browser login result")) { - throw new Error(`installer report should flag missing human rollout records.\nSTDOUT:\n${incomplete.stdout}\nSTDERR:\n${incomplete.stderr}`); - } - - fs.writeFileSync(path.join(deploymentRoot, "prod", "browser-smoke.json"), JSON.stringify({ - ok: true, - method: "dry-run", - selectedChurch: "Example Church", - dashboardLoaded: true, - }, null, 2)); - ["staging", "prod"].forEach((environmentName) => { - fs.writeFileSync(path.join(deploymentRoot, environmentName, "bootstrap-admin.json"), JSON.stringify({ - ok: true, - dryRun: false, - }, null, 2)); - }); - - const complete = runJsonScript("scripts/installer-report.mjs", [ - `--deployment-root=${deploymentRoot}`, - "--environment=all", - "--write=true", - "--output=json", - ]); - - const reportPath = path.join(deploymentRoot, "deployment-report.md"); - if (complete.status !== 0 - || complete.parsed?.ok !== true - || !fs.existsSync(reportPath)) { - throw new Error(`installer report should write a complete rollout report from evidence and supplied records.\nSTDOUT:\n${complete.stdout}\nSTDERR:\n${complete.stderr}`); - } - - const body = fs.readFileSync(reportPath, "utf8"); - if (!body.includes("# B1Admin Deployment Report") - || !body.includes("Complete environments: 2/2") - || !body.includes("GitHub Actions run id: `222`") - || !body.includes("B1Admin commit SHA: `prod-b1`") - || !body.includes("Api commit SHA: `prod-api`") - || !body.includes("API base URL: `https://api.example.com`") - || !body.includes("Browser login result: passed:")) { - throw new Error(`installer report markdown is missing expected rollout evidence.\n${body}`); - } - - const prodOnly = runJsonScript("scripts/installer-report.mjs", [ - `--deployment-root=${deploymentRoot}`, - "--environment=prod", - "--output=json", - ]); - if (prodOnly.status !== 0 - || prodOnly.parsed?.environments?.length !== 1 - || !String(prodOnly.parsed?.markdown || "").includes("- Prod browser workflow tested by: ") - || String(prodOnly.parsed?.markdown || "").includes("- Staging browser workflow tested by: ")) { - throw new Error(`installer report should support a clean prod-only sign-off.\nSTDOUT:\n${prodOnly.stdout}\nSTDERR:\n${prodOnly.stderr}`); - } - } finally { - fs.rmSync(deploymentRoot, { recursive: true, force: true }); - } -} - -function expectShowRolloutStatusSummarizesMultipleEnvironments() { - const tempRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-rollout-status-env-root-")); - - try { - for (const environmentName of ["staging", "prod"]) { - const targetDir = path.join(tempRoot, environmentName); - fs.mkdirSync(targetDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", environmentName, fileName), - path.join(targetDir, fileName), - ); - } - } - - restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); - restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); - - const prepareProdResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=prod", - `--environment-dir=${path.join(tempRoot, "prod")}`, - "--account-id=123456789012", - "--admin-root-url=https://admin.customer.test", - "--cors-origin=https://admin.customer.test", - "--content-root-url=https://content.customer.test", - "--store-api-url=https://store.customer.test", - "--transfer-url=https://transfer.customer.test", - "--support-email=support@customer.test", - "--support-phone=918-994-2638", - "--support-site-url=https://support.customer.test", - "--website-base-url=https://{subdomain}.customer.test", - "--mobile-app-url=https://customer.test/app", - "--domain-cname-target=proxy.customer.test", - "--domain-a-target=3.23.251.61", - "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", - "--write=true", - "--write-secret-file=true", - "--output=json", - ]); - - if (prepareProdResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before rollout-status verification.\nSTDOUT:\n${prepareProdResult.stdout}\nSTDERR:\n${prepareProdResult.stderr}`); - } - - withFakePackagableApiRepo((fakeApiRepoPath) => withFakeGhForDispatchGithubAwsDeploy(({ env }) => { - const result = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ - `--environment-root-dir=${tempRoot}`, - `--api-repo-path=${fakeApiRepoPath}`, - "--output=json", - ], env); - - if (result.status !== 1) { - throw new Error(`show-rollout-status should exit non-zero when at least one environment is still blocked.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - if (actual.ok !== false || actual.environmentCount !== 2 || actual.readyEnvironmentCount !== 1 || actual.blockedEnvironmentCount !== 1) { - throw new Error(`show-rollout-status did not report the expected ready/blocked counts.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.blockedEnvironments) || actual.blockedEnvironments.join(",") !== "staging" || !Array.isArray(actual.readyEnvironments) || actual.readyEnvironments.join(",") !== "prod") { - throw new Error(`show-rollout-status did not report the expected ready/blocked environment names.\nSTDOUT:\n${result.stdout}`); - } - if (actual.blockerCategories?.starterOrInput?.environmentCount !== 1 - || actual.blockerCategories?.localExecution?.environmentCount < 1 - || actual.blockerCategories?.githubActionsExecution?.environmentCount < 1 - || actual.blockerCategories?.localGithubDispatch?.environmentCount !== 0) { - throw new Error(`show-rollout-status did not report the expected blocker-category summary.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.overallHighlightedBlockers) || !actual.overallHighlightedBlockers.some((entry) => String(entry).includes("AWS_APP_CONFIG_SECRET_JSON"))) { - throw new Error(`show-rollout-status did not surface the expected cross-environment blocker summary.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.recommendedNextSteps) || actual.recommendedNextSteps.length !== 1) { - throw new Error(`show-rollout-status did not surface the expected cross-environment next-step summary.\nSTDOUT:\n${result.stdout}`); - } - - if (!String(actual.recommendedNextCommand || "").startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") - || !String(actual.recommendedNextCommand || "").endsWith("--account-id= --output=json")) { - throw new Error(`show-rollout-status should surface the first blocked environment's primary command.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.commandSummary?.global) || actual.commandSummary.global[0] !== actual.recommendedNextCommand) { - throw new Error(`show-rollout-status should expose the top recommended command in commandSummary.global.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.commandSummary?.all) || !actual.commandSummary.all.some((command) => String(command).includes("ENV_DIR=") - && String(command).includes("/prod") - && String(command).includes("./infrastructure/environments/prod/deploy-split-stack.sh"))) { - throw new Error(`show-rollout-status should expose the ordered cross-environment command list in commandSummary.all.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.commandSummary?.byEnvironment?.staging) || actual.commandSummary.byEnvironment.staging.some((command) => command === actual.recommendedNextCommand)) { - throw new Error(`show-rollout-status should omit the global top command from commandSummary.byEnvironment entries.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.commandSummary?.byEnvironment?.prod) || !actual.commandSummary.byEnvironment.prod.some((command) => String(command).includes("./infrastructure/environments/prod/deploy-split-stack.sh"))) { - throw new Error(`show-rollout-status should preserve per-environment fallback commands in commandSummary.byEnvironment.\nSTDOUT:\n${result.stdout}`); - } - - const staging = (actual.environments || []).find((entry) => entry.environment === "staging"); - const prod = (actual.environments || []).find((entry) => entry.environment === "prod"); - - if (!staging || !prod) { - throw new Error(`show-rollout-status did not return both staging and prod summaries.\nSTDOUT:\n${result.stdout}`); - } - if (staging.status !== "blocked" || staging.starterAndInputBlockerCount !== 15 || staging.recommendedPath !== "none") { - throw new Error(`show-rollout-status did not preserve the blocked staging summary.\nSTDOUT:\n${result.stdout}`); - } - if (prod.status !== "ready" || prod.starterAndInputBlockerCount !== 0 || prod.recommendedPath !== "local") { - throw new Error(`show-rollout-status did not preserve the locally ready prod summary.\nSTDOUT:\n${result.stdout}`); - } - if (prod.localExecutionOk !== true || prod.githubActionsExecutionOk !== false || prod.localGithubDispatchOk !== true) { - throw new Error(`show-rollout-status did not preserve the expected prod execution-path readiness details.\nSTDOUT:\n${result.stdout}`); - } - }), { includeLayer: true }); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -} - -function expectShowRolloutStatusOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/show-rollout-status-output.sample.json"); - const tempRoot = path.join(rootDir, ".tmp-rollout-status-sample-env"); - - fs.rmSync(tempRoot, { recursive: true, force: true }); - fs.mkdirSync(path.join(tempRoot, "staging"), { recursive: true }); - fs.mkdirSync(path.join(tempRoot, "prod"), { recursive: true }); - - try { - for (const environmentName of ["staging", "prod"]) { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", environmentName, fileName), - path.join(tempRoot, environmentName, fileName), - ); - } - } - - restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); - restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); - - const prepareProdResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=prod", - `--environment-dir=${path.join(tempRoot, "prod")}`, - "--account-id=123456789012", - "--admin-root-url=https://admin.customer.test", - "--cors-origin=https://admin.customer.test", - "--content-root-url=https://content.customer.test", - "--store-api-url=https://store.customer.test", - "--transfer-url=https://transfer.customer.test", - "--support-email=support@customer.test", - "--support-phone=918-994-2638", - "--support-site-url=https://support.customer.test", - "--website-base-url=https://{subdomain}.customer.test", - "--mobile-app-url=https://customer.test/app", - "--domain-cname-target=proxy.customer.test", - "--domain-a-target=3.23.251.61", - "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", - "--write=true", - "--write-secret-file=true", - "--output=json", - ]); - - if (prepareProdResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before rollout-status sample verification.\nSTDOUT:\n${prepareProdResult.stdout}\nSTDERR:\n${prepareProdResult.stderr}`); - } - - let result; - let githubFocusedResult; - withFakeGhForDispatchGithubAwsDeploy(({ env }) => { - result = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ - "--environment-root-dir=.tmp-rollout-status-sample-env", - "--output=json", - ], env); - githubFocusedResult = runJsonScriptWithEnv("scripts/show-rollout-status.mjs", [ - "--environment-root-dir=.tmp-rollout-status-sample-env", - "--deployment-intent=github-actions", - "--output=json", - ], env); - }); - - if (result.status !== 1) { - throw new Error(`show-rollout-status output sample contract run should stay blocked while the local Api repo is unreadable and GitHub secret materialization is still missing.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - if (githubFocusedResult.status !== 1) { - throw new Error(`show-rollout-status github-focused sample contract run should stay blocked while starter or GitHub-specific blockers remain.\nSTDOUT:\n${githubFocusedResult.stdout}\nSTDERR:\n${githubFocusedResult.stderr}`); - } - - const actual = result.parsed || {}; - const githubFocusedActual = githubFocusedResult.parsed || {}; - expectObjectContainsKeys("show-rollout-status output sample", actual, sample); - - if (sample.ok !== false || sample.environmentCount !== 2 || sample.blockedEnvironmentCount !== 2) { - throw new Error(`show-rollout-status output sample should document a blocked two-environment rollout snapshot.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.blockedEnvironments) || sample.blockedEnvironments.join(",") !== "staging,prod" || !Array.isArray(sample.readyEnvironments) || sample.readyEnvironments.length !== 0) { - throw new Error(`show-rollout-status output sample should list the expected ready/blocked environment names.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.blockerCategories?.starterOrInput?.environmentCount !== 1 - || sample.blockerCategories?.localExecution?.environmentCount !== 2 - || sample.blockerCategories?.githubActionsExecution?.environmentCount !== 2 - || sample.blockerCategories?.localGithubDispatch?.environmentCount !== 0) { - throw new Error(`show-rollout-status output sample should include the expected blocker-category summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.recommendedNextCommand !== "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json") { - throw new Error(`show-rollout-status output sample should surface the staging starter prep command first.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.commandSummary?.global) || sample.commandSummary.global[0] !== sample.recommendedNextCommand) { - throw new Error(`show-rollout-status output sample should expose the global command list.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.commandSummary?.all) || !sample.commandSummary.all.includes("yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json")) { - throw new Error(`show-rollout-status output sample should include the ordered cross-environment command list.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.commandSummary?.byEnvironment?.staging) || sample.commandSummary.byEnvironment.staging.some((command) => command === sample.recommendedNextCommand)) { - throw new Error(`show-rollout-status output sample should omit the top-level command from staging-specific commandSummary entries.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.commandSummary?.byEnvironment?.prod) || !sample.commandSummary.byEnvironment.prod.some((command) => String(command).includes("deployment-source=backend-artifact"))) { - throw new Error(`show-rollout-status output sample should preserve prod fallback commands in commandSummary.byEnvironment.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.overallHighlightedBlockers) || !sample.overallHighlightedBlockers.some((entry) => String(entry).includes("AWS_APP_CONFIG_SECRET_JSON"))) { - throw new Error(`show-rollout-status output sample should include the cross-environment blocker summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.recommendedNextSteps) || sample.recommendedNextSteps.length !== 2) { - throw new Error(`show-rollout-status output sample should include the cross-environment next-step summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - - const environments = sample.environments || []; - const staging = environments.find((entry) => entry.environment === "staging"); - const prod = environments.find((entry) => entry.environment === "prod"); - - if (!staging || !prod) { - throw new Error(`show-rollout-status output sample should include both staging and prod summaries.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (staging.status !== "blocked" || staging.starterAndInputBlockerCount !== 15 || staging.localGithubDispatchOk !== true) { - throw new Error(`show-rollout-status output sample should preserve the blocked staging summary with local gh ready.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(staging.alternateCommands) || !staging.alternateCommands.some((command) => String(command).includes("dispatch:github-aws-deploy"))) { - throw new Error(`show-rollout-status output sample should include staging alternate deploy commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (prod.status !== "blocked" || prod.starterAndInputBlockerCount !== 0 || prod.primaryCommand !== "yarn sync:github-app-config-secret -- --environment=prod --secret-file=.tmp-rollout-status-sample-env/prod/app-config-secret.json") { - throw new Error(`show-rollout-status output sample should preserve the execution-blocked prod summary.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(prod.highlightedBlockers) || !prod.highlightedBlockers.some((blocker) => String(blocker).includes("AWS_APP_CONFIG_SECRET_JSON"))) { - throw new Error(`show-rollout-status output sample should include the prod GitHub secret materialization blocker.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (githubFocusedActual.deploymentIntent !== "github-actions" || !Array.isArray(githubFocusedActual.ignoredBlockerCategories) || !githubFocusedActual.ignoredBlockerCategories.includes("localExecution")) { - throw new Error(`show-rollout-status github-focused mode should report that localExecution blockers are ignored in the rollout summary.\nSTDOUT:\n${githubFocusedResult.stdout}`); - } - if (!Array.isArray(githubFocusedActual.overallHighlightedBlockers) || githubFocusedActual.overallHighlightedBlockers.some((entry) => String(entry).includes("Local api-repo path is not readable from this workspace"))) { - throw new Error(`show-rollout-status github-focused mode should suppress local Api readability blockers from the overall summary.\nSTDOUT:\n${githubFocusedResult.stdout}`); - } - if (!Array.isArray(githubFocusedActual.commandSummary?.all) || githubFocusedActual.commandSummary.all.some((command) => String(command).includes("deploy-split-stack.sh"))) { - throw new Error(`show-rollout-status github-focused mode should keep local deploy commands out of the recommended command list.\nSTDOUT:\n${githubFocusedResult.stdout}`); - } - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -} - -function expectShowRolloutStatusCommandsOutputWorks() { - const tempRoot = fs.mkdtempSync(path.join(rootDir, ".tmp-rollout-status-commands-root-")); - - try { - for (const environmentName of ["staging", "prod"]) { - const targetDir = path.join(tempRoot, environmentName); - fs.mkdirSync(targetDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", environmentName, fileName), - path.join(targetDir, fileName), - ); - } - } - - restoreStarterTemplateDefaults(path.join(tempRoot, "staging"), "staging"); - restoreStarterTemplateDefaults(path.join(tempRoot, "prod"), "prod"); - - const result = spawnSync("node", ["scripts/show-rollout-status.mjs", `--environment-root-dir=${tempRoot}`, "--output=commands"], { - cwd: rootDir, - encoding: "utf8", - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const lines = stdout.trim().split("\n"); - - if (result.status !== 1) { - throw new Error(`show-rollout-status commands mode should exit non-zero when any environment is blocked.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - if (!lines[0].startsWith("yarn prepare:environment-starter -- --environment=staging --environment-dir=") - || !lines[0].endsWith(" --account-id= --output=json")) { - throw new Error(`show-rollout-status commands mode should print the top recommended command first.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("# staging") || !stdout.includes("# prod")) { - throw new Error(`show-rollout-status commands mode should split command lists by environment.\nSTDOUT:\n${stdout}`); - } - if ((stdout.match(new RegExp(`^${lines[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm")) || []).length !== 1) { - throw new Error(`show-rollout-status commands mode should not repeat the same top-level remediation command inside each environment block.\nSTDOUT:\n${stdout}`); - } - if (!stdout.includes("gh workflow run deploy-aws-self-hosted.yml")) { - throw new Error(`show-rollout-status commands mode should include alternate deploy commands from the underlying plan.\nSTDOUT:\n${stdout}`); - } - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployMarkdownOutputWorks() { - let result; - withFailingGhForDispatchGithubAwsDeploy((env) => withRawStarterEnvironment("staging", (tempDir) => { - result = spawnSync("node", ["scripts/plan-environment-deploy.mjs", "--environment=staging", `--environment-dir=${tempDir}`, "--api-repo-path=.", "--output=markdown"], { - cwd: rootDir, - encoding: "utf8", - env: { ...process.env, ...env }, - }); - })); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - - if (result.status !== 1) { - throw new Error(`plan-environment-deploy markdown mode should be blocked while staging placeholders remain.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - const expectedSnippets = [ - "# Environment Deploy Plan: staging", - "## Blockers", - "## Recommendation", - "## Starter Prep", - "## Preflight", - "## Local Run", - "## GitHub Actions Run", - "## GitHub Secrets", - "aws-staging-deployment-evidence", - "aws-staging-preflight-plan", - "saved-output follow-up commands", - "jwtSecret", - "encryptionKey", - "audit:api-repo-contract", - "prepare:environment-starter", - "Local GitHub dispatch: blocked", - "gh auth login -h github.com", - ]; - - for (const snippet of expectedSnippets) { - if (!stdout.includes(snippet)) { - throw new Error(`plan-environment-deploy markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectPlanEnvironmentDeployReadyPackageManifestModeWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-deploy-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before ready-mode plan verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - const manifestPath = path.join(tempDir, "package-manifest.json"); - fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - let planResult; - withFakeGhForDispatchGithubAwsDeploy(({ env }) => { - planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${manifestPath}`, - "--github-auth-mode=static", - "--sync-app-config-secret=true", - "--run-api-migrations=true", - "--api-migration-action=status", - "--api-migration-module=membership", - "--output=json", - ], env); - }); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should report ready after prepare write mode and valid package-manifest inputs.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true || actual.starterSummary?.blockerCount !== 0) { - throw new Error(`plan-environment-deploy ready-mode result should be ok with zero starter blockers.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.localExecution?.ok !== true || actual.githubActionsExecution?.ok !== true) { - throw new Error(`plan-environment-deploy ready-mode result should mark both local and GitHub execution as ready.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.localGithubDispatch?.ok !== true || actual.localGithubDispatch?.blockerCount !== 0) { - throw new Error(`plan-environment-deploy ready-mode result should mark local gh dispatch as ready when gh auth succeeds.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedExecution?.path !== "either") { - throw new Error(`plan-environment-deploy ready-mode result should recommend either path when both are ready.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedCommands?.primary !== actual.commands?.local) { - throw new Error(`plan-environment-deploy ready-mode result should recommend the local command first when both paths are ready.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.postDeployCommands?.verifyWithHttp || "").includes("--check-http=true")) { - throw new Error(`plan-environment-deploy ready-mode result should include the HTTP verification follow-up command.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.githubSecretSyncCommand || "").includes("yarn sync:github-app-config-secret -- --environment=staging") - || !String(actual.githubSecretSyncCommand || "").includes("--secret-file=")) { - throw new Error(`plan-environment-deploy ready-mode result should include the GitHub app-config secret sync helper command when app-config-secret.json exists.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.postDeployCommands?.ensureOutputsDir || "").includes("mkdir -p deployment/staging") - || !String(actual.postDeployCommands?.saveBackendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks") - || !String(actual.postDeployCommands?.saveFrontendOutputs || "").includes("mkdir -p deployment/staging && aws cloudformation describe-stacks")) { - throw new Error(`plan-environment-deploy ready-mode result should include output-capture follow-up commands.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.postDeployCommands?.saveOutputsWithHelper || "").includes("yarn save:split-stack-outputs -- --environment=staging --region=us-east-1")) { - throw new Error(`plan-environment-deploy ready-mode result should include the helper-based output capture command.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.postDeployCommands?.showSavedSummary || "").includes("yarn show:deployment-summary -- --summary-file=deployment/staging/deployment-summary.json --output=markdown")) { - throw new Error(`plan-environment-deploy ready-mode result should include the saved-summary render command.\nSTDOUT:\n${planResult.stdout}`); - } - if (!String(actual.postDeployCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=deployment/staging/backend-outputs.json") - || !String(actual.postDeployCommands?.verifyFromSavedOutputsWithHttp || "").includes("--check-http=true") - || !String(actual.postDeployCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets") - || !String(actual.postDeployCommands?.publishFrontendAssetsFromSavedOutputs || "").includes("yarn publish:frontend-assets -- --frontend-outputs-file=deployment/staging/frontend-outputs.json")) { - throw new Error(`plan-environment-deploy ready-mode result should include saved-output reuse follow-up commands.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.githubPostDeploy?.artifactName !== "aws-staging-deployment-evidence" - || actual.githubPostDeploy?.artifactPath !== "deployment/staging/" - || actual.githubPostDeploy?.failureArtifactName !== "aws-staging-preflight-plan" - || actual.githubPostDeploy?.failureArtifactPath !== "deployment/staging/preflight-plan.md" - || !Array.isArray(actual.githubPostDeploy?.summaryIncludes) - || !actual.githubPostDeploy.summaryIncludes.includes("preflight deploy plan") - || !actual.githubPostDeploy.summaryIncludes.includes("saved-output follow-up commands")) { - throw new Error(`plan-environment-deploy ready-mode result should include the GitHub post-deploy handoff.\nSTDOUT:\n${planResult.stdout}`); - } - - const requiredSecrets = actual.requiredGithubSecrets || []; - for (const secretName of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_APP_CONFIG_SECRET_JSON"]) { - if (!requiredSecrets.includes(secretName)) { - throw new Error(`plan-environment-deploy ready-mode result is missing required GitHub secret: ${secretName}\nSTDOUT:\n${planResult.stdout}`); - } - } - - const localCommand = actual.commands?.local || ""; - const localPreviewCommand = actual.commands?.localPreview || ""; - const githubWrapperCommand = actual.commands?.githubActionsWrapper || ""; - const githubWrapperPreviewCommand = actual.commands?.githubActionsWrapperPreview || ""; - const githubCommand = actual.commands?.githubActions || ""; - const githubPreviewCommand = actual.commands?.githubActionsPreview || ""; - - for (const snippet of [ - "PACKAGE_MANIFEST_FILE=", - "SYNC_APP_CONFIG_SECRET='true'", - "RUN_API_MIGRATIONS='true'", - "API_MIGRATION_ACTION='status'", - "API_MIGRATION_MODULE='membership'", - ]) { - if (!localCommand.includes(snippet)) { - throw new Error(`plan-environment-deploy ready-mode local command is missing expected content: ${snippet}\nSTDOUT:\n${planResult.stdout}`); - } - } - if (!localPreviewCommand.includes("PREVIEW_ONLY='true'")) { - throw new Error(`plan-environment-deploy ready-mode local preview command should include PREVIEW_ONLY='true'.\nSTDOUT:\n${planResult.stdout}`); - } - - if (!githubWrapperCommand.includes("yarn dispatch:github-aws-deploy --") - || !githubWrapperCommand.includes("--deployment-source=package-manifest") - || !githubWrapperCommand.includes("--sync-app-config-secret=true")) { - throw new Error(`plan-environment-deploy ready-mode GitHub wrapper command is missing expected content.\nSTDOUT:\n${planResult.stdout}`); - } - if (!githubWrapperPreviewCommand.includes("--preview-only=true")) { - throw new Error(`plan-environment-deploy ready-mode GitHub preview wrapper command should include --preview-only=true.\nSTDOUT:\n${planResult.stdout}`); - } - - for (const snippet of [ - "deployment_source='package-manifest'", - "sync_app_config_secret='true'", - "run_api_migrations='true'", - "api_migration_action='status'", - "api_migration_module='membership'", - ]) { - if (!githubCommand.includes(snippet)) { - throw new Error(`plan-environment-deploy ready-mode GitHub command is missing expected content: ${snippet}\nSTDOUT:\n${planResult.stdout}`); - } - } - if (!githubPreviewCommand.includes("preview_only='true'")) { - throw new Error(`plan-environment-deploy ready-mode GitHub preview command should include preview_only='true'.\nSTDOUT:\n${planResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployGithubNeedsSecretMaterializationWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-github-secret-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, "staging"); - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--admin-root-url=https://admin-staging.customer.test", - "--cors-origin=https://admin-staging.customer.test", - "--content-root-url=https://content-staging.customer.test", - "--store-api-url=https://store-staging.customer.test", - "--transfer-url=https://transfer-staging.customer.test", - "--support-email=support@customer.test", - "--support-phone=918-994-2638", - "--support-site-url=https://support-staging.customer.test", - "--website-base-url=https://{subdomain}.customer.test", - "--mobile-app-url=https://customer.test/app", - "--domain-cname-target=proxy.customer.test", - "--domain-a-target=3.23.251.61", - "--default-stock-photo=https://content.customer.test/stockPhotos/default.png", - "--write=true", - "--write-secret-file=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before GitHub secret-materialization plan verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - const manifestPath = path.join(tempDir, "package-manifest.json"); - fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const planResult = runJsonScript("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${manifestPath}`, - "--output=json", - ]); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when only the GitHub secret-materialization blocker remains.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true || actual.localExecution?.ok !== true || actual.githubActionsExecution?.ok !== false) { - throw new Error(`plan-environment-deploy should keep the local path ready while blocking GitHub until app-config-secret is materialized there.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedExecution?.path !== "local") { - throw new Error(`plan-environment-deploy should recommend the local path when GitHub is missing app-config-secret materialization.\nSTDOUT:\n${planResult.stdout}`); - } - if (!actual.githubActionsExecution?.blockers?.some((entry) => String(entry).includes("Enable sync-app-config-secret and provide AWS_APP_CONFIG_SECRET_JSON"))) { - throw new Error(`plan-environment-deploy should explain how to materialize app-config-secret on the GitHub runner.\nSTDOUT:\n${planResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployLocalOnlyExecutionBlockerWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-local-only-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before local-only execution blocker verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); - let planResult; - withFakeGhForDispatchGithubAwsDeploy(({ env }) => { - planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${missingManifestPath}`, - "--sync-app-config-secret=true", - "--output=json", - ], env); - }); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when starter files are prepared and package-manifest input is present, even if the local file path is missing.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true || actual.githubActionsExecution?.ok !== true || actual.localExecution?.ok !== false) { - throw new Error(`plan-environment-deploy should distinguish local-only execution blockers from shared readiness blockers.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedExecution?.path !== "github-actions") { - throw new Error(`plan-environment-deploy should recommend GitHub Actions when it is ready and the local path is not.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedCommands?.primary !== actual.commands?.githubActionsWrapper) { - throw new Error(`plan-environment-deploy should recommend the GitHub wrapper command first when only GitHub is ready.\nSTDOUT:\n${planResult.stdout}`); - } - - const localBlockers = actual.localExecution?.blockers || []; - if (!localBlockers.some((entry) => String(entry).includes("Local package manifest file does not exist yet:"))) { - throw new Error(`plan-environment-deploy did not surface the missing local package manifest as a local-only execution blocker.\nSTDOUT:\n${planResult.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployUnreadableApiRepoLocalOnlyBlockerWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-unreadable-api-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before unreadable api-repo verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - withFakeGhForDispatchGithubAwsDeploy(({ env }) => withUnreadableFakeApiRepoDirectory((fakeApiRepoPath) => { - const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=api-repo", - `--api-repo-path=${fakeApiRepoPath}`, - "--sync-app-config-secret=true", - "--output=json", - ], env); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when starter files are prepared and only the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true || actual.githubActionsExecution?.ok !== true || actual.localExecution?.ok !== false) { - throw new Error(`plan-environment-deploy should classify an unreadable local api-repo path as a local-only execution blocker.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedExecution?.path !== "github-actions") { - throw new Error(`plan-environment-deploy should recommend GitHub Actions when the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedCommands?.primary !== actual.commands?.githubActionsWrapper) { - throw new Error(`plan-environment-deploy should recommend the GitHub wrapper command first when only the local api-repo path is unreadable.\nSTDOUT:\n${planResult.stdout}`); - } - - const localBlockers = actual.localExecution?.blockers || []; - if (!localBlockers.some((entry) => String(entry).includes("Local api-repo path is not readable from this workspace:"))) { - throw new Error(`plan-environment-deploy did not surface the unreadable local api-repo path.\nSTDOUT:\n${planResult.stdout}`); - } - if (!localBlockers.some((entry) => String(entry).includes("deployment-source=package-manifest")) - || !localBlockers.some((entry) => String(entry).includes("deployment-source=backend-artifact"))) { - throw new Error(`plan-environment-deploy did not include the expected manifest/artifact fallback guidance.\nSTDOUT:\n${planResult.stdout}`); - } - const packageManifestPlan = String(actual.localFallbackCommands?.packageManifestPlan || ""); - const packageManifestLocal = String(actual.localFallbackCommands?.packageManifestLocal || ""); - const backendArtifactPlan = String(actual.localFallbackCommands?.backendArtifactPlan || ""); - const backendArtifactLocal = String(actual.localFallbackCommands?.backendArtifactLocal || ""); - - if (!packageManifestPlan.includes("--deployment-source=package-manifest") - || !packageManifestLocal.includes("PACKAGE_MANIFEST_FILE=") - || !backendArtifactPlan.includes("--deployment-source=backend-artifact") - || !backendArtifactLocal.includes("BACKEND_ARTIFACT_SOURCE_FILE=")) { - throw new Error(`plan-environment-deploy did not include the expected concrete fallback commands.\nSTDOUT:\n${planResult.stdout}`); - } - if (backendArtifactLocal.includes("MIGRATION_ARTIFACT_SOURCE_FILE=") - || backendArtifactLocal.includes("DEPENDENCIES_LAYER_SOURCE_FILE=")) { - throw new Error(`plan-environment-deploy should keep the local backend-artifact fallback focused on the backend zip unless the user supplies extra artifacts separately.\nSTDOUT:\n${planResult.stdout}`); - } - if (!Array.isArray(actual.nextSteps) - || !actual.nextSteps.some((entry) => String(entry).includes("switch the local run to package-manifest or backend-artifact mode"))) { - throw new Error(`plan-environment-deploy did not add the expected fallback next step.\nSTDOUT:\n${planResult.stdout}`); - } - })); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployGithubOnlyNeedsGhAuthWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-github-only-auth-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before github-only gh-auth verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); - withFailingGhForDispatchGithubAwsDeploy((env) => { - const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${missingManifestPath}`, - "--sync-app-config-secret=true", - "--output=json", - ], env); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when GitHub is the only deploy path and gh auth is the remaining machine blocker.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true - || actual.recommendedExecution?.path !== "github-actions" - || actual.githubActionsExecution?.ok !== true - || actual.localExecution?.ok !== false - || actual.localGithubDispatch?.ok !== false) { - throw new Error(`plan-environment-deploy should classify this as a GitHub-only path blocked locally by gh auth.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedCommands?.primary !== "gh auth login -h github.com") { - throw new Error(`plan-environment-deploy should recommend fixing gh auth before a GitHub-only deploy from this machine.\nSTDOUT:\n${planResult.stdout}`); - } - if (!Array.isArray(actual.nextSteps) - || !String(actual.nextSteps[0] || "").includes("Run `gh auth login -h github.com` first")) { - throw new Error(`plan-environment-deploy should prioritize the gh auth remediation step in nextSteps.\nSTDOUT:\n${planResult.stdout}`); - } - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployGhNetworkFailureWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-gh-network-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before gh network failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - const missingManifestPath = path.join(tempDir, "missing-package-manifest.json"); - withNetworkFailingGhForPlanEnvironmentDeploy((env) => { - const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${missingManifestPath}`, - "--sync-app-config-secret=true", - "--output=json", - ], env); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when GitHub is the only deploy path and the remaining machine blocker is connectivity.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.localGithubDispatch?.ok !== false - || !actual.localGithubDispatch?.blockers?.some((entry) => String(entry).includes("could not reach github.com"))) { - throw new Error(`plan-environment-deploy should classify gh network failures separately from invalid-token auth failures.\nSTDOUT:\n${planResult.stdout}`); - } - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployExecutionRemediationCommandWorks() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-plan-environment-remediation-")); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--account-id=123456789012", - "--write=true", - "--write-secret-file=true", - "--output=json", - ]); - - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before execution-remediation verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - - withFailingGhForDispatchGithubAwsDeploy((env) => withUnreadableFakeApiRepoDirectory((fakeApiRepoPath) => { - const planResult = runJsonScriptWithEnv("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${tempDir}`, - "--deployment-source=api-repo", - `--api-repo-path=${fakeApiRepoPath}`, - "--output=json", - ], env); - - if (planResult.status !== 0) { - throw new Error(`plan-environment-deploy should stay overall-ready when only execution-specific blockers remain.\nSTDOUT:\n${planResult.stdout}\nSTDERR:\n${planResult.stderr}`); - } - - const actual = planResult.parsed || {}; - if (actual.ok !== true - || actual.recommendedExecution?.path !== "none" - || actual.localExecution?.ok !== false - || actual.githubActionsExecution?.ok !== false - || actual.localGithubDispatch?.ok !== false) { - throw new Error(`plan-environment-deploy should classify this as execution-only blockers with no runnable path yet.\nSTDOUT:\n${planResult.stdout}`); - } - if (actual.recommendedCommands?.primary !== "gh auth login -h github.com") { - throw new Error(`plan-environment-deploy should recommend fixing local gh auth first when GitHub dispatch remediation depends on it.\nSTDOUT:\n${planResult.stdout}`); - } - if (!Array.isArray(actual.nextSteps) - || !String(actual.nextSteps[0] || "").includes("Run `gh auth login -h github.com` first")) { - throw new Error(`plan-environment-deploy should prioritize the gh auth remediation step in nextSteps when execution-only blockers remain.\nSTDOUT:\n${planResult.stdout}`); - } - const alternates = actual.recommendedCommands?.alternates || []; - if (!alternates.some((entry) => String(entry).includes("sync:github-app-config-secret")) - || !alternates.some((entry) => String(entry).includes("--deployment-source=package-manifest")) - || !alternates.some((entry) => String(entry).includes("--deployment-source=backend-artifact"))) { - throw new Error(`plan-environment-deploy should include the GitHub secret sync and local fallback remediation commands.\nSTDOUT:\n${planResult.stdout}`); - } - })); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPlanEnvironmentDeployBackendArtifactInputBlockerWorks() { - const result = runJsonScript("scripts/plan-environment-deploy.mjs", [ - "--environment=staging", - "--deployment-source=backend-artifact", - "--output=json", - ]); - - if (result.status !== 1) { - throw new Error(`plan-environment-deploy backend-artifact mode should fail when no backend artifact source file is provided.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const inputBlockers = result.parsed?.inputBlockers || []; - if (!inputBlockers.some((entry) => String(entry).includes("backend-artifact-source-file is required"))) { - throw new Error(`plan-environment-deploy backend-artifact mode did not report the missing backend-artifact-source-file blocker.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectDeployBackendPackageManifestMissingMigrationArtifact() { - withFakePackageManifest((manifestPath) => { - withFakeAwsAllowingS3Cp((env) => { - const result = runScriptWithEnv("scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--run-migrations=true", - "--migration-handler=index.migrate", - ], env); - - if (result.status === 0) { - throw new Error(`deploy-backend package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("Source file not found:")) { - throw new Error(`deploy-backend package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); - }, { missingMigrationArtifact: true }); -} - -function expectDeployAwsPackageManifestMissingMigrationArtifact() { - withFakePackageManifest((manifestPath) => { - withFakeAwsAllowingS3Cp((env) => { - const result = runScriptWithEnv("scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--run-migrations=true", - "--migration-handler=index.migrate", - "--skip-frontend", - ], env); - - if (result.status === 0) { - throw new Error(`deploy-aws package manifest missing migration artifact unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("Source file not found:")) { - throw new Error(`deploy-aws package manifest missing migration artifact did not include expected missing artifact error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); - }, { missingMigrationArtifact: true }); -} - -function expectDeployBackendJsonIncludesManifestProvenance() { - withFakePackageManifest((manifestPath) => { - withFakeAwsForBackendDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-backend json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.resolvedPackageManifestFile !== manifestPath) { - throw new Error(`deploy-backend json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); - } - - const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); - if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { - throw new Error(`deploy-backend json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectDeployAwsJsonIncludesManifestProvenance() { - withFakePackageManifest((manifestPath) => { - withFakeAwsForBackendDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-stack-name=example-backend", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--skip-frontend", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-aws json provenance failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.resolvedPackageManifestFile !== manifestPath) { - throw new Error(`deploy-aws json output did not include the resolved manifest path.\nSTDOUT:\n${result.stdout}`); - } - - const expectedBackendArtifact = path.join(path.dirname(manifestPath), "api-test-self-contained.zip"); - if (parsed.resolvedBackendArtifactSourceFile !== expectedBackendArtifact) { - throw new Error(`deploy-aws json output did not include the resolved backend artifact path.\nSTDOUT:\n${result.stdout}`); - } - - if (parsed.backend?.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { - throw new Error(`deploy-aws nested backend json output did not reflect the uploaded backend artifact key.\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectDeployBackendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-backend-output.sample.json"); - - withFakePackageManifest((manifestPath) => { - withFakeAwsForBackendDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-backend output sample", actual, sample); - expectObjectContainsKeys("deploy-backend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - - if (sample.stackName !== "example-backend") { - throw new Error(`deploy-backend output sample should document stackName=example-backend.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.lambdaCodeS3Bucket !== "my-artifacts-bucket") { - throw new Error(`deploy-backend output sample should document lambdaCodeS3Bucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.lambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { - throw new Error(`deploy-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.resolvedPackageManifestFile).includes("package-manifest.sample.json")) { - throw new Error(`deploy-backend output sample should point to the sample manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.resolvedBackendArtifactSourceFile).includes("/")) { - throw new Error(`deploy-backend output sample should show a manifest-relative backend artifact placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - }); -} - -function expectDeployBootstrapOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-bootstrap-output.sample.json"); - - withFakeAwsForBootstrapDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-bootstrap.mjs", [ - "--stack-name=example-bootstrap", - "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-bootstrap output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-bootstrap output sample", actual, sample); - expectObjectContainsKeys("deploy-bootstrap output sample", actual.parameters || {}, sample.parameters || {}, "parameters"); - expectObjectContainsKeys("deploy-bootstrap output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - - if (sample.stackName !== "example-bootstrap") { - throw new Error(`deploy-bootstrap output sample should document stackName=example-bootstrap.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.region !== "us-east-1") { - throw new Error(`deploy-bootstrap output sample should document region=us-east-1.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parameters?.TemplateBucketName !== "b1admin-prod-templates-123456789012") { - throw new Error(`deploy-bootstrap output sample should document the sample template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parameters?.ArtifactBucketName !== "b1admin-prod-artifacts-123456789012") { - throw new Error(`deploy-bootstrap output sample should document the sample artifact bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.outputs?.TemplateBucketName !== "b1admin-prod-templates-123456789012") { - throw new Error(`deploy-bootstrap output sample should document the resolved TemplateBucketName output.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.outputs?.ArtifactBucketName !== "b1admin-prod-artifacts-123456789012") { - throw new Error(`deploy-bootstrap output sample should document the resolved ArtifactBucketName output.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectDeployFrontendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-frontend-output.sample.json"); - - withFakeAwsForFrontendDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--infrastructure-only", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-frontend output sample", actual, sample); - expectObjectContainsKeys("deploy-frontend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - expectObjectContainsKeys("deploy-frontend output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); - - if (sample.stackName !== "example-frontend") { - throw new Error(`deploy-frontend output sample should document stackName=example-frontend.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.environmentName !== "prod" || sample.region !== "us-east-1") { - throw new Error(`deploy-frontend output sample should document the default region/environment identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { - throw new Error(`deploy-frontend output sample should document the resolved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.appUrl !== "https://admin.example.com") { - throw new Error(`deploy-frontend output sample should document the resolved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBuild !== false || sample.infrastructureOnly !== true || sample.frontendPublished !== false) { - throw new Error(`deploy-frontend output sample should document the infrastructure-only JSON result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectDeployFrontendPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-frontend-publish-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendDeploy((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-frontend publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-frontend publish output sample", actual, sample); - expectObjectContainsKeys("deploy-frontend publish output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - expectObjectContainsKeys("deploy-frontend publish output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.stackName !== "example-frontend") { - throw new Error(`deploy-frontend publish output sample should document stackName=example-frontend.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBuild !== false || sample.infrastructureOnly !== false || sample.frontendPublished !== true) { - throw new Error(`deploy-frontend publish output sample should document the build-driven publish result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { - throw new Error(`deploy-frontend publish output sample should document the resolved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.appUrl !== "https://admin.example.com") { - throw new Error(`deploy-frontend publish output sample should document the resolved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-frontend publish output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`deploy-frontend publish output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-frontend publish output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectDeployAwsFullOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-aws-full-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForSplitStackFullDeploy((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--backend-stack-name=example-backend", - "--frontend-stack-name=example-frontend", - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--lambda-code-s3-key=b1admin/prod/backend/api.zip", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-aws full output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-aws full output sample", actual, sample); - expectObjectContainsKeys("deploy-aws full output sample", actual.backend || {}, sample.backend || {}, "backend"); - expectObjectContainsKeys("deploy-aws full output sample", actual.backend?.outputs || {}, sample.backend?.outputs || {}, "backend.outputs"); - expectObjectContainsKeys("deploy-aws full output sample", actual.frontend || {}, sample.frontend || {}, "frontend"); - expectObjectContainsKeys("deploy-aws full output sample", actual.frontend?.outputs || {}, sample.frontend?.outputs || {}, "frontend.outputs"); - expectObjectContainsKeys("deploy-aws full output sample", actual.frontend?.backendBuildEnv || {}, sample.frontend?.backendBuildEnv || {}, "frontend.backendBuildEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.skipBackend !== false || sample.skipFrontend !== false || sample.publishFrontendAssets !== false) { - throw new Error(`deploy-aws full output sample should document the standard end-to-end wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolvedArtifactBucket !== "my-artifacts-bucket" || sample.resolvedLambdaCodeS3Key !== "b1admin/prod/backend/api.zip") { - throw new Error(`deploy-aws full output sample should document the resolved backend artifact location.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backend?.stackName !== "example-backend" || sample.frontend?.stackName !== "example-frontend") { - throw new Error(`deploy-aws full output sample should document the nested backend/frontend stack identities.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.frontendPublished !== true || sample.frontend?.infrastructureOnly !== false) { - throw new Error(`deploy-aws full output sample should document the nested build-and-publish frontend result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-aws full output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-aws full output sample contract run did not receive the expected frontend build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectValidateFrontendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-frontend-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=frontend", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-frontend output sample", actual, sample); - expectObjectContainsKeys("validate-frontend output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "frontend" || sample.frontendPublishMode !== false) { - throw new Error(`validate-frontend output sample should document an ok frontend deploy validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { - throw new Error(`validate-frontend output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.frontendDomain !== "admin.example.com") { - throw new Error(`validate-frontend output sample should document the frontend custom domain.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Mode: frontend")) { - throw new Error(`validate-frontend output sample should document the frontend mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { - throw new Error(`validate-frontend output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectStagingBootstrapStarterValidation() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=bootstrap", - "--region=us-east-1", - "--stack-name=b1admin-staging-bootstrap", - "--parameters-file=infrastructure/environments/staging/bootstrap-parameters.json", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`staging bootstrap starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.environmentName !== "staging") { - throw new Error(`staging bootstrap starter validation did not preserve EnvironmentName=staging.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.parametersFile !== "infrastructure/environments/staging/bootstrap-parameters.json") { - throw new Error(`staging bootstrap starter validation did not expose the expected parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.resolved?.artifactBucket !== "replace-me-staging-artifact-bucket") { - throw new Error(`staging bootstrap starter validation did not expose the expected resolved artifact bucket.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectStagingSplitStackStarterValidation() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--region=us-east-1", - "--backend-parameters-file=infrastructure/environments/staging/backend-parameters.json", - "--frontend-parameters-file=infrastructure/environments/staging/frontend-parameters.json", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`staging split-stack starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.environmentName !== "staging") { - throw new Error(`staging split-stack starter validation did not preserve EnvironmentName=staging.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.backendParametersFile !== "infrastructure/environments/staging/backend-parameters.json") { - throw new Error(`staging split-stack starter validation did not expose the expected backend parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.frontendParametersFile !== "infrastructure/environments/staging/frontend-parameters.json") { - throw new Error(`staging split-stack starter validation did not expose the expected frontend parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.resolved?.artifactKey !== "b1admin/staging/backend/api.zip") { - throw new Error(`staging split-stack starter validation did not derive the expected staging artifact key.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectStagingDeployScriptStopsOnPlaceholders() { - withRawStarterRepo("staging", ({ rootPath, scriptPath }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const combined = `${stdout}\n${stderr}`; - - if ((result.status ?? 1) === 0) { - throw new Error(`staging deploy script unexpectedly succeeded with placeholder values still present.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - if (!combined.includes("# Environment Starter Audit: staging") || !combined.includes("Unsafe starter default")) { - throw new Error(`staging deploy script did not stop on starter audit blockers as expected.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - for (const snippet of [ - "Starter audit failed for staging.", - "yarn prepare:environment-starter -- --environment=staging --account-id= --output=json", - "yarn plan:environment-deploy -- --environment=staging --output=markdown", - ]) { - if (!combined.includes(snippet)) { - throw new Error(`staging deploy script did not print the expected recovery guidance: ${snippet}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - } - }); -} - -function expectStagingDeployScriptSavesOutputsByDefault() { - withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - env, - }); - - if ((result.status ?? 1) !== 0) { - throw new Error(`staging deploy script failed unexpectedly in the fake harness.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); - const commands = invocations.map((line) => line.split("\u001f")); - const scriptNames = commands.map((parts) => parts[1]); - - const expectedOrder = [ - "audit:environment-starter", - "plan:environment-deploy", - "validate:aws-deploy", - "deploy:bootstrap", - "validate:aws-deploy", - "deploy:aws", - "save:split-stack-outputs", - "verify:split-stack", - ]; - - if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { - throw new Error(`staging deploy script did not run the expected npm command order.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - - const saveInvocation = commands.find((parts) => parts[1] === "save:split-stack-outputs") || []; - if (!saveInvocation.includes("--environment=staging") - || !saveInvocation.includes("--region=us-east-1") - || !saveInvocation.includes("--output-dir=deployment/staging")) { - throw new Error(`staging deploy script did not invoke save:split-stack-outputs with the expected defaults.\nInvocation:\n${JSON.stringify(saveInvocation, null, 2)}`); - } - }); -} - -function expectStagingDeployScriptPreviewOnlyStopsAfterPlan() { - withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - env: { - ...env, - PREVIEW_ONLY: "true", - }, - }); - - if ((result.status ?? 1) !== 0) { - throw new Error(`staging deploy script preview-only mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); - const scriptNames = invocations.map((line) => line.split("\u001f")[1]); - const expectedOrder = [ - "audit:environment-starter", - "plan:environment-deploy", - ]; - - if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { - throw new Error(`staging deploy script preview-only mode should stop after the deploy plan.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - - const combined = `${result.stdout || ""}\n${result.stderr || ""}`; - if (!combined.includes("Preview-only mode enabled; stopping after starter audit and deploy plan.")) { - throw new Error(`staging deploy script preview-only mode did not print the expected stop message.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); -} - -function expectStagingDeployScriptStopsOnUnreadableApiRepo() { - withStarterScriptHarness("staging", ({ rootPath, scriptPath, logPath, env }) => { - const unreadableApiRepo = path.join(rootPath, "UnreadableApi"); - fs.mkdirSync(unreadableApiRepo, { recursive: true }); - fs.writeFileSync(path.join(unreadableApiRepo, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.chmodSync(unreadableApiRepo, 0o000); - - try { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - env: { - ...env, - API_REPO_PATH: "./UnreadableApi", - }, - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const combined = `${stdout}\n${stderr}`; - - if ((result.status ?? 1) === 0) { - throw new Error(`staging deploy script unexpectedly succeeded with an unreadable local Api repo path.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - if (!combined.includes("Local Api repo path is not readable from this shell:") - || !combined.includes("PACKAGE_MANIFEST_FILE") - || !combined.includes("BACKEND_ARTIFACT_SOURCE_FILE")) { - throw new Error(`staging deploy script did not print the expected unreadable-api fallback guidance.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - const invocations = fs.existsSync(logPath) - ? fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean) - : []; - const scriptNames = invocations.map((line) => line.split("\u001f")[1]); - const expectedOrder = [ - "audit:environment-starter", - "plan:environment-deploy", - "validate:aws-deploy", - "deploy:bootstrap", - ]; - - if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { - throw new Error(`staging deploy script should stop before split-stack validation when the local Api repo is unreadable.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - } finally { - fs.chmodSync(unreadableApiRepo, 0o755); - } - }); -} - -function expectValidatorUnreadableApiRepoIncludesFallbackGuidance() { - withUnreadableFakeApiRepo((fakeApiRepoPath) => { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--api-repo-path=${fakeApiRepoPath}`, - "--output=json", - ]); - - if (result.status === 0) { - throw new Error(`validator unexpectedly succeeded for an unreadable api repo package file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - if (!Array.isArray(actual.info) - || !actual.info.some((entry) => String(entry).includes("switch to --package-manifest-file")) - || !actual.info.some((entry) => String(entry).includes("--backend-artifact-source-file")) - || !actual.info.some((entry) => String(entry).includes("GitHub Actions api-repo path"))) { - throw new Error(`validator did not include the expected unreadable-api fallback guidance.\nSTDOUT:\n${result.stdout}`); - } - }); -} - -function expectProdBootstrapStarterValidation() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=bootstrap", - "--region=us-east-1", - "--stack-name=b1admin-prod-bootstrap", - "--parameters-file=infrastructure/environments/prod/bootstrap-parameters.json", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`prod bootstrap starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.environmentName !== "prod") { - throw new Error(`prod bootstrap starter validation did not preserve EnvironmentName=prod.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.parametersFile !== "infrastructure/environments/prod/bootstrap-parameters.json") { - throw new Error(`prod bootstrap starter validation did not expose the expected parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.resolved?.artifactBucket !== "replace-me-prod-artifact-bucket") { - throw new Error(`prod bootstrap starter validation did not expose the expected resolved artifact bucket.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectProdSplitStackStarterValidation() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--region=us-east-1", - "--backend-parameters-file=infrastructure/environments/prod/backend-parameters.json", - "--frontend-parameters-file=infrastructure/environments/prod/frontend-parameters.json", - "--output=json", - ]); - - if (result.status !== 0 || !result.parsed?.ok) { - throw new Error(`prod split-stack starter validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed.environmentName !== "prod") { - throw new Error(`prod split-stack starter validation did not preserve EnvironmentName=prod.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.backendParametersFile !== "infrastructure/environments/prod/backend-parameters.json") { - throw new Error(`prod split-stack starter validation did not expose the expected backend parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.frontendParametersFile !== "infrastructure/environments/prod/frontend-parameters.json") { - throw new Error(`prod split-stack starter validation did not expose the expected frontend parameters file path.\nSTDOUT:\n${result.stdout}`); - } - - if (result.parsed.resolved?.artifactKey !== "b1admin/prod/backend/api.zip") { - throw new Error(`prod split-stack starter validation did not derive the expected prod artifact key.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectProdDeployScriptStopsOnPlaceholders() { - withRawStarterRepo("prod", ({ rootPath, scriptPath }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - }); - - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const combined = `${stdout}\n${stderr}`; - - if ((result.status ?? 1) === 0) { - throw new Error(`prod deploy script unexpectedly succeeded with placeholder values still present.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - - if (!combined.includes("# Environment Starter Audit: prod") || !combined.includes("Unsafe starter default")) { - throw new Error(`prod deploy script did not stop on starter audit blockers as expected.\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - for (const snippet of [ - "Starter audit failed for prod.", - "yarn prepare:environment-starter -- --environment=prod --account-id= --output=json", - "yarn plan:environment-deploy -- --environment=prod --output=markdown", - ]) { - if (!combined.includes(snippet)) { - throw new Error(`prod deploy script did not print the expected recovery guidance: ${snippet}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); - } - } - }); -} - -function expectProdDeployScriptCanSkipSavingOutputs() { - withStarterScriptHarness("prod", ({ rootPath, scriptPath, logPath, env }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - env: { - ...env, - SAVE_OUTPUTS_AFTER_DEPLOY: "false", - }, - }); - - if ((result.status ?? 1) !== 0) { - throw new Error(`prod deploy script failed unexpectedly in the fake harness.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); - const commands = invocations.map((line) => line.split("\u001f")); - const scriptNames = commands.map((parts) => parts[1]); - - if (scriptNames.includes("save:split-stack-outputs")) { - throw new Error(`prod deploy script should skip save:split-stack-outputs when SAVE_OUTPUTS_AFTER_DEPLOY=false.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - if (!scriptNames.includes("plan:environment-deploy")) { - throw new Error(`prod deploy script should run plan:environment-deploy before the deploy steps.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - if (!scriptNames.includes("verify:split-stack")) { - throw new Error(`prod deploy script should still verify after deploy when SAVE_OUTPUTS_AFTER_DEPLOY=false.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - }); -} - -function expectProdDeployScriptPreviewOnlyStopsAfterPlan() { - withStarterScriptHarness("prod", ({ rootPath, scriptPath, logPath, env }) => { - const result = spawnSync("bash", [scriptPath], { - cwd: rootPath, - encoding: "utf8", - env: { - ...env, - PREVIEW_ONLY: "true", - }, - }); - - if ((result.status ?? 1) !== 0) { - throw new Error(`prod deploy script preview-only mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const invocations = fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); - const scriptNames = invocations.map((line) => line.split("\u001f")[1]); - const expectedOrder = [ - "audit:environment-starter", - "plan:environment-deploy", - ]; - - if (JSON.stringify(scriptNames) !== JSON.stringify(expectedOrder)) { - throw new Error(`prod deploy script preview-only mode should stop after the deploy plan.\nActual:\n${JSON.stringify(scriptNames, null, 2)}`); - } - - const combined = `${result.stdout || ""}\n${result.stderr || ""}`; - if (!combined.includes("Preview-only mode enabled; stopping after starter audit and deploy plan.")) { - throw new Error(`prod deploy script preview-only mode did not print the expected stop message.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); -} - -function expectValidateBootstrapOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-bootstrap-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=bootstrap", - "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-bootstrap output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-bootstrap output sample", actual, sample); - expectObjectContainsKeys("validate-bootstrap output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "bootstrap" || sample.bootstrapMode !== true) { - throw new Error(`validate-bootstrap output sample should document an ok bootstrap validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parametersFile !== "infrastructure/examples/bootstrap-parameters.sample.json") { - throw new Error(`validate-bootstrap output sample should point to the sample bootstrap parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.templateBucket !== "b1admin-prod-templates-123456789012") { - throw new Error(`validate-bootstrap output sample should document the resolved template bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "b1admin-prod-artifacts-123456789012") { - throw new Error(`validate-bootstrap output sample should document the resolved artifact bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:bootstrap"))) { - throw new Error(`validate-bootstrap output sample should include a deploy:bootstrap next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidatorBootstrapRespectsEnvironmentName() { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-bootstrap-env-")); - const paramsPath = path.join(tempDir, "bootstrap-parameters.json"); - - try { - fs.writeFileSync(paramsPath, `${JSON.stringify({ - ProjectName: "b1admin", - EnvironmentName: "staging", - TemplateBucketName: "bootstrap-staging-templates-123456789012", - ArtifactBucketName: "bootstrap-staging-artifacts-123456789012", - EnableBucketVersioning: "true", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=bootstrap", - "--region=us-east-1", - "--stack-name=b1admin-staging-bootstrap", - `--parameters-file=${paramsPath}`, - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`bootstrap EnvironmentName validation failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - if (result.parsed?.environmentName !== "staging") { - throw new Error(`bootstrap validator did not preserve EnvironmentName from the parameters file.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectValidateApiMigrationsOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-api-migrations-output.sample.json"); - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-api-migrations-sample-")); - - try { - const fakeApiRepoPath = path.join(tempDir, "api"); - const outputsPath = path.join(tempDir, "outputs.json"); - const secretPath = path.join(tempDir, "database-secret.json"); - - fs.mkdirSync(path.join(fakeApiRepoPath, "tools", "migrations", "attendance"), { recursive: true }); - fs.mkdirSync(path.join(fakeApiRepoPath, "node_modules"), { recursive: true }); - fs.writeFileSync(path.join(fakeApiRepoPath, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.writeFileSync(path.join(fakeApiRepoPath, "tools", "migrate.ts"), "export {};\n"); - fs.writeFileSync(path.join(fakeApiRepoPath, "tools", "kysely-config.ts"), "const MODULES = [\"membership\", \"attendance\"] as const;\nexport { MODULES };\n"); - fs.writeFileSync(path.join(fakeApiRepoPath, "serverless.yml"), "functions:\n socket:\n handler: lambda.socket\n"); - fs.writeFileSync(outputsPath, `${JSON.stringify({ - DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", - DatabasePort: "3306", - AttendanceDatabaseName: "attendance", - }, null, 2)}\n`); - fs.writeFileSync(secretPath, `${JSON.stringify({ - username: "churchapps", - password: "replace-me", - }, null, 2)}\n`); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=api-migrations", - `--api-repo-path=${fakeApiRepoPath}`, - `--outputs-file=${outputsPath}`, - `--db-secret-file=${secretPath}`, - "--action=status", - "--module=attendance", - "--dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-api-migrations output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-api-migrations output sample", actual, sample); - expectObjectContainsKeys("validate-api-migrations output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "api-migrations" || sample.apiMigrationsMode !== true) { - throw new Error(`validate-api-migrations output sample should document an ok standalone api-migrations validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.resolved?.apiRepoMigrationModules) || sample.resolved.apiRepoMigrationModules.join(",") !== "membership,attendance") { - throw new Error(`validate-api-migrations output sample should document the detected migration module set.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.resolved?.apiRepoMigrationDirectories) || sample.resolved.apiRepoMigrationDirectories.join(",") !== "attendance") { - throw new Error(`validate-api-migrations output sample should document the detected migration directory set.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Standalone Api CLI migration validation")) { - throw new Error(`validate-api-migrations output sample should document the standalone migration validator mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("API migration outputs file: /abs/path/to/"))) { - throw new Error(`validate-api-migrations output sample should show the outputs-file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("yarn run:api-migrations -- --api-repo-path="))) { - throw new Error(`validate-api-migrations output sample should include the standalone run:api-migrations next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { - throw new Error(`validate-api-migrations output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectValidateBackendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-backend-output.sample.json"); - - withFakePackageManifest((manifestPath) => { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=backend", - "--stack-name=example-backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-backend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-backend output sample", actual, sample); - expectObjectContainsKeys("validate-backend output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "backend") { - throw new Error(`validate-backend output sample should document an ok backend validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { - throw new Error(`validate-backend output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactKey !== "b1admin/prod/backend/api.zip") { - throw new Error(`validate-backend output sample should document the derived backend artifact key.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.resolved?.packageManifestFile || "").includes("package-manifest.sample.json")) { - throw new Error(`validate-backend output sample should point to the sample manifest path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.resolved?.backendArtifactSource || "").includes("/")) { - throw new Error(`validate-backend output sample should show a manifest-relative backend artifact placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("upload:backend-artifact"))) { - throw new Error(`validate-backend output sample should include an upload:backend-artifact next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:backend"))) { - throw new Error(`validate-backend output sample should include a deploy:backend next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectValidateSplitStackOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-split-stack-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-split-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-split-stack output sample", actual, sample); - expectObjectContainsKeys("validate-split-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "split-stack" || sample.splitStackPublishOnly !== false) { - throw new Error(`validate-split-stack output sample should document an ok non-publish split-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendParametersFile !== "infrastructure/examples/backend-parameters.sample.json") { - throw new Error(`validate-split-stack output sample should point to the sample backend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { - throw new Error(`validate-split-stack output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { - throw new Error(`validate-split-stack output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { - throw new Error(`validate-split-stack output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Split-stack validation: backend + frontend")) { - throw new Error(`validate-split-stack output sample should document the split-stack validation mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend parameters file: /abs/path/to/"))) { - throw new Error(`validate-split-stack output sample should show the frontend parameters file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.errors) || sample.errors.length !== 0 || !Array.isArray(sample.warnings) || sample.warnings.length !== 0) { - throw new Error(`validate-split-stack output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidateSplitStackFrontendInfraOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--frontend-infrastructure-only", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-split-stack frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-split-stack frontend-infrastructure output sample", actual, sample); - expectObjectContainsKeys("validate-split-stack frontend-infrastructure output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "split-stack" || sample.frontendInfrastructureOnly !== true) { - throw new Error(`validate-split-stack frontend-infrastructure output sample should document an ok hosting-only split-stack validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Frontend infrastructure-only deploy requested.")) { - throw new Error(`validate-split-stack frontend-infrastructure output sample should document the frontend infrastructure-only mode.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("frontend hosting provisioned but frontend asset publishing deferred"))) { - throw new Error(`validate-split-stack frontend-infrastructure output sample should document the deferred frontend publish phase.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || sample.warnings.length !== 0 || !Array.isArray(sample.errors) || sample.errors.length !== 0) { - throw new Error(`validate-split-stack frontend-infrastructure output sample should document a clean validation result with no warnings or errors.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidateSplitStackPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-split-stack-publish-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-split-stack publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-split-stack publish output sample", actual, sample); - expectObjectContainsKeys("validate-split-stack publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "split-stack" || sample.splitStackPublishOnly !== true) { - throw new Error(`validate-split-stack publish output sample should document an ok split-stack publish-only validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendParametersFile !== "infrastructure/examples/backend-parameters.sample.json") { - throw new Error(`validate-split-stack publish output sample should point to the sample backend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendParametersFile !== "infrastructure/examples/frontend-parameters.sample.json") { - throw new Error(`validate-split-stack publish output sample should point to the sample frontend parameters file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactBucket !== "my-artifacts-bucket") { - throw new Error(`validate-split-stack publish output sample should document artifactBucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.artifactKey !== "b1admin/backend/api.zip") { - throw new Error(`validate-split-stack publish output sample should document artifactKey=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend outputs file: /abs/path/to/"))) { - throw new Error(`validate-split-stack publish output sample should show the frontend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Frontend parameters file: /abs/path/to/"))) { - throw new Error(`validate-split-stack publish output sample should show the frontend parameters file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { - throw new Error(`validate-split-stack publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("deploy:aws"))) { - throw new Error(`validate-split-stack publish output sample should include a deploy:aws next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectValidateFrontendPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/validate-frontend-publish-output.sample.json"); - - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=frontend-publish", - "--bucket=example-frontend-bucket", - "--distribution-id=EXAMPLE123", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validate-frontend publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("validate-frontend publish output sample", actual, sample); - expectObjectContainsKeys("validate-frontend publish output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "frontend-publish" || sample.frontendPublishMode !== true) { - throw new Error(`validate-frontend publish output sample should document an ok frontend-publish validation result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.some((line) => String(line).includes("Backend outputs file: /abs/path/to/"))) { - throw new Error(`validate-frontend publish output sample should show the backend outputs file placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Frontend publish bucket: example-frontend-bucket")) { - throw new Error(`validate-frontend publish output sample should document the publish bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.info) || !sample.info.includes("Frontend distribution ID: EXAMPLE123")) { - throw new Error(`validate-frontend publish output sample should document the distribution id.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.warnings) || !sample.warnings.some((line) => String(line).includes("/abs/path/to/B1Admin/node_modules"))) { - throw new Error(`validate-frontend publish output sample should show the node_modules warning placeholder.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.nextSteps) || !sample.nextSteps.some((step) => String(step).includes("publish:frontend-assets"))) { - throw new Error(`validate-frontend publish output sample should include a publish:frontend-assets next step.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectUploadBackendArtifactOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/upload-backend-artifact-output.sample.json"); - - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-upload-backend-sample-")); - const sourceFile = path.join(tempDir, "api.zip"); - fs.writeFileSync(sourceFile, "fake backend zip"); - - try { - withFakeAwsForUploadBackendArtifact((env) => { - const result = runJsonScriptWithEnv("scripts/upload-backend-artifact.mjs", [ - "--bootstrap-stack-name=example-bootstrap", - `--source-file=${path.relative(rootDir, sourceFile)}`, - "--artifact-key=b1admin/backend/api.zip", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`upload-backend-artifact output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("upload-backend-artifact output sample", actual, sample); - - if (sample.artifactLabel !== "Backend artifact") { - throw new Error(`upload-backend-artifact output sample should document the default artifact label.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.bucket !== "my-artifacts-bucket") { - throw new Error(`upload-backend-artifact output sample should document bucket=my-artifacts-bucket.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.key !== "b1admin/backend/api.zip") { - throw new Error(`upload-backend-artifact output sample should document key=b1admin/backend/api.zip.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.s3Uri !== "s3://my-artifacts-bucket/b1admin/backend/api.zip") { - throw new Error(`upload-backend-artifact output sample should document the uploaded S3 URI.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.bootstrapStackName !== "example-bootstrap") { - throw new Error(`upload-backend-artifact output sample should document bootstrapStackName=example-bootstrap.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.sourceFile).includes("/abs/path/to/")) { - throw new Error(`upload-backend-artifact output sample should show a source file placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPublishLambdaLayerOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/publish-lambda-layer-output.sample.json"); - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-publish-layer-sample-")); - const sourceFile = path.join(tempDir, "layer.zip"); - fs.writeFileSync(sourceFile, "fake layer zip"); - - try { - withFakeAwsForPublishLambdaLayer((env) => { - const result = runJsonScriptWithEnv("scripts/publish-lambda-layer.mjs", [ - "--layer-name=b1admin-prod-dependencies", - `--source-file=${path.relative(rootDir, sourceFile)}`, - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`publish-lambda-layer output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("publish-lambda-layer output sample", actual, sample); - expectObjectContainsKeys("publish-lambda-layer output sample", actual.Content || {}, sample.Content || {}, "Content"); - - if (sample.LayerArn !== "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies") { - throw new Error(`publish-lambda-layer output sample should document the layer ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.LayerVersionArn !== "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3") { - throw new Error(`publish-lambda-layer output sample should document the layer version ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.Version !== 3) { - throw new Error(`publish-lambda-layer output sample should document Version=3.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.CompatibleRuntimes) || sample.CompatibleRuntimes[0] !== "nodejs22.x") { - throw new Error(`publish-lambda-layer output sample should document the default compatible runtime.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.CompatibleArchitectures) || sample.CompatibleArchitectures[0] !== "arm64") { - throw new Error(`publish-lambda-layer output sample should document the default compatible architecture.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectSyncAppConfigSecretOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/sync-app-config-secret-output.sample.json"); - - withFakeAwsForSyncAppConfigSecret((env) => { - const result = runJsonScriptWithEnv("scripts/sync-app-config-secret.mjs", [ - "--secret-file=infrastructure/examples/app-config-secret.sample.json", - "--secret-name=b1admin-prod-app-config", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`sync-app-config-secret output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("sync-app-config-secret output sample", actual, sample); - - if (sample.action !== "created") { - throw new Error(`sync-app-config-secret output sample should document the created path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.name !== "b1admin-prod-app-config") { - throw new Error(`sync-app-config-secret output sample should document the secret name.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.arn !== "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123") { - throw new Error(`sync-app-config-secret output sample should document the created secret ARN.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.versionId !== "11111111-2222-3333-4444-555555555555") { - throw new Error(`sync-app-config-secret output sample should document the returned secret version id.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectSyncGithubAppConfigSecretOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/sync-github-app-config-secret-output.sample.json"); - - withFakeGhForSyncGithubAppConfigSecret(({ env, capturePath }) => { - const result = runJsonScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ - "--environment=staging", - "--secret-file=infrastructure/examples/app-config-secret.sample.json", - "--repo=ChurchApps/B1Admin", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`sync-github-app-config-secret output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - const capture = JSON.parse(fs.readFileSync(capturePath, "utf8")); - expectObjectContainsKeys("sync-github-app-config-secret output sample", actual, sample); - - if (sample.action !== "stored") { - throw new Error(`sync-github-app-config-secret output sample should document the stored path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.secretName !== "AWS_APP_CONFIG_SECRET_JSON") { - throw new Error(`sync-github-app-config-secret output sample should document the GitHub secret name.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.githubEnvironment !== "aws-staging") { - throw new Error(`sync-github-app-config-secret output sample should document the derived GitHub environment.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.repo !== "ChurchApps/B1Admin") { - throw new Error(`sync-github-app-config-secret output sample should document the target GitHub repository.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.keyCount !== 19) { - throw new Error(`sync-github-app-config-secret output sample should document keyCount=19 for the checked sample input.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.commandPreview || "").includes("gh secret set 'AWS_APP_CONFIG_SECRET_JSON'")) { - throw new Error(`sync-github-app-config-secret output sample should document the reusable gh command preview.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - - if (capture.args[0] !== "secret" || capture.args[1] !== "set" || capture.args[2] !== "AWS_APP_CONFIG_SECRET_JSON") { - throw new Error(`sync-github-app-config-secret did not call gh secret set with the expected secret name.\nCapture:\n${JSON.stringify(capture, null, 2)}`); - } - if (!capture.args.includes("--env") || !capture.args.includes("aws-staging")) { - throw new Error(`sync-github-app-config-secret did not pass the expected GitHub environment.\nCapture:\n${JSON.stringify(capture, null, 2)}`); - } - if (!capture.args.includes("--repo") || !capture.args.includes("ChurchApps/B1Admin")) { - throw new Error(`sync-github-app-config-secret did not pass the expected GitHub repository.\nCapture:\n${JSON.stringify(capture, null, 2)}`); - } - if (!capture.args.includes("--app") || !capture.args.includes("actions")) { - throw new Error(`sync-github-app-config-secret did not scope the secret to GitHub Actions.\nCapture:\n${JSON.stringify(capture, null, 2)}`); - } - if (!capture.secretBody || typeof capture.secretBody.jwtSecret !== "string" || typeof capture.secretBody.encryptionKey !== "string") { - throw new Error(`sync-github-app-config-secret did not pass the normalized JSON secret body.\nCapture:\n${JSON.stringify(capture, null, 2)}`); - } - }); -} - -function expectSyncLegacySsmOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/sync-legacy-ssm-output.sample.json"); - - withFakeAwsForSyncLegacySsm((env) => { - const result = runJsonScriptWithEnv("scripts/sync-legacy-ssm-parameters.mjs", [ - "--stack-name=example-backend", - "--environment=prod", - "--dry-run=true", - "--app-config-secret-file=infrastructure/examples/app-config-secret.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`sync-legacy-ssm output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("sync-legacy-ssm output sample", actual, sample); - - if (sample.stackName !== "example-backend" || sample.environment !== "prod" || sample.prefix !== "/prod") { - throw new Error(`sync-legacy-ssm output sample should document the default stack/environment/prefix identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.dryRun !== true || sample.overwrite !== true) { - throw new Error(`sync-legacy-ssm output sample should document the dry-run overwrite defaults.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parameterCount !== 10) { - throw new Error(`sync-legacy-ssm output sample should document parameterCount=10 for the checked sample inputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!Array.isArray(sample.parameters) || !sample.parameters.some((entry) => entry.name === "/prod/webPushSubject")) { - throw new Error(`sync-legacy-ssm output sample should include the sample webPushSubject parameter.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.parameters.some((entry) => "value" in entry) || (actual.parameters || []).some((entry) => "value" in entry)) { - throw new Error(`sync-legacy-ssm output must list parameter names only; values are secrets and must not appear.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectDispatchGithubAwsDeployOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/dispatch-github-aws-deploy-output.sample.json"); - const relativeEnvDir = ".tmp-dispatch-github-deploy-env"; - const tempDir = path.join(rootDir, relativeEnvDir); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - `--environment-dir=${relativeEnvDir}`, - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy sample verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - const manifestPath = path.join(tempDir, "package-manifest.json"); - fs.writeFileSync(manifestPath, `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - withFakeGhForDispatchGithubAwsDeploy(({ env }) => { - const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - `--environment-dir=${relativeEnvDir}`, - "--deployment-source=package-manifest", - `--package-manifest-file=${relativeEnvDir}/package-manifest.json`, - "--repo=ChurchApps/B1Admin", - "--dry-run=true", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`dispatch-github-aws-deploy output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("dispatch-github-aws-deploy output sample", actual, sample); - - if (sample.action !== "validated") { - throw new Error(`dispatch-github-aws-deploy output sample should document the dry-run validated path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.workflowEnvironmentName !== "aws-staging") { - throw new Error(`dispatch-github-aws-deploy output sample should document the GitHub environment name.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.deploymentSource !== "package-manifest") { - throw new Error(`dispatch-github-aws-deploy output sample should document the package-manifest deployment source.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.previewOnly !== false) { - throw new Error(`dispatch-github-aws-deploy output sample should document the default non-preview dispatch path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.syncAppConfigSecret !== true || sample.secretSync?.attempted !== true || sample.secretSync?.performed !== false) { - throw new Error(`dispatch-github-aws-deploy output sample should document the dry-run secret sync path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.secretSync?.command || "").includes("sync:github-app-config-secret")) { - throw new Error(`dispatch-github-aws-deploy output sample should document the GitHub secret sync helper command.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.dispatchCommand || "").includes("gh workflow run deploy-aws-self-hosted.yml")) { - throw new Error(`dispatch-github-aws-deploy output sample should document the workflow dispatch command.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.workflowInputs?.preview_only !== "false" || !String(sample.dispatchCommand || "").includes("preview_only='false'")) { - throw new Error(`dispatch-github-aws-deploy output sample should document the preview_only workflow input explicitly.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.followUpCommands?.listRuns || "").includes("gh run list --workflow deploy-aws-self-hosted.yml") - || !String(sample.followUpCommands?.watchLatestRun || "").includes("gh run watch $(") - || !String(sample.followUpCommands?.viewLatestRun || "").includes("gh run view $(")) { - throw new Error(`dispatch-github-aws-deploy output sample should document the post-dispatch GitHub run follow-up commands.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectPublishFrontendOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/publish-frontend-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`publish-frontend output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("publish-frontend output sample", actual, sample); - expectObjectContainsKeys("publish-frontend output sample", actual.outputs || {}, sample.outputs || {}, "outputs"); - expectObjectContainsKeys("publish-frontend output sample", actual.backendBuildEnv || {}, sample.backendBuildEnv || {}, "backendBuildEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.bucket !== "example-frontend-bucket" || sample.distributionId !== "EXAMPLE123") { - throw new Error(`publish-frontend output sample should document the saved publish target outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.appUrl !== "https://admin.example.com") { - throw new Error(`publish-frontend output sample should document the saved app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`publish-frontend output sample should document REACT_APP_API_BASE from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`publish-frontend output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBuild !== false || sample.frontendPublished !== true) { - throw new Error(`publish-frontend output sample should document a successful build-driven publish.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`publish-frontend output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectVerifySplitStackOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/verify-split-stack-output.sample.json"); - - const result = runJsonScript("scripts/verify-split-stack.mjs", [ - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--check-aws=false", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`verify-split-stack output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("verify-split-stack output sample", actual, sample); - expectObjectContainsKeys("verify-split-stack output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - - if (sample.ok !== true || sample.mode !== "split-stack" || sample.checkAws !== false) { - throw new Error(`verify-split-stack output sample should document a successful outputs-file verification run with AWS checks disabled.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { - throw new Error(`verify-split-stack output sample should point to the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendOutputsFile !== "infrastructure/examples/frontend-outputs.sample.json") { - throw new Error(`verify-split-stack output sample should point to the sample frontend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.apiBaseUrl !== "https://api.example.com") { - throw new Error(`verify-split-stack output sample should document the resolved API base URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`verify-split-stack output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.frontendBucketName !== "example-frontend-bucket" || sample.resolved?.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`verify-split-stack output sample should document the resolved frontend hosting outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - const backendSourceCheck = sample.checks?.find((check) => check.name === "backend outputs source"); - if (!backendSourceCheck || !String(backendSourceCheck.detail).includes("/abs/path/to/B1Admin/infrastructure/examples/backend-outputs.sample.json")) { - throw new Error(`verify-split-stack output sample should show the backend outputs placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - const frontendSourceCheck = sample.checks?.find((check) => check.name === "frontend outputs source"); - if (!frontendSourceCheck || !String(frontendSourceCheck.detail).includes("/abs/path/to/B1Admin/infrastructure/examples/frontend-outputs.sample.json")) { - throw new Error(`verify-split-stack output sample should show the frontend outputs placeholder path.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - const skippedAwsCheck = sample.checks?.find((check) => check.name === "frontend bucket aws reachability"); - if (!skippedAwsCheck || skippedAwsCheck.skipped !== true) { - throw new Error(`verify-split-stack output sample should show the skipped AWS reachability check when --check-aws=false.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } -} - -function expectSaveSplitStackOutputsOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/save-split-stack-outputs-output.sample.json"); - const outputDir = ".tmp-save-split-stack-contract"; - const outputDirPath = path.join(rootDir, outputDir); - - fs.rmSync(outputDirPath, { recursive: true, force: true }); - - try { - withFakeAwsForSaveSplitStackOutputs((env) => { - fs.mkdirSync(outputDirPath, { recursive: true }); - fs.writeFileSync(path.join(outputDirPath, "preflight-plan.md"), "# Preflight Plan\n"); - - const result = runJsonScriptWithEnv("scripts/save-split-stack-outputs.mjs", [ - "--environment=staging", - "--region=us-east-1", - `--output-dir=${outputDir}`, - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`save-split-stack-outputs output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("save-split-stack-outputs output sample", actual, sample); - expectObjectContainsKeys("save-split-stack-outputs output sample", actual.files || {}, sample.files || {}, "files"); - expectObjectContainsKeys("save-split-stack-outputs output sample", actual.resolved || {}, sample.resolved || {}, "resolved"); - expectObjectContainsKeys("save-split-stack-outputs output sample", actual.followUpCommands || {}, sample.followUpCommands || {}, "followUpCommands"); - - if (sample.ok !== true || sample.environment !== "staging" || sample.region !== "us-east-1") { - throw new Error(`save-split-stack-outputs output sample should document a successful staging capture run.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.stackNames?.backend !== "b1admin-staging-backend" || sample.stackNames?.frontend !== "b1admin-staging-frontend") { - throw new Error(`save-split-stack-outputs output sample should document the derived staging stack names.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.apiBaseUrl !== "https://api.example.com" || sample.resolved?.frontendAppUrl !== "https://admin.example.com") { - throw new Error(`save-split-stack-outputs output sample should document the resolved staging URLs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.resolved?.frontendBucketName !== "example-frontend-bucket" || sample.resolved?.frontendDistributionId !== "EXAMPLE123") { - throw new Error(`save-split-stack-outputs output sample should document the resolved frontend hosting outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.files?.backendOutputsFile !== ".tmp-save-split-stack-contract/backend-outputs.json" - || sample.files?.frontendOutputsFile !== ".tmp-save-split-stack-contract/frontend-outputs.json" - || sample.files?.summaryFile !== ".tmp-save-split-stack-contract/deployment-summary.json" - || sample.files?.preflightPlanFile !== ".tmp-save-split-stack-contract/preflight-plan.md") { - throw new Error(`save-split-stack-outputs output sample should document the saved output file locations.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (!String(sample.followUpCommands?.showDeploymentSummary || "").includes("yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown") - || !String(sample.followUpCommands?.verifyFromSavedOutputs || "").includes("--backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json") - || !String(sample.followUpCommands?.publishFromSavedOutputs || "").includes("--skip-backend --skip-frontend --publish-frontend-assets")) { - throw new Error(`save-split-stack-outputs output sample should document the follow-up commands that reuse the saved files.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - } finally { - fs.rmSync(outputDirPath, { recursive: true, force: true }); - } -} - -function expectSaveSplitStackOutputsEnvironmentModeWorks() { - const outputDir = ".tmp-save-split-stack-outputs"; - const outputDirPath = path.join(rootDir, outputDir); - - fs.rmSync(outputDirPath, { recursive: true, force: true }); - - try { - withFakeAwsForSaveSplitStackOutputs((env) => { - fs.mkdirSync(outputDirPath, { recursive: true }); - fs.writeFileSync(path.join(outputDirPath, "preflight-plan.md"), "# Preflight Plan\n"); - - const result = runJsonScriptWithEnv("scripts/save-split-stack-outputs.mjs", [ - "--environment=staging", - "--region=us-east-1", - `--output-dir=${outputDir}`, - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`save-split-stack-outputs environment mode failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - const backendOutputsPath = path.join(rootDir, outputDir, "backend-outputs.json"); - const frontendOutputsPath = path.join(rootDir, outputDir, "frontend-outputs.json"); - const summaryPath = path.join(rootDir, outputDir, "deployment-summary.json"); - - if (!fs.existsSync(backendOutputsPath) || !fs.existsSync(frontendOutputsPath) || !fs.existsSync(summaryPath)) { - throw new Error(`save-split-stack-outputs did not write all expected output files.\nSTDOUT:\n${result.stdout}`); - } - - const backendFile = readJsonFile(path.relative(rootDir, backendOutputsPath)); - const frontendFile = readJsonFile(path.relative(rootDir, frontendOutputsPath)); - const summaryFile = readJsonFile(path.relative(rootDir, summaryPath)); - - if (backendFile.Stacks?.[0]?.Outputs?.find((output) => output.OutputKey === "ApiBaseUrl")?.OutputValue !== "https://api.example.com") { - throw new Error(`save-split-stack-outputs did not save the raw backend stack outputs.\nSaved backend file:\n${JSON.stringify(backendFile, null, 2)}`); - } - if (frontendFile.Stacks?.[0]?.Outputs?.find((output) => output.OutputKey === "AppUrl")?.OutputValue !== "https://admin.example.com") { - throw new Error(`save-split-stack-outputs did not save the raw frontend stack outputs.\nSaved frontend file:\n${JSON.stringify(frontendFile, null, 2)}`); - } - if (summaryFile.resolved?.appConfigSecretArn !== "arn:aws:secretsmanager:us-east-1:123456789012:secret:example") { - throw new Error(`save-split-stack-outputs summary did not capture the backend secret ARN.\nSaved summary file:\n${JSON.stringify(summaryFile, null, 2)}`); - } - if (summaryFile.files?.preflightPlanFile !== `${outputDir}/preflight-plan.md`) { - throw new Error(`save-split-stack-outputs summary did not capture the preflight plan file when present.\nSaved summary file:\n${JSON.stringify(summaryFile, null, 2)}`); - } - if (actual.followUpCommands?.showDeploymentSummary !== `yarn show:deployment-summary -- --summary-file=${outputDir}/deployment-summary.json --output=markdown`) { - throw new Error(`save-split-stack-outputs did not return the expected summary-render follow-up command.\nSTDOUT:\n${result.stdout}`); - } - if (actual.followUpCommands?.publishFrontendAssetsFromSavedOutputs !== `yarn publish:frontend-assets -- --frontend-outputs-file=${outputDir}/frontend-outputs.json --backend-outputs-file=${outputDir}/backend-outputs.json`) { - throw new Error(`save-split-stack-outputs did not return the expected publish follow-up command.\nSTDOUT:\n${result.stdout}`); - } - }); - } finally { - fs.rmSync(outputDirPath, { recursive: true, force: true }); - } -} - -function expectSaveSplitStackOutputsMissingArgsIsClean() { - const result = runJsonScript("scripts/save-split-stack-outputs.mjs", [ - "--output=json", - ]); - - if (result.status === 0) { - throw new Error(`save-split-stack-outputs unexpectedly succeeded without stack names or environment.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const errors = result.parsed?.errors || []; - if (!errors.includes("Provide --backend-stack-name or --environment.") || !errors.includes("Provide --frontend-stack-name or --environment.")) { - throw new Error(`save-split-stack-outputs did not report the missing required inputs cleanly.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectShowDeploymentSummaryMarkdownWorks() { - const result = runScript("scripts/show-deployment-summary.mjs", [ - "--summary-file=infrastructure/examples/save-split-stack-outputs-output.sample.json", - "--output=markdown", - ]); - - if (result.status !== 0) { - throw new Error(`show-deployment-summary markdown run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const stdout = result.stdout || ""; - for (const snippet of [ - "## staging summary", - "API base URL", - "CloudFront distribution", - "### Saved files", - "### Follow-up commands", - "Summary file:", - "Preflight plan:", - ]) { - if (!stdout.includes(snippet)) { - throw new Error(`show-deployment-summary markdown output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectShowDeploymentSummaryCommandsWorks() { - const result = runScript("scripts/show-deployment-summary.mjs", [ - "--summary-file=infrastructure/examples/save-split-stack-outputs-output.sample.json", - "--output=commands", - ]); - - if (result.status !== 0) { - throw new Error(`show-deployment-summary commands run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const stdout = result.stdout || ""; - for (const snippet of [ - "yarn verify:split-stack -- --region=us-east-1 --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json", - "yarn publish:frontend-assets -- --frontend-outputs-file=.tmp-save-split-stack-contract/frontend-outputs.json --backend-outputs-file=.tmp-save-split-stack-contract/backend-outputs.json", - "yarn show:deployment-summary -- --summary-file=.tmp-save-split-stack-contract/deployment-summary.json --output=markdown", - ]) { - if (!stdout.includes(snippet)) { - throw new Error(`show-deployment-summary commands output is missing expected content: ${snippet}\nSTDOUT:\n${stdout}`); - } - } -} - -function expectShowDeploymentSummaryMissingFileIsClean() { - const result = runJsonScript("scripts/show-deployment-summary.mjs", [ - "--summary-file=does-not-exist.json", - "--output=json", - ]); - - if (result.status === 0) { - throw new Error(`show-deployment-summary unexpectedly succeeded with a missing summary file.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const errors = result.parsed?.errors || []; - if (!errors.some((message) => String(message).includes('Could not load deployment summary "does-not-exist.json"'))) { - throw new Error(`show-deployment-summary did not report the missing summary file cleanly.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectDeployAwsPublishOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-aws-publish-output.sample.json"); - - withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendPublish((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--skip-build", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-aws publish output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-aws publish output sample", actual, sample); - expectObjectContainsKeys("deploy-aws publish output sample", actual.frontendPublish || {}, sample.frontendPublish || {}, "frontendPublish"); - expectObjectContainsKeys("deploy-aws publish output sample", actual.frontendPublish?.outputs || {}, sample.frontendPublish?.outputs || {}, "frontendPublish.outputs"); - - if (sample.region !== "us-east-1" || sample.environment !== "prod" || sample.projectName !== "b1admin") { - throw new Error(`deploy-aws publish output sample should document the default region/environment/project identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBackend !== true || sample.skipFrontend !== true || sample.skipBuild !== true || sample.publishFrontendAssets !== true) { - throw new Error(`deploy-aws publish output sample should document the publish-only skip-build flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendOutputsFile !== "infrastructure/examples/frontend-outputs.sample.json") { - throw new Error(`deploy-aws publish output sample should point at the sample frontend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublish?.bucket !== "example-frontend-bucket" || sample.frontendPublish?.distributionId !== "EXAMPLE123") { - throw new Error(`deploy-aws publish output sample should document the saved frontend publish target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublish?.appUrl !== "https://admin.example.com") { - throw new Error(`deploy-aws publish output sample should document the saved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublish?.skipBuild !== true || sample.frontendPublish?.frontendPublished !== true) { - throw new Error(`deploy-aws publish output sample should document a successful skip-build publish helper result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); - }); -} - -function expectDeployAwsFrontendInfraOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-aws-frontend-infra-output.sample.json"); - - withFakeAwsForFrontendDeploy((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--skip-backend", - "--frontend-infrastructure-only", - "--frontend-stack-name=example-frontend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-aws frontend-infrastructure output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual, sample); - expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend || {}, sample.frontend || {}, "frontend"); - expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend?.outputs || {}, sample.frontend?.outputs || {}, "frontend.outputs"); - expectObjectContainsKeys("deploy-aws frontend-infrastructure output sample", actual.frontend?.backendBuildEnv || {}, sample.frontend?.backendBuildEnv || {}, "frontend.backendBuildEnv"); - - if (sample.region !== "us-east-1" || sample.environment !== "prod" || sample.projectName !== "b1admin") { - throw new Error(`deploy-aws frontend-infrastructure output sample should document the default region/environment/project identity.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBackend !== true || sample.frontendInfrastructureOnly !== true || sample.publishFrontendAssets !== false) { - throw new Error(`deploy-aws frontend-infrastructure output sample should document the staged hosting-only wrapper flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { - throw new Error(`deploy-aws frontend-infrastructure output sample should point at the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.bucket !== "example-frontend-bucket" || sample.frontend?.distributionId !== "EXAMPLE123") { - throw new Error(`deploy-aws frontend-infrastructure output sample should document the resolved frontend hosting target.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.appUrl !== "https://admin.example.com") { - throw new Error(`deploy-aws frontend-infrastructure output sample should document the resolved frontend app URL.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-aws frontend-infrastructure output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontend?.infrastructureOnly !== true || sample.frontend?.frontendPublished !== false) { - throw new Error(`deploy-aws frontend-infrastructure output sample should document the nested infrastructure-only frontend result.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - }); -} - -function expectDeployAwsPublishBuildOutputSampleMatchesContract() { - const sample = readJsonFile("infrastructure/examples/deploy-aws-publish-build-output.sample.json"); - - withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-aws publish build output sample contract run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - expectObjectContainsKeys("deploy-aws publish build output sample", actual, sample); - expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish || {}, sample.frontendPublish || {}, "frontendPublish"); - expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish?.outputs || {}, sample.frontendPublish?.outputs || {}, "frontendPublish.outputs"); - expectObjectContainsKeys("deploy-aws publish build output sample", actual.frontendPublish?.backendBuildEnv || {}, sample.frontendPublish?.backendBuildEnv || {}, "frontendPublish.backendBuildEnv"); - - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (sample.backendOutputsFile !== "infrastructure/examples/backend-outputs.sample.json") { - throw new Error(`deploy-aws publish build output sample should point at the sample backend outputs file.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.skipBuild !== false || sample.frontendPublish?.skipBuild !== false) { - throw new Error(`deploy-aws publish build output sample should document the build-driven publish flow.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublish?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-aws publish build output sample should document REACT_APP_API_BASE from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (sample.frontendPublish?.backendBuildEnv?.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`deploy-aws publish build output sample should document REACT_APP_DEFAULT_STOCK_PHOTO from saved backend outputs.\nSample:\n${JSON.stringify(sample, null, 2)}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com" || capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-aws publish build output sample contract run did not receive the expected build env.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }); -} - -function expectError(name, invocation, expectedMessage) { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", invocation); - if (result.status === 0) { - throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - const errors = result.parsed?.errors || []; - if (!errors.some((message) => message.includes(expectedMessage))) { - throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } -} - -function expectScriptError(name, scriptPath, invocation, expectedMessage) { - const result = runScript(scriptPath, invocation); - if (result.status === 0) { - throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes(expectedMessage)) { - throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } -} - -function expectScriptErrorClean(name, scriptPath, invocation, expectedMessage) { - const result = runScript(scriptPath, invocation); - if (result.status === 0) { - throw new Error(`${name} unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes(expectedMessage)) { - throw new Error(`${name} did not include expected error "${expectedMessage}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const unwantedFragments = ["node:internal/errors", "Node.js v"]; - const unexpected = unwantedFragments.find((fragment) => combined.includes(fragment)); - if (unexpected) { - throw new Error(`${name} still leaked a raw Node stack trace fragment "${unexpected}".\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } -} - -function expectScriptOk(name, scriptPath, invocation) { - const result = runScript(scriptPath, invocation); - if (result.status !== 0) { - throw new Error(`${name} failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } -} - -function withFakePackagableApiRepo(callback, options = {}) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-packagable-api-repo-")); - - try { - fs.mkdirSync(path.join(tempDir, "config"), { recursive: true }); - fs.mkdirSync(path.join(tempDir, "dist"), { recursive: true }); - fs.mkdirSync(path.join(tempDir, "node_modules", "fake-dependency"), { recursive: true }); - fs.mkdirSync(path.join(tempDir, "tools", "migrations", "membership"), { recursive: true }); - fs.writeFileSync(path.join(tempDir, "config", "default.json"), "{}\n"); - fs.writeFileSync(path.join(tempDir, "dist", "index.js"), "export const ok = true;\n"); - fs.writeFileSync(path.join(tempDir, "lambda.js"), "exports.handler = async () => ({ statusCode: 200, body: 'ok' });\n"); - fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - scripts: { - "build:prod": "echo build", - "build-layer": "echo build-layer", - }, - }, null, 2)}\n`); - fs.writeFileSync(path.join(tempDir, "node_modules", "fake-dependency", "index.js"), "module.exports = {};\n"); - fs.writeFileSync(path.join(tempDir, "serverless.yml"), `service: fake-api -provider: - name: aws - runtime: nodejs22.x -functions: - web: - handler: lambda.web - socket: - handler: lambda.socket - timer15Min: - handler: lambda.timer15Min -environment: - MEMBERSHIP_CONNECTION_STRING: \${ssm:/prod/membershipConnectionString} -`); - fs.writeFileSync(path.join(tempDir, "tools", "kysely-config.ts"), `const MODULES = ["membership", "attendance"] as const;\nexport { MODULES };\n`); - - if (options.includeLayer) { - fs.mkdirSync(path.join(tempDir, "layer", "nodejs"), { recursive: true }); - fs.writeFileSync(path.join(tempDir, "layer", "nodejs", "index.js"), "module.exports = {};\n"); - } - - callback(tempDir); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withUnreadableFakeApiRepo(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-unreadable-api-repo-")); - const packageJsonPath = path.join(tempDir, "package.json"); - - try { - fs.writeFileSync(packageJsonPath, `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.chmodSync(packageJsonPath, 0o000); - callback(tempDir); - } finally { - try { - fs.chmodSync(packageJsonPath, 0o644); - } catch {} - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withUnreadableFakeApiRepoDirectory(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-unreadable-api-repo-dir-")); - const packageJsonPath = path.join(tempDir, "package.json"); - - try { - fs.writeFileSync(packageJsonPath, `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - fs.chmodSync(tempDir, 0o000); - callback(tempDir); - } finally { - try { - fs.chmodSync(tempDir, 0o755); - } catch {} - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectAuditApiRepoContractWorks() { - withFakePackagableApiRepo((fakeApiRepoPath) => { - const result = runJsonScript("scripts/audit-api-repo-contract.mjs", [ - `--api-repo-path=${fakeApiRepoPath}`, - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`audit-api-repo-contract should succeed for a readable fake Api repo.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - if (actual.ok !== true || actual.packaging?.autoPackageReady !== true || actual.contract?.ready !== true) { - throw new Error(`audit-api-repo-contract did not report the expected ready state.\nSTDOUT:\n${result.stdout}`); - } - if (actual.recommended?.packageMode !== "layered-or-self-contained") { - throw new Error(`audit-api-repo-contract did not recommend the expected package mode.\nSTDOUT:\n${result.stdout}`); - } - if (!Array.isArray(actual.migrations?.modules) || !actual.migrations.modules.includes("membership") || !actual.migrations.modules.includes("attendance")) { - throw new Error(`audit-api-repo-contract did not detect the expected migration modules.\nSTDOUT:\n${result.stdout}`); - } - }, { includeLayer: true }); -} - -function expectAuditApiRepoContractUnreadablePathIsClean() { - const result = runJsonScript("scripts/audit-api-repo-contract.mjs", [ - "--api-repo-path=/definitely/missing/api-repo", - "--output=json", - ]); - - if (result.status === 0) { - throw new Error(`audit-api-repo-contract unexpectedly succeeded for a missing repo path.\nSTDOUT:\n${result.stdout}`); - } - - const actual = result.parsed || {}; - if (actual.ok !== false || !Array.isArray(actual.errors) || !actual.errors.some((entry) => String(entry).includes("API repo path not found:"))) { - throw new Error(`audit-api-repo-contract did not return the expected missing-path error.\nSTDOUT:\n${result.stdout}`); - } -} - -function withFakePackageManifest(callback, options = {}) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-package-manifest-")); - const manifestPath = path.join(tempDir, "api-test-self-contained.manifest.json"); - const backendArtifactAbsolutePath = options.missingBackendArtifact - ? path.join(tempDir, "missing-api.zip") - : path.join(tempDir, "api-test-self-contained.zip"); - const migrationArtifactAbsolutePath = (options.includeMigrationArtifact || options.missingMigrationArtifact) - ? path.join(tempDir, "api-test-migrations.zip") - : ""; - const layerArtifactAbsolutePath = options.includeLayerArtifact - ? path.join(tempDir, "api-test-dependencies-layer.zip") - : ""; - - try { - if (!options.missingBackendArtifact) { - fs.writeFileSync(backendArtifactAbsolutePath, "fake artifact\n"); - } - if (options.includeMigrationArtifact && !options.missingMigrationArtifact) { - fs.writeFileSync(migrationArtifactAbsolutePath, "fake migration artifact\n"); - } - if (options.includeLayerArtifact) { - fs.writeFileSync(layerArtifactAbsolutePath, "fake layer\n"); - } - - fs.writeFileSync(manifestPath, `${JSON.stringify({ - apiRepoPath: path.join(rootDir, "..", "Api"), - packageMode: options.packageMode || "self-contained", - environment: "test", - build: false, - buildCommand: "build:prod", - buildLayer: Boolean(options.includeLayerArtifact), - buildLayerCommand: options.includeLayerArtifact ? "build-layer" : "", - backendArtifactPath: path.basename(backendArtifactAbsolutePath), - migrationArtifactPath: migrationArtifactAbsolutePath ? path.basename(migrationArtifactAbsolutePath) : "", - dependenciesLayerArtifactPath: layerArtifactAbsolutePath ? path.basename(layerArtifactAbsolutePath) : "", - manifestPath: path.basename(manifestPath), - recommendedNextSteps: { - uploadBackendArtifact: "yarn upload:backend-artifact -- --source-file=fake.zip", - deployMode: "Use the resulting backend zip directly.", - }, - includedBackendEntries: ["dist"], - }, null, 2)}\n`); - callback(manifestPath); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeFrontendBuildOutput(callback) { - const distDir = path.join(rootDir, "dist"); - const backupDir = `${distDir}.backup-smoke`; - const hadDist = fs.existsSync(distDir); - - try { - if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - if (hadDist) { - fs.renameSync(distDir, backupDir); - } - - fs.mkdirSync(distDir, { recursive: true }); - fs.writeFileSync(path.join(distDir, "index.html"), "smoke\n"); - fs.writeFileSync(path.join(distDir, "sw.js"), "self.addEventListener('install', () => {});\n"); - callback(); - } finally { - fs.rmSync(distDir, { recursive: true, force: true }); - if (hadDist && fs.existsSync(backupDir)) { - fs.renameSync(backupDir, distDir); - } else if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - } -} - -function withMissingFrontendBuildOutput(callback) { - const distDir = path.join(rootDir, "dist"); - const backupDir = `${distDir}.backup-smoke-missing`; - const hadDist = fs.existsSync(distDir); - - try { - if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - if (hadDist) { - fs.renameSync(distDir, backupDir); - } - - callback(); - } finally { - fs.rmSync(distDir, { recursive: true, force: true }); - if (hadDist && fs.existsSync(backupDir)) { - fs.renameSync(backupDir, distDir); - } else if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - } -} - -function withMissingFrontendNodeModules(callback) { - const nodeModulesDir = path.join(rootDir, "node_modules"); - const backupDir = `${nodeModulesDir}.backup-smoke-missing-deps`; - const hadNodeModules = fs.existsSync(nodeModulesDir); - - try { - if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - if (hadNodeModules) { - fs.renameSync(nodeModulesDir, backupDir); - } - - callback(); - } finally { - fs.rmSync(nodeModulesDir, { recursive: true, force: true }); - if (hadNodeModules && fs.existsSync(backupDir)) { - fs.renameSync(backupDir, nodeModulesDir); - } else if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - } -} - -function writeFakeFrontendDependencyMarker(nodeModulesDir) { - const viteCliPath = path.join(nodeModulesDir, "vite", "dist", "node", "cli.js"); - fs.mkdirSync(path.dirname(viteCliPath), { recursive: true }); - fs.writeFileSync(viteCliPath, "export {};\n"); -} - -function withStarterScriptHarness(environmentName, callback) { - const tempRoot = fs.mkdtempSync(path.join(rootDir, `.tmp-${environmentName}-starter-script-`)); - const environmentRoot = path.join(tempRoot, "infrastructure", "environments", environmentName); - const sourceRoot = path.join(rootDir, "infrastructure", "environments", environmentName); - const fakeBin = path.join(tempRoot, "bin"); - const fakeApiRepo = path.join(tempRoot, "Api"); - const npmPath = path.join(fakeBin, "npm"); - const logPath = path.join(tempRoot, "npm-invocations.log"); - - try { - fs.mkdirSync(environmentRoot, { recursive: true }); - fs.mkdirSync(fakeBin, { recursive: true }); - fs.mkdirSync(fakeApiRepo, { recursive: true }); - - for (const fileName of fs.readdirSync(sourceRoot)) { - const sourcePath = path.join(sourceRoot, fileName); - const targetPath = path.join(environmentRoot, fileName); - fs.copyFileSync(sourcePath, targetPath); - } - - for (const jsonName of ["bootstrap-parameters.json", "backend-parameters.json"]) { - const jsonPath = path.join(environmentRoot, jsonName); - const updated = fs.readFileSync(jsonPath, "utf8").replaceAll("replace-me", "ready"); - fs.writeFileSync(jsonPath, updated); - } - - fs.writeFileSync(path.join(fakeApiRepo, "package.json"), `${JSON.stringify({ - name: "fake-api-repo", - private: true, - }, null, 2)}\n`); - - fs.writeFileSync(npmPath, `#!/usr/bin/env node -import fs from "node:fs"; -const line = process.argv.slice(2).join("\\u001f"); -fs.appendFileSync(${JSON.stringify(logPath)}, line + "\\n"); -process.exit(0); -`); - fs.chmodSync(npmPath, 0o755); - - callback({ - rootPath: tempRoot, - scriptPath: path.join(environmentRoot, "deploy-split-stack.sh"), - logPath, - env: { - ...process.env, - API_REPO_PATH: "./Api", - PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ""}`, - }, - }); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -} - -function replaceStarterBackendDefaults(environmentDir, environmentName = "staging") { - const backendPath = path.join(environmentDir, "backend-parameters.json"); - const backend = JSON.parse(fs.readFileSync(backendPath, "utf8")); - const suffix = environmentName === "prod" ? "prod" : environmentName; - - backend.WebsiteBaseUrl = `https://{subdomain}.${suffix}.b1test.org`; - backend.ContentRootUrl = `https://content-${suffix}.b1test.org`; - backend.B1AdminRootUrl = `https://admin-${suffix}.b1test.org`; - backend.CorsOrigin = `https://admin-${suffix}.b1test.org`; - backend.StoreApiUrl = `https://store-${suffix}.b1test.org`; - backend.TransferUrl = `https://transfer-${suffix}.b1test.org`; - backend.SupportEmail = `support@${suffix}.b1test.org`; - backend.SupportPhone = "800-555-0199"; - backend.SupportSiteUrl = `https://support-${suffix}.b1test.org`; - - fs.writeFileSync(backendPath, `${JSON.stringify(backend, null, 2)}\n`); -} - -function restoreStarterTemplateDefaults(environmentDir, environmentName = "staging") { - const bootstrapPath = path.join(environmentDir, "bootstrap-parameters.json"); - const backendPath = path.join(environmentDir, "backend-parameters.json"); - const secretTemplatePath = path.join(environmentDir, "app-config-secret.template.json"); - const environmentSuffix = environmentName === "prod" ? "" : `-${environmentName}`; - - const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, "utf8")); - bootstrap.TemplateBucketName = `replace-me-b1admin-${environmentName}-templates-123456789012`; - bootstrap.ArtifactBucketName = `replace-me-b1admin-${environmentName}-artifacts-123456789012`; - fs.writeFileSync(bootstrapPath, `${JSON.stringify(bootstrap, null, 2)}\n`); - - const backend = JSON.parse(fs.readFileSync(backendPath, "utf8")); - backend.LambdaCodeS3Bucket = `replace-me-b1admin-${environmentName}-artifacts-123456789012`; - backend.WebsiteBaseUrl = "https://{subdomain}.example.com"; - backend.ContentRootUrl = `https://content${environmentSuffix}.example.com`; - backend.B1AdminRootUrl = `https://admin${environmentSuffix}.example.com`; - backend.CorsOrigin = `https://admin${environmentSuffix}.example.com`; - backend.StoreApiUrl = `https://store${environmentSuffix}.example.com`; - backend.TransferUrl = `https://transfer${environmentSuffix}.example.com`; - backend.SupportEmail = "support@example.com"; - backend.SupportPhone = "555-555-5555"; - backend.SupportSiteUrl = "https://support.example.com"; - backend.MobileAppUrl = ""; - backend.DomainCnameTarget = ""; - backend.DomainATarget = ""; - backend.DefaultStockPhoto = ""; - backend.GoogleAnalyticsTag = ""; - fs.writeFileSync(backendPath, `${JSON.stringify(backend, null, 2)}\n`); - - const secretTemplate = JSON.parse(fs.readFileSync(secretTemplatePath, "utf8")); - secretTemplate.jwtSecret = "replace-me-long-random-jwt-secret"; - secretTemplate.encryptionKey = "replace-me-long-random-encryption-key"; - secretTemplate.webPushSubject = "mailto:support@example.com"; - fs.writeFileSync(secretTemplatePath, `${JSON.stringify(secretTemplate, null, 2)}\n`); -} - -function withRawStarterEnvironment(environmentName, callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, `.tmp-raw-${environmentName}-starter-`)); - - try { - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", environmentName, fileName), - path.join(tempDir, fileName), - ); - } - - restoreStarterTemplateDefaults(tempDir, environmentName); - callback(tempDir); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withRawStarterRepo(environmentName, callback) { - const tempRoot = fs.mkdtempSync(path.join(rootDir, `.tmp-raw-${environmentName}-repo-`)); - const tempScripts = path.join(tempRoot, "scripts"); - const tempEnvRoot = path.join(tempRoot, "infrastructure", "environments", environmentName); - - try { - fs.mkdirSync(tempScripts, { recursive: true }); - fs.mkdirSync(tempEnvRoot, { recursive: true }); - - fs.copyFileSync(path.join(rootDir, "package.json"), path.join(tempRoot, "package.json")); - fs.cpSync(path.join(rootDir, "scripts"), tempScripts, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - "deploy-split-stack.sh", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", environmentName, fileName), - path.join(tempEnvRoot, fileName), - ); - } - - restoreStarterTemplateDefaults(tempEnvRoot, environmentName); - callback({ - rootPath: tempRoot, - scriptPath: path.join(tempEnvRoot, "deploy-split-stack.sh"), - }); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -} - -function withFakeFrontendNodeModules(callback) { - const nodeModulesDir = path.join(rootDir, "node_modules"); - const backupDir = `${nodeModulesDir}.backup-smoke`; - const hadNodeModules = fs.existsSync(nodeModulesDir); - - try { - if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - if (hadNodeModules) { - fs.renameSync(nodeModulesDir, backupDir); - } - - fs.mkdirSync(nodeModulesDir, { recursive: true }); - writeFakeFrontendDependencyMarker(nodeModulesDir); - callback(); - } finally { - fs.rmSync(nodeModulesDir, { recursive: true, force: true }); - if (hadNodeModules && fs.existsSync(backupDir)) { - fs.renameSync(backupDir, nodeModulesDir); - } else if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } - } -} - -function withFakeFrontendBuildHarness(callback) { - const nodeModulesDir = path.join(rootDir, "node_modules"); - const nodeModulesBackupDir = `${nodeModulesDir}.backup-smoke`; - const distDir = path.join(rootDir, "dist"); - const distBackupDir = `${distDir}.backup-smoke`; - const toolsDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-frontend-build-")); - const vitePath = path.join(toolsDir, "vite"); - const envCapturePath = path.join(toolsDir, "build-env.json"); - const hadNodeModules = fs.existsSync(nodeModulesDir); - const hadDist = fs.existsSync(distDir); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -const args = process.argv.slice(2); -if (args[0] !== "build") { - process.stderr.write("Unexpected vite invocation: " + args.join(" ") + "\\n"); - process.exit(1); -} -const rootDir = ${JSON.stringify(rootDir)}; -const capturePath = ${JSON.stringify(envCapturePath)}; -const distDir = path.join(rootDir, "dist"); -fs.mkdirSync(distDir, { recursive: true }); -fs.writeFileSync(path.join(distDir, "index.html"), "fake build\\n"); -fs.writeFileSync(path.join(distDir, "sw.js"), "self.addEventListener('install', () => {});\\n"); -fs.writeFileSync(capturePath, JSON.stringify({ - REACT_APP_STAGE: process.env.REACT_APP_STAGE || "", - REACT_APP_API_BASE: process.env.REACT_APP_API_BASE || "", - REACT_APP_CONTENT_ROOT: process.env.REACT_APP_CONTENT_ROOT || "", - REACT_APP_B1_WEBSITE_URL: process.env.REACT_APP_B1_WEBSITE_URL || "", - REACT_APP_LESSONS_API: process.env.REACT_APP_LESSONS_API || "", - REACT_APP_TRANSFER_URL: process.env.REACT_APP_TRANSFER_URL || "", - REACT_APP_SUPPORT_EMAIL: process.env.REACT_APP_SUPPORT_EMAIL || "", - REACT_APP_SUPPORT_PHONE: process.env.REACT_APP_SUPPORT_PHONE || "", - REACT_APP_SUPPORT_SITE_URL: process.env.REACT_APP_SUPPORT_SITE_URL || "", - REACT_APP_MOBILE_APP_URL: process.env.REACT_APP_MOBILE_APP_URL || "", - REACT_APP_DOMAIN_CNAME_TARGET: process.env.REACT_APP_DOMAIN_CNAME_TARGET || "", - REACT_APP_DOMAIN_A_TARGET: process.env.REACT_APP_DOMAIN_A_TARGET || "", - REACT_APP_DEFAULT_STOCK_PHOTO: process.env.REACT_APP_DEFAULT_STOCK_PHOTO || "", -}, null, 2) + "\\n"); -`; - - try { - if (fs.existsSync(nodeModulesBackupDir)) fs.rmSync(nodeModulesBackupDir, { recursive: true, force: true }); - if (fs.existsSync(distBackupDir)) fs.rmSync(distBackupDir, { recursive: true, force: true }); - if (hadNodeModules) fs.renameSync(nodeModulesDir, nodeModulesBackupDir); - if (hadDist) fs.renameSync(distDir, distBackupDir); - - fs.mkdirSync(nodeModulesDir, { recursive: true }); - writeFakeFrontendDependencyMarker(nodeModulesDir); - fs.writeFileSync(vitePath, script); - fs.chmodSync(vitePath, 0o755); - - callback({ - PATH: `${toolsDir}${path.delimiter}${process.env.PATH || ""}`, - envCapturePath, - }); - } finally { - fs.rmSync(distDir, { recursive: true, force: true }); - if (hadDist && fs.existsSync(distBackupDir)) fs.renameSync(distBackupDir, distDir); - else if (fs.existsSync(distBackupDir)) fs.rmSync(distBackupDir, { recursive: true, force: true }); - - fs.rmSync(nodeModulesDir, { recursive: true, force: true }); - if (hadNodeModules && fs.existsSync(nodeModulesBackupDir)) fs.renameSync(nodeModulesBackupDir, nodeModulesDir); - else if (fs.existsSync(nodeModulesBackupDir)) fs.rmSync(nodeModulesBackupDir, { recursive: true, force: true }); - - fs.rmSync(toolsDir, { recursive: true, force: true }); - } -} - -function withFakeAwsAllowingS3Cp(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-s3cp-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "s3" && args[1] === "cp") { - process.exit(0); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForBackendDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-backend-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "s3" && args[1] === "cp") { - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "deploy") { - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-backend") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForBootstrapDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-bootstrap-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "cloudformation" && args[1] === "deploy") { - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-bootstrap") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "TemplateBucketName", OutputValue: "b1admin-prod-templates-123456789012" }, - { OutputKey: "ArtifactBucketName", OutputValue: "b1admin-prod-artifacts-123456789012" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForFrontendDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-frontend-")); - const awsPath = path.join(tempDir, "aws"); - const statePath = path.join(tempDir, "state.json"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -const args = process.argv.slice(2); -const statePath = ${JSON.stringify(statePath)}; -let state = { describeCount: 0 }; -if (fs.existsSync(statePath)) { - state = JSON.parse(fs.readFileSync(statePath, "utf8")); -} -if (args[0] === "cloudformation" && args[1] === "deploy") { - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-frontend") { - state.describeCount += 1; - fs.writeFileSync(statePath, JSON.stringify(state)); - if (state.describeCount > 1) { - process.stderr.write("Unexpected repeated frontend stack lookup\\n"); - process.exit(1); - } - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, - { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" }, - { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -if (args[0] === "s3" && (args[1] === "sync" || args[1] === "cp")) { - process.exit(0); -} -if (args[0] === "cloudfront" && args[1] === "create-invalidation") { - process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); - process.exit(0); -} -process.stderr.write("Unexpected aws command: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForSplitStackFullDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-split-stack-full-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "s3" && (args[1] === "cp" || args[1] === "sync")) { - process.exit(0); -} -if (args[0] === "cloudfront" && args[1] === "create-invalidation") { - process.stdout.write(JSON.stringify({ Invalidation: { Id: "TEST" } })); - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "deploy") { - process.exit(0); -} -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-backend") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "ApiBaseUrl", OutputValue: "https://api.example.com" }, - { OutputKey: "ContentRootUrl", OutputValue: "https://content.example.com" }, - { OutputKey: "WebsiteBaseUrl", OutputValue: "https://{subdomain}.example.com" }, - { OutputKey: "LessonsApiUrl", OutputValue: "https://lessons-api.example.com" }, - { OutputKey: "TransferUrl", OutputValue: "https://transfer.example.com" }, - { OutputKey: "SupportEmail", OutputValue: "support@example.com" }, - { OutputKey: "SupportPhone", OutputValue: "555-555-5555" }, - { OutputKey: "SupportSiteUrl", OutputValue: "https://support.example.com" }, - { OutputKey: "MobileAppUrl", OutputValue: "https://example.com/app" }, - { OutputKey: "DomainCnameTarget", OutputValue: "proxy.example.com" }, - { OutputKey: "DomainATarget", OutputValue: "203.0.113.10" }, - { OutputKey: "DefaultStockPhoto", OutputValue: "https://content.example.com/stockPhotos/default.jpg" }, - { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123" }, - { OutputKey: "DatabaseEndpoint", OutputValue: "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com" }, - { OutputKey: "DatabasePort", OutputValue: "3306" }, - { OutputKey: "DatabaseSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123" }, - { OutputKey: "MembershipDatabaseName", OutputValue: "membership" }, - { OutputKey: "AttendanceDatabaseName", OutputValue: "attendance" }, - { OutputKey: "ContentDatabaseName", OutputValue: "content" }, - { OutputKey: "GivingDatabaseName", OutputValue: "giving" }, - { OutputKey: "MessagingDatabaseName", OutputValue: "messaging" }, - { OutputKey: "DoingDatabaseName", OutputValue: "doing" }, - { OutputKey: "ReportingDatabaseName", OutputValue: "reporting" } - ] - }] - })); - process.exit(0); - } - if (stackName === "example-frontend") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, - { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" }, - { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForFrontendPublish(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-frontend-publish-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "s3" && args[1] === "sync") { - process.exit(0); -} -if (args[0] === "s3" && args[1] === "cp") { - process.exit(0); -} -if (args[0] === "cloudfront" && args[1] === "create-invalidation") { - process.exit(0); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForSaveSplitStackOutputs(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-save-split-stack-outputs-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "b1admin-staging-backend") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "ApiBaseUrl", OutputValue: "https://api.example.com" }, - { OutputKey: "AppConfigSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:example" } - ] - }] - })); - process.exit(0); - } - if (stackName === "b1admin-staging-frontend") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "AppUrl", OutputValue: "https://admin.example.com" }, - { OutputKey: "SiteBucketName", OutputValue: "example-frontend-bucket" }, - { OutputKey: "CloudFrontDistributionId", OutputValue: "EXAMPLE123" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForUploadBackendArtifact(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-upload-backend-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - const stackNameIndex = args.indexOf("--stack-name"); - const stackName = stackNameIndex >= 0 ? args[stackNameIndex + 1] : ""; - if (stackName === "example-bootstrap") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "ArtifactBucketName", OutputValue: "my-artifacts-bucket" } - ] - }] - })); - process.exit(0); - } - process.stderr.write("Unexpected stack lookup: " + stackName + "\\n"); - process.exit(1); -} -if (args[0] === "s3" && args[1] === "cp") { - process.exit(0); -} -process.stderr.write("Unexpected aws command: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForPublishLambdaLayer(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-publish-layer-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "lambda" && args[1] === "publish-layer-version") { - process.stdout.write(JSON.stringify({ - Content: { - Location: "https://lambda.us-east-1.amazonaws.com/2018-10-31/layers/b1admin-prod-dependencies/versions/3", - CodeSha256: "examplecodesha256value=", - CodeSize: 12345 - }, - LayerArn: "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies", - LayerVersionArn: "arn:aws:lambda:us-east-1:123456789012:layer:b1admin-prod-dependencies:3", - Description: "Published by B1Admin AWS deployment tooling", - CreatedDate: "2026-01-15T12:34:56.000+0000", - Version: 3, - CompatibleRuntimes: ["nodejs22.x"], - CompatibleArchitectures: ["arm64"] - })); - process.exit(0); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForSyncAppConfigSecret(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-sync-app-config-secret-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "secretsmanager" && args[1] === "describe-secret") { - process.stderr.write("An error occurred (ResourceNotFoundException) when calling the DescribeSecret operation: Secrets Manager can't find the specified secret.\\n"); - process.exit(254); -} -if (args[0] === "secretsmanager" && args[1] === "create-secret") { - process.stdout.write(JSON.stringify({ - ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-app-config-abc123", - Name: "b1admin-prod-app-config", - VersionId: "11111111-2222-3333-4444-555555555555" - })); - process.exit(0); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeGhForSyncGithubAppConfigSecret(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-sync-app-config-secret-")); - const ghPath = path.join(tempDir, "gh"); - const capturePath = path.join(tempDir, "capture.json"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -const args = process.argv.slice(2); -if (args[0] === "auth" && args[1] === "status") { - process.exit(0); -} -if (args[0] === "secret" && args[1] === "set") { - const bodyIndex = args.indexOf("--body"); - const secretBody = bodyIndex >= 0 ? JSON.parse(args[bodyIndex + 1] || "{}") : null; - fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ args, secretBody }, null, 2) + "\\n"); - process.exit(0); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - callback({ - env: { - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }, - capturePath, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFailingGhForSyncGithubAppConfigSecret(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-sync-app-config-secret-fail-")); - const ghPath = path.join(tempDir, "gh"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "auth" && args[1] === "status") { - process.stdout.write("github.com\\n"); - process.stderr.write(" X Failed to log in to github.com account example (default)\\n"); - process.stderr.write(" - Active account: true\\n"); - process.stderr.write(" - The token in default is invalid.\\n"); - process.exit(1); -} -process.stderr.write("mock gh failure\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeGhForDispatchGithubAwsDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-dispatch-github-aws-deploy-")); - const ghPath = path.join(tempDir, "gh"); - const capturePath = path.join(tempDir, "capture.jsonl"); - const script = `#!/usr/bin/env node -import fs from "node:fs"; -const args = process.argv.slice(2); -let capture = { args }; -if (args[0] === "auth" && args[1] === "status") { - capture.kind = "auth"; -} else if (args[0] === "secret" && args[1] === "set") { - const bodyIndex = args.indexOf("--body"); - capture.secretBody = bodyIndex >= 0 ? JSON.parse(args[bodyIndex + 1] || "{}") : null; - capture.kind = "secret"; -} else if (args[0] === "workflow" && args[1] === "run") { - capture.kind = "workflow"; -} else { - process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); - process.exit(1); -} -fs.appendFileSync(${JSON.stringify(capturePath)}, JSON.stringify(capture) + "\\n"); -process.exit(0); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - callback({ - env: { - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }, - capturePath, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFailingGhForDispatchGithubAwsDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-dispatch-github-aws-deploy-fail-")); - const ghPath = path.join(tempDir, "gh"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "auth" && args[1] === "status") { - process.stdout.write("github.com\\n"); - process.stderr.write(" X Failed to log in to github.com account example (default)\\n"); - process.stderr.write(" - Active account: true\\n"); - process.stderr.write(" - The token in default is invalid.\\n"); - process.exit(1); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withNetworkFailingGhForPlanEnvironmentDeploy(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-gh-plan-environment-network-fail-")); - const ghPath = path.join(tempDir, "gh"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "auth" && args[1] === "status") { - process.stderr.write("error connecting to github.com\\n"); - process.stderr.write("check your internet connection or https://githubstatus.com\\n"); - process.exit(1); -} -process.stderr.write("Unexpected gh invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(ghPath, script); - fs.chmodSync(ghPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function withFakeAwsForSyncLegacySsm(callback) { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-fake-aws-sync-legacy-ssm-")); - const awsPath = path.join(tempDir, "aws"); - const script = `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "cloudformation" && args[1] === "describe-stacks") { - process.stdout.write(JSON.stringify({ - Stacks: [{ - Outputs: [ - { OutputKey: "DatabaseEndpoint", OutputValue: "b1admin-prod.cluster-example.us-east-1.rds.amazonaws.com" }, - { OutputKey: "DatabasePort", OutputValue: "3306" }, - { OutputKey: "DatabaseSecretArn", OutputValue: "arn:aws:secretsmanager:us-east-1:123456789012:secret:b1admin-prod-db-master-abc123" }, - { OutputKey: "MembershipDatabaseName", OutputValue: "membership" }, - { OutputKey: "AttendanceDatabaseName", OutputValue: "attendance" }, - { OutputKey: "ContentDatabaseName", OutputValue: "content" }, - { OutputKey: "GivingDatabaseName", OutputValue: "giving" }, - { OutputKey: "MessagingDatabaseName", OutputValue: "messaging" }, - { OutputKey: "DoingDatabaseName", OutputValue: "doing" }, - { OutputKey: "ReportingDatabaseName", OutputValue: "reporting" } - ] - }] - })); - process.exit(0); -} -if (args[0] === "secretsmanager" && args[1] === "get-secret-value") { - process.stdout.write(JSON.stringify({ - SecretString: JSON.stringify({ - username: "churchapps", - password: "replace-me" - }) - })); - process.exit(0); -} -process.stderr.write("Unexpected aws invocation: " + args.join(" ") + "\\n"); -process.exit(1); -`; - - try { - fs.writeFileSync(awsPath, script); - fs.chmodSync(awsPath, 0o755); - callback({ - PATH: `${tempDir}${path.delimiter}${process.env.PATH || ""}`, - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function expectDeployFrontendSkipBuildIgnoresBackendStack() { - withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendDeploy((env) => { - const result = runScriptWithEnv("scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--backend-stack-name=definitely-not-a-real-backend-stack", - "--skip-build", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-frontend skip-build unexpectedly failed.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (combined.includes("Could not read backend stack")) { - throw new Error(`deploy-frontend skip-build still tried to read backend stack.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - if (combined.includes("Unexpected repeated frontend stack lookup")) { - throw new Error(`deploy-frontend still re-read frontend stack outputs during publish.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - if (!combined.includes("Deployment complete.")) { - throw new Error(`deploy-frontend skip-build did not complete successfully.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }); - }); -} - -function expectValidatorReportingMigrationNoNextStep() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--api-repo-path=../Api", - "--run-api-migrations=true", - "--api-migration-module=reporting", - "--api-migration-dry-run=true", - "--output=json", - ]); - - if (result.status !== 0) { - throw new Error(`validator reporting migration scenario failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const nextSteps = result.parsed?.nextSteps || []; - if (nextSteps.some((step) => String(step).includes("run:api-migrations"))) { - throw new Error(`validator still suggested run:api-migrations for unsupported reporting migrations.\nSTDOUT:\n${result.stdout}`); - } -} - -function expectStandaloneValidatorReportingMigrationNoNextStep() { - const result = runJsonScript("scripts/validate-aws-deploy.mjs", [ - "--mode=api-migrations", - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--action=status", - "--module=reporting", - "--output=json", - ]); - - if (result.status === 0) { - throw new Error(`standalone validator reporting scenario unexpectedly passed.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const nextSteps = result.parsed?.nextSteps || []; - if (nextSteps.some((step) => String(step).includes("run:api-migrations"))) { - throw new Error(`standalone validator still suggested run:api-migrations for unsupported reporting migrations.\nSTDOUT:\n${result.stdout}`); - } -} - -function runCase(name, fn, results) { - try { - fn(); - results.push({ name, ok: true }); - } catch (error) { - results.push({ - name, - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } -} - -function main() { - const scriptsToCheck = [ - "scripts/deploy-bootstrap.mjs", - "scripts/audit-api-repo-contract.mjs", - "scripts/deploy-frontend.mjs", - "scripts/deploy-backend.mjs", - "scripts/deploy-aws.mjs", - "scripts/upload-backend-artifact.mjs", - "scripts/publish-frontend-assets.mjs", - "scripts/package-api-backend.mjs", - "scripts/installer-common.mjs", - "scripts/installer-init.mjs", - "scripts/installer-customer-values.mjs", - "scripts/installer-update.mjs", - "scripts/installer-run.mjs", - "scripts/installer-start.mjs", - "scripts/setup-private-deployment-repo.mjs", - "scripts/installer-app-config-secret.mjs", - "scripts/installer-aws-handoff.mjs", - "scripts/installer-aws-roles.mjs", - "scripts/installer-configure.mjs", - "scripts/installer-doctor.mjs", - "scripts/installer-aws-preflight.mjs", - "scripts/installer-preflight.mjs", - "scripts/installer-deploy.mjs", - "scripts/installer-observe.mjs", - "scripts/installer-report.mjs", - "scripts/installer-verify.mjs", - "scripts/installer-bootstrap-admin.mjs", - "scripts/installer-adopt-frontend-origin.mjs", - "scripts/installer-github-setup.mjs", - "scripts/installer-github-readiness.mjs", - "scripts/installer-browser-smoke.mjs", - "scripts/audit-environment-starter.mjs", - "scripts/prepare-environment-starter.mjs", - "scripts/plan-environment-deploy.mjs", - "scripts/show-rollout-status.mjs", - "scripts/dispatch-github-aws-deploy.mjs", - "scripts/save-split-stack-outputs.mjs", - "scripts/show-deployment-summary.mjs", - "scripts/verify-split-stack.mjs", - "scripts/run-api-migrations-data-api.mjs", - "scripts/publish-lambda-layer.mjs", - "scripts/sync-app-config-secret.mjs", - "scripts/sync-github-app-config-secret.mjs", - "scripts/sync-legacy-ssm-parameters.mjs", - "scripts/validate-aws-deploy.mjs", - "scripts/smoke-aws-tooling.mjs", - ]; - const shellScriptsToCheck = [ - "infrastructure/environments/prod/deploy-split-stack.sh", - "infrastructure/environments/staging/deploy-split-stack.sh", - ]; - const templatesToParse = [ - "infrastructure/cloudformation/bootstrap.yaml", - "infrastructure/cloudformation/frontend-site.yaml", - "infrastructure/cloudformation/backend-api.yaml", - ]; - const workflowsToParse = [ - ".github/workflows/deploy-aws-self-hosted.yml", - ".github/workflows/deploy-demo.yml", - ".github/workflows/deploy-prod.yml", - ".github/workflows/deploy-staging.yml", - ]; - const jsonFilesToParse = [ - "infrastructure/environments/customer-values.sample.json", - "infrastructure/examples/app-config-secret.sample.json", - "infrastructure/examples/audit-environment-starter-output.sample.json", - "infrastructure/examples/plan-environment-deploy-output.sample.json", - "infrastructure/examples/prepare-environment-starter-output.sample.json", - "infrastructure/examples/deploy-backend-output.sample.json", - "infrastructure/examples/backend-stack-outputs.sample.json", - "infrastructure/examples/backend-outputs.sample.json", - "infrastructure/examples/backend-parameters.sample.json", - "infrastructure/examples/bootstrap-parameters.sample.json", - "infrastructure/examples/database-secret.sample.json", - "infrastructure/examples/deploy-bootstrap-output.sample.json", - "infrastructure/examples/deploy-aws-frontend-infra-output.sample.json", - "infrastructure/examples/deploy-aws-full-output.sample.json", - "infrastructure/examples/deploy-aws-publish-build-output.sample.json", - "infrastructure/examples/deploy-aws-publish-output.sample.json", - "infrastructure/examples/deploy-frontend-output.sample.json", - "infrastructure/examples/deploy-frontend-publish-output.sample.json", - "infrastructure/examples/dispatch-github-aws-deploy-output.sample.json", - "infrastructure/examples/frontend-outputs.sample.json", - "infrastructure/examples/frontend-parameters.sample.json", - "infrastructure/examples/package-api-backend-output.sample.json", - "infrastructure/examples/package-manifest.sample.json", - "infrastructure/examples/publish-lambda-layer-output.sample.json", - "infrastructure/examples/publish-frontend-output.sample.json", - "infrastructure/examples/save-split-stack-outputs-output.sample.json", - "infrastructure/examples/show-rollout-status-output.sample.json", - "infrastructure/examples/sync-app-config-secret-output.sample.json", - "infrastructure/examples/sync-github-app-config-secret-output.sample.json", - "infrastructure/examples/sync-legacy-ssm-output.sample.json", - "infrastructure/examples/upload-backend-artifact-output.sample.json", - "infrastructure/examples/verify-split-stack-output.sample.json", - "infrastructure/examples/validate-api-migrations-output.sample.json", - "infrastructure/examples/validate-backend-output.sample.json", - "infrastructure/examples/validate-bootstrap-output.sample.json", - "infrastructure/examples/validate-frontend-output.sample.json", - "infrastructure/examples/validate-frontend-publish-output.sample.json", - "infrastructure/examples/validate-split-stack-frontend-infra-output.sample.json", - "infrastructure/examples/validate-split-stack-output.sample.json", - "infrastructure/examples/validate-split-stack-publish-output.sample.json", - "infrastructure/environments/prod/app-config-secret.template.json", - "infrastructure/environments/prod/backend-parameters.json", - "infrastructure/environments/prod/bootstrap-parameters.json", - "infrastructure/environments/prod/frontend-parameters.json", - "infrastructure/environments/staging/app-config-secret.template.json", - "infrastructure/environments/staging/backend-parameters.json", - "infrastructure/environments/staging/bootstrap-parameters.json", - "infrastructure/environments/staging/frontend-parameters.json", - ]; - const siblingApiRepoPath = path.resolve(rootDir, "..", "Api"); - const siblingApiRepoReadable = canReadFile(path.join(siblingApiRepoPath, "package.json")) - && canReadFile(path.join(siblingApiRepoPath, "serverless.yml")) - && canReadFile(path.join(siblingApiRepoPath, "tools", "kysely-config.ts")) - && canReadDirectory(path.join(siblingApiRepoPath, "tools", "migrations")); - - const results = []; - - scriptsToCheck.forEach((scriptPath) => runCase(`parse ${scriptPath}`, () => runCheck(scriptPath), results)); - shellScriptsToCheck.forEach((scriptPath) => runCase(`parse ${scriptPath}`, () => runShellCheck(scriptPath), results)); - templatesToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runYamlParse(filePath), results)); - workflowsToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runYamlParse(filePath), results)); - jsonFilesToParse.forEach((filePath) => runCase(`parse ${filePath}`, () => runJsonParse(filePath), results)); - runCase("json example contract coverage is complete", () => expectJsonExampleContractCoverage(jsonFilesToParse), results); - runCase("environment starter kits stay in sync", () => expectEnvironmentStarterParity(), results); - runCase("deploy-aws workflow uploads deployment evidence artifact", () => expectDeployAwsWorkflowUploadsEvidenceArtifact(), results); - - if (siblingApiRepoReadable) { - runCase("api repo serverless env key coverage", () => checkBackendTemplateContainsApiRepoEnvKeys(siblingApiRepoPath), results); - runCase("package-api-backend child failure is clean", () => expectScriptErrorClean("package-api-backend child failure is clean", "scripts/package-api-backend.mjs", [ - "--api-repo-path=../Api", - "--build-command=definitely-not-a-real-build-command", - ], "definitely-not-a-real-build-command"), results); - runCase("validator reporting migration no next step", () => expectValidatorReportingMigrationNoNextStep(), results); - runCase("standalone validator reporting migration no next step", () => expectStandaloneValidatorReportingMigrationNoNextStep(), results); - } else { - addSkippedResults(results, [ - "api repo serverless env key coverage", - "package-api-backend child failure is clean", - "validator reporting migration no next step", - "standalone validator reporting migration no next step", - ]); - } - - runCase("package-api-backend unreadable package file is clean", () => withUnreadableFakeApiRepo((fakeApiRepoPath) => { - expectScriptErrorClean("package-api-backend unreadable package file is clean", "scripts/package-api-backend.mjs", [ - `--api-repo-path=${fakeApiRepoPath}`, - "--build=false", - ], "API package.json is not readable:"); - }), results); - - runCase("audit-api-repo-contract output works", () => expectAuditApiRepoContractWorks(), results); - runCase("audit-api-repo-contract missing path is clean", () => expectAuditApiRepoContractUnreadablePathIsClean(), results); - runCase("package-api-backend json includes manifest deploy hints", () => expectPackageApiBackendJsonIncludesManifestDeployHints(), results); - runCase("package-api-backend output sample matches contract", () => expectPackageApiBackendOutputSampleMatchesContract(), results); - runCase("audit-environment-starter output sample matches contract", () => expectAuditEnvironmentStarterOutputSampleMatchesContract(), results); - runCase("audit-environment-starter markdown output works", () => expectAuditEnvironmentStarterMarkdownOutputWorks(), results); - runCase("plan-environment-deploy output sample matches contract", () => expectPlanEnvironmentDeployOutputSampleMatchesContract(), results); - runCase("show-rollout-status output sample matches contract", () => expectShowRolloutStatusOutputSampleMatchesContract(), results); - runCase("plan-environment-deploy commands output works", () => expectPlanEnvironmentDeployCommandsOutputWorks(), results); - runCase("installer setup scaffolds private deployment repo", () => expectInstallerSetupScaffoldsPrivateDeploymentRepo(), results); - runCase("installer init creates guided starting point", () => expectInstallerInitCreatesGuidedStartingPoint(), results); - runCase("installer customer-values writes guided answers", () => expectInstallerCustomerValuesWritesGuidedAnswers(), results); - runCase("installer run executes guided step", () => expectInstallerRunExecutesGuidedStep(), results); - runCase("installer update dry-run plans guided update", () => expectInstallerUpdateDryRun(), results); - runCase("installer start recommends next step", () => expectInstallerStartRecommendsNextStep(), results); - runCase("customer file awsRegion alias works", () => expectCustomerFileAwsRegionAliasWorks(), results); - runCase("installer aws handoff writes admin document", () => expectInstallerAwsHandoffWritesAdminDocument(), results); - runCase("installer aws roles generates policy files", () => expectInstallerAwsRolesGeneratesPolicyFiles(), results); - runCase("installer github setup plans and writes secrets", () => expectInstallerGithubSetupPlansAndWritesSecrets(), results); - runCase("installer github readiness checks environment secrets", () => expectInstallerGithubReadinessChecksEnvironmentSecrets(), results); - runCase("installer observe summarizes downloaded evidence", () => expectInstallerObserveSummarizesDownloadedEvidence(), results); - runCase("installer observe downloads preview artifact fallback", () => expectInstallerObserveDownloadsPreviewArtifactFallback(), results); - runCase("installer observe warns on incomplete deployment artifact", () => expectInstallerObserveWarnsOnIncompleteDeploymentArtifact(), results); - runCase("installer report generates rollout record", () => expectInstallerReportGeneratesRolloutRecord(), results); - runCase("show-rollout-status summarizes multiple environments", () => expectShowRolloutStatusSummarizesMultipleEnvironments(), results); - runCase("show-rollout-status commands output works", () => expectShowRolloutStatusCommandsOutputWorks(), results); - runCase("plan-environment-deploy markdown output works", () => expectPlanEnvironmentDeployMarkdownOutputWorks(), results); - runCase("plan-environment-deploy ready package-manifest mode works", () => expectPlanEnvironmentDeployReadyPackageManifestModeWorks(), results); - runCase("plan-environment-deploy github needs secret materialization", () => expectPlanEnvironmentDeployGithubNeedsSecretMaterializationWorks(), results); - runCase("plan-environment-deploy local-only execution blocker works", () => expectPlanEnvironmentDeployLocalOnlyExecutionBlockerWorks(), results); - runCase("plan-environment-deploy unreadable api-repo local-only blocker works", () => expectPlanEnvironmentDeployUnreadableApiRepoLocalOnlyBlockerWorks(), results); - runCase("plan-environment-deploy github-only path still needs gh auth", () => expectPlanEnvironmentDeployGithubOnlyNeedsGhAuthWorks(), results); - runCase("plan-environment-deploy gh network failure works", () => expectPlanEnvironmentDeployGhNetworkFailureWorks(), results); - runCase("plan-environment-deploy execution remediation command works", () => expectPlanEnvironmentDeployExecutionRemediationCommandWorks(), results); - runCase("plan-environment-deploy backend-artifact input blocker works", () => expectPlanEnvironmentDeployBackendArtifactInputBlockerWorks(), results); - runCase("prepare-environment-starter output sample matches contract", () => expectPrepareEnvironmentStarterOutputSampleMatchesContract(), results); - runCase("prepare-environment-starter commands output works", () => expectPrepareEnvironmentStarterCommandsOutputWorks(), results); - runCase("prepare-environment-starter markdown output works", () => expectPrepareEnvironmentStarterMarkdownOutputWorks(), results); - runCase("prepare-environment-starter write mode clears generated blockers", () => expectPrepareEnvironmentStarterWriteModeClearsGeneratedBlockers(), results); - runCase("prepare-environment-starter write mode can clear starter defaults", () => expectPrepareEnvironmentStarterWriteModeCanClearStarterDefaults(), results); - runCase("prepare-environment-starter root-domain shortcut works", () => expectPrepareEnvironmentStarterRootDomainShortcutWorks(), results); - runCase("prepare-environment-starter custom-domain inputs work", () => expectPrepareEnvironmentStarterCustomDomainInputsWork(), results); - runCase("prepare-environment-starter write mode can skip secret file", () => expectPrepareEnvironmentStarterWriteModeCanSkipSecretFile(), results); - runCase("prepare-environment-starter optional public fields work", () => expectPrepareEnvironmentStarterOptionalPublicFieldsWork(), results); - runCase("save-split-stack-outputs output sample matches contract", () => expectSaveSplitStackOutputsOutputSampleMatchesContract(), results); - runCase("save-split-stack-outputs environment mode works", () => expectSaveSplitStackOutputsEnvironmentModeWorks(), results); - runCase("save-split-stack-outputs missing args is clean", () => expectSaveSplitStackOutputsMissingArgsIsClean(), results); - runCase("show-deployment-summary markdown works", () => expectShowDeploymentSummaryMarkdownWorks(), results); - runCase("show-deployment-summary commands works", () => expectShowDeploymentSummaryCommandsWorks(), results); - runCase("show-deployment-summary missing file is clean", () => expectShowDeploymentSummaryMissingFileIsClean(), results); - runCase("package manifest sample matches contract", () => expectPackageManifestSampleMatchesContract(), results); - runCase("deploy-bootstrap output sample matches contract", () => expectDeployBootstrapOutputSampleMatchesContract(), results); - runCase("deploy-frontend output sample matches contract", () => expectDeployFrontendOutputSampleMatchesContract(), results); - runCase("deploy-frontend publish output sample matches contract", () => expectDeployFrontendPublishOutputSampleMatchesContract(), results); - runCase("deploy-backend output sample matches contract", () => expectDeployBackendOutputSampleMatchesContract(), results); - runCase("deploy-aws frontend-infrastructure output sample matches contract", () => expectDeployAwsFrontendInfraOutputSampleMatchesContract(), results); - runCase("deploy-aws full output sample matches contract", () => expectDeployAwsFullOutputSampleMatchesContract(), results); - runCase("deploy-aws publish output sample matches contract", () => expectDeployAwsPublishOutputSampleMatchesContract(), results); - runCase("deploy-aws publish build output sample matches contract", () => expectDeployAwsPublishBuildOutputSampleMatchesContract(), results); - runCase("publish-lambda-layer output sample matches contract", () => expectPublishLambdaLayerOutputSampleMatchesContract(), results); - runCase("dispatch-github-aws-deploy output sample matches contract", () => expectDispatchGithubAwsDeployOutputSampleMatchesContract(), results); - runCase("sync-app-config-secret output sample matches contract", () => expectSyncAppConfigSecretOutputSampleMatchesContract(), results); - runCase("sync-github-app-config-secret output sample matches contract", () => expectSyncGithubAppConfigSecretOutputSampleMatchesContract(), results); - runCase("sync-legacy-ssm output sample matches contract", () => expectSyncLegacySsmOutputSampleMatchesContract(), results); - runCase("upload-backend-artifact output sample matches contract", () => expectUploadBackendArtifactOutputSampleMatchesContract(), results); - runCase("validate-api-migrations output sample matches contract", () => expectValidateApiMigrationsOutputSampleMatchesContract(), results); - runCase("validate-backend output sample matches contract", () => expectValidateBackendOutputSampleMatchesContract(), results); - runCase("validate-bootstrap output sample matches contract", () => expectValidateBootstrapOutputSampleMatchesContract(), results); - runCase("prod bootstrap starter validation works", () => expectProdBootstrapStarterValidation(), results); - runCase("prod split-stack starter validation works", () => expectProdSplitStackStarterValidation(), results); - runCase("prod deploy script stops on placeholders", () => expectProdDeployScriptStopsOnPlaceholders(), results); - runCase("prod deploy script can skip saving outputs", () => expectProdDeployScriptCanSkipSavingOutputs(), results); - runCase("prod deploy script preview-only mode stops after plan", () => expectProdDeployScriptPreviewOnlyStopsAfterPlan(), results); - runCase("staging bootstrap starter validation works", () => expectStagingBootstrapStarterValidation(), results); - runCase("staging split-stack starter validation works", () => expectStagingSplitStackStarterValidation(), results); - runCase("staging deploy script stops on placeholders", () => expectStagingDeployScriptStopsOnPlaceholders(), results); - runCase("staging deploy script saves outputs by default", () => expectStagingDeployScriptSavesOutputsByDefault(), results); - runCase("staging deploy script preview-only mode stops after plan", () => expectStagingDeployScriptPreviewOnlyStopsAfterPlan(), results); - runCase("staging deploy script stops on unreadable api repo", () => expectStagingDeployScriptStopsOnUnreadableApiRepo(), results); - runCase("validate-frontend output sample matches contract", () => expectValidateFrontendOutputSampleMatchesContract(), results); - runCase("validate-frontend publish output sample matches contract", () => expectValidateFrontendPublishOutputSampleMatchesContract(), results); - runCase("validate-split-stack frontend-infrastructure output sample matches contract", () => expectValidateSplitStackFrontendInfraOutputSampleMatchesContract(), results); - runCase("validate-split-stack output sample matches contract", () => expectValidateSplitStackOutputSampleMatchesContract(), results); - runCase("validate-split-stack publish output sample matches contract", () => expectValidateSplitStackPublishOutputSampleMatchesContract(), results); - runCase("publish-frontend output sample matches contract", () => expectPublishFrontendOutputSampleMatchesContract(), results); - runCase("verify-split-stack output sample matches contract", () => expectVerifySplitStackOutputSampleMatchesContract(), results); - - runCase("validator unreadable api repo package file", () => withUnreadableFakeApiRepo((fakeApiRepoPath) => { - expectError("validator unreadable api repo package file", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--api-repo-path=${fakeApiRepoPath}`, - "--output=json", - ], "API repo package.json is not readable:"); - }), results); - runCase("validator unreadable api repo fallback guidance", () => expectValidatorUnreadableApiRepoIncludesFallbackGuidance(), results); - - runCase("validator frontend mode", () => expectOk("frontend mode", [ - "--mode=frontend", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--output=json", - ]), results); - - runCase("validator bootstrap mode respects EnvironmentName", () => expectValidatorBootstrapRespectsEnvironmentName(), results); - - runCase("validator bootstrap mode", () => expectOk("bootstrap mode", [ - "--mode=bootstrap", - "--parameters-file=infrastructure/examples/bootstrap-parameters.sample.json", - "--output=json", - ]), results); - - runCase("validator bootstrap next step keeps stack-name", () => expectBootstrapValidatorNextStep(), results); - runCase("validator package manifest next step reuses artifact path", () => expectPackageManifestValidatorNextStep(), results); - runCase("validator package manifest migration next step reuses artifact path", () => expectPackageManifestValidatorMigrationNextStep(), results); - - runCase("validator split-stack mode", () => expectOk("split-stack mode", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--output=json", - ]), results); - - runCase("validator full-stack mode is removed", () => expectScriptError("validator full-stack mode is removed", "scripts/validate-aws-deploy.mjs", [ - "--mode=full-stack", - ], "The full-stack deployment mode has been removed"), results); - - runCase("validator frontend publish mode", () => expectOk("frontend publish mode", [ - "--mode=frontend-publish", - "--bucket=example-frontend-bucket", - "--distribution-id=EXAMPLE123", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ]), results); - - runCase("validator split-stack mode with backend outputs file", () => expectOk("split-stack mode with backend outputs file", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ]), results); - - runCase("validator backend mode with package manifest file", () => withFakePackageManifest((manifestPath) => { - expectOk("backend mode with package manifest file", [ - "--mode=backend", - "--stack-name=example-backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--output=json", - ]); - }), results); - - runCase("validator env var fallback with underscores", () => { - const result = runJsonScriptWithEnv("scripts/validate-aws-deploy.mjs", [ - "--mode=split-stack", - "--output=json", - ], { - BACKEND_PARAMETERS_FILE: "infrastructure/examples/backend-parameters.sample.json", - FRONTEND_PARAMETERS_FILE: "infrastructure/examples/frontend-parameters.sample.json", - }); - - if (result.status !== 0) { - throw new Error(`validator env var fallback with underscores failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - if (!result.parsed?.ok) { - throw new Error(`validator env var fallback with underscores returned ok=false unexpectedly.\nSTDOUT:\n${result.stdout}`); - } - }, results); - - if (siblingApiRepoReadable) { - runCase("validator api-migrations mode", () => expectOk("api-migrations mode", [ - "--mode=api-migrations", - "--api-repo-path=../Api", - "--outputs-file=infrastructure/examples/backend-stack-outputs.sample.json", - "--db-secret-file=infrastructure/examples/database-secret.sample.json", - "--action=status", - "--module=all", - "--dry-run=true", - "--output=json", - ]), results); - } else { - addSkippedResults(results, ["validator api-migrations mode"]); - } - - runCase("validator split-stack invalid publish combo", () => expectError("split-stack invalid publish combo", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--frontend-infrastructure-only", - "--publish-frontend-assets", - "--output=json", - ], "cannot be combined with --frontend-infrastructure-only"), results); - - runCase("validator frontend invalid skip-build combo", () => expectError("frontend invalid skip-build combo", [ - "--mode=frontend", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--infrastructure-only", - "--skip-build", - "--output=json", - ], "has no effect together with --infrastructure-only"), results); - - runCase("validator missing parameters file", () => expectError("missing parameters file", [ - "--mode=bootstrap", - "--parameters-file=does-not-exist.json", - "--output=json", - ], "Parameters file could not be loaded"), results); - - if (siblingApiRepoReadable) { - runCase("validator api-migrations missing target", () => expectError("api-migrations missing target", [ - "--mode=api-migrations", - "--api-repo-path=../Api", - "--output=json", - ], "Api-migrations mode needs --stack-name or --outputs-file"), results); - - runCase("validator reporting migration requires dry run", () => expectError("reporting migration requires dry run", [ - "--mode=backend", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--api-repo-path=../Api", - "--run-api-migrations=true", - "--api-migration-module=reporting", - "--output=json", - ], "Direct reporting migrations are not currently runnable outside dry-run mode"), results); - - runCase("validator api-migrations single-module minimal outputs", () => { - const tempDir = fs.mkdtempSync(path.join(rootDir, ".tmp-validate-api-migration-")); - try { - const outputsPath = path.join(tempDir, "outputs.json"); - const secretPath = path.join(tempDir, "database-secret.json"); - fs.writeFileSync(outputsPath, `${JSON.stringify({ - DatabaseEndpoint: "example.cluster.us-east-1.rds.amazonaws.com", - DatabasePort: "3306", - AttendanceDatabaseName: "attendance", - }, null, 2)}\n`); - fs.writeFileSync(secretPath, `${JSON.stringify({ - username: "churchapps", - password: "replace-me", - }, null, 2)}\n`); - - expectOk("api-migrations single-module minimal outputs", [ - "--mode=api-migrations", - "--api-repo-path=../Api", - `--outputs-file=${outputsPath}`, - `--db-secret-file=${secretPath}`, - "--action=status", - "--module=attendance", - "--dry-run=true", - "--output=json", - ]); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }, results); - } else { - addSkippedResults(results, [ - "validator api-migrations missing target", - "validator reporting migration requires dry run", - "validator api-migrations single-module minimal outputs", - ]); - } - - runCase("validator unreadable bootstrap stack", () => expectError("unreadable bootstrap stack", [ - "--mode=split-stack", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--output=json", - ], 'Bootstrap stack "definitely-not-a-real-bootstrap-stack" could not be read'), results); - - runCase("validator split-stack publish-only ignores bootstrap stack", () => expectOk("split-stack publish-only ignores bootstrap stack", [ - "--mode=split-stack", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--output=json", - ]), results); - - runCase("validator split-stack publish-only with frontend outputs file", () => expectOk("split-stack publish-only with frontend outputs file", [ - "--mode=split-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-parameters-file=infrastructure/examples/frontend-parameters.sample.json", - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ]), results); - - runCase("deploy-frontend invalid skip-build combo", () => expectScriptError("deploy-frontend invalid skip-build combo", "scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--infrastructure-only", - "--skip-build", - ], "--skip-build has no effect when --infrastructure-only is set."), results); - - runCase("deploy-frontend missing build output in skip-build mode", () => withMissingFrontendBuildOutput(() => { - expectScriptError("deploy-frontend missing build output in skip-build mode", "scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--skip-build", - ], "Build output not found:"); - }), results); - - runCase("deploy-frontend skip-build ignores backend stack", () => expectDeployFrontendSkipBuildIgnoresBackendStack(), results); - - runCase("deploy-bootstrap duplicate bucket names", () => expectScriptError("deploy-bootstrap duplicate bucket names", "scripts/deploy-bootstrap.mjs", [ - "--stack-name=example-bootstrap", - "--template-bucket-name=example-bootstrap-bucket", - "--artifact-bucket-name=example-bootstrap-bucket", - ], "TemplateBucketName and ArtifactBucketName must be different"), results); - - runCase("deploy-bootstrap missing parameters file", () => expectScriptError("deploy-bootstrap missing parameters file", "scripts/deploy-bootstrap.mjs", [ - "--stack-name=example-bootstrap", - "--parameters-file=does-not-exist.json", - ], 'Could not load parameters file "does-not-exist.json"'), results); - - runCase("deploy-frontend missing parameters file", () => expectScriptError("deploy-frontend missing parameters file", "scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--parameters-file=does-not-exist.json", - ], 'Could not load parameters file "does-not-exist.json"'), results); - - runCase("deploy-frontend unreadable backend stack", () => expectScriptError("deploy-frontend unreadable backend stack", "scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--backend-stack-name=definitely-not-a-real-backend-stack", - "--infrastructure-only", - ], 'Could not read backend stack "definitely-not-a-real-backend-stack"'), results); - - runCase("deploy-frontend env var fallback with underscores", () => { - const result = runScriptWithEnv("scripts/deploy-frontend.mjs", [ - "--infrastructure-only", - ], { - STACK_NAME: "example-frontend", - BACKEND_OUTPUTS_FILE: "does-not-exist.json", - }); - - if (result.status === 0) { - throw new Error(`deploy-frontend env var fallback with underscores unexpectedly passed.\nSTDOUT:\n${result.stdout}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes('Could not load backend outputs file "does-not-exist.json"')) { - throw new Error(`deploy-frontend env var fallback with underscores did not include expected backend outputs file error.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }, results); - - runCase("deploy-frontend missing backend outputs file", () => expectScriptError("deploy-frontend missing backend outputs file", "scripts/deploy-frontend.mjs", [ - "--stack-name=example-frontend", - "--backend-outputs-file=does-not-exist.json", - "--infrastructure-only", - ], 'Could not load backend outputs file "does-not-exist.json"'), results); - - runCase("deploy-backend missing parameters file", () => expectScriptError("deploy-backend missing parameters file", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--parameters-file=does-not-exist.json", - ], 'Could not load parameters file "does-not-exist.json"'), results); - - runCase("deploy-backend package manifest file without api repo", () => withFakePackageManifest((manifestPath) => { - expectScriptError("deploy-backend package manifest file without api repo", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - ], "Source file not found:"); - }, { missingBackendArtifact: true }), results); - runCase("deploy-backend json includes manifest provenance", () => expectDeployBackendJsonIncludesManifestProvenance(), results); - runCase("deploy-backend package manifest missing migration artifact", () => expectDeployBackendPackageManifestMissingMigrationArtifact(), results); - - runCase("deploy-backend unreadable bootstrap stack", () => expectScriptError("deploy-backend unreadable bootstrap stack", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--output=json", - ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); - - if (siblingApiRepoReadable) { - runCase("deploy-backend unsupported reporting migration target", () => expectScriptError("deploy-backend unsupported reporting migration target", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--run-api-migrations=true", - "--api-migration-module=reporting", - ], "Refusing to deploy with --run-api-migrations=true for an unsupported migration target"), results); - } else { - addSkippedResults(results, ["deploy-backend unsupported reporting migration target"]); - } - - runCase("deploy-backend invalid migration action", () => expectScriptError("deploy-backend invalid migration action", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--run-api-migrations=true", - "--api-migration-action=nope", - "--api-migration-dry-run=true", - ], 'Invalid api-migration-action "nope"'), results); - - runCase("deploy-backend direct migration runner is removed", () => expectScriptError("deploy-backend direct migration runner is removed", "scripts/deploy-backend.mjs", [ - "--stack-name=example-backend", - "--run-api-migrations=true", - "--api-migration-runner=direct", - "--api-migration-dry-run=true", - ], 'The "direct" migration runner has been removed'), results); - - runCase("deploy-aws missing backend parameters file", () => expectScriptError("deploy-aws missing backend parameters file", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=does-not-exist.json", - ], 'Could not load parameters file "does-not-exist.json"'), results); - - runCase("deploy-aws unreadable bootstrap stack", () => expectScriptError("deploy-aws unreadable bootstrap stack", "scripts/deploy-aws.mjs", [ - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--skip-frontend", - ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); - - runCase("deploy-aws package manifest file without api repo", () => withFakePackageManifest((manifestPath) => { - expectScriptError("deploy-aws package manifest file without api repo", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - `--package-manifest-file=${manifestPath}`, - "--lambda-code-s3-bucket=my-artifacts-bucket", - "--skip-frontend", - ], "Source file not found:"); - }, { missingBackendArtifact: true }), results); - runCase("deploy-aws json includes manifest provenance", () => expectDeployAwsJsonIncludesManifestProvenance(), results); - runCase("deploy-aws package manifest missing migration artifact", () => expectDeployAwsPackageManifestMissingMigrationArtifact(), results); - - runCase("deploy-aws publish-only ignores bootstrap stack", () => withFakeFrontendBuildOutput(() => { - expectScriptErrorClean("deploy-aws publish-only ignores bootstrap stack", "scripts/deploy-aws.mjs", [ - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--frontend-stack-name=definitely-not-a-real-frontend-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-stack-name=definitely-not-a-real-backend-stack", - "--skip-build", - ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'); - }), results); - - runCase("deploy-aws invalid publish combo", () => expectScriptError("deploy-aws invalid publish combo", "scripts/deploy-aws.mjs", [ - "--region=us-east-1", - "--project-name=b1admin", - "--environment=prod", - "--frontend-infrastructure-only", - "--publish-frontend-assets", - ], "--publish-frontend-assets cannot be combined with --frontend-infrastructure-only."), results); - - runCase("deploy-aws missing backend outputs file for frontend deploy", () => expectScriptError("deploy-aws missing backend outputs file for frontend deploy", "scripts/deploy-aws.mjs", [ - "--skip-backend", - "--frontend-infrastructure-only", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-outputs-file=does-not-exist.json", - ], 'Could not load backend outputs file "does-not-exist.json"'), results); - - runCase("deploy-aws missing backend outputs file for publish-only", () => withFakeFrontendNodeModules(() => { - expectScriptError("deploy-aws missing backend outputs file for publish-only", "scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-outputs-file=does-not-exist.json", - ], 'Could not load backend outputs file "does-not-exist.json"'); - }), results); - - runCase("deploy-aws publish-only prefers frontend outputs file over frontend stack", () => withFakeFrontendBuildOutput(() => { - expectScriptErrorClean("deploy-aws publish-only prefers frontend outputs file over frontend stack", "scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--skip-build", - "--frontend-stack-name=definitely-not-a-real-frontend-stack", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-outputs-file=does-not-exist.json", - ], 'Could not load frontend outputs file "does-not-exist.json"'); - }), results); - - runCase("deploy-aws publish-only with frontend outputs file works", () => withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendPublish((env) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--skip-build", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`deploy-aws publish-only with frontend outputs file failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.frontendPublish?.bucket !== "example-frontend-bucket") { - throw new Error(`deploy-aws publish-only did not reuse the saved bucket from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendPublish?.distributionId !== "EXAMPLE123") { - throw new Error(`deploy-aws publish-only did not reuse the saved distribution from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendPublish?.appUrl !== "https://admin.example.com") { - throw new Error(`deploy-aws publish-only did not reuse the saved app URL from frontend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (!parsed.frontendPublish?.frontendPublished || parsed.skipBuild !== true || parsed.skipFrontend !== true || parsed.skipBackend !== true) { - throw new Error(`deploy-aws publish-only did not complete the outputs-driven skip-build follow-up cleanly.\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - runCase("deploy-aws publish-only backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`deploy-aws publish-only backend outputs build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (parsed.frontendPublish?.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-aws publish-only did not expose REACT_APP_API_BASE from saved backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.frontendPublish?.backendBuildEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { - throw new Error(`deploy-aws publish-only did not expose REACT_APP_SUPPORT_EMAIL from saved backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`deploy-aws publish-only did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`deploy-aws publish-only did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`deploy-aws publish-only did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - if (siblingApiRepoReadable) { - runCase("deploy-aws unsupported reporting migration target", () => expectScriptError("deploy-aws unsupported reporting migration target", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--run-api-migrations=true", - "--api-migration-module=reporting", - "--skip-frontend", - ], "Refusing to deploy with --run-api-migrations=true for an unsupported migration target"), results); - } else { - addSkippedResults(results, ["deploy-aws unsupported reporting migration target"]); - } - - runCase("deploy-aws invalid migration action", () => expectScriptError("deploy-aws invalid migration action", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--run-api-migrations=true", - "--api-migration-action=nope", - "--api-migration-dry-run=true", - "--skip-frontend", - ], 'Invalid api-migration-action "nope"'), results); - - runCase("deploy-aws run-api-migrations requires backend step", () => expectScriptError("deploy-aws run-api-migrations requires backend step", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--run-api-migrations=true", - "--skip-backend", - "--skip-frontend", - ], "--run-api-migrations=true requires the backend deploy step"), results); - - runCase("deploy-aws direct migration runner is removed", () => expectScriptError("deploy-aws direct migration runner is removed", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--run-api-migrations=true", - "--api-migration-runner=direct", - "--api-migration-dry-run=true", - "--skip-frontend", - ], 'The "direct" migration runner has been removed'), results); - - runCase("deploy-aws missing frontend dependencies", () => withMissingFrontendNodeModules(() => expectScriptError("deploy-aws missing frontend dependencies", "scripts/deploy-aws.mjs", [ - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--skip-backend", - ], "Frontend dependencies are not installed:")), results); - - runCase("deploy-aws publish-only missing frontend dependencies is clean", () => withMissingFrontendNodeModules(() => expectScriptErrorClean("deploy-aws publish-only missing frontend dependencies is clean", "scripts/deploy-aws.mjs", [ - "--skip-backend", - "--skip-frontend", - "--publish-frontend-assets", - "--backend-parameters-file=infrastructure/examples/backend-parameters.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--frontend-stack-name=definitely-not-a-real-frontend-stack", - "--backend-stack-name=definitely-not-a-real-backend-stack", - ], "Frontend dependencies are not installed:")), results); - - runCase("publish-frontend-assets missing outputs file", () => expectScriptError("publish-frontend-assets missing outputs file", "scripts/publish-frontend-assets.mjs", [ - "--frontend-outputs-file=does-not-exist.json", - "--skip-build", - "--bucket=example-bucket", - "--distribution-id=EXAMPLE123", - ], 'Could not load frontend outputs file "does-not-exist.json"'), results); - - runCase("publish-frontend-assets missing build output in skip-build mode", () => withMissingFrontendBuildOutput(() => { - expectScriptError("publish-frontend-assets missing build output in skip-build mode", "scripts/publish-frontend-assets.mjs", [ - "--bucket=example-bucket", - "--distribution-id=EXAMPLE123", - "--skip-build", - ], "Build output not found:"); - }), results); - - runCase("publish-frontend-assets unreadable frontend stack", () => expectScriptError("publish-frontend-assets unreadable frontend stack", "scripts/publish-frontend-assets.mjs", [ - "--stack-name=definitely-not-a-real-frontend-stack", - ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'), results); - - runCase("publish-frontend-assets frontend outputs file works", () => withFakeFrontendBuildOutput(() => { - withFakeAwsForFrontendPublish((env) => { - const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--skip-build", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`publish-frontend-assets frontend outputs file run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - if (parsed.bucket !== "example-frontend-bucket") { - throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved bucket.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.distributionId !== "EXAMPLE123") { - throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved distribution.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.appUrl !== "https://admin.example.com") { - throw new Error(`publish-frontend-assets frontend outputs file did not reuse the saved app URL.\nSTDOUT:\n${result.stdout}`); - } - if (!parsed.frontendPublished || parsed.skipBuild !== true) { - throw new Error(`publish-frontend-assets frontend outputs file did not complete the skip-build publish flow cleanly.\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - runCase("publish-frontend-assets backend outputs file drives build env", () => withFakeFrontendBuildHarness(({ envCapturePath, PATH }) => { - withFakeAwsForFrontendPublish((awsEnv) => { - const result = runJsonScriptWithEnv("scripts/publish-frontend-assets.mjs", [ - "--frontend-outputs-file=infrastructure/examples/frontend-outputs.sample.json", - "--backend-outputs-file=infrastructure/examples/backend-outputs.sample.json", - "--output=json", - ], { - ...awsEnv, - PATH: `${awsEnv.PATH || ""}${path.delimiter}${PATH}`, - }); - - if (result.status !== 0) { - throw new Error(`publish-frontend-assets backend outputs file build run failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const parsed = result.parsed || {}; - const capturedEnv = JSON.parse(fs.readFileSync(envCapturePath, "utf8")); - if (parsed.backendBuildEnv?.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`publish-frontend-assets did not expose REACT_APP_API_BASE from backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (parsed.backendBuildEnv?.REACT_APP_SUPPORT_EMAIL !== "support@example.com") { - throw new Error(`publish-frontend-assets did not expose REACT_APP_SUPPORT_EMAIL from backend outputs.\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_API_BASE !== "https://api.example.com") { - throw new Error(`publish-frontend-assets did not pass REACT_APP_API_BASE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_DEFAULT_STOCK_PHOTO !== "https://content.example.com/stockPhotos/default.jpg") { - throw new Error(`publish-frontend-assets did not pass REACT_APP_DEFAULT_STOCK_PHOTO into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - if (capturedEnv.REACT_APP_STAGE !== "prod") { - throw new Error(`publish-frontend-assets did not pass REACT_APP_STAGE into the frontend build.\nCaptured env:\n${JSON.stringify(capturedEnv, null, 2)}\nSTDOUT:\n${result.stdout}`); - } - }); - }), results); - - runCase("publish-frontend-assets skip-build ignores backend stack", () => withFakeFrontendBuildOutput(() => { - expectScriptErrorClean("publish-frontend-assets skip-build ignores backend stack", "scripts/publish-frontend-assets.mjs", [ - "--stack-name=definitely-not-a-real-frontend-stack", - "--backend-stack-name=definitely-not-a-real-backend-stack", - "--skip-build", - ], 'Could not read frontend stack "definitely-not-a-real-frontend-stack"'); - }), results); - - runCase("publish-lambda-layer invalid source file", () => expectScriptErrorClean("publish-lambda-layer invalid source file", "scripts/publish-lambda-layer.mjs", [ - "--source-file=package.json", - "--layer-name=test-layer", - ], "Source file must be a .zip archive"), results); - - runCase("sync-app-config-secret lookup failure is clean", () => expectScriptErrorClean("sync-app-config-secret lookup failure is clean", "scripts/sync-app-config-secret.mjs", [ - "--secret-file=infrastructure/examples/app-config-secret.sample.json", - "--secret-name=test-secret", - ], 'Could not look up Secrets Manager secret "test-secret"'), results); - - runCase("sync-github-app-config-secret gh failure is clean", () => withFailingGhForSyncGithubAppConfigSecret((env) => { - const result = runScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ - "--environment=staging", - "--secret-file=infrastructure/examples/app-config-secret.sample.json", - "--repo=ChurchApps/B1Admin", - ], env); - - if (result.status === 0) { - throw new Error(`sync-github-app-config-secret unexpectedly succeeded during mocked gh failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again.")) { - throw new Error(`sync-github-app-config-secret failure did not surface the gh auth guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }), results); - - runCase("sync-github-app-config-secret gh network failure is clean", () => withNetworkFailingGhForPlanEnvironmentDeploy((env) => { - const result = runScriptWithEnv("scripts/sync-github-app-config-secret.mjs", [ - "--environment=staging", - "--secret-file=infrastructure/examples/app-config-secret.sample.json", - "--repo=ChurchApps/B1Admin", - ], env); - - if (result.status === 0) { - throw new Error(`sync-github-app-config-secret unexpectedly succeeded during mocked gh network failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("GitHub CLI could not reach github.com from this machine. Check network access and GitHub availability before syncing this secret from here.")) { - throw new Error(`sync-github-app-config-secret did not surface the gh connectivity guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - }), results); - - runCase("dispatch-github-aws-deploy dispatches workflow after secret sync", () => withFakeGhForDispatchGithubAwsDeploy(({ env, capturePath }) => { - const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-run"); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-run", - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy runtime verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-run", - "--deployment-source=package-manifest", - "--package-manifest-file=.tmp-dispatch-github-deploy-run/package-manifest.json", - "--repo=ChurchApps/B1Admin", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`dispatch-github-aws-deploy runtime verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); - const secretCapture = captures.find((entry) => entry.kind === "secret"); - const workflowCapture = captures.find((entry) => entry.kind === "workflow"); - - if (actual.action !== "dispatched" || actual.secretSync?.performed !== true) { - throw new Error(`dispatch-github-aws-deploy should report a real dispatch after syncing the GitHub secret.\nSTDOUT:\n${result.stdout}`); - } - if (actual.previewOnly !== false || actual.workflowInputs?.preview_only !== "false") { - throw new Error(`dispatch-github-aws-deploy should preserve the default non-preview workflow input.\nSTDOUT:\n${result.stdout}`); - } - if (!String(actual.followUpCommands?.watchLatestRun || "").includes("gh run watch $(") - || !String(actual.followUpCommands?.viewLatestRun || "").includes("gh run view $(")) { - throw new Error(`dispatch-github-aws-deploy should expose follow-up commands for the latest GitHub Actions run.\nSTDOUT:\n${result.stdout}`); - } - if (!secretCapture || !workflowCapture) { - throw new Error(`dispatch-github-aws-deploy should call both gh secret set and gh workflow run.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - if (!secretCapture.args.includes("--env") || !secretCapture.args.includes("aws-staging")) { - throw new Error(`dispatch-github-aws-deploy did not sync the expected GitHub environment secret.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - if (!workflowCapture.args.includes("--repo") || !workflowCapture.args.includes("ChurchApps/B1Admin")) { - throw new Error(`dispatch-github-aws-deploy did not dispatch the workflow against the expected repository.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - if (!workflowCapture.args.includes("-f") || !workflowCapture.args.includes("sync_app_config_secret=true")) { - throw new Error(`dispatch-github-aws-deploy did not enable sync_app_config_secret in the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - if (!workflowCapture.args.includes("preview_only=false")) { - throw new Error(`dispatch-github-aws-deploy did not pass preview_only=false into the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), results); - - runCase("dispatch-github-aws-deploy can dispatch preview-only mode", () => withFakeGhForDispatchGithubAwsDeploy(({ env, capturePath }) => { - const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-preview-only"); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-preview-only", - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy preview-only verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-preview-only", - "--deployment-source=package-manifest", - "--package-manifest-file=.tmp-dispatch-github-deploy-preview-only/package-manifest.json", - "--repo=ChurchApps/B1Admin", - "--preview-only=true", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`dispatch-github-aws-deploy preview-only verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - const captures = fs.readFileSync(capturePath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); - const workflowCapture = captures.find((entry) => entry.kind === "workflow"); - - if (actual.previewOnly !== true || actual.workflowInputs?.preview_only !== "true") { - throw new Error(`dispatch-github-aws-deploy should expose preview-only mode in its JSON result.\nSTDOUT:\n${result.stdout}`); - } - if (!workflowCapture || !workflowCapture.args.includes("preview_only=true")) { - throw new Error(`dispatch-github-aws-deploy did not pass preview_only=true into the workflow dispatch.\nCaptures:\n${JSON.stringify(captures, null, 2)}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), results); - - runCase("dispatch-github-aws-deploy gh auth failure is clean", () => withFailingGhForDispatchGithubAwsDeploy((env) => { - const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-auth-fail"); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-auth-fail", - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy auth failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const result = runScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-auth-fail", - "--deployment-source=package-manifest", - "--package-manifest-file=.tmp-dispatch-github-deploy-auth-fail/package-manifest.json", - "--repo=ChurchApps/B1Admin", - ], env); - - if (result.status === 0) { - throw new Error(`dispatch-github-aws-deploy unexpectedly succeeded during mocked gh auth failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("GitHub CLI is not authenticated with a valid token. Re-authenticate with `gh auth login -h github.com` and try again.")) { - throw new Error(`dispatch-github-aws-deploy did not surface the gh auth guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), results); - - runCase("dispatch-github-aws-deploy gh network failure is clean", () => withNetworkFailingGhForPlanEnvironmentDeploy((env) => { - const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-network-fail"); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-network-fail", - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy network failure verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const result = runScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-network-fail", - "--deployment-source=package-manifest", - "--package-manifest-file=.tmp-dispatch-github-deploy-network-fail/package-manifest.json", - "--repo=ChurchApps/B1Admin", - ], env); - - if (result.status === 0) { - throw new Error(`dispatch-github-aws-deploy unexpectedly succeeded during mocked gh network failure.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const combined = `${result.stdout}\n${result.stderr}`; - if (!combined.includes("GitHub CLI could not reach github.com from this machine. Check network access and GitHub availability before dispatching the workflow from here.")) { - throw new Error(`dispatch-github-aws-deploy did not surface the gh connectivity guidance cleanly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), results); - - runCase("dispatch-github-aws-deploy can skip gh auth check explicitly", () => withFailingGhForDispatchGithubAwsDeploy((env) => { - const tempDir = path.join(rootDir, ".tmp-dispatch-github-deploy-skip-auth"); - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.mkdirSync(tempDir, { recursive: true }); - - for (const fileName of [ - "bootstrap-parameters.json", - "backend-parameters.json", - "frontend-parameters.json", - "app-config-secret.template.json", - ]) { - fs.copyFileSync( - path.join(rootDir, "infrastructure", "environments", "staging", fileName), - path.join(tempDir, fileName), - ); - } - - const prepareResult = runJsonScript("scripts/prepare-environment-starter.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-skip-auth", - "--account-id=123456789012", - "--write=true", - "--output=json", - ]); - if (prepareResult.status !== 0) { - throw new Error(`prepare-environment-starter should succeed before dispatch-github-aws-deploy skip-auth verification.\nSTDOUT:\n${prepareResult.stdout}\nSTDERR:\n${prepareResult.stderr}`); - } - - replaceStarterBackendDefaults(tempDir, "staging"); - fs.writeFileSync(path.join(tempDir, "package-manifest.json"), `${JSON.stringify({ artifactPath: "./api.zip" }, null, 2)}\n`); - - const result = runJsonScriptWithEnv("scripts/dispatch-github-aws-deploy.mjs", [ - "--environment=staging", - "--environment-dir=.tmp-dispatch-github-deploy-skip-auth", - "--deployment-source=package-manifest", - "--package-manifest-file=.tmp-dispatch-github-deploy-skip-auth/package-manifest.json", - "--repo=ChurchApps/B1Admin", - "--dry-run=true", - "--skip-gh-auth-check=true", - "--output=json", - ], env); - - if (result.status !== 0) { - throw new Error(`dispatch-github-aws-deploy skip-gh-auth-check verification failed unexpectedly.\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`); - } - - const actual = result.parsed || {}; - if (actual.action !== "validated" || actual.secretSync?.attempted !== true) { - throw new Error(`dispatch-github-aws-deploy should still produce a dry-run validation result when skip-gh-auth-check=true.\nSTDOUT:\n${result.stdout}`); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), results); - - runCase("upload-backend-artifact unreadable bootstrap stack", () => expectScriptError("upload-backend-artifact unreadable bootstrap stack", "scripts/upload-backend-artifact.mjs", [ - "--source-file=package.json", - "--artifact-key=test.zip", - "--bootstrap-stack-name=definitely-not-a-real-bootstrap-stack", - ], 'Could not read bootstrap stack "definitely-not-a-real-bootstrap-stack"'), results); - - runCase("sync-legacy-ssm unreadable stack", () => expectScriptErrorClean("sync-legacy-ssm unreadable stack", "scripts/sync-legacy-ssm-parameters.mjs", [ - "--stack-name=test", - "--environment=prod", - "--dry-run=true", - ], 'Could not read stack "test"'), results); - - const failed = results.filter((result) => !result.ok); - const summary = { - ok: failed.length === 0, - total: results.length, - passed: results.length - failed.length, - failed: failed.length, - results, - }; - - if (jsonOutput) { - process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); - if (failed.length > 0) process.exit(1); - return; - } - - if (failed.length > 0) { - failed.forEach((result) => process.stderr.write(`FAILED: ${result.name}\n${result.error}\n\n`)); - process.exit(1); - } - - process.stdout.write("AWS tooling smoke checks passed.\n"); -} - -main(); From 3c0741cf402de98bf8b44afc69a0d1505a15c2b3 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 20:08:08 -0500 Subject: [PATCH 9/9] Fix the ten confirmed review findings From the adversarially verified code review of this branch: - backend-api.yaml declares LambdaCodeS3ObjectVersion and MigrationCodeS3ObjectVersion and wires them into every Lambda Code block, so the deploy scripts' existing versionId plumbing now both passes CloudFormation validation and actually forces function updates when a new zip lands under the same S3 key - OIDC deploy policy grants s3:GetObjectVersion (publish-layer-version with S3ObjectVersion reads the versioned object with the caller's credentials) - deploy-aws getBooleanArgString honors space-separated values, so `--run-api-migrations false` no longer runs migrations - sync-legacy-ssm --output=json defaults back to dry-run, restoring the side-effect-free preview contract; pass --dry-run=false to sync while emitting JSON (deploy-backend's invocation never passes json, so real deploys still sync) - removed the execute-api VPC endpoint: API Gateway VPC endpoints do not support the WebSocket @connections API, so it could never work; docs now list WebSocket pushes among the NAT-required features - staging deploy-split-stack.sh defaults API_MIGRATION_RUNNER=data-api; validate-aws-deploy defaults to data-api, rejects "direct" with the same removal message as the deploy scripts, and drops the deleted direct-runner checks - installer-commit scans `git diff --cached` after staging and aborts (unstaging first) if any staged path looks like a secret, instead of trusting a five-path list; allowlist moved to installer-common as the shared DEPLOY_REPO_SAFE_PATHS - installer-run spawns yarn/npm through a shell on Windows (cmd shims cannot be spawned directly), quoting args with spaces - installer-start's synced check watches only the safe-path allowlist, so stray files like .DS_Store no longer wedge the runner in a commit loop; the scaffold .gitignore also ignores .DS_Store/Thumbs.db Verified: full smoke harness green against this tree (fixture for the api-migrations validation scenario updated on the harness branch for the data-api default). Co-Authored-By: Claude Fable 5 --- .../cloudformation/backend-api.yaml | 46 ++++++++++--------- infrastructure/environments/operations.md | 2 +- .../private-deployment-gitignore.sample | 2 + .../staging/deploy-split-stack.sh | 2 +- .../iam/github-oidc-deploy-policy.sample.json | 1 + scripts/deploy-aws.mjs | 6 ++- scripts/installer-commit.mjs | 30 +++++++----- scripts/installer-common.mjs | 14 ++++++ scripts/installer-run.mjs | 9 +++- scripts/installer-start.mjs | 5 +- scripts/sync-legacy-ssm-parameters.mjs | 5 +- scripts/validate-aws-deploy.mjs | 34 +++++--------- 12 files changed, 94 insertions(+), 62 deletions(-) diff --git a/infrastructure/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml index 168250e0c..4c3c9c8a6 100644 --- a/infrastructure/cloudformation/backend-api.yaml +++ b/infrastructure/cloudformation/backend-api.yaml @@ -35,6 +35,10 @@ Parameters: LambdaCodeS3Key: Type: String Description: S3 key for the packaged backend Lambda zip. + LambdaCodeS3ObjectVersion: + Type: String + Default: "" + Description: Optional S3 object version of the backend Lambda zip. Pinning the version makes CloudFormation update the functions when a new zip is uploaded under the same key. LambdaHandler: Type: String Default: lambda.web @@ -130,6 +134,10 @@ Parameters: Type: String Default: "" Description: Optional S3 key for the migration Lambda package. Falls back to LambdaCodeS3Key when omitted. + MigrationCodeS3ObjectVersion: + Type: String + Default: "" + Description: Optional S3 object version of the migration Lambda package. Only used when MigrationCodeS3Key is set; otherwise the migration function follows LambdaCodeS3ObjectVersion. MigrationHandler: Type: String Default: "" @@ -313,9 +321,6 @@ Rules: Conditions: UseNatGateway: !Equals [!Ref CreateNatGateway, "true"] CreatePrivateAwsEndpoints: !Equals [!Ref CreateNatGateway, "false"] - CreatePrivateExecuteApiEndpoint: !And - - !Condition CreatePrivateAwsEndpoints - - !Condition CreateWebSocketApi CreateWebSocketApi: !Equals [!Ref EnableWebSocketApi, "true"] CreateScheduledWorkers: !Equals [!Ref EnableScheduledWorkers, "true"] RunMigrationResources: !Equals [!Ref RunMigrations, "true"] @@ -346,6 +351,8 @@ Conditions: HasObservabilityLayerArn: !Not [!Equals [!Ref ObservabilityLayerArn, ""]] HasMigrationCodeS3Bucket: !Not [!Equals [!Ref MigrationCodeS3Bucket, ""]] HasMigrationCodeS3Key: !Not [!Equals [!Ref MigrationCodeS3Key, ""]] + HasLambdaCodeS3ObjectVersion: !Not [!Equals [!Ref LambdaCodeS3ObjectVersion, ""]] + HasMigrationCodeS3ObjectVersion: !Not [!Equals [!Ref MigrationCodeS3ObjectVersion, ""]] HasMigrationRuntime: !Not [!Equals [!Ref MigrationRuntime, ""]] HasLambdaNodeOptions: !Not [!Equals [!Ref LambdaNodeOptions, ""]] UseS3FileStore: !Equals [!Ref FileStore, "S3"] @@ -595,24 +602,11 @@ Resources: - !Ref PrivateSubnet1 - !Ref PrivateSubnet2 - # WebSocket pushes call the API Gateway management API (execute-api) from - # inside the VPC; without NAT this endpoint is the only route to it. - # Note: SES has no usable API interface endpoint, so outbound email (and any - # third-party integration such as payment gateways) still requires the NAT - # gateway. See infrastructure/environments/operations.md. - ExecuteApiVpcEndpoint: - Type: AWS::EC2::VPCEndpoint - Condition: CreatePrivateExecuteApiEndpoint - Properties: - VpcId: !Ref BackendVpc - VpcEndpointType: Interface - PrivateDnsEnabled: true - ServiceName: !Sub "com.amazonaws.${AWS::Region}.execute-api" - SecurityGroupIds: - - !Ref VpcEndpointSecurityGroup - SubnetIds: - - !Ref PrivateSubnet1 - - !Ref PrivateSubnet2 + # No-NAT limitations: API Gateway VPC endpoints only support private REST + # APIs, so WebSocket pushes (the @connections management API), the SES API + # (outbound email), and any third-party integration such as payment gateways + # all still require the NAT gateway. See + # infrastructure/environments/operations.md. DatabaseSubnetGroup: Type: AWS::RDS::DBSubnetGroup @@ -895,6 +889,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref LambdaHandler Runtime: !Ref LambdaRuntime Architectures: @@ -1108,6 +1103,10 @@ Resources: Code: S3Bucket: !If [HasMigrationCodeS3Bucket, !Ref MigrationCodeS3Bucket, !Ref LambdaCodeS3Bucket] S3Key: !If [HasMigrationCodeS3Key, !Ref MigrationCodeS3Key, !Ref LambdaCodeS3Key] + S3ObjectVersion: !If + - HasMigrationCodeS3Key + - !If [HasMigrationCodeS3ObjectVersion, !Ref MigrationCodeS3ObjectVersion, !Ref "AWS::NoValue"] + - !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref MigrationHandler Runtime: !If [HasMigrationRuntime, !Ref MigrationRuntime, !Ref LambdaRuntime] Architectures: @@ -1313,6 +1312,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref SocketLambdaHandler Runtime: !Ref LambdaRuntime Architectures: @@ -1452,6 +1452,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref Timer15MinLambdaHandler Runtime: !Ref LambdaRuntime Architectures: @@ -1524,6 +1525,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref TimerMidnightLambdaHandler Runtime: !Ref LambdaRuntime Architectures: @@ -1601,6 +1603,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref TimerScheduledTasksLambdaHandler Runtime: !Ref LambdaRuntime Architectures: @@ -1678,6 +1681,7 @@ Resources: Code: S3Bucket: !Ref LambdaCodeS3Bucket S3Key: !Ref LambdaCodeS3Key + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] Handler: !Ref TimerWebhooksLambdaHandler Runtime: !Ref LambdaRuntime Architectures: diff --git a/infrastructure/environments/operations.md b/infrastructure/environments/operations.md index e000de91b..3932ec9fc 100644 --- a/infrastructure/environments/operations.md +++ b/infrastructure/environments/operations.md @@ -47,7 +47,7 @@ The application secrets (JWT signing key, encryption key, third-party API keys) - Create a billing alert once: AWS console > Billing and Cost Management > Budgets > Create budget. A monthly cost budget with an email alert at your expected amount (see [What Costs Money?](./start-here.md#what-costs-money)) catches surprises. - After any `reset:staging` / `reset:prod`, Aurora leaves a final snapshot by design. Snapshots cost money monthly. When you are sure you do not need one: RDS > Snapshots > select it > Actions > Delete snapshot. -- The NAT gateway (about $33/month) is required for outbound connections to non-AWS services: payment gateways (Stripe/PayPal), Mautic, YouTube lookups, and outgoing email through SES's API. Only set `CreateNatGateway` to `"false"` if you use none of those; AWS-internal features (database, secrets, file storage, text-to-speech, WebSocket pushes) keep working through private endpoints, which have their own smaller cost (roughly $8/month per endpoint). +- The NAT gateway (about $33/month) is required for outbound connections the backend makes to anything outside the VPC: payment gateways (Stripe/PayPal), Mautic, YouTube lookups, outgoing email through SES's API, and real-time WebSocket pushes (chat/notifications). Only set `CreateNatGateway` to `"false"` if you use none of those; the core features (database, secrets, file storage, text-to-speech) keep working through private endpoints, which have their own smaller cost (roughly $8/month per endpoint). ## Security Posture Notes diff --git a/infrastructure/environments/private-deployment-gitignore.sample b/infrastructure/environments/private-deployment-gitignore.sample index 7e175943d..92e967250 100644 --- a/infrastructure/environments/private-deployment-gitignore.sample +++ b/infrastructure/environments/private-deployment-gitignore.sample @@ -3,3 +3,5 @@ environments/*/bootstrap-admin-secret.json customer-values.json deployment/ *.log +.DS_Store +Thumbs.db diff --git a/infrastructure/environments/staging/deploy-split-stack.sh b/infrastructure/environments/staging/deploy-split-stack.sh index 97c570407..7b62f68e0 100755 --- a/infrastructure/environments/staging/deploy-split-stack.sh +++ b/infrastructure/environments/staging/deploy-split-stack.sh @@ -20,7 +20,7 @@ RUN_API_MIGRATIONS="${RUN_API_MIGRATIONS:-false}" RUN_BOOTSTRAP_ADMIN="${RUN_BOOTSTRAP_ADMIN:-false}" API_MIGRATION_ACTION="${API_MIGRATION_ACTION:-up}" API_MIGRATION_MODULE="${API_MIGRATION_MODULE:-all}" -API_MIGRATION_RUNNER="${API_MIGRATION_RUNNER:-direct}" +API_MIGRATION_RUNNER="${API_MIGRATION_RUNNER:-data-api}" VERIFY_AFTER_DEPLOY="${VERIFY_AFTER_DEPLOY:-true}" VERIFY_HTTP_AFTER_DEPLOY="${VERIFY_HTTP_AFTER_DEPLOY:-false}" SAVE_OUTPUTS_AFTER_DEPLOY="${SAVE_OUTPUTS_AFTER_DEPLOY:-true}" diff --git a/infrastructure/iam/github-oidc-deploy-policy.sample.json b/infrastructure/iam/github-oidc-deploy-policy.sample.json index c5cf1d694..50ccaa505 100644 --- a/infrastructure/iam/github-oidc-deploy-policy.sample.json +++ b/infrastructure/iam/github-oidc-deploy-policy.sample.json @@ -65,6 +65,7 @@ "Effect": "Allow", "Action": [ "s3:GetObject", + "s3:GetObjectVersion", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload" diff --git a/scripts/deploy-aws.mjs b/scripts/deploy-aws.mjs index ee9b5f1ee..fdef6b0d3 100644 --- a/scripts/deploy-aws.mjs +++ b/scripts/deploy-aws.mjs @@ -26,7 +26,11 @@ function hasFlag(name) { } function getBooleanArgString(name, fallback = "") { - return hasFlag(name) ? "true" : getArg(name, fallback); + // Check for an explicit value first (--name=false or --name false) so a + // space-separated "false" is honored; only a bare flag means "true". + const value = getArg(name, ""); + if (value !== "") return value; + return hasFlag(name) ? "true" : fallback; } function exitForCommandError(error, quiet = false) { diff --git a/scripts/installer-commit.mjs b/scripts/installer-commit.mjs index a9add1ba6..ddeaec658 100644 --- a/scripts/installer-commit.mjs +++ b/scripts/installer-commit.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { + DEPLOY_REPO_SAFE_PATHS, boolArg, explainFailure, failText, @@ -12,18 +13,18 @@ import { rootDir, } from "./installer-common.mjs"; -// Only these paths are ever staged. Secrets never appear here, and the -// check-ignore guard below refuses to continue if the .gitignore that keeps -// them out is missing. -const SAFE_PATHS = [ - "README.md", - ".gitignore", - ".github", - "customer-values.sample.json", - "environments", - "iam", - "aws-admin-handoff.md", -]; +// Only the shared safe-path allowlist is ever staged. Secrets never appear +// there, the check-ignore guard below refuses to continue if the .gitignore +// that keeps them out is missing, and a post-staging scan aborts if anything +// secret-shaped was staged anyway. +const SAFE_PATHS = DEPLOY_REPO_SAFE_PATHS; + +function looksSecret(stagedPath) { + if (/(^|\/)customer-values\.json$/.test(stagedPath)) return true; + if (!stagedPath.endsWith(".json")) return false; + if (stagedPath.endsWith(".template.json") || stagedPath.endsWith(".sample.json")) return false; + return /secret/i.test(stagedPath); +} const SENSITIVE_GLOBS = [ "customer-values.json", @@ -83,6 +84,11 @@ function main() { if (!stage.ok) failText(`Could not stage files: ${stage.stderr.trim()}`, outputMode); const staged = run("git", ["diff", "--cached", "--name-only"], deployRepoDir).stdout.trim(); + const stagedSecrets = staged.split("\n").filter(Boolean).filter(looksSecret); + if (stagedSecrets.length > 0) { + run("git", ["reset", "-q", "--", ...stagedSecrets], deployRepoDir); + failText(`Refusing to commit: these staged files look like secrets and are not covered by .gitignore: ${stagedSecrets.join(", ")}. Add them to the private repository's .gitignore, then retry.`, outputMode); + } let committed = false; if (staged) { const identityOk = run("git", ["config", "user.email"], deployRepoDir).stdout.trim() !== ""; diff --git a/scripts/installer-common.mjs b/scripts/installer-common.mjs index 7eab16529..04e99e6aa 100644 --- a/scripts/installer-common.mjs +++ b/scripts/installer-common.mjs @@ -5,6 +5,20 @@ import { fileURLToPath } from "node:url"; export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +// The only paths the installer ever stages/commits in the private deployment +// repository. installer-commit stages exactly these; installer-start's +// "Private repository synced" check watches exactly these, so stray files +// (e.g. .DS_Store) neither get committed nor wedge the guided runner. +export const DEPLOY_REPO_SAFE_PATHS = [ + "README.md", + ".gitignore", + ".github", + "customer-values.sample.json", + "environments", + "iam", + "aws-admin-handoff.md", +]; + let customerValuesCache; function rawCliArg(name) { diff --git a/scripts/installer-run.mjs b/scripts/installer-run.mjs index 1c71d91af..20426c95b 100644 --- a/scripts/installer-run.mjs +++ b/scripts/installer-run.mjs @@ -186,9 +186,16 @@ async function main() { } history.push({ step, action: "run", command: nextCommand }); - const result = spawnSync(invocation.command, invocation.args, { + // On Windows, yarn/npm are .cmd shims that spawnSync cannot execute + // without a shell; quote args so paths with spaces survive the shell. + const isWindows = process.platform === "win32"; + const spawnArgs = isWindows + ? invocation.args.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)) + : invocation.args; + const result = spawnSync(invocation.command, spawnArgs, { cwd: rootDir, encoding: "utf8", + shell: isWindows, stdio: outputMode === "json" ? "pipe" : ["inherit", "inherit", "pipe"], maxBuffer: 20 * 1024 * 1024, }); diff --git a/scripts/installer-start.mjs b/scripts/installer-start.mjs index 194bb624b..43f1cb5a9 100644 --- a/scripts/installer-start.mjs +++ b/scripts/installer-start.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { + DEPLOY_REPO_SAFE_PATHS, boolArg, getArg, printJson, @@ -13,7 +14,9 @@ import { function gitSynced(deployRepoDir) { if (!fs.existsSync(path.join(deployRepoDir, ".git"))) return false; const runGit = (args) => spawnSync("git", args, { cwd: deployRepoDir, encoding: "utf8", stdio: "pipe" }); - const status = runGit(["status", "--porcelain"]); + // Only watch the paths installer-commit actually stages; stray files such + // as .DS_Store must not wedge the runner in a commit loop. + const status = runGit(["status", "--porcelain", "--", ...DEPLOY_REPO_SAFE_PATHS]); if (status.status !== 0 || status.stdout.trim() !== "") return false; const ahead = runGit(["rev-list", "--count", "@{u}..HEAD"]); return ahead.status === 0 && ahead.stdout.trim() === "0"; diff --git a/scripts/sync-legacy-ssm-parameters.mjs b/scripts/sync-legacy-ssm-parameters.mjs index ed6debe50..bbd26542d 100644 --- a/scripts/sync-legacy-ssm-parameters.mjs +++ b/scripts/sync-legacy-ssm-parameters.mjs @@ -179,8 +179,11 @@ function main() { const appConfigSecretArn = getArg("app-config-secret-arn"); const includeEmpty = parseBoolean(getArg("include-empty", "false"), false); const overwrite = parseBoolean(getArg("overwrite", "true"), true); - const dryRun = parseBoolean(getArg("dry-run", "false"), false); const outputMode = getArg("output", "text"); + // JSON output has historically been a side-effect-free preview; keep that + // contract by defaulting json mode to dry-run. Pass --dry-run=false + // explicitly to sync while emitting JSON. + const dryRun = parseBoolean(getArg("dry-run", outputMode === "json" ? "true" : "false"), outputMode === "json"); requireValue("stack-name", stackName); requireValue("environment", environment); diff --git a/scripts/validate-aws-deploy.mjs b/scripts/validate-aws-deploy.mjs index 75fbc43eb..c76416c31 100644 --- a/scripts/validate-aws-deploy.mjs +++ b/scripts/validate-aws-deploy.mjs @@ -406,7 +406,7 @@ function main() { const standaloneMigrationModuleArg = apiMigrationsMode ? getArg("module") : ""; const apiMigrationAction = getArg("api-migration-action", standaloneMigrationActionArg || "up"); const apiMigrationModule = getArg("api-migration-module", standaloneMigrationModuleArg || "all"); - const apiMigrationRunner = getArg("api-migration-runner", "direct"); + const apiMigrationRunner = getArg("api-migration-runner", "data-api"); const apiMigrationApiRepoPathArg = getArg("api-migration-api-repo-path", apiRepoPathArg || "../Api"); const apiMigrationApiRepoPath = path.resolve(rootDir, apiMigrationApiRepoPathArg); const standaloneMigrationDbSecretArn = apiMigrationsMode ? getArg("db-secret-arn") : ""; @@ -622,8 +622,10 @@ function main() { errors.push(`Invalid api-migration-module "${apiMigrationModule}". Use all, membership, attendance, content, giving, messaging, doing, or reporting.`); } - if (migrationValidationRequested && !["direct", "data-api"].includes(apiMigrationRunner)) { - errors.push(`Invalid api-migration-runner "${apiMigrationRunner}". Use direct or data-api.`); + if (migrationValidationRequested && apiMigrationRunner === "direct") { + errors.push('The "direct" migration runner has been removed; migrations run through the RDS Data API. Use --api-migration-runner=data-api (the default).'); + } else if (migrationValidationRequested && !["data-api"].includes(apiMigrationRunner)) { + errors.push(`Invalid api-migration-runner "${apiMigrationRunner}". Use data-api.`); } if (migrationValidationRequested && !fs.existsSync(apiMigrationApiRepoPath)) { @@ -645,25 +647,11 @@ function main() { errors.push(`API migration repo package.json is not readable: ${path.join(apiMigrationApiRepoPath, "package.json")}`); apiMigrationRepoFallbackSuggested = true; } - if (apiMigrationRunner === "direct") { - if (!fs.existsSync(path.join(apiMigrationApiRepoPath, "tools", "migrate.ts"))) { - errors.push(`API migration repo is missing tools/migrate.ts: ${path.join(apiMigrationApiRepoPath, "tools", "migrate.ts")}`); - } else if (!canReadPath(path.join(apiMigrationApiRepoPath, "tools", "migrate.ts"))) { - errors.push(`API migration repo migrate tool is not readable: ${path.join(apiMigrationApiRepoPath, "tools", "migrate.ts")}`); - } - if (!fs.existsSync(path.join(apiMigrationApiRepoPath, "node_modules"))) { - errors.push(`API migration repo dependencies are not installed: ${path.join(apiMigrationApiRepoPath, "node_modules")}`); - } else if (!canReadPath(path.join(apiMigrationApiRepoPath, "node_modules"))) { - errors.push(`API migration repo dependencies are not readable: ${path.join(apiMigrationApiRepoPath, "node_modules")}`); - apiMigrationRepoFallbackSuggested = true; - } - } else { - const localTypescriptPath = path.join(rootDir, "node_modules", "typescript", "package.json"); - if (!fs.existsSync(localTypescriptPath)) { - errors.push(`B1Admin is missing typescript for Data API migrations: ${localTypescriptPath}`); - } else if (!canReadPath(localTypescriptPath)) { - errors.push(`B1Admin typescript package is not readable: ${localTypescriptPath}`); - } + const localTypescriptPath = path.join(rootDir, "node_modules", "typescript", "package.json"); + if (!fs.existsSync(localTypescriptPath)) { + errors.push(`B1Admin is missing typescript for Data API migrations: ${localTypescriptPath}`); + } else if (!canReadPath(localTypescriptPath)) { + errors.push(`B1Admin typescript package is not readable: ${localTypescriptPath}`); } if (apiRepoMigrationModules.length > 0) { info.push(`API repo migration modules: ${apiRepoMigrationModules.join(", ")}`); @@ -680,7 +668,7 @@ function main() { if (apiMigrationDryRun) { warnings.push(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. A ${apiMigrationRunner} migration run for ${apiMigrationModule} will currently skip without applying anything.`); } else { - errors.push(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. ${apiMigrationRunner === "direct" ? "Direct" : "Data API"} ${apiMigrationModule} migrations are not currently runnable outside dry-run mode.`); + errors.push(`The current Api repo has no tools/migrations/${apiMigrationModule} directory. Data API ${apiMigrationModule} migrations are not currently runnable outside dry-run mode.`); } } }