From 59f2e53623dfbc2a5b6a16c0c261ab54bd6041b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 19:06:05 +0000 Subject: [PATCH 1/3] Add Cloud Run deployment infrastructure Prepare for migration from Vercel to Google Cloud Run: - Add GET /health endpoint for Cloud Run startup/liveness probes - Add graceful SIGTERM/SIGINT shutdown handling (8s timeout) - Add multi-stage Dockerfile (node:20-slim, pnpm, prod-only deps) - Add .dockerignore to minimize build context - Add GitHub Actions deploy workflow with Workload Identity Federation - Add workflow_call trigger to test.yml for reuse by deploy workflow https://claude.ai/code/session_01Gr1yHcWyzydrvgsiHFFqv4 --- .dockerignore | 22 +++++++++++ .github/workflows/deploy.yml | 71 ++++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 1 + Dockerfile | 36 ++++++++++++++++++ src/server.ts | 24 +++++++++++- 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..71504bf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +node_modules +dist +.env.local +.env*.local +.git +.github +.vercel +.vscode +.claude +coverage +tests +*.test.ts +*.spec.ts +dev.sh +.clinerules +.DS_Store +docs +CHANGELOG.md +README.md +CLAUDE.md +vitest.config.ts +.gitleaks.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0f88e57 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy to Cloud Run + +on: + push: + branches: [main] + +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + REGION: us-central1 + SERVICE_NAME: pii-mcp + REGISTRY: us-central1-docker.pkg.dev + REPOSITORY: pii-mcp-repo + +jobs: + test: + uses: ./.github/workflows/test.yml + + deploy: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.REGISTRY }} + + - name: Build and push Docker image + run: | + IMAGE=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }} + docker build -t $IMAGE . + docker push $IMAGE + + - name: Deploy to Cloud Run + run: | + IMAGE=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }} + gcloud run deploy ${{ env.SERVICE_NAME }} \ + --image=$IMAGE \ + --region=${{ env.REGION }} \ + --platform=managed \ + --allow-unauthenticated \ + --port=8080 \ + --memory=512Mi \ + --cpu=1 \ + --min-instances=0 \ + --max-instances=10 \ + --concurrency=80 \ + --timeout=120s \ + --set-env-vars="NODE_ENV=production,ANON_MODE_RATE_LIMIT_REQUESTS=${{ vars.ANON_MODE_RATE_LIMIT_REQUESTS }},ANON_MODE_RATE_LIMIT_WINDOW_MS=${{ vars.ANON_MODE_RATE_LIMIT_WINDOW_MS }}" \ + --set-secrets="VAULT_URL=VAULT_URL:latest,VAULT_ID=VAULT_ID:latest,WORKSPACE_ID=WORKSPACE_ID:latest,ACCOUNT_ID=ACCOUNT_ID:latest,ANON_MODE_API_KEY=ANON_MODE_API_KEY:latest,ANON_MODE_VAULT_ID=ANON_MODE_VAULT_ID:latest,ANON_MODE_VAULT_URL=ANON_MODE_VAULT_URL:latest" \ + --startup-probe-path=/health \ + --startup-probe-initial-delay=0s \ + --startup-probe-period=3s \ + --startup-probe-failure-threshold=5 \ + --liveness-probe-path=/health \ + --liveness-probe-period=15s diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f0b354..d5729c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: [ main, master ] pull_request: branches: [ main, master ] + workflow_call: jobs: test: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6d1de6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Stage 1: Build +FROM node:20-slim AS builder +RUN corepack enable && corepack prepare pnpm@10 --activate +WORKDIR /app + +# Install dependencies first (layer cache) +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile + +# Build UI apps +COPY ui/ ui/ +COPY scripts/ scripts/ +RUN pnpm build:ui && pnpm build:ui-imports + +# Build server +COPY src/ src/ +COPY tsconfig.json ./ +RUN pnpm build:server + +# Stage 2: Production +FROM node:20-slim AS production +RUN corepack enable && corepack prepare pnpm@10 --activate +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile --prod + +COPY --from=builder /app/dist ./dist +COPY public/ ./public/ + +ENV PORT=8080 +ENV NODE_ENV=production +EXPOSE 8080 + +USER node +CMD ["node", "dist/server.js"] diff --git a/src/server.ts b/src/server.ts index 81923ed..7c7a8c2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -174,6 +174,11 @@ app.use(express.json({ limit: "5mb" })); // Limit for base64-encoded files // Serve static files from the public directory app.use(express.static("public")); +// Health check endpoint for Cloud Run probes +app.get("/health", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); + // Create rate limiter for anonymous mode const anonymousRateLimiter = createAnonymousRateLimiter( getAnonymousRateLimitConfig() @@ -301,7 +306,7 @@ export default app; // Only start the server if this file is run directly (not imported) if (import.meta.url === `file://${process.argv[1]}`) { const port = parseInt(process.env.PORT || "3000"); - app + const httpServer = app .listen(port, () => { console.log(`Skyflow MCP Server running on http://localhost:${port}/mcp`); }) @@ -309,4 +314,21 @@ if (import.meta.url === `file://${process.argv[1]}`) { console.error("Server error:", error); process.exit(1); }); + + // Graceful shutdown for Cloud Run SIGTERM + function shutdown(signal: string) { + console.log(`Received ${signal}, shutting down gracefully...`); + httpServer.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); + // Force exit if graceful shutdown takes too long (within Cloud Run's 10s grace period) + setTimeout(() => { + console.error("Forced shutdown after timeout"); + process.exit(1); + }, 8000).unref(); + } + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); } From cb8cd8379d52d537c43f20192a3aa3d7df97d13e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:30:23 +0000 Subject: [PATCH 2/3] Address PR review: fix shutdown exit code, pin Node base image - Change forced shutdown timeout from process.exit(1) to process.exit(0) since forced exit after grace period is expected, not an error - Pin Dockerfile base image from node:20-slim to node:20.19.0-slim for reproducible builds https://claude.ai/code/session_01Gr1yHcWyzydrvgsiHFFqv4 --- Dockerfile | 4 ++-- src/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e6d1de6..25b6612 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build -FROM node:20-slim AS builder +FROM node:20.19.0-slim AS builder RUN corepack enable && corepack prepare pnpm@10 --activate WORKDIR /app @@ -18,7 +18,7 @@ COPY tsconfig.json ./ RUN pnpm build:server # Stage 2: Production -FROM node:20-slim AS production +FROM node:20.19.0-slim AS production RUN corepack enable && corepack prepare pnpm@10 --activate WORKDIR /app diff --git a/src/server.ts b/src/server.ts index 7c7a8c2..9d51c51 100644 --- a/src/server.ts +++ b/src/server.ts @@ -325,7 +325,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { // Force exit if graceful shutdown takes too long (within Cloud Run's 10s grace period) setTimeout(() => { console.error("Forced shutdown after timeout"); - process.exit(1); + process.exit(0); }, 8000).unref(); } From d5f5710b98c5a1a51ac3421832e084b2f4afc12c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:27:14 +0000 Subject: [PATCH 3/3] Address review: remove unused secrets, pin pnpm, rebase on main - Remove WORKSPACE_ID and ACCOUNT_ID from deploy.yml --set-secrets (removed from codebase in PR #22) - Pin pnpm to 10.11.0 in Dockerfile for reproducible builds - Rebase onto latest main (picks up de-identify_file removal, entities param, and env cleanup from PRs #19 and #22) https://claude.ai/code/session_01Gr1yHcWyzydrvgsiHFFqv4 --- .github/workflows/deploy.yml | 2 +- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f88e57..3d63696 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,7 +62,7 @@ jobs: --concurrency=80 \ --timeout=120s \ --set-env-vars="NODE_ENV=production,ANON_MODE_RATE_LIMIT_REQUESTS=${{ vars.ANON_MODE_RATE_LIMIT_REQUESTS }},ANON_MODE_RATE_LIMIT_WINDOW_MS=${{ vars.ANON_MODE_RATE_LIMIT_WINDOW_MS }}" \ - --set-secrets="VAULT_URL=VAULT_URL:latest,VAULT_ID=VAULT_ID:latest,WORKSPACE_ID=WORKSPACE_ID:latest,ACCOUNT_ID=ACCOUNT_ID:latest,ANON_MODE_API_KEY=ANON_MODE_API_KEY:latest,ANON_MODE_VAULT_ID=ANON_MODE_VAULT_ID:latest,ANON_MODE_VAULT_URL=ANON_MODE_VAULT_URL:latest" \ + --set-secrets="VAULT_URL=VAULT_URL:latest,VAULT_ID=VAULT_ID:latest,ANON_MODE_API_KEY=ANON_MODE_API_KEY:latest,ANON_MODE_VAULT_ID=ANON_MODE_VAULT_ID:latest,ANON_MODE_VAULT_URL=ANON_MODE_VAULT_URL:latest" \ --startup-probe-path=/health \ --startup-probe-initial-delay=0s \ --startup-probe-period=3s \ diff --git a/Dockerfile b/Dockerfile index 25b6612..65488a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Stage 1: Build FROM node:20.19.0-slim AS builder -RUN corepack enable && corepack prepare pnpm@10 --activate +RUN corepack enable && corepack prepare pnpm@10.11.0 --activate WORKDIR /app # Install dependencies first (layer cache) @@ -19,7 +19,7 @@ RUN pnpm build:server # Stage 2: Production FROM node:20.19.0-slim AS production -RUN corepack enable && corepack prepare pnpm@10 --activate +RUN corepack enable && corepack prepare pnpm@10.11.0 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./