diff --git a/.ebextensions/01_migrations.config b/.ebextensions/01_migrations.config new file mode 100644 index 0000000..a66a0b2 --- /dev/null +++ b/.ebextensions/01_migrations.config @@ -0,0 +1,16 @@ +# Runs the EF Core migrations bundle on the leader instance only, before the new +# application version becomes active. The bundle is a self-contained linux-x64 +# executable placed at the root of the deployment archive by +# scripts/package-elastic-beanstalk.sh. +# +# The connection string is provided at runtime by the existing Elastic Beanstalk +# environment property ConnectionStrings__DefaultConnection. It must NOT be +# passed on the command line (it would leak into the process table and logs); the +# bundle reads it through the application configuration environment provider. +# +# A non-zero exit fails the deployment (Elastic Beanstalk aborts on container +# command failure), so a migration error never yields a live broken deployment. +container_commands: + 01_run_ef_migrations: + command: "chmod +x ./efbundle && ./efbundle" + leader_only: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1637baf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: /infra/cdk + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..41554a7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,197 @@ +name: Validate and deploy dev + +on: + pull_request: + branches: [dev] + push: + branches: [dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate deployable source bundle + runs-on: ubuntu-latest + steps: + - name: Checkout + # actions/checkout @ v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + + - name: Set up .NET + # actions/setup-dotnet @ v6.0.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: "8.0.x" + + - name: Set up Node.js + # actions/setup-node @ v6.5.0 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: "22.22.1" + cache: npm + cache-dependency-path: infra/cdk/package-lock.json + + - name: Repository quality gate + run: bash scripts/governance-check.sh + + - name: Validate CDK deployment infrastructure + run: | + npm ci --prefix infra/cdk + npm run synth --prefix infra/cdk + + - name: Build Elastic Beanstalk source bundle + run: bash scripts/package-elastic-beanstalk.sh + + - name: Inspect source bundle contract + run: | + set -euo pipefail + unzip -t .artifacts/elastic-beanstalk/site.zip + unzip -Z1 .artifacts/elastic-beanstalk/site.zip \ + > .artifacts/elastic-beanstalk/zip-contents.txt + grep -Fxq "efbundle" .artifacts/elastic-beanstalk/zip-contents.txt + grep -Fxq ".ebextensions/01_migrations.config" \ + .artifacts/elastic-beanstalk/zip-contents.txt + + deploy: + name: Deploy shoc-backend to Elastic Beanstalk dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: + name: dev + concurrency: + group: deploy-dev + cancel-in-progress: false + steps: + - name: Checkout + # actions/checkout @ v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Set up .NET + # actions/setup-dotnet @ v6.0.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: "8.0.x" + + - name: Build Elastic Beanstalk source bundle + run: bash scripts/package-elastic-beanstalk.sh + + - name: Configure AWS credentials (OIDC) + # aws-actions/configure-aws-credentials @ v6.2.3 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Capture current environment version + run: | + set -euo pipefail + prev="$(aws elasticbeanstalk describe-environments \ + --environment-names shoc-backend-dev \ + --region us-east-1 \ + --query 'Environments[0].VersionLabel' \ + --output text)" + echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt + echo "Previous version label: $prev" + + - name: Deploy prebuilt bundle to existing environment + # aws-actions/aws-elasticbeanstalk-deploy @ v1.0.6 + uses: aws-actions/aws-elasticbeanstalk-deploy@cfad3e5e4452cd9c8923cbee2f862e96ba4b52c4 + with: + aws-region: us-east-1 + application-name: shoc-backend + environment-name: shoc-backend-dev + version-label: ${{ github.sha }} + deployment-package-path: .artifacts/elastic-beanstalk/site.zip + s3-bucket-name: elasticbeanstalk-us-east-1-396287094661 + create-application-if-not-exists: "false" + create-environment-if-not-exists: "false" + create-s3-bucket-if-not-exists: "false" + use-existing-application-version-if-available: "true" + wait-for-deployment: "true" + wait-for-environment-recovery: "true" + + - name: Post-deploy smoke + run: bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com + + - name: Restore previous application version on failure (schema is not reverted) + if: failure() + run: | + set -euo pipefail + prev_file=".artifacts/elastic-beanstalk/previous-version.txt" + if [ ! -f "$prev_file" ]; then + echo "No previous version captured; nothing to roll back." >&2 + exit 0 + fi + prev="$(cat "$prev_file")" + if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then + echo "No previous version recorded; nothing to roll back." >&2 + exit 0 + fi + + echo "Waiting for any in-flight environment update to settle..." + status="Unknown" + current="Unknown" + health="Unknown" + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names shoc-backend-dev \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + break + fi + sleep 15 + done + + if [ "$status" != "Ready" ]; then + echo "Environment did not settle before rollback." >&2 + exit 1 + fi + if [ "$current" = "$prev" ]; then + echo "Environment is already on previous version $prev." + exit 0 + fi + + echo "Restoring shoc-backend-dev application code to version label: $prev" + echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." + aws elasticbeanstalk update-environment \ + --environment-name shoc-backend-dev \ + --version-label "$prev" \ + --region us-east-1 + + echo "Waiting for previous version to become healthy..." + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names shoc-backend-dev \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then + echo "Application version restore complete; previous code is Ready and healthy." + exit 0 + fi + echo "Rollback reached Ready in an unexpected version/health state." >&2 + exit 1 + fi + sleep 15 + done + + echo "Environment did not return to Ready within rollback window." >&2 + exit 1 diff --git a/.gitignore b/.gitignore index b303f68..bac4700 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,12 @@ FodyWeavers.xsd appsettings.Development.json .DS_Store + +# CDK (infra/cdk) generated artifacts +infra/cdk/node_modules/ +infra/cdk/dist/ +infra/cdk/cdk.out/ +infra/cdk/.cdk.staging/ + +# Deployment packaging artifacts +.artifacts/ diff --git a/infra/cdk/README.md b/infra/cdk/README.md new file mode 100644 index 0000000..d285409 --- /dev/null +++ b/infra/cdk/README.md @@ -0,0 +1,103 @@ +# shoc-backend CDK (dev deployment IAM) + +This CDK v2 app owns exactly one thing in the `shoc-backend` AWS account +(`396287094661`, `us-east-1`): the **GitHub OIDC deploy role** used by the +`dev` deployment workflow in `.github/workflows/deploy.yml`. + +## Ownership boundary (deliberate) + +CDK owns: + +- The IAM role `githubdeploy-shoc-backend-dev`. +- Its OIDC trust relationship to + `arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com` + scoped to `repo:Sea-Haven-Industries/shoc-backend:environment:dev`. +- Its least-privilege inline permissions policy. + +CDK does **not** own, create, import, replace, or modify any of the following. +They are referenced by exact identifier only and remain owned by their original +provisioning path: + +- Elastic Beanstalk application `shoc-backend` +- Elastic Beanstalk environment `shoc-backend-dev` +- DNS, VPC, EC2, RDS, and existing service/instance roles +- S3 bucket `elasticbeanstalk-us-east-1-396287094661` +- Environment configuration / option settings +- Database schema (migrations are applied by Elastic Beanstalk at deploy time, + not by CDK) + +The role is retained on stack deletion (`DeletionPolicy=Retain`, +`UpdateReplacePolicy=Retain`) so an accidental teardown cannot orphan the trust +or lock out deployments. + +## Least-privilege policy summary + +The role grants only: + +- The three read-only Elastic Beanstalk actions used by deploy, wait, and + rollback (`DescribeApplicationVersions`, `DescribeEnvironments`, and + `DescribeEvents`). These use `Resource: "*"` because Elastic Beanstalk + describe actions are not reliably constrained by resource ARN. +- `elasticbeanstalk:CreateApplicationVersion` on application `shoc-backend`. +- `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only. +- `s3:ListBucket` on `elasticbeanstalk-us-east-1-396287094661` (the official + action's ownership-safe `HeadBucket` check) and `s3:PutObject` only under the + `shoc-backend/` object prefix. + +It grants **no** IAM mutation or `PassRole`, **no** RDS / EC2 / Secrets Manager +access, and **no** administrator policy. There are no wildcard mutation +surfaces. + +## Prerequisites + +- Node >= 22.22.1 and npm. +- AWS credentials authorized to create/inspect CloudFormation, IAM roles, and + trust policies in account `396287094661`. +- The GitHub OIDC provider + `arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com` + must already exist in the account (created once, outside this stack). + +## Commands + +```bash +npm ci # install pinned dependencies +npm run build # type-check / compile to dist/ +npm run synth # synthesize the CloudFormation template +npm run diff # diff deployed stack vs local (requires AWS) +npm run deploy # deploy the stack (requires AWS) +``` + +All commands run from `infra/cdk/`. + +## CI integration + +`npm run synth` is the deterministic local/CI validation. After synth, inspect +`cdk.out/shoc-backend-deploy-dev.template.json` and verify the synthesized +`AWS::IAM::Role`: + +- Trust policy `StringEquals` matches the exact audience and subject above. +- The inline policy contains no `Resource: "*"` mutation action and no service + outside `elasticbeanstalk` / `s3`. + +The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must +hold the ARN output by this stack (`GithubDeployRoleArn`). + +The GitHub `dev` environment is an external release control and must restrict +deployments to the `dev` branch. Required reviewers should be configured when +the repository plan supports environment reviewers. The workflow also checks +the exact branch before requesting an OIDC token. + +## Migration and recovery contract + +The deployment bundle applies pending EF Core migrations before the new +application starts. Migrations must therefore use an expand/contract sequence: + +- Expand changes must remain backward compatible with the previously deployed + application version. +- Destructive contract changes are deployed only after all application versions + relying on the old schema have been retired. +- A failed deployment restores the previous **application version only**. + Database schema is not downgraded, and schema rollback is not claimed. + +This contract preserves the usefulness of application-version recovery without +misrepresenting it as a tested database downgrade. diff --git a/infra/cdk/app.ts b/infra/cdk/app.ts new file mode 100644 index 0000000..1762b60 --- /dev/null +++ b/infra/cdk/app.ts @@ -0,0 +1,20 @@ +import * as cdk from 'aws-cdk-lib'; +import { DeployDevStack } from './deploy-dev-stack'; + +const app = new cdk.App(); + +new DeployDevStack(app, 'shoc-backend-deploy-dev', { + env: { + account: '396287094661', + region: 'us-east-1', + }, + terminationProtection: true, + tags: { + Project: 'shoc-backend', + Environment: 'dev', + ManagedBy: 'cdk', + Component: 'deploy-role', + }, +}); + +app.synth(); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json new file mode 100644 index 0000000..9732ab2 --- /dev/null +++ b/infra/cdk/cdk.json @@ -0,0 +1,8 @@ +{ + "app": "node dist/app.js", + "versionReporting": false, + "context": { + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:checkSecretUsage": true + } +} diff --git a/infra/cdk/deploy-dev-stack.ts b/infra/cdk/deploy-dev-stack.ts new file mode 100644 index 0000000..3e212ed --- /dev/null +++ b/infra/cdk/deploy-dev-stack.ts @@ -0,0 +1,92 @@ +import * as cdk from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +const ACCOUNT_ID = '396287094661'; +const REGION = 'us-east-1'; +const APPLICATION_NAME = 'shoc-backend'; +const ENVIRONMENT_NAME = 'shoc-backend-dev'; +const REPO = 'Sea-Haven-Industries/shoc-backend'; +const BUCKET_NAME = `elasticbeanstalk-${REGION}-${ACCOUNT_ID}`; + +export class DeployDevStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { + super(scope, id, props); + + const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; + const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; + const bucketArn = `arn:aws:s3:::${BUCKET_NAME}`; + const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; + + const deployRole = new iam.Role(this, 'GithubDeployRole', { + roleName: 'githubdeploy-shoc-backend-dev', + description: + 'Least-privilege GitHub OIDC deploy role for shoc-backend dev. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk.', + assumedBy: new iam.FederatedPrincipal( + oidcProviderArn, + { + StringEquals: { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', + 'token.actions.githubusercontent.com:sub': `repo:${REPO}:environment:dev`, + }, + }, + 'sts:AssumeRoleWithWebIdentity', + ), + }); + + deployRole.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + const cfnRole = deployRole.node.defaultChild as iam.CfnRole; + cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; + + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'elasticbeanstalk:DescribeEnvironments', + 'elasticbeanstalk:DescribeApplicationVersions', + 'elasticbeanstalk:DescribeEvents', + ], + resources: ['*'], + }), + ); + + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['elasticbeanstalk:CreateApplicationVersion'], + resources: [applicationArn], + }), + ); + + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['elasticbeanstalk:UpdateEnvironment'], + resources: [environmentArn], + }), + ); + + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:ListBucket'], + resources: [bucketArn], + }), + ); + + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:PutObject'], + resources: [`${bucketArn}/${APPLICATION_NAME}/*`], + }), + ); + + new cdk.CfnOutput(this, 'GithubDeployRoleArn', { + value: deployRole.roleArn, + description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', + exportName: 'shoc-backend-deploy-dev-role-arn', + }); + } +} diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json new file mode 100644 index 0000000..9f4bae1 --- /dev/null +++ b/infra/cdk/package-lock.json @@ -0,0 +1,333 @@ +{ + "name": "shoc-backend-cdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shoc-backend-cdk", + "version": "0.1.0", + "dependencies": { + "aws-cdk-lib": "2.262.1", + "constructs": "10.7.1" + }, + "devDependencies": { + "@types/node": "22.20.1", + "aws-cdk": "2.1133.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.22.1" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.282", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", + "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "54.14.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.14.0.tgz", + "integrity": "sha512-JCZCzgp3SuXQVljaKqXnttHzcezEHt9Ag/YipK0XwUFD+Iz2T4jY7gUc3pA25Uq6pzY2n9DvO/nEU++dPXW4Rw==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.5" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.8.5", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.1133.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1133.0.tgz", + "integrity": "sha512-DGlCBwqxSHTe1u/IoT5WV+Bbd2K3UhwFHsdMqb2nQa1fkJmaQgoNFu0It+p3HxyMWeW+gKsVzT5KcjQPQ6Kzuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.262.1", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.262.1.tgz", + "integrity": "sha512-B6YP4r6ojUZCDhl+qBu/CrWzcipR8sIgshcqYvgw013sghPXmVkYdJ3yuI9+DKML3YLSjQrHy1nGJs+Nqq7JCg==", + "bundleDependencies": [ + "@aws/cloudformation-validate", + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.282", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", + "@aws-cdk/cloud-assembly-api": "^2.2.6", + "@aws-cdk/cloud-assembly-schema": "^54.11.0", + "@aws/cloudformation-validate": "1.5.1-beta", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.6", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.5", + "punycode": "^2.3.1", + "semver": "^7.8.5", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.6", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=54.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": { + "version": "1.5.1-beta", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.8.5", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constructs": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.7.1.tgz", + "integrity": "sha512-ulK25Sg2Nv1jW+A9VeV7fu9GFN9KdVTNWdXMoapNRwqkQzSdToro/Tttk3Ak5BGI80NRA/d2nSzKnoZ27LizLw==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json new file mode 100644 index 0000000..4f0cf1b --- /dev/null +++ b/infra/cdk/package.json @@ -0,0 +1,24 @@ +{ + "name": "shoc-backend-cdk", + "version": "0.1.0", + "private": true, + "description": "CDK ownership boundary for the shoc-backend dev deployment IAM role.", + "engines": { + "node": ">=22.22.1" + }, + "scripts": { + "build": "tsc", + "synth": "npm run build && cdk synth", + "diff": "npm run build && cdk diff", + "deploy": "npm run build && cdk deploy" + }, + "dependencies": { + "aws-cdk-lib": "2.262.1", + "constructs": "10.7.1" + }, + "devDependencies": { + "@types/node": "22.20.1", + "aws-cdk": "2.1133.0", + "typescript": "5.9.3" + } +} diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json new file mode 100644 index 0000000..75cb2e3 --- /dev/null +++ b/infra/cdk/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "moduleResolution": "node", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist" + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist", "cdk.out"] +} diff --git a/scripts/package-elastic-beanstalk.sh b/scripts/package-elastic-beanstalk.sh new file mode 100755 index 0000000..c9e81df --- /dev/null +++ b/scripts/package-elastic-beanstalk.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source +# bundle for the shoc-backend .NET 8 application. +# +# Layout of the resulting ZIP (the Beanstalk application root): +# ./ published Api.SeaHavenIndustries (self-contained, linux-x64) +# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x) +# ./.ebextensions/* leader-only migration container command +# +# The bundle reads ConnectionStrings__DefaultConnection from the runtime +# environment (Elastic Beanstalk property). No connection string or secret is +# written into the package. +# +# The script only ever removes its own generated directory (.artifacts/elastic-beanstalk) +# and validates that path before doing so. +# +# Usage: +# bash scripts/package-elastic-beanstalk.sh [output.zip] +# +# Environment: +# DOTNET_BIN optional path to the dotnet executable +# RUNTIME optional target RID for local validation (default: linux-x64) +set -euo pipefail + +OUTPUT_ZIP="${1:-.artifacts/elastic-beanstalk/site.zip}" +STAGING_DIR=".artifacts/elastic-beanstalk/staging" +TOOLS_DIR=".artifacts/dotnet-tools" + +log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +die() { printf '\033[31mERR\033[0m %s\n' "$1" >&2; exit 1; } + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +case "$OUTPUT_ZIP" in + .artifacts/elastic-beanstalk/*.zip) : ;; + *) die "output must be a .zip beneath .artifacts/elastic-beanstalk" ;; +esac + +if [[ -n "${DOTNET_BIN:-}" ]]; then + DOTNET="$DOTNET_BIN" +elif command -v dotnet >/dev/null 2>&1; then + DOTNET="$(command -v dotnet)" +elif [[ -x "$HOME/.dotnet/dotnet" ]]; then + DOTNET="$HOME/.dotnet/dotnet" +else + die "dotnet is unavailable; set DOTNET_BIN or install the .NET 8 SDK." +fi + +DOTNET_DIR="$(cd "$(dirname "$DOTNET")" && pwd)" +export PATH="$DOTNET_DIR:$PATH" +if [[ -d "$DOTNET_DIR/host/fxr" ]]; then + export DOTNET_ROOT="$DOTNET_DIR" +fi + +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 +export TZ=UTC +export SOURCE_DATE_EPOCH="315532800" + +API_PROJECT="Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj" +MIGRATIONS_PROJECT="Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj" +EF_VERSION="8.0.8" +RUNTIME="${RUNTIME:-linux-x64}" + +[[ -f "$API_PROJECT" ]] || die "missing API project: $API_PROJECT" +[[ -f "$MIGRATIONS_PROJECT" ]] || die "missing migrations project: $MIGRATIONS_PROJECT" +command -v zip >/dev/null 2>&1 || die "zip is required to build the source bundle." + +GENERATED_ROOT="$(dirname "$STAGING_DIR")" +case "$GENERATED_ROOT" in + .artifacts/elastic-beanstalk) : ;; + *) die "refusing to remove unexpected generated dir: $GENERATED_ROOT" ;; +esac + +log "clean generated artifacts" +rm -rf "$GENERATED_ROOT" "$TOOLS_DIR" +mkdir -p "$STAGING_DIR" "$TOOLS_DIR" + +log "publish $API_PROJECT (Release, self-contained, $RUNTIME)" +"$DOTNET" publish "$API_PROJECT" \ + -c Release \ + --self-contained \ + --runtime "$RUNTIME" \ + -o "$STAGING_DIR" \ + /p:ContinuousIntegrationBuild=true \ + /p:UseAppHost=true + +log "install dotnet-ef $EF_VERSION (local tool path)" +if ! "$DOTNET" tool install dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR" 2>/dev/null; then + "$DOTNET" tool update dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR" +fi +EF="$TOOLS_DIR/dotnet-ef" + +log "build EF migrations bundle (self-contained, $RUNTIME)" +"$EF" migrations bundle \ + --project "$MIGRATIONS_PROJECT" \ + --startup-project "$API_PROJECT" \ + --configuration Release \ + --self-contained \ + --runtime "$RUNTIME" \ + --output "$STAGING_DIR/efbundle" + +chmod 0755 "$STAGING_DIR/efbundle" + +log "copy .ebextensions into bundle root" +mkdir -p "$STAGING_DIR/.ebextensions" +cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/" + +log "verify no committed secret placeholders survived publish" +if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then + die "potential secret detected in publish output; refusing to package." +fi + +log "assemble source bundle (contents, not the containing directory)" +( + cd "$STAGING_DIR" + # ZIP stores file mtimes. Normalize them so identical source/build inputs + # produce byte-identical source bundles. + find . -type f -exec touch -t 198001010000 {} + + find . -type f -print | LC_ALL=C sort \ + | zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP" +) + +log "package written: $OUTPUT_ZIP" +printf ' contents: %d files\n' "$(find "$STAGING_DIR" -type f | wc -l | tr -d ' ')" +printf ' size: %s bytes\n' "$(wc -c < "$REPO_ROOT/$OUTPUT_ZIP" | tr -d ' ')" +printf ' efbundle: %s\n' "$(file -b "$STAGING_DIR/efbundle" 2>/dev/null || echo present)" diff --git a/scripts/smoke-elastic-beanstalk.sh b/scripts/smoke-elastic-beanstalk.sh new file mode 100755 index 0000000..703c0b0 --- /dev/null +++ b/scripts/smoke-elastic-beanstalk.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# smoke-elastic-beanstalk.sh — post-deploy smoke checks for the shoc-backend +# dev environment. +# +# Checks: +# 1. swagger.json is reachable (HTTP 200) +# 2. swagger advertises POST /api/webhooks/work-orders +# 3. swagger still advertises POST /api/Authentication/login +# 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled), +# never 404 or a server error other than the intentional 503 +# +# Usage: +# bash scripts/smoke-elastic-beanstalk.sh [base-url] +# bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com +set -euo pipefail + +BASE_URL="${1:-https://api.dev.seahaven.com}" +BASE_URL="${BASE_URL%/}" + +SWAGGER_URL="$BASE_URL/swagger/v1/swagger.json" +WEBHOOK_URL="$BASE_URL/api/webhooks/work-orders" + +log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; } +die() { printf '\033[31mFAIL\033[0m %s\n' "$1" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || die "curl is required." + +mkdir -p .artifacts/elastic-beanstalk + +log "swagger reachable: $SWAGGER_URL" +swagger_http=$(curl -sS -o .artifacts/elastic-beanstalk/swagger.json \ + -w '%{http_code}' --max-time 30 "$SWAGGER_URL" || true) +[[ "$swagger_http" == "200" ]] \ + || die "swagger.json returned HTTP $swagger_http (expected 200)." +swagger_file=".artifacts/elastic-beanstalk/swagger.json" +ok "swagger.json HTTP 200" + +log "swagger advertises webhook and login routes" +grep -q '"/api/webhooks/work-orders"' "$swagger_file" \ + || die "swagger.json missing /api/webhooks/work-orders route." +grep -q '"/api/Authentication/login"' "$swagger_file" \ + || die "swagger.json missing /api/Authentication/login route." +ok "webhook and login routes present" + +log "unauthenticated webhook POST: $WEBHOOK_URL" +webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ + -X POST -H 'Content-Type: application/json' \ + --data '{"smoke":true}' "$WEBHOOK_URL" || true) + +case "$webhook_http" in + 401) ok "webhook returned 401 (unauthorized) as expected." ;; + 503) ok "webhook returned 503 (intentionally disabled) as expected." ;; + 404) die "webhook returned 404 — route not wired (deployment broken)." ;; + 5*) die "webhook returned HTTP $webhook_http — unexpected server error." ;; + *) die "webhook returned HTTP $webhook_http — expected 401 or 503." ;; +esac + +log "smoke: all checks passed"