Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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,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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
workflow_call:

jobs:
test:
Expand Down
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Stage 1: Build
FROM node:20.19.0-slim AS builder
RUN corepack enable && corepack prepare pnpm@10.11.0 --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.19.0-slim AS production
RUN corepack enable && corepack prepare pnpm@10.11.0 --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"]
24 changes: 23 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -301,12 +306,29 @@ 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`);
})
.on("error", (error) => {
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(0);
}, 8000).unref();
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
Loading