diff --git a/.github/workflows/deploy-aws-self-hosted.yml b/.github/workflows/deploy-aws-self-hosted.yml new file mode 100644 index 000000000..4fb2a4af0 --- /dev/null +++ b/.github/workflows/deploy-aws-self-hosted.yml @@ -0,0 +1,384 @@ +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" + + - 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: | + 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 + 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..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 @@ -80,3 +81,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..44eea2145 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,47 @@ +# 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. + +## Architecture + +A self-hosted B1Admin deployment is three CloudFormation stacks per environment, deployed in order: + +| 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 | + +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. + +## Deployment flow + +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: + +- `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`). + +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. + +## Configuration + +- 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`). + +## Cost and teardown + +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. + +## Conventions + +- 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). +- 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/cloudformation/backend-api.yaml b/infrastructure/cloudformation/backend-api.yaml new file mode 100644 index 000000000..4c3c9c8a6 --- /dev/null +++ b/infrastructure/cloudformation/backend-api.yaml @@ -0,0 +1,1947 @@ +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. + 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 + 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. + 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: "" + 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, ""]] + 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"] + 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 + + 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 + + # 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 + 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: [!Ref CorsOrigin] + ExposedHeaders: [ETag] + MaxAge: 3000 + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + VersioningConfiguration: + Status: Enabled + + # 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: + Bucket: !Ref ManagedAssetBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + 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 + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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 + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - "" + CONTENT_ROOT: !If + - HasContentRootUrl + - !Ref ContentRootUrl + - !If + - HasResolvedAssetBucket + - !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 + 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] + 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: + - !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 + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - "" + 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 + DependsOn: + - DatabaseInstanceWriter + - MigrationLogGroup + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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 + - !If + - CreateManagedAssetBucket + - !Sub "https://${ManagedAssetDistribution.DomainName}" + - !Sub "https://${AssetBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" + - "" + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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 + S3ObjectVersion: !If [HasLambdaCodeS3ObjectVersion, !Ref LambdaCodeS3ObjectVersion, !Ref "AWS::NoValue"] + 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: + - !Ref CorsOrigin + 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 + - !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: + 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/environments/README.md b/infrastructure/environments/README.md new file mode 100644 index 000000000..030a94dbd --- /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. +- [`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. +- [`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. +- [`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/first-rollout-checklist.md b/infrastructure/environments/first-rollout-checklist.md new file mode 100644 index 000000000..4434001e1 --- /dev/null +++ b/infrastructure/environments/first-rollout-checklist.md @@ -0,0 +1,66 @@ +# First Rollout Checklist + +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. +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/operations.md b/infrastructure/environments/operations.md new file mode 100644 index 000000000..3932ec9fc --- /dev/null +++ b/infrastructure/environments/operations.md @@ -0,0 +1,64 @@ +# 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 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 + +- 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). +- 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/private-deployment-gitignore.sample b/infrastructure/environments/private-deployment-gitignore.sample new file mode 100644 index 000000000..92e967250 --- /dev/null +++ b/infrastructure/environments/private-deployment-gitignore.sample @@ -0,0 +1,7 @@ +environments/*/app-config-secret.json +environments/*/bootstrap-admin-secret.json +customer-values.json +deployment/ +*.log +.DS_Store +Thumbs.db 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..e33ba38f1 --- /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. 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 +- 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..fad31ed45 --- /dev/null +++ b/infrastructure/environments/setup/aws-iam-roles.md @@ -0,0 +1,35 @@ +# 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 +``` + +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: + +```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..8d185f464 --- /dev/null +++ b/infrastructure/environments/setup/deployment-repository.md @@ -0,0 +1,87 @@ +# 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 lives beside the B1Admin source repository on the operator machine. + +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: + +```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 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 +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..395d06e4a --- /dev/null +++ b/infrastructure/environments/setup/local-runtime.md @@ -0,0 +1,44 @@ +# 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` (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. 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: + +```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`, `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/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..7b62f68e0 --- /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:-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}" +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..4a7cd8e1f --- /dev/null +++ b/infrastructure/environments/start-here.md @@ -0,0 +1,792 @@ +# 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. 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. +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 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: + +```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. 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: + +```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`, `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. + +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. 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 +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 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, 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: + +```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 + +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. 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. + +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. 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. + +## 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, 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. + +## 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 | + +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. + +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`. 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. + +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) +- [IAM setup guide](../iam/README.md) 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..87bfdfa7c --- /dev/null +++ b/infrastructure/iam/cloudformation-execution-policy.sample.json @@ -0,0 +1,218 @@ +{ + "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:*" + ], + "Resource": "*" + }, + { + "Sid": "SecretsRandomPassword", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetRandomPassword" + ], + "Resource": "*" + }, + { + "Sid": "ProjectSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:UpdateSecret" + ], + "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: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": "arn:aws:iam:::role/--*" + }, + { + "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..50ccaa505 --- /dev/null +++ b/infrastructure/iam/github-oidc-deploy-policy.sample.json @@ -0,0 +1,150 @@ +{ + "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:GetObjectVersion", + "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": "OptionalCertificateValidation", + "Effect": "Allow", + "Action": [ + "acm:DescribeCertificate" + ], + "Resource": "*" + }, + { + "Sid": "OptionalLayerPublish", + "Effect": "Allow", + "Action": [ + "lambda:PublishLayerVersion" + ], + "Resource": "arn:aws:lambda:::layer:--*" + }, + { + "Sid": "OptionalDataApiMigrations", + "Effect": "Allow", + "Action": [ + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:ExecuteStatement", + "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": "arn:aws:ssm:::parameter//*" + } + ] +} 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..3fd6694f3 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,57 @@ "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: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", + "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", + "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-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", + "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", + "deploy:backend": "node scripts/deploy-backend.mjs", + "deploy:frontend": "node scripts/deploy-frontend.mjs", + "deploy:aws": "node scripts/deploy-aws.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..fdef6b0d3 --- /dev/null +++ b/scripts/deploy-aws.mjs @@ -0,0 +1,819 @@ +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 = "") { + // 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) { + 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(scriptPath, args, options = {}) { + const { quiet = false, ...execOptions } = options; + if (!quiet) console.log(`\n> node ${scriptPath} ${args.map(maskSensitiveArg).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") { + 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.`); + } +} + +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", "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"); + 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); + } + + 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 (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 (runApiMigrationsEnabled) { + 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 (runApiMigrationsEnabled) { + const resolvedApiMigrationRepoPath = path.resolve(rootDir, apiMigrationApiRepoPath || apiRepoPath || "../Api"); + if (!fs.existsSync(resolvedApiMigrationRepoPath)) { + fail(`API migration repo not found: ${resolvedApiMigrationRepoPath}`); + } + 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..45e302730 --- /dev/null +++ b/scripts/deploy-backend.mjs @@ -0,0 +1,711 @@ +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 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 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 (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.`); + } +} + +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", "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"); + 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 === "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 = "scripts/run-api-migrations-data-api.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/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/installer-adopt-frontend-origin.mjs b/scripts/installer-adopt-frontend-origin.mjs new file mode 100644 index 000000000..a46253c41 --- /dev/null +++ b/scripts/installer-adopt-frontend-origin.mjs @@ -0,0 +1,98 @@ +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`); + // 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 = { + ok: true, + environment, + changed, + written: write && changed, + frontendAppUrl, + backendParametersFile: relativeToRoot(backendParametersFile), + before, + next, + followUp: changed + ? [ + "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.", + ] + : [], + }; + + 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..9fc944eea --- /dev/null +++ b/scripts/installer-aws-roles.mjs @@ -0,0 +1,345 @@ +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"; + +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 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}`; +} + +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}\``); + }); + + 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}\``)); + + 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); + 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); + } + 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; + }); + } + + 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; + 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)}`, + ]; + 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: apply ? Boolean(applied?.ok) : true, + write, + apply, + applied, + 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)); + } + + if (!result.ok) process.exit(1); +} + +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..0f656cb78 --- /dev/null +++ b/scripts/installer-browser-smoke.mjs @@ -0,0 +1,282 @@ +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(); + } +} + +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}\``, + `- 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); + 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); + 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." }, + ], + }; + let mode = dryRun ? "dry-run" : modeArg; + + if (!dryRun) { + 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, + 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-commit.mjs b/scripts/installer-commit.mjs new file mode 100644 index 000000000..ddeaec658 --- /dev/null +++ b/scripts/installer-commit.mjs @@ -0,0 +1,164 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + DEPLOY_REPO_SAFE_PATHS, + boolArg, + explainFailure, + failText, + getArg, + inferDeployRepo, + printJson, + relativeToRoot, + rootDir, +} from "./installer-common.mjs"; + +// 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", + "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(); + 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() !== ""; + 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 new file mode 100644 index 000000000..04e99e6aa --- /dev/null +++ b/scripts/installer-common.mjs @@ -0,0 +1,267 @@ +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)), ".."); + +// 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) { + 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); +} + +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", + "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..890315b7b --- /dev/null +++ b/scripts/installer-customer-values.mjs @@ -0,0 +1,241 @@ +import crypto from "node:crypto"; +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); +} + +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, + 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 (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); + 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(`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-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..11e88efc8 --- /dev/null +++ b/scripts/installer-doctor.mjs @@ -0,0 +1,215 @@ +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 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("", "## Setup 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", "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 = [ + { + 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 : "", + }, + { + 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("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 (!awsRegionValue.trim()) { + nextSteps.push("Run `yarn installer:customer-values` and answer the AWS region question. `us-east-1` is the normal choice."); + } + 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."); + } + 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..20426c95b --- /dev/null +++ b/scripts/installer-run.mjs @@ -0,0 +1,245 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import readline from "node:readline/promises"; +import { + boolArg, + explainFailure, + 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] === "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"] }; + } + 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("--apply=true") + || command.includes("installer:commit") + || 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 }); + // 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, + }); + + if ((result.status ?? 1) !== 0) { + const hint = explainFailure(`${result.stdout || ""}\n${result.stderr || ""}`); + if (outputMode === "json") { + printJson({ + ok: false, + complete: false, + failedCommand: nextCommand, + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + hint, + history, + }); + } else { + 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.`; + if (outputMode === "json") { + printJson({ ok: true, complete: false, paused: true, reason: "max-steps", history }); + } else { + console.log(message); + } + } finally { + rl.close(); + } +} + +main().catch((error) => { + 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 new file mode 100644 index 000000000..43f1cb5a9 --- /dev/null +++ b/scripts/installer-start.mjs @@ -0,0 +1,273 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + DEPLOY_REPO_SAFE_PATHS, + boolArg, + getArg, + printJson, + relativeToRoot, + rootDir, + 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" }); + // 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"; +} + +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") && 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; + + 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`, + 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`, + 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 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), + 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/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..40ac875b9 --- /dev/null +++ b/scripts/package-api-backend.mjs @@ -0,0 +1,250 @@ +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}`, + deployMode: + packageMode === "self-contained" + ? "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, + }; + + 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..05f3055a6 --- /dev/null +++ b/scripts/publish-lambda-layer.mjs @@ -0,0 +1,132 @@ +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 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")); + const compatibleArchitectures = parseCsv(getArg("compatible-architectures", "arm64")); + const outputMode = getArg("output", "text"); + + requireValue("layer-name", layerName); + if (!sourceFile && !(contentBucket && contentKey)) { + fail("Provide either --source-file or --content-bucket and --content-key."); + } + 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 = [ + "lambda", + "publish-layer-version", + "--layer-name", + layerName, + ...contentArg, + "--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/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/sync-app-config-secret.mjs b/scripts/sync-app-config-secret.mjs new file mode 100644 index 000000000..08edd1084 --- /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", + `file://${resolvedSecretFile}`, + "--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", + `file://${resolvedSecretFile}`, + "--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..bbd26542d --- /dev/null +++ b/scripts/sync-legacy-ssm-parameters.mjs @@ -0,0 +1,294 @@ +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)), ".."); + +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 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://${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) { + 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 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); + + 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); + + if (!dryRun) { + for (const parameter of parameters) { + putParameter(parameter, region, overwrite); + } + } + + // Parameter values are secrets; report names only. + const result = { + stackName, + region, + environment, + prefix, + overwrite, + dryRun, + parameterCount: parameters.length, + parameters: parameters.map((parameter) => ({ name: parameter.name })), + }; + + 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; + } + + 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..da86132f9 --- /dev/null +++ b/scripts/upload-backend-artifact.mjs @@ -0,0 +1,181 @@ +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 }); + + 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, + bucket, + key, + sourceFile: resolvedSource, + s3Uri: `s3://${bucket}/${key}`, + versionId, + 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}`); + if (versionId) console.log(`VersionId: ${versionId}`); +} + +main(); diff --git a/scripts/validate-aws-deploy.mjs b/scripts/validate-aws-deploy.mjs new file mode 100644 index 000000000..c76416c31 --- /dev/null +++ b/scripts/validate-aws-deploy.mjs @@ -0,0 +1,1462 @@ +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", "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"; + 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", "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") : ""; + 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 && 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)) { + 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; + } + 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. 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 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."); + } + 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 && frontendPublishStackName) { + const splitStackDescribe = tryExec("aws", [ + "cloudformation", + "describe-stacks", + "--stack-name", + frontendPublishStackName, + "--region", + region, + "--output", + "json", + ]); + if (!splitStackDescribe.ok) { + 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 "${frontendPublishStackName}" is missing SiteBucketName output.`); + } + if (!splitStackOutputs.CloudFrontDistributionId) { + 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]); + 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();