Skip to content

Commit b9d66a7

Browse files
authored
feat(caretaker): egress cloud run service skeleton (google-gemini#28167)
1 parent 6197c5d commit b9d66a7

8 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
dist
3+
.env
4+
*.log
5+
.git
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM node:20-slim
2+
WORKDIR /app
3+
COPY package*.json ./
4+
RUN npm ci
5+
COPY . .
6+
RUN npm run build
7+
EXPOSE 8080
8+
CMD ["node", "dist/server.js"]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "egress-service",
3+
"version": "1.0.0",
4+
"description": "GitHub Egress Pub/Sub Cloud Run worker service",
5+
"main": "dist/server.js",
6+
"scripts": {
7+
"dev": "tsx watch src/server.ts",
8+
"build": "tsc",
9+
"start": "node dist/server.js",
10+
"test": "vitest run"
11+
},
12+
"dependencies": {
13+
"@octokit/auth-app": "^8.2.0",
14+
"@octokit/rest": "^20.1.1",
15+
"dotenv": "^16.4.5",
16+
"express": "^4.19.2"
17+
},
18+
"devDependencies": {
19+
"@types/express": "^4.17.21",
20+
"@types/node": "^20.12.12",
21+
"@types/supertest": "^6.0.3",
22+
"supertest": "^7.1.4",
23+
"tsx": "^4.9.3",
24+
"typescript": "^5.4.5",
25+
"vitest": "^1.6.0"
26+
}
27+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import request from 'supertest';
9+
import { app } from './app.js';
10+
11+
/**
12+
* Helper function simulating GCP Cloud Pub/Sub HTTP Push message wrapper.
13+
* Encodes the payload object into Base64 format inside message.data.
14+
*/
15+
function createPubSubPushEnvelope(payload: unknown): {
16+
message: { data: string };
17+
} {
18+
const jsonString =
19+
typeof payload === 'string' ? payload : JSON.stringify(payload);
20+
const base64Data = Buffer.from(jsonString).toString('base64');
21+
return { message: { data: base64Data } };
22+
}
23+
24+
describe('Egress Service App Router', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
});
28+
29+
it('GET / should return 200 OK with structured health debug info', async () => {
30+
const res = await request(app).get('/');
31+
expect(res.status).toBe(200);
32+
expect(res.body).toEqual({
33+
status: 'healthy',
34+
service: 'caretaker-egress-service',
35+
revision: 'local',
36+
});
37+
});
38+
39+
it('POST / should return 400 if Pub/Sub envelope is invalid', async () => {
40+
const res = await request(app).post('/').send('not a json object');
41+
expect(res.status).toBe(400);
42+
});
43+
44+
it('POST / should return 400 if message.data is missing', async () => {
45+
const res = await request(app).post('/').send({ message: {} });
46+
expect(res.status).toBe(400);
47+
expect(res.text).toBe('Missing message.data');
48+
});
49+
50+
it('POST / should return 400 if message.data is invalid JSON', async () => {
51+
const invalidEnvelope = createPubSubPushEnvelope('invalid-raw-json-string');
52+
const res = await request(app).post('/').send(invalidEnvelope);
53+
expect(res.status).toBe(400);
54+
expect(res.text).toBe('Malformed payload: invalid JSON');
55+
});
56+
57+
it('POST / should return 400 if egress payload is missing required fields', async () => {
58+
const incompleteEvent = { action: 'COMMENT', payload: { owner: 'google' } };
59+
const res = await request(app)
60+
.post('/')
61+
.send(createPubSubPushEnvelope(incompleteEvent));
62+
expect(res.status).toBe(400);
63+
expect(res.text).toContain('Malformed payload');
64+
});
65+
66+
it('POST / should trigger handleEgressEvent stub and return 200 for valid payloads', async () => {
67+
const validEvent = {
68+
action: 'COMMENT',
69+
payload: {
70+
owner: 'google-gemini',
71+
repo: 'gemini-cli',
72+
issueNumber: 100,
73+
commentBody: 'Test comment',
74+
},
75+
};
76+
77+
const res = await request(app)
78+
.post('/')
79+
.send(createPubSubPushEnvelope(validEvent));
80+
81+
expect(res.status).toBe(200);
82+
expect(res.text).toBe('OK');
83+
});
84+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import express from 'express';
8+
import dotenv from 'dotenv';
9+
import {
10+
isPubSubMessageEnvelope,
11+
isEgressEvent,
12+
type EgressEvent,
13+
} from './types.js';
14+
15+
dotenv.config();
16+
17+
/**
18+
* Top-down stub handler for Egress events.
19+
* Octokit GitHub REST API integration will be added in a follow-up PR.
20+
*
21+
* @param event - The validated EgressEvent object decoded from Pub/Sub push envelope.
22+
*/
23+
export async function handleEgressEvent(event: EgressEvent): Promise<void> {
24+
console.log(
25+
`[EGRESS_STUB] Received ${event.action} event for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`,
26+
);
27+
}
28+
29+
export const app = express();
30+
app.use(express.json());
31+
32+
// Health check endpoint for Cloud Run liveness/readiness probes
33+
app.get('/', (_req, res) => {
34+
res.json({
35+
status: 'healthy',
36+
service: process.env.K_SERVICE || 'caretaker-egress-service',
37+
revision: process.env.K_REVISION || 'local',
38+
});
39+
});
40+
41+
// Pub/Sub push subscription endpoint
42+
app.post('/', async (req, res) => {
43+
if (!isPubSubMessageEnvelope(req.body)) {
44+
return res.status(400).send('Invalid Pub/Sub message envelope');
45+
}
46+
47+
const data = req.body.message?.data;
48+
if (!data) {
49+
return res.status(400).send('Missing message.data');
50+
}
51+
52+
let event: unknown;
53+
try {
54+
const jsonStr = Buffer.from(data, 'base64').toString('utf-8');
55+
event = JSON.parse(jsonStr);
56+
} catch {
57+
return res.status(400).send('Malformed payload: invalid JSON');
58+
}
59+
60+
if (!isEgressEvent(event)) {
61+
return res
62+
.status(400)
63+
.send('Malformed payload: missing or invalid required egress fields');
64+
}
65+
66+
try {
67+
await handleEgressEvent(event);
68+
console.log(
69+
`[EGRESS] Successfully executed ${event.action} for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`,
70+
);
71+
return res.status(200).send('OK');
72+
} catch (err) {
73+
console.error('[EGRESS_ERROR] Error handling egress event execution:', err);
74+
return res
75+
.status(500)
76+
.send(err instanceof Error ? err.message : 'Internal Server Error');
77+
}
78+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { app } from './app.js';
8+
9+
const port = process.env.PORT || 8080;
10+
11+
app.listen(port, () => {
12+
console.log(`Egress service listening on port ${port}`);
13+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH';
8+
9+
export interface EgressEventPayload {
10+
owner: string;
11+
repo: string;
12+
issueNumber: number;
13+
commentBody?: string;
14+
labels?: string[];
15+
patchContent?: string;
16+
branchName?: string;
17+
}
18+
19+
export interface EgressEvent {
20+
action: EgressAction;
21+
payload: EgressEventPayload;
22+
}
23+
24+
export interface PubSubMessage {
25+
data?: string;
26+
messageId?: string;
27+
publishTime?: string;
28+
attributes?: Record<string, string>;
29+
}
30+
31+
/**
32+
* Standard GCP Cloud Pub/Sub HTTP Push message wrapper envelope.
33+
*
34+
* @see https://cloud.google.com/pubsub/docs/push#delivery_format
35+
*/
36+
export interface PubSubMessageEnvelope {
37+
message?: PubSubMessage;
38+
subscription?: string;
39+
}
40+
41+
function isObject(obj: unknown): obj is Record<string, unknown> {
42+
return typeof obj === 'object' && obj !== null;
43+
}
44+
45+
/**
46+
* Type guard for PubSubMessageEnvelope to eliminate unsafe 'as' casts.
47+
*/
48+
export function isPubSubMessageEnvelope(
49+
obj: unknown,
50+
): obj is PubSubMessageEnvelope {
51+
if (!isObject(obj)) {
52+
return false;
53+
}
54+
if ('message' in obj) {
55+
if (obj.message !== undefined && !isObject(obj.message)) {
56+
return false;
57+
}
58+
}
59+
return true;
60+
}
61+
62+
/**
63+
* Type guard for EgressEvent.
64+
*/
65+
export function isEgressEvent(obj: unknown): obj is EgressEvent {
66+
if (!isObject(obj)) {
67+
return false;
68+
}
69+
if (
70+
typeof obj.action !== 'string' ||
71+
!['COMMENT', 'LABEL', 'PATCH'].includes(obj.action)
72+
) {
73+
return false;
74+
}
75+
if (!isObject(obj.payload)) {
76+
return false;
77+
}
78+
const payload = obj.payload;
79+
return (
80+
typeof payload.owner === 'string' &&
81+
typeof payload.repo === 'string' &&
82+
typeof payload.issueNumber === 'number'
83+
);
84+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
6+
"lib": ["ES2022"],
7+
"outDir": "./dist",
8+
"rootDir": "./src",
9+
"strict": true,
10+
"esModuleInterop": true,
11+
"skipLibCheck": true,
12+
"forceConsistentCasingInFileNames": true
13+
},
14+
"include": ["src/**/*.ts"],
15+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
16+
}

0 commit comments

Comments
 (0)