diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3c2e2f6..9fa81db 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,51 +1,114 @@ -name: Deploy +name: CI and deploy on: push: branches: [main] + pull_request: + branches: [main] + +concurrency: + group: tigermap-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run build + deploy-frontend: name: Frontend → Cloudflare Pages + if: github.event_name == 'push' + needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.5 - - run: bun install - working-directory: apps/web + - run: bun install --frozen-lockfile - name: Build - working-directory: apps/web env: - VITE_CAMPUS_MAP_TOKEN: ${{ secrets.VITE_CAMPUS_MAP_TOKEN }} - VITE_CAMPUS_MAP_STYLE: ${{ secrets.VITE_CAMPUS_MAP_STYLE }} - VITE_TIGERAPPS_MAPBOX_TOKEN: ${{ secrets.VITE_TIGERAPPS_MAPBOX_TOKEN }} - run: npx vite build + VITE_CAMPUS_MAP_TOKEN: ${{ vars.VITE_CAMPUS_MAP_TOKEN }} + VITE_CAMPUS_MAP_STYLE: ${{ vars.VITE_CAMPUS_MAP_STYLE }} + VITE_TIGERAPPS_MAPBOX_TOKEN: ${{ vars.VITE_TIGERAPPS_MAPBOX_TOKEN }} + run: bun run build --filter=@tigermap/web - name: Deploy to Cloudflare Pages - working-directory: apps/web env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: bunx wrangler pages deploy dist --project-name=tigermap --commit-dirty=true + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: >- + bunx wrangler@4.78.0 pages deploy apps/web/dist + --project-name=tigermap + --branch=main + --commit-hash=${{ github.sha }} deploy-backend: name: Backend → EC2 + if: github.event_name == 'push' + needs: validate runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - - name: Deploy via SSH - uses: appleboy/ssh-action@v1 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_KEY }} - script: | - export PATH=$HOME/.bun/bin:$PATH - cd ~/TigerMap - git pull origin main - bun install - sudo systemctl restart tigermap-api + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Deploy with Systems Manager + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + run: | + command_id=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --comment "Deploy TigerMap ${GITHUB_SHA}" \ + --parameters 'commands=[ + "set -eu", + "runuser -u ubuntu -- git -C /home/ubuntu/TigerMap fetch origin main", + "runuser -u ubuntu -- git -C /home/ubuntu/TigerMap checkout main", + "runuser -u ubuntu -- git -C /home/ubuntu/TigerMap merge --ff-only origin/main", + "runuser -u ubuntu -- /home/ubuntu/.bun/bin/bun install --cwd /home/ubuntu/TigerMap --frozen-lockfile", + "sudo systemctl restart tigermap-api", + "i=0; until curl -fsS http://127.0.0.1:3001/api/categories >/dev/null; do i=$((i+1)); if [ $i -ge 30 ]; then sudo journalctl -u tigermap-api -n 100 --no-pager; exit 1; fi; sleep 2; done" + ]' \ + --query 'Command.CommandId' \ + --output text) + + status=Pending + for _ in $(seq 1 80); do + status=$(aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query Status \ + --output text 2>/dev/null || true) + case "$status" in + Success|Cancelled|Failed|TimedOut) break ;; + esac + sleep 3 + done + + aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query '{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}' + test "$status" = Success diff --git a/.gitignore b/.gitignore index 853d9d4..9781562 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules dist .turbo +.wrangler .env .env.local *.log diff --git a/apps/api/package.json b/apps/api/package.json index cde081c..c13f347 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "fastify": "^5.0.0" }, "devDependencies": { + "@types/bun": "^1.3.5", "typescript": "^5.7.0" } } diff --git a/bun.lock b/bun.lock index 1bd56ce..4f4b3fa 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "fastify": "^5.0.0", }, "devDependencies": { + "@types/bun": "^1.3.5", "typescript": "^5.7.0", }, }, @@ -313,12 +314,16 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/geojson-vt": ["@types/geojson-vt@3.2.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g=="], + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + "@types/pbf": ["@types/pbf@3.0.5", "", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -357,6 +362,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bytewise": ["bytewise@1.1.0", "", { "dependencies": { "bytewise-core": "^1.2.2", "typewise": "^1.0.3" } }, "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ=="], "bytewise-core": ["bytewise-core@1.2.3", "", { "dependencies": { "typewise-core": "^1.2" } }, "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA=="], @@ -657,6 +664,8 @@ "typewise-core": ["typewise-core@1.2.0", "", {}, "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "union-value": ["union-value@1.0.1", "", { "dependencies": { "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", "set-value": "^2.0.1" } }, "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], diff --git a/infra/aws/README.md b/infra/aws/README.md new file mode 100644 index 0000000..5a01ccd --- /dev/null +++ b/infra/aws/README.md @@ -0,0 +1,44 @@ +# TigerMap production infrastructure + +TigerMap uses Cloudflare Pages for the web app and one EC2 instance for the +Fastify API and its local SQLite data. + +## AWS resources + +- API instance: `i-0e1651e5f2954427d` (`tigermap-api`) +- API Elastic IP: `3.91.107.148` +- API security group: `sg-052b6b922dce1a08e` (`tigermap-api-sg`) +- Instance role/profile: `TigerMapInstanceRole` +- GitHub OIDC deploy role: `TigerMapGitHubDeployRole` +- Encrypted runtime parameters: `/tigermap/production/*` + +The GitHub role is scoped to the `main` branch of the exact TigerAppsOrg +repository ID. It can only send an SSM command to the TigerMap instance and +read that command's result. No long-lived AWS key is stored in GitHub. + +The EC2 environment file remains the runtime source for the current service. +Its values are also backed up as encrypted SSM parameters for recovery. Never +commit decrypted parameter values. + +The API security group exposes HTTP on port 80 for the Pages Function proxy. +Administrative access uses SSM, so the instance has no public SSH ingress. + +## GitHub configuration + +Repository variables: + +- `AWS_REGION=us-east-1` +- `AWS_DEPLOY_ROLE_ARN=arn:aws:iam::104733724423:role/TigerMapGitHubDeployRole` +- `EC2_INSTANCE_ID=i-0e1651e5f2954427d` +- `CLOUDFLARE_ACCOUNT_ID=e2744783298a24185adfbc07174ba2ae` +- `VITE_CAMPUS_MAP_TOKEN` (public token embedded in the web bundle) +- `VITE_CAMPUS_MAP_STYLE` +- `VITE_TIGERAPPS_MAPBOX_TOKEN` (public token embedded in the web bundle) + +Repository secrets: + +- `CLOUDFLARE_API_TOKEN` + +Backend deployment uses SSM. The production checkout is updated with a +fast-forward-only merge, dependencies are installed from the lockfile, and +the API is restarted only after those steps succeed. diff --git a/infra/aws/ec2-assume-role-policy.json b/infra/aws/ec2-assume-role-policy.json new file mode 100644 index 0000000..87c7d7c --- /dev/null +++ b/infra/aws/ec2-assume-role-policy.json @@ -0,0 +1,12 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/infra/aws/github-assume-role-policy.json b/infra/aws/github-assume-role-policy.json new file mode 100644 index 0000000..720531b --- /dev/null +++ b/infra/aws/github-assume-role-policy.json @@ -0,0 +1,18 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::104733724423:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": "repo:TigerAppsOrg@1450294/TigerMap@1373863252:ref:refs/heads/main" + } + } + } + ] +} diff --git a/infra/aws/github-deploy-policy.json b/infra/aws/github-deploy-policy.json new file mode 100644 index 0000000..15cc127 --- /dev/null +++ b/infra/aws/github-deploy-policy.json @@ -0,0 +1,23 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RunTigerMapDeployment", + "Effect": "Allow", + "Action": "ssm:SendCommand", + "Resource": [ + "arn:aws:ec2:us-east-1:104733724423:instance/i-0e1651e5f2954427d", + "arn:aws:ssm:us-east-1::document/AWS-RunShellScript" + ] + }, + { + "Sid": "ReadTigerMapDeploymentResult", + "Effect": "Allow", + "Action": [ + "ssm:GetCommandInvocation", + "ssm:ListCommandInvocations" + ], + "Resource": "*" + } + ] +} diff --git a/infra/aws/instance-parameters-policy.json b/infra/aws/instance-parameters-policy.json new file mode 100644 index 0000000..60063c1 --- /dev/null +++ b/infra/aws/instance-parameters-policy.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadTigerMapProductionParameters", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "arn:aws:ssm:us-east-1:104733724423:parameter/tigermap/production/*" + } + ] +}