@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.
@@ -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
+ }