From f7af4e5180cf92eea8190e383fd5daeeb2578c2d Mon Sep 17 00:00:00 2001 From: Chad Date: Wed, 1 Jul 2026 17:44:38 -0700 Subject: [PATCH] feat(caretaker): egress cloud run service skeleton (#28167) --- .../cloudrun/egress-service/.dockerignore | 5 ++ .../cloudrun/egress-service/Dockerfile | 8 ++ .../cloudrun/egress-service/package.json | 27 ++++++ .../cloudrun/egress-service/src/app.test.ts | 84 +++++++++++++++++++ .../cloudrun/egress-service/src/app.ts | 78 +++++++++++++++++ .../cloudrun/egress-service/src/server.ts | 13 +++ .../cloudrun/egress-service/src/types.ts | 84 +++++++++++++++++++ .../cloudrun/egress-service/tsconfig.json | 16 ++++ 8 files changed, 315 insertions(+) create mode 100644 tools/caretaker-agent/cloudrun/egress-service/.dockerignore create mode 100644 tools/caretaker-agent/cloudrun/egress-service/Dockerfile create mode 100644 tools/caretaker-agent/cloudrun/egress-service/package.json create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/app.ts create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/server.ts create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/types.ts create mode 100644 tools/caretaker-agent/cloudrun/egress-service/tsconfig.json diff --git a/tools/caretaker-agent/cloudrun/egress-service/.dockerignore b/tools/caretaker-agent/cloudrun/egress-service/.dockerignore new file mode 100644 index 0000000000..4d87ef0c3b --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +*.log +.git diff --git a/tools/caretaker-agent/cloudrun/egress-service/Dockerfile b/tools/caretaker-agent/cloudrun/egress-service/Dockerfile new file mode 100644 index 0000000000..6ee81f6a11 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build +EXPOSE 8080 +CMD ["node", "dist/server.js"] diff --git a/tools/caretaker-agent/cloudrun/egress-service/package.json b/tools/caretaker-agent/cloudrun/egress-service/package.json new file mode 100644 index 0000000000..57345c4312 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/package.json @@ -0,0 +1,27 @@ +{ + "name": "egress-service", + "version": "1.0.0", + "description": "GitHub Egress Pub/Sub Cloud Run worker service", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run" + }, + "dependencies": { + "@octokit/auth-app": "^8.2.0", + "@octokit/rest": "^20.1.1", + "dotenv": "^16.4.5", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.12.12", + "@types/supertest": "^6.0.3", + "supertest": "^7.1.4", + "tsx": "^4.9.3", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + } +} diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts new file mode 100644 index 0000000000..a680c7b2a1 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import { app } from './app.js'; + +/** + * Helper function simulating GCP Cloud Pub/Sub HTTP Push message wrapper. + * Encodes the payload object into Base64 format inside message.data. + */ +function createPubSubPushEnvelope(payload: unknown): { + message: { data: string }; +} { + const jsonString = + typeof payload === 'string' ? payload : JSON.stringify(payload); + const base64Data = Buffer.from(jsonString).toString('base64'); + return { message: { data: base64Data } }; +} + +describe('Egress Service App Router', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('GET / should return 200 OK with structured health debug info', async () => { + const res = await request(app).get('/'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: 'healthy', + service: 'caretaker-egress-service', + revision: 'local', + }); + }); + + it('POST / should return 400 if Pub/Sub envelope is invalid', async () => { + const res = await request(app).post('/').send('not a json object'); + expect(res.status).toBe(400); + }); + + it('POST / should return 400 if message.data is missing', async () => { + const res = await request(app).post('/').send({ message: {} }); + expect(res.status).toBe(400); + expect(res.text).toBe('Missing message.data'); + }); + + it('POST / should return 400 if message.data is invalid JSON', async () => { + const invalidEnvelope = createPubSubPushEnvelope('invalid-raw-json-string'); + const res = await request(app).post('/').send(invalidEnvelope); + expect(res.status).toBe(400); + expect(res.text).toBe('Malformed payload: invalid JSON'); + }); + + it('POST / should return 400 if egress payload is missing required fields', async () => { + const incompleteEvent = { action: 'COMMENT', payload: { owner: 'google' } }; + const res = await request(app) + .post('/') + .send(createPubSubPushEnvelope(incompleteEvent)); + expect(res.status).toBe(400); + expect(res.text).toContain('Malformed payload'); + }); + + it('POST / should trigger handleEgressEvent stub and return 200 for valid payloads', async () => { + const validEvent = { + action: 'COMMENT', + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 100, + commentBody: 'Test comment', + }, + }; + + const res = await request(app) + .post('/') + .send(createPubSubPushEnvelope(validEvent)); + + expect(res.status).toBe(200); + expect(res.text).toBe('OK'); + }); +}); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts new file mode 100644 index 0000000000..b7b3a04549 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import dotenv from 'dotenv'; +import { + isPubSubMessageEnvelope, + isEgressEvent, + type EgressEvent, +} from './types.js'; + +dotenv.config(); + +/** + * Top-down stub handler for Egress events. + * Octokit GitHub REST API integration will be added in a follow-up PR. + * + * @param event - The validated EgressEvent object decoded from Pub/Sub push envelope. + */ +export async function handleEgressEvent(event: EgressEvent): Promise { + console.log( + `[EGRESS_STUB] Received ${event.action} event for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`, + ); +} + +export const app = express(); +app.use(express.json()); + +// Health check endpoint for Cloud Run liveness/readiness probes +app.get('/', (_req, res) => { + res.json({ + status: 'healthy', + service: process.env.K_SERVICE || 'caretaker-egress-service', + revision: process.env.K_REVISION || 'local', + }); +}); + +// Pub/Sub push subscription endpoint +app.post('/', async (req, res) => { + if (!isPubSubMessageEnvelope(req.body)) { + return res.status(400).send('Invalid Pub/Sub message envelope'); + } + + const data = req.body.message?.data; + if (!data) { + return res.status(400).send('Missing message.data'); + } + + let event: unknown; + try { + const jsonStr = Buffer.from(data, 'base64').toString('utf-8'); + event = JSON.parse(jsonStr); + } catch { + return res.status(400).send('Malformed payload: invalid JSON'); + } + + if (!isEgressEvent(event)) { + return res + .status(400) + .send('Malformed payload: missing or invalid required egress fields'); + } + + try { + await handleEgressEvent(event); + console.log( + `[EGRESS] Successfully executed ${event.action} for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`, + ); + return res.status(200).send('OK'); + } catch (err) { + console.error('[EGRESS_ERROR] Error handling egress event execution:', err); + return res + .status(500) + .send(err instanceof Error ? err.message : 'Internal Server Error'); + } +}); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/server.ts b/tools/caretaker-agent/cloudrun/egress-service/src/server.ts new file mode 100644 index 0000000000..2e706af75e --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/server.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { app } from './app.js'; + +const port = process.env.PORT || 8080; + +app.listen(port, () => { + console.log(`Egress service listening on port ${port}`); +}); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/types.ts b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts new file mode 100644 index 0000000000..bdac528e42 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH'; + +export interface EgressEventPayload { + owner: string; + repo: string; + issueNumber: number; + commentBody?: string; + labels?: string[]; + patchContent?: string; + branchName?: string; +} + +export interface EgressEvent { + action: EgressAction; + payload: EgressEventPayload; +} + +export interface PubSubMessage { + data?: string; + messageId?: string; + publishTime?: string; + attributes?: Record; +} + +/** + * Standard GCP Cloud Pub/Sub HTTP Push message wrapper envelope. + * + * @see https://cloud.google.com/pubsub/docs/push#delivery_format + */ +export interface PubSubMessageEnvelope { + message?: PubSubMessage; + subscription?: string; +} + +function isObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null; +} + +/** + * Type guard for PubSubMessageEnvelope to eliminate unsafe 'as' casts. + */ +export function isPubSubMessageEnvelope( + obj: unknown, +): obj is PubSubMessageEnvelope { + if (!isObject(obj)) { + return false; + } + if ('message' in obj) { + if (obj.message !== undefined && !isObject(obj.message)) { + return false; + } + } + return true; +} + +/** + * Type guard for EgressEvent. + */ +export function isEgressEvent(obj: unknown): obj is EgressEvent { + if (!isObject(obj)) { + return false; + } + if ( + typeof obj.action !== 'string' || + !['COMMENT', 'LABEL', 'PATCH'].includes(obj.action) + ) { + return false; + } + if (!isObject(obj.payload)) { + return false; + } + const payload = obj.payload; + return ( + typeof payload.owner === 'string' && + typeof payload.repo === 'string' && + typeof payload.issueNumber === 'number' + ); +} diff --git a/tools/caretaker-agent/cloudrun/egress-service/tsconfig.json b/tools/caretaker-agent/cloudrun/egress-service/tsconfig.json new file mode 100644 index 0000000000..af209ba34b --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}