@skippercorp/skipper 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/package.json +48 -0
- package/src/app/cli.ts +31 -0
- package/src/app/register-commands.ts +37 -0
- package/src/command/a.ts +213 -0
- package/src/command/aws/bootstrap.ts +508 -0
- package/src/command/aws/cloudformation.ts +243 -0
- package/src/command/aws/defaults.ts +103 -0
- package/src/command/aws/deploy-template.ts +308 -0
- package/src/command/aws/deploy.ts +593 -0
- package/src/command/aws/github.ts +358 -0
- package/src/command/aws/index.ts +17 -0
- package/src/command/aws/lambda/eventbridge-handler.ts +83 -0
- package/src/command/aws/lambda/handler.ts +521 -0
- package/src/command/aws/lambda/types.ts +86 -0
- package/src/command/aws/network.ts +51 -0
- package/src/command/aws/run.ts +566 -0
- package/src/command/aws/template.ts +406 -0
- package/src/command/aws/verify-issue-subscription.ts +782 -0
- package/src/command/clone.ts +67 -0
- package/src/command/rm.ts +126 -0
- package/src/command/run.ts +43 -0
- package/src/index.ts +16 -0
- package/src/shared/command/interactive.ts +120 -0
- package/src/shared/validation/parse-json.ts +59 -0
- package/src/worker/aws-params.ts +54 -0
- package/src/worker/contract.ts +324 -0
- package/src/worker/github-events.ts +57 -0
- package/src/worker/load.ts +86 -0
- package/src/worker/route.ts +91 -0
- package/src/worker/serialize.ts +175 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudFormationClient,
|
|
3
|
+
DescribeStacksCommand,
|
|
4
|
+
} from "@aws-sdk/client-cloudformation";
|
|
5
|
+
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";
|
|
6
|
+
import { Webhooks } from "@octokit/webhooks";
|
|
7
|
+
import { parseJson } from "../../../shared/validation/parse-json.js";
|
|
8
|
+
import { WORKERS_SHA256_PARAM } from "../../../worker/aws-params.js";
|
|
9
|
+
import { routeWorkers } from "../../../worker/route.js";
|
|
10
|
+
import { decodeWorkerManifest } from "../../../worker/serialize.js";
|
|
11
|
+
import type { WorkerManifest } from "../../../worker/contract.js";
|
|
12
|
+
import {
|
|
13
|
+
type GitHubPayload,
|
|
14
|
+
type QueueEnvelope,
|
|
15
|
+
isGitHubPayload,
|
|
16
|
+
isQueueEnvelope,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
type SQSEvent = {
|
|
20
|
+
Records?: Array<{
|
|
21
|
+
body: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const clusterArn = requiredEnv("ECS_CLUSTER_ARN");
|
|
26
|
+
const taskDefinitionArn = requiredEnv("ECS_TASK_DEFINITION_ARN");
|
|
27
|
+
const securityGroupId = requiredEnv("ECS_SECURITY_GROUP_ID");
|
|
28
|
+
const subnetIds = requiredEnv("ECS_SUBNET_IDS").split(",").map((v) => v.trim()).filter(Boolean);
|
|
29
|
+
const webhookSecret = requiredEnv("WEBHOOK_SECRET");
|
|
30
|
+
const assignPublicIp = process.env.ECS_ASSIGN_PUBLIC_IP === "DISABLED" ? "DISABLED" : "ENABLED";
|
|
31
|
+
const containerName = process.env.ECS_CONTAINER_NAME ?? "webhook";
|
|
32
|
+
const workersStackName = process.env.WORKERS_STACK_NAME?.trim() ?? "";
|
|
33
|
+
const workersSha256 = process.env.WORKERS_SHA256?.trim() ?? "";
|
|
34
|
+
const workerIdFilter = process.env.SKIPPER_WORKER_ID?.trim() ?? "";
|
|
35
|
+
|
|
36
|
+
const webhooks = new Webhooks({ secret: webhookSecret });
|
|
37
|
+
const ecs = new ECSClient({ region: process.env.AWS_REGION });
|
|
38
|
+
const cloudformation = new CloudFormationClient({ region: process.env.AWS_REGION });
|
|
39
|
+
|
|
40
|
+
let cachedWorkers: { sha256: string; manifest: WorkerManifest | undefined } | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process SQS webhook events and launch ECS tasks.
|
|
44
|
+
*
|
|
45
|
+
* @since 1.0.0
|
|
46
|
+
* @category AWS.Lambda
|
|
47
|
+
*/
|
|
48
|
+
export async function handler(event: SQSEvent): Promise<void> {
|
|
49
|
+
for (const record of event.Records ?? []) {
|
|
50
|
+
await handleRecord(record.body);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle one queue record.
|
|
56
|
+
*
|
|
57
|
+
* @since 1.0.0
|
|
58
|
+
* @category AWS.Lambda
|
|
59
|
+
*/
|
|
60
|
+
async function handleRecord(body: string): Promise<void> {
|
|
61
|
+
const envelope = parseEnvelope(body);
|
|
62
|
+
const rawBody = decodeBase64(envelope.rawBodyB64 ?? "");
|
|
63
|
+
const payload = parseJson(rawBody, isGitHubPayload, "github payload");
|
|
64
|
+
const headers = normalizeHeaders(envelope.headers);
|
|
65
|
+
const webhookMeta = readWebhookMeta(headers);
|
|
66
|
+
const eventFromPayload = inferEventFromPayload(payload);
|
|
67
|
+
if (!webhookMeta) {
|
|
68
|
+
const missingHeaders = listMissingWebhookHeaders(headers).join(",");
|
|
69
|
+
console.warn(
|
|
70
|
+
`Skipping webhook missing headers=${missingHeaders} event=${eventFromPayload} action=${payload.action ?? "none"} repo=${payload.repository?.full_name ?? "unknown"}`,
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(
|
|
75
|
+
`Received webhook event=${webhookMeta.githubEvent} action=${payload.action ?? "none"} delivery=${webhookMeta.deliveryId} repo=${payload.repository?.full_name ?? "unknown"}`,
|
|
76
|
+
);
|
|
77
|
+
await verifyWebhookBody(rawBody, webhookMeta.signature);
|
|
78
|
+
const workers = await loadWorkersManifest();
|
|
79
|
+
const environments = buildTaskEnvironments(payload, webhookMeta, workers);
|
|
80
|
+
if (environments.length === 0) {
|
|
81
|
+
console.log(
|
|
82
|
+
`No worker matched event=${webhookMeta.githubEvent} action=${payload.action ?? "none"}`,
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const environment of environments) {
|
|
87
|
+
await runTask(environment);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read required env variable.
|
|
93
|
+
*
|
|
94
|
+
* @since 1.0.0
|
|
95
|
+
* @category AWS.Lambda
|
|
96
|
+
*/
|
|
97
|
+
function requiredEnv(name: string): string {
|
|
98
|
+
const value = process.env[name];
|
|
99
|
+
if (!value) throw new Error(`missing env ${name}`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse queue envelope with compatibility fallback.
|
|
105
|
+
*
|
|
106
|
+
* @since 1.0.0
|
|
107
|
+
* @category AWS.Lambda
|
|
108
|
+
*/
|
|
109
|
+
function parseEnvelope(body: string): QueueEnvelope {
|
|
110
|
+
try {
|
|
111
|
+
return parseJson(body, isQueueEnvelope, "queue envelope");
|
|
112
|
+
} catch {
|
|
113
|
+
const parsed = parseLegacyEnvelope(body);
|
|
114
|
+
if (parsed) return parsed;
|
|
115
|
+
throw new Error("invalid queue envelope");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Decode base64 payload to utf8.
|
|
121
|
+
*
|
|
122
|
+
* @since 1.0.0
|
|
123
|
+
* @category AWS.Lambda
|
|
124
|
+
*/
|
|
125
|
+
function decodeBase64(value: string): string {
|
|
126
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Normalize header keys to lowercase.
|
|
131
|
+
*
|
|
132
|
+
* @since 1.0.0
|
|
133
|
+
* @category AWS.Lambda
|
|
134
|
+
*/
|
|
135
|
+
function normalizeHeaders(
|
|
136
|
+
headers: Record<string, string | undefined> | undefined,
|
|
137
|
+
): Record<string, string | undefined> {
|
|
138
|
+
const result: Record<string, string | undefined> = {};
|
|
139
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
140
|
+
result[key.toLowerCase()] = value;
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build repository clone URL from payload.
|
|
147
|
+
*
|
|
148
|
+
* @since 1.0.0
|
|
149
|
+
* @category AWS.Lambda
|
|
150
|
+
*/
|
|
151
|
+
function resolveRepositoryUrl(payload: GitHubPayload): string | undefined {
|
|
152
|
+
const cloneUrl = payload.repository?.clone_url?.trim();
|
|
153
|
+
if (cloneUrl) return cloneUrl;
|
|
154
|
+
|
|
155
|
+
const fullName = payload.repository?.full_name?.trim();
|
|
156
|
+
if (!fullName) return undefined;
|
|
157
|
+
return `https://github.com/${fullName}.git`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve prompt from payload or env.
|
|
162
|
+
*
|
|
163
|
+
* @since 1.0.0
|
|
164
|
+
* @category AWS.Lambda
|
|
165
|
+
*/
|
|
166
|
+
function resolvePrompt(payload: GitHubPayload): string | undefined {
|
|
167
|
+
const promptFromPayload =
|
|
168
|
+
payload.prompt?.trim() ??
|
|
169
|
+
payload.client_payload?.prompt?.trim() ??
|
|
170
|
+
payload.inputs?.prompt?.trim();
|
|
171
|
+
if (promptFromPayload) return promptFromPayload;
|
|
172
|
+
|
|
173
|
+
return process.env.PROMPT?.trim();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Load and cache worker manifest from stack parameters.
|
|
178
|
+
*
|
|
179
|
+
* @since 1.0.0
|
|
180
|
+
* @category AWS.Lambda
|
|
181
|
+
*/
|
|
182
|
+
async function loadWorkersManifest(): Promise<WorkerManifest | undefined> {
|
|
183
|
+
if (!workersStackName || !workersSha256) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (cachedWorkers?.sha256 === workersSha256) {
|
|
187
|
+
return cachedWorkers.manifest;
|
|
188
|
+
}
|
|
189
|
+
const response = await cloudformation.send(
|
|
190
|
+
new DescribeStacksCommand({ StackName: workersStackName }),
|
|
191
|
+
);
|
|
192
|
+
const stack = response.Stacks?.[0];
|
|
193
|
+
if (!stack) {
|
|
194
|
+
throw new Error(`stack not found: ${workersStackName}`);
|
|
195
|
+
}
|
|
196
|
+
const parameterValues = readStackParameterValues(stack.Parameters ?? []);
|
|
197
|
+
const manifest = decodeWorkerManifest(parameterValues);
|
|
198
|
+
const parameterSha = parameterValues[WORKERS_SHA256_PARAM]?.trim() ?? "";
|
|
199
|
+
if (parameterSha !== workersSha256) {
|
|
200
|
+
throw new Error("worker manifest hash mismatch between lambda env and stack params");
|
|
201
|
+
}
|
|
202
|
+
cachedWorkers = { sha256: workersSha256, manifest };
|
|
203
|
+
return manifest;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build stack parameter map from list.
|
|
208
|
+
*
|
|
209
|
+
* @since 1.0.0
|
|
210
|
+
* @category AWS.Lambda
|
|
211
|
+
*/
|
|
212
|
+
function readStackParameterValues(
|
|
213
|
+
parameters: Array<{ ParameterKey?: string; ParameterValue?: string }>,
|
|
214
|
+
): Record<string, string | undefined> {
|
|
215
|
+
const values: Record<string, string | undefined> = {};
|
|
216
|
+
for (const parameter of parameters) {
|
|
217
|
+
if (!parameter.ParameterKey) continue;
|
|
218
|
+
values[parameter.ParameterKey] = parameter.ParameterValue;
|
|
219
|
+
}
|
|
220
|
+
return values;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse legacy queue envelope format.
|
|
225
|
+
*
|
|
226
|
+
* @since 1.0.0
|
|
227
|
+
* @category AWS.Lambda
|
|
228
|
+
*/
|
|
229
|
+
function parseLegacyEnvelope(body: string): QueueEnvelope | undefined {
|
|
230
|
+
const prefix = "{rawBodyB64=";
|
|
231
|
+
const headerMarker = ", headers={";
|
|
232
|
+
if (!body.startsWith(prefix) || !body.endsWith("}}")) return undefined;
|
|
233
|
+
|
|
234
|
+
const markerIndex = body.indexOf(headerMarker);
|
|
235
|
+
if (markerIndex === -1) return undefined;
|
|
236
|
+
|
|
237
|
+
const rawBodyB64 = body.slice(prefix.length, markerIndex).trim();
|
|
238
|
+
const headerPayload = body.slice(markerIndex + headerMarker.length, body.length - 2);
|
|
239
|
+
const headers = parseLegacyHeaders(headerPayload);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
rawBodyB64: rawBodyB64.length > 0 ? rawBodyB64 : undefined,
|
|
243
|
+
headers,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse legacy header serialization.
|
|
249
|
+
*
|
|
250
|
+
* @since 1.0.0
|
|
251
|
+
* @category AWS.Lambda
|
|
252
|
+
*/
|
|
253
|
+
function parseLegacyHeaders(value: string): Record<string, string | undefined> {
|
|
254
|
+
const headers: Record<string, string | undefined> = {};
|
|
255
|
+
for (const part of value.split(", ")) {
|
|
256
|
+
const index = part.indexOf("=");
|
|
257
|
+
if (index === -1) continue;
|
|
258
|
+
const key = part.slice(0, index).trim();
|
|
259
|
+
if (!key) continue;
|
|
260
|
+
headers[key] = part.slice(index + 1).trim();
|
|
261
|
+
}
|
|
262
|
+
return headers;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type WebhookMeta = {
|
|
266
|
+
signature: string;
|
|
267
|
+
githubEvent: string;
|
|
268
|
+
deliveryId: string;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
type IssueContext = {
|
|
272
|
+
number: string;
|
|
273
|
+
url?: string;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Infer GitHub event type from payload shape.
|
|
278
|
+
*
|
|
279
|
+
* @since 1.0.0
|
|
280
|
+
* @category AWS.Lambda
|
|
281
|
+
*/
|
|
282
|
+
function inferEventFromPayload(payload: GitHubPayload): string {
|
|
283
|
+
if (payload.pull_request) return "pull_request";
|
|
284
|
+
if (payload.issue) return "issues";
|
|
285
|
+
const record = payload as Record<string, unknown>;
|
|
286
|
+
if (typeof record.zen === "string") return "ping";
|
|
287
|
+
return "unknown";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read normalized issue context from webhook payload.
|
|
292
|
+
*
|
|
293
|
+
* @since 1.0.0
|
|
294
|
+
* @category AWS.Lambda
|
|
295
|
+
*/
|
|
296
|
+
function readIssueContext(payload: GitHubPayload): IssueContext | undefined {
|
|
297
|
+
if (!payload.issue) return undefined;
|
|
298
|
+
const issueNumber = payload.issue.number;
|
|
299
|
+
if (typeof issueNumber !== "number" || !Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const issueUrl = payload.issue.html_url?.trim();
|
|
303
|
+
return {
|
|
304
|
+
number: String(issueNumber),
|
|
305
|
+
url: issueUrl && issueUrl.length > 0 ? issueUrl : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* List missing required webhook headers.
|
|
311
|
+
*
|
|
312
|
+
* @since 1.0.0
|
|
313
|
+
* @category AWS.Lambda
|
|
314
|
+
*/
|
|
315
|
+
function listMissingWebhookHeaders(headers: Record<string, string | undefined>): string[] {
|
|
316
|
+
const required = ["x-hub-signature-256", "x-github-event", "x-github-delivery"];
|
|
317
|
+
return required.filter((name) => !headers[name]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Read required webhook headers.
|
|
322
|
+
*
|
|
323
|
+
* @since 1.0.0
|
|
324
|
+
* @category AWS.Lambda
|
|
325
|
+
*/
|
|
326
|
+
function readWebhookMeta(headers: Record<string, string | undefined>): WebhookMeta | undefined {
|
|
327
|
+
if (listMissingWebhookHeaders(headers).length > 0) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const signature = headers["x-hub-signature-256"];
|
|
331
|
+
const githubEvent = headers["x-github-event"];
|
|
332
|
+
const deliveryId = headers["x-github-delivery"];
|
|
333
|
+
if (!signature || !githubEvent || !deliveryId) return undefined;
|
|
334
|
+
return { signature, githubEvent, deliveryId };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Verify webhook signature.
|
|
339
|
+
*
|
|
340
|
+
* @since 1.0.0
|
|
341
|
+
* @category AWS.Lambda
|
|
342
|
+
*/
|
|
343
|
+
async function verifyWebhookBody(rawBody: string, signature: string): Promise<void> {
|
|
344
|
+
const verified = await webhooks.verify(rawBody, signature);
|
|
345
|
+
if (!verified) throw new Error("invalid webhook signature");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Build ECS env vars for task.
|
|
350
|
+
*
|
|
351
|
+
* @since 1.0.0
|
|
352
|
+
* @category AWS.Lambda
|
|
353
|
+
*/
|
|
354
|
+
function buildTaskEnvironments(
|
|
355
|
+
payload: GitHubPayload,
|
|
356
|
+
webhookMeta: WebhookMeta,
|
|
357
|
+
manifest: WorkerManifest | undefined,
|
|
358
|
+
): Array<Array<{ name: string; value: string }>> {
|
|
359
|
+
const repositoryUrl = resolveRepositoryUrl(payload);
|
|
360
|
+
if (!repositoryUrl) throw new Error("missing repository clone url");
|
|
361
|
+
const baseEnvironment = [
|
|
362
|
+
{ name: "GITHUB_EVENT", value: webhookMeta.githubEvent },
|
|
363
|
+
{ name: "GITHUB_DELIVERY", value: webhookMeta.deliveryId },
|
|
364
|
+
{ name: "GITHUB_REPO", value: payload.repository?.full_name ?? "unknown" },
|
|
365
|
+
{ name: "GITHUB_ACTION", value: payload.action ?? "none" },
|
|
366
|
+
{ name: "REPOSITORY_URL", value: repositoryUrl },
|
|
367
|
+
];
|
|
368
|
+
const issueContext = readIssueContext(payload);
|
|
369
|
+
if (issueContext) {
|
|
370
|
+
baseEnvironment.push({ name: "GITHUB_ISSUE_NUMBER", value: issueContext.number });
|
|
371
|
+
pushOptionalEnv(baseEnvironment, "GITHUB_ISSUE_URL", issueContext.url);
|
|
372
|
+
}
|
|
373
|
+
if (!manifest) {
|
|
374
|
+
if (workerIdFilter.length > 0) {
|
|
375
|
+
throw new Error("worker-scoped lambda missing workers manifest");
|
|
376
|
+
}
|
|
377
|
+
return [buildLegacyTaskEnvironment(payload, baseEnvironment)];
|
|
378
|
+
}
|
|
379
|
+
const matchedWorkers = filterWorkersById(
|
|
380
|
+
routeWorkers(manifest, {
|
|
381
|
+
provider: "github",
|
|
382
|
+
event: webhookMeta.githubEvent,
|
|
383
|
+
action: payload.action,
|
|
384
|
+
repository: payload.repository?.full_name,
|
|
385
|
+
baseBranch: payload.pull_request?.base?.ref,
|
|
386
|
+
headBranch: payload.pull_request?.head?.ref,
|
|
387
|
+
draft: payload.pull_request?.draft,
|
|
388
|
+
}),
|
|
389
|
+
workerIdFilter,
|
|
390
|
+
);
|
|
391
|
+
return matchedWorkers.map((worker) => buildWorkerTaskEnvironment(baseEnvironment, worker));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Filter matched workers when lambda is scoped to one worker id.
|
|
396
|
+
*
|
|
397
|
+
* @since 1.0.0
|
|
398
|
+
* @category AWS.Lambda
|
|
399
|
+
*/
|
|
400
|
+
function filterWorkersById(
|
|
401
|
+
workers: WorkerManifest["workers"],
|
|
402
|
+
workerId: string,
|
|
403
|
+
): WorkerManifest["workers"] {
|
|
404
|
+
if (workerId.length === 0) {
|
|
405
|
+
return workers;
|
|
406
|
+
}
|
|
407
|
+
return workers.filter((worker) => worker.metadata.id === workerId);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Build legacy task environment when no workers configured.
|
|
412
|
+
*
|
|
413
|
+
* @since 1.0.0
|
|
414
|
+
* @category AWS.Lambda
|
|
415
|
+
*/
|
|
416
|
+
function buildLegacyTaskEnvironment(
|
|
417
|
+
payload: GitHubPayload,
|
|
418
|
+
baseEnvironment: Array<{ name: string; value: string }>,
|
|
419
|
+
): Array<{ name: string; value: string }> {
|
|
420
|
+
const prompt = resolvePrompt(payload);
|
|
421
|
+
if (!prompt) throw new Error("missing prompt");
|
|
422
|
+
const environment = [...baseEnvironment, { name: "PROMPT", value: prompt }];
|
|
423
|
+
pushOptionalEnv(environment, "GITHUB_TOKEN", process.env.GITHUB_TOKEN);
|
|
424
|
+
pushOptionalEnv(environment, "ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY);
|
|
425
|
+
return environment;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Build worker-specific task environment.
|
|
430
|
+
*
|
|
431
|
+
* @since 1.0.0
|
|
432
|
+
* @category AWS.Lambda
|
|
433
|
+
*/
|
|
434
|
+
function buildWorkerTaskEnvironment(
|
|
435
|
+
baseEnvironment: Array<{ name: string; value: string }>,
|
|
436
|
+
worker: WorkerManifest["workers"][number],
|
|
437
|
+
): Array<{ name: string; value: string }> {
|
|
438
|
+
const mode = worker.runtime.mode ?? "apply";
|
|
439
|
+
const allowPush = worker.runtime.allowPush ?? mode !== "comment-only";
|
|
440
|
+
const environment = [
|
|
441
|
+
...baseEnvironment,
|
|
442
|
+
{ name: "PROMPT", value: worker.runtime.prompt },
|
|
443
|
+
{ name: "SKIPPER_WORKER_ID", value: worker.metadata.id },
|
|
444
|
+
{ name: "SKIPPER_WORKER_TYPE", value: worker.metadata.type },
|
|
445
|
+
{ name: "SKIPPER_WORKER_MODE", value: mode },
|
|
446
|
+
{ name: "SKIPPER_ALLOW_PUSH", value: allowPush ? "1" : "0" },
|
|
447
|
+
];
|
|
448
|
+
if (worker.runtime.agent) {
|
|
449
|
+
environment.push({ name: "ECS_AGENT", value: worker.runtime.agent });
|
|
450
|
+
}
|
|
451
|
+
for (const [key, value] of Object.entries(worker.runtime.env ?? {})) {
|
|
452
|
+
environment.push({ name: key, value });
|
|
453
|
+
}
|
|
454
|
+
pushOptionalEnv(environment, "GITHUB_TOKEN", process.env.GITHUB_TOKEN);
|
|
455
|
+
pushOptionalEnv(environment, "ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY);
|
|
456
|
+
return environment;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Add optional environment variable.
|
|
461
|
+
*
|
|
462
|
+
* @since 1.0.0
|
|
463
|
+
* @category AWS.Lambda
|
|
464
|
+
*/
|
|
465
|
+
function pushOptionalEnv(
|
|
466
|
+
environment: Array<{ name: string; value: string }>,
|
|
467
|
+
name: string,
|
|
468
|
+
value: string | undefined,
|
|
469
|
+
): void {
|
|
470
|
+
if (value) {
|
|
471
|
+
environment.push({ name, value });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Run ECS task and assert success.
|
|
477
|
+
*
|
|
478
|
+
* @since 1.0.0
|
|
479
|
+
* @category AWS.Lambda
|
|
480
|
+
*/
|
|
481
|
+
async function runTask(environment: Array<{ name: string; value: string }>): Promise<void> {
|
|
482
|
+
const response = await ecs.send(
|
|
483
|
+
new RunTaskCommand({
|
|
484
|
+
cluster: clusterArn,
|
|
485
|
+
taskDefinition: taskDefinitionArn,
|
|
486
|
+
launchType: "FARGATE",
|
|
487
|
+
networkConfiguration: {
|
|
488
|
+
awsvpcConfiguration: {
|
|
489
|
+
subnets: subnetIds,
|
|
490
|
+
securityGroups: [securityGroupId],
|
|
491
|
+
assignPublicIp,
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
overrides: {
|
|
495
|
+
containerOverrides: [{ name: containerName, environment }],
|
|
496
|
+
},
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
assertRunTaskSuccess(response.failures, response.tasks?.length ?? 0);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Assert ECS runTask response.
|
|
504
|
+
*
|
|
505
|
+
* @since 1.0.0
|
|
506
|
+
* @category AWS.Lambda
|
|
507
|
+
*/
|
|
508
|
+
function assertRunTaskSuccess(
|
|
509
|
+
failures: Array<{ reason?: string; detail?: string }> | undefined,
|
|
510
|
+
taskCount: number,
|
|
511
|
+
): void {
|
|
512
|
+
if ((failures?.length ?? 0) > 0) {
|
|
513
|
+
const details = (failures ?? [])
|
|
514
|
+
.map((failure) => `${failure.reason ?? "unknown"}:${failure.detail ?? ""}`)
|
|
515
|
+
.join(", ");
|
|
516
|
+
throw new Error(`ecs runTask failed ${details}`);
|
|
517
|
+
}
|
|
518
|
+
if (taskCount === 0) {
|
|
519
|
+
throw new Error("ecs runTask created no tasks");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type QueueEnvelope = {
|
|
2
|
+
rawBodyB64?: string;
|
|
3
|
+
headers?: Record<string, string | undefined>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type GitHubPayload = {
|
|
7
|
+
prompt?: string;
|
|
8
|
+
inputs?: {
|
|
9
|
+
prompt?: string;
|
|
10
|
+
};
|
|
11
|
+
client_payload?: {
|
|
12
|
+
prompt?: string;
|
|
13
|
+
};
|
|
14
|
+
repository?: {
|
|
15
|
+
full_name?: string;
|
|
16
|
+
clone_url?: string;
|
|
17
|
+
};
|
|
18
|
+
issue?: {
|
|
19
|
+
number?: number;
|
|
20
|
+
html_url?: string;
|
|
21
|
+
};
|
|
22
|
+
pull_request?: {
|
|
23
|
+
draft?: boolean;
|
|
24
|
+
base?: {
|
|
25
|
+
ref?: string;
|
|
26
|
+
};
|
|
27
|
+
head?: {
|
|
28
|
+
ref?: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
action?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check queue envelope shape.
|
|
36
|
+
*
|
|
37
|
+
* @since 1.0.0
|
|
38
|
+
* @category AWS.Lambda
|
|
39
|
+
*/
|
|
40
|
+
export function isQueueEnvelope(value: unknown): value is QueueEnvelope {
|
|
41
|
+
if (!isRecord(value)) return false;
|
|
42
|
+
if (value.rawBodyB64 !== undefined && typeof value.rawBodyB64 !== "string") {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (value.headers !== undefined && !isHeaderMap(value.headers)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check webhook payload shape.
|
|
53
|
+
*
|
|
54
|
+
* @since 1.0.0
|
|
55
|
+
* @category AWS.Lambda
|
|
56
|
+
*/
|
|
57
|
+
export function isGitHubPayload(value: unknown): value is GitHubPayload {
|
|
58
|
+
if (!isRecord(value)) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check plain record shape.
|
|
64
|
+
*
|
|
65
|
+
* @since 1.0.0
|
|
66
|
+
* @category AWS.Lambda
|
|
67
|
+
*/
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === "object" && value !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check header map values.
|
|
74
|
+
*
|
|
75
|
+
* @since 1.0.0
|
|
76
|
+
* @category AWS.Lambda
|
|
77
|
+
*/
|
|
78
|
+
function isHeaderMap(value: unknown): value is Record<string, string | undefined> {
|
|
79
|
+
if (!isRecord(value)) return false;
|
|
80
|
+
for (const headerValue of Object.values(value)) {
|
|
81
|
+
if (headerValue !== undefined && typeof headerValue !== "string") {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DescribeSubnetsCommand,
|
|
3
|
+
DescribeVpcsCommand,
|
|
4
|
+
EC2Client,
|
|
5
|
+
} from "@aws-sdk/client-ec2";
|
|
6
|
+
|
|
7
|
+
export type DefaultNetwork = {
|
|
8
|
+
vpcId: string;
|
|
9
|
+
subnetIds: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discover default VPC and available subnets.
|
|
14
|
+
*
|
|
15
|
+
* @since 1.0.0
|
|
16
|
+
* @category AWS.Deploy
|
|
17
|
+
*/
|
|
18
|
+
export async function discoverDefaultNetwork(
|
|
19
|
+
client: EC2Client,
|
|
20
|
+
): Promise<DefaultNetwork> {
|
|
21
|
+
const vpcs = await client.send(
|
|
22
|
+
new DescribeVpcsCommand({
|
|
23
|
+
Filters: [{ Name: "isDefault", Values: ["true"] }],
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const vpcId = vpcs.Vpcs?.[0]?.VpcId;
|
|
28
|
+
if (!vpcId) throw new Error("default VPC not found");
|
|
29
|
+
|
|
30
|
+
const subnetsRes = await client.send(
|
|
31
|
+
new DescribeSubnetsCommand({
|
|
32
|
+
Filters: [
|
|
33
|
+
{ Name: "vpc-id", Values: [vpcId] },
|
|
34
|
+
{ Name: "state", Values: ["available"] },
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const subnetIds = (subnetsRes.Subnets ?? [])
|
|
40
|
+
.map((subnet: { SubnetId?: string }) => subnet.SubnetId)
|
|
41
|
+
.filter((id: string | undefined): id is string => Boolean(id));
|
|
42
|
+
|
|
43
|
+
if (subnetIds.length === 0) {
|
|
44
|
+
throw new Error(`no available subnets in default VPC ${vpcId}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
vpcId,
|
|
49
|
+
subnetIds,
|
|
50
|
+
};
|
|
51
|
+
}
|