@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,508 @@
1
+ import { CloudFormationClient, type Output } from "@aws-sdk/client-cloudformation";
2
+ import { EC2Client } from "@aws-sdk/client-ec2";
3
+ import { randomBytes } from "node:crypto";
4
+ import type { Command } from "commander";
5
+ import { parseUnknownJson } from "../../shared/validation/parse-json.js";
6
+ import { collectGithubEventsFromWorkers } from "../../worker/github-events.js";
7
+ import { loadWorkers } from "../../worker/load.js";
8
+ import { encodeWorkerManifest } from "../../worker/serialize.js";
9
+ import {
10
+ isSimpleName,
11
+ parseTags,
12
+ resolveDeployDefaults,
13
+ } from "./defaults.js";
14
+ import { deployStack, getFailureSummary } from "./cloudformation.js";
15
+ import { resolveGithubRepo, upsertGithubWebhook } from "./github.js";
16
+ import { discoverDefaultNetwork } from "./network.js";
17
+ import { buildTemplate } from "./template.js";
18
+
19
+ type BootstrapOptions = {
20
+ region?: string;
21
+ profile?: string;
22
+ dir?: string;
23
+ stackName?: string;
24
+ apiName?: string;
25
+ stageName?: string;
26
+ timeoutMinutes: number;
27
+ dryRunTemplate?: boolean;
28
+ tags?: string;
29
+ githubRepo?: string;
30
+ githubEvents?: string;
31
+ githubSecret?: string;
32
+ skipGithubWebhook?: boolean;
33
+ strictWorkers?: boolean;
34
+ eventBusName?: string;
35
+ eventSource?: string;
36
+ eventDetailType?: string;
37
+ };
38
+
39
+ type BootstrapContext = {
40
+ rootDir: string;
41
+ service: string;
42
+ env: string;
43
+ region: string;
44
+ stackName: string;
45
+ apiName: string;
46
+ stageName: string;
47
+ githubRepo?: string;
48
+ githubEvents: string[];
49
+ webhookSecret: string;
50
+ workerCount: number;
51
+ workerIds: string[];
52
+ workerManifestByteLength: number;
53
+ workerParameterValues: Record<string, string>;
54
+ timeoutMinutes: number;
55
+ tags?: Record<string, string>;
56
+ skipGithubWebhook: boolean;
57
+ eventBusName: string;
58
+ eventSource: string;
59
+ eventDetailType: string;
60
+ };
61
+
62
+ type BootstrapNetwork = {
63
+ vpcId: string;
64
+ subnetIds: string[];
65
+ };
66
+
67
+ /**
68
+ * Register AWS bootstrap command.
69
+ *
70
+ * @since 1.0.0
71
+ * @category AWS.Bootstrap
72
+ */
73
+ export function registerAwsBootstrapCommand(program: Command): void {
74
+ const command = program
75
+ .command("bootstrap")
76
+ .description("Bootstrap AWS API Gateway -> EventBridge infrastructure");
77
+ configureBootstrapCommand(command);
78
+ }
79
+
80
+ /**
81
+ * Register AWS deploy command.
82
+ *
83
+ * @since 1.0.0
84
+ * @category AWS.Bootstrap
85
+ */
86
+ export function registerAwsDeployAliasCommand(program: Command): void {
87
+ const command = program
88
+ .command("deploy")
89
+ .description("Deploy AWS infrastructure with CloudFormation");
90
+ configureBootstrapCommand(command);
91
+ }
92
+
93
+ /**
94
+ * Configure shared options for bootstrap/deploy commands.
95
+ *
96
+ * @since 1.0.0
97
+ * @category AWS.Bootstrap
98
+ */
99
+ function configureBootstrapCommand(command: Command): void {
100
+ command
101
+ .argument("[service]", "Service name (default: current directory)")
102
+ .argument("[env]", "Environment (default: AWS_PROFILE or sandbox)")
103
+ .option(
104
+ "--region <region>",
105
+ "AWS region (default: AWS_REGION/AWS_DEFAULT_REGION/us-east-1)",
106
+ )
107
+ .option("--profile <profile>", "AWS profile")
108
+ .option("--dir <path>", "Repository root directory (default: cwd)")
109
+ .option("--stack-name <name>", "CloudFormation stack name")
110
+ .option("--api-name <name>", "API Gateway name")
111
+ .option("--stage-name <name>", "API Gateway stage name")
112
+ .option("--event-bus-name <name>", "EventBridge bus name")
113
+ .option("--event-source <source>", "EventBridge source")
114
+ .option("--event-detail-type <type>", "EventBridge detail-type")
115
+ .option("--timeout-minutes <n>", "Wait timeout minutes", parseNumber, 30)
116
+ .option("--tags <k=v,...>", "Stack tags")
117
+ .option(
118
+ "--github-repo <owner/repo>",
119
+ "GitHub repo for webhook (default: current git repo)",
120
+ )
121
+ .option("--github-events <events>", "Webhook events csv (default: *)")
122
+ .option("--github-secret <secret>", "Webhook secret override")
123
+ .option("--skip-github-webhook", "Skip GitHub webhook upsert")
124
+ .option("--strict-workers", "Fail when no workers are found")
125
+ .option("--dry-run-template", "Print template and parameters only")
126
+ .action(handleBootstrapAction);
127
+ }
128
+
129
+ /**
130
+ * Execute bootstrap command action.
131
+ *
132
+ * @since 1.0.0
133
+ * @category AWS.Bootstrap
134
+ */
135
+ async function handleBootstrapAction(
136
+ serviceArg: string | undefined,
137
+ envArg: string | undefined,
138
+ options: BootstrapOptions,
139
+ ): Promise<void> {
140
+ const context = await buildBootstrapContext(serviceArg, envArg, options);
141
+ const templateBody = buildTemplate();
142
+ if (options.dryRunTemplate) {
143
+ printDryRun(templateBody, context);
144
+ return;
145
+ }
146
+ await runWithProfile(options.profile, async () => {
147
+ await executeBootstrap(templateBody, context);
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Build normalized bootstrap context.
153
+ *
154
+ * @since 1.0.0
155
+ * @category AWS.Bootstrap
156
+ */
157
+ async function buildBootstrapContext(
158
+ serviceArg: string | undefined,
159
+ envArg: string | undefined,
160
+ options: BootstrapOptions,
161
+ ): Promise<BootstrapContext> {
162
+ const rootDir = options.dir ?? process.cwd();
163
+ const defaults = resolveDeployDefaults(rootDir);
164
+ const service = serviceArg ?? defaults.service;
165
+ const env = envArg ?? defaults.env;
166
+ const region = options.region ?? defaults.region;
167
+ const workers = await loadWorkers(rootDir);
168
+ if ((options.strictWorkers ?? false) && workers.length === 0) {
169
+ throw new Error("no workers found in .skipper/worker/*.ts");
170
+ }
171
+ const encodedWorkers = encodeWorkerManifest({ workers });
172
+ const workerIds = workers.map((worker) => worker.metadata.id);
173
+ const workerEvents = collectGithubEventsFromWorkers(workers);
174
+ const skipGithubWebhook = options.skipGithubWebhook ?? false;
175
+ const githubRepo = skipGithubWebhook
176
+ ? undefined
177
+ : await resolveGithubRepo(options.githubRepo, process.env, rootDir);
178
+ const githubEvents = resolveGithubEvents(
179
+ parseOptionalGithubEvents(options.githubEvents),
180
+ workerEvents,
181
+ );
182
+ const webhookSecret = options.githubSecret ?? createWebhookSecret(service, env);
183
+ validateInput(service, env);
184
+ assertWebhookRepo(skipGithubWebhook, githubRepo);
185
+ return {
186
+ rootDir,
187
+ service,
188
+ env,
189
+ region,
190
+ stackName: options.stackName ?? `${service}-${env}-bootstrap`,
191
+ apiName: options.apiName ?? `${service}-${env}-events-api`,
192
+ stageName: options.stageName ?? env,
193
+ githubRepo,
194
+ githubEvents,
195
+ webhookSecret,
196
+ workerCount: encodedWorkers.workerCount,
197
+ workerIds,
198
+ workerManifestByteLength: encodedWorkers.byteLength,
199
+ workerParameterValues: encodedWorkers.parameterValues,
200
+ timeoutMinutes: options.timeoutMinutes,
201
+ tags: parseTags(options.tags),
202
+ skipGithubWebhook,
203
+ eventBusName: options.eventBusName ?? `${service}-${env}`,
204
+ eventSource: options.eventSource ?? `${service}.webhook`,
205
+ eventDetailType: options.eventDetailType ?? "WebhookReceived",
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Print dry-run deployment payload.
211
+ *
212
+ * @since 1.0.0
213
+ * @category AWS.Bootstrap
214
+ */
215
+ function printDryRun(templateBody: string, context: BootstrapContext): void {
216
+ console.log(
217
+ JSON.stringify(
218
+ {
219
+ stackName: context.stackName,
220
+ region: context.region,
221
+ eventBridge: {
222
+ eventBusName: context.eventBusName,
223
+ eventSource: context.eventSource,
224
+ eventDetailType: context.eventDetailType,
225
+ },
226
+ githubWebhook: context.skipGithubWebhook
227
+ ? { enabled: false }
228
+ : {
229
+ enabled: true,
230
+ repo: context.githubRepo,
231
+ events: context.githubEvents,
232
+ secretConfigured: Boolean(context.webhookSecret),
233
+ },
234
+ workers: {
235
+ rootDir: context.rootDir,
236
+ workerCount: context.workerCount,
237
+ workerIds: context.workerIds,
238
+ serializedJsonBytes: context.workerManifestByteLength,
239
+ workerParameterKeys: Object.keys(context.workerParameterValues).sort(),
240
+ },
241
+ template: parseUnknownJson(templateBody, "cloudformation template"),
242
+ },
243
+ null,
244
+ 2,
245
+ ),
246
+ );
247
+ }
248
+
249
+ /**
250
+ * Run deploy body with optional profile.
251
+ *
252
+ * @since 1.0.0
253
+ * @category AWS.Bootstrap
254
+ */
255
+ async function runWithProfile(
256
+ profile: string | undefined,
257
+ run: () => Promise<void>,
258
+ ): Promise<void> {
259
+ const previousProfile = process.env.AWS_PROFILE;
260
+ if (profile) {
261
+ process.env.AWS_PROFILE = profile;
262
+ }
263
+ try {
264
+ await run();
265
+ } finally {
266
+ if (!profile) return;
267
+ if (previousProfile === undefined) {
268
+ delete process.env.AWS_PROFILE;
269
+ return;
270
+ }
271
+ process.env.AWS_PROFILE = previousProfile;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Execute bootstrap flow.
277
+ *
278
+ * @since 1.0.0
279
+ * @category AWS.Bootstrap
280
+ */
281
+ async function executeBootstrap(templateBody: string, context: BootstrapContext): Promise<void> {
282
+ try {
283
+ const ec2 = new EC2Client({ region: context.region });
284
+ const network = await discoverDefaultNetwork(ec2);
285
+ const client = new CloudFormationClient({ region: context.region });
286
+ const result = await deployStack({
287
+ client,
288
+ stackName: context.stackName,
289
+ templateBody,
290
+ parameters: createTemplateParameters(context, network),
291
+ timeoutMinutes: context.timeoutMinutes,
292
+ tags: context.tags,
293
+ });
294
+ printDeployOutputs(context.stackName, result.action, result.outputs);
295
+ await upsertWebhookIfEnabled(context, result.outputs);
296
+ } catch (error) {
297
+ await handleBootstrapError(error, context.stackName, context.region);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Build CloudFormation parameters map.
303
+ *
304
+ * @since 1.0.0
305
+ * @category AWS.Bootstrap
306
+ */
307
+ export function createTemplateParameters(
308
+ context: BootstrapContext,
309
+ network: BootstrapNetwork,
310
+ ): Record<string, string> {
311
+ return {
312
+ ServiceName: context.service,
313
+ Environment: context.env,
314
+ ApiName: context.apiName,
315
+ StageName: context.stageName,
316
+ VpcId: network.vpcId,
317
+ SubnetIds: network.subnetIds.join(","),
318
+ EventBusName: context.eventBusName,
319
+ EventSource: context.eventSource,
320
+ EventDetailType: context.eventDetailType,
321
+ WebhookSecret: context.webhookSecret,
322
+ ...context.workerParameterValues,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Print stack output values.
328
+ *
329
+ * @since 1.0.0
330
+ * @category AWS.Bootstrap
331
+ */
332
+ function printDeployOutputs(
333
+ stackName: string,
334
+ action: "create" | "update" | "noop",
335
+ outputs: Output[],
336
+ ): void {
337
+ console.log(`Stack ${stackName} ${action === "noop" ? "unchanged" : "deployed"}`);
338
+ for (const output of outputs) {
339
+ if (output.OutputKey && output.OutputValue) {
340
+ console.log(`${output.OutputKey}: ${output.OutputValue}`);
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Upsert GitHub webhook when enabled.
347
+ *
348
+ * @since 1.0.0
349
+ * @category AWS.Bootstrap
350
+ */
351
+ async function upsertWebhookIfEnabled(
352
+ context: BootstrapContext,
353
+ outputs: Output[],
354
+ ): Promise<void> {
355
+ if (context.skipGithubWebhook) return;
356
+ const repo = requireValue(context.githubRepo, "github repo");
357
+ const apiInvokeUrl = getRequiredOutput(outputs, "ApiInvokeUrl");
358
+ const hook = await upsertGithubWebhook({
359
+ repo,
360
+ webhookUrl: apiInvokeUrl,
361
+ events: context.githubEvents,
362
+ secret: context.webhookSecret,
363
+ });
364
+ console.log(`GitHub webhook ${hook.action}: ${repo} -> ${apiInvokeUrl}`);
365
+ console.log("GitHub webhook secret rotated");
366
+ }
367
+
368
+ /**
369
+ * Handle bootstrap failure output.
370
+ *
371
+ * @since 1.0.0
372
+ * @category AWS.Bootstrap
373
+ */
374
+ async function handleBootstrapError(
375
+ error: unknown,
376
+ stackName: string,
377
+ region: string,
378
+ ): Promise<never> {
379
+ console.error(`Bootstrap failed: ${error instanceof Error ? error.message : error}`);
380
+ const client = new CloudFormationClient({ region });
381
+ const details = await getFailureSummary(client, stackName).catch(() => []);
382
+ for (const line of details) {
383
+ console.error(`- ${line}`);
384
+ }
385
+ process.exit(1);
386
+ }
387
+
388
+ /**
389
+ * Parse positive number option.
390
+ *
391
+ * @since 1.0.0
392
+ * @category AWS.Bootstrap
393
+ */
394
+ function parseNumber(value: string): number {
395
+ const parsed = Number.parseInt(value, 10);
396
+ if (Number.isNaN(parsed) || parsed <= 0) {
397
+ throw new Error("timeout must be positive integer");
398
+ }
399
+ return parsed;
400
+ }
401
+
402
+ /**
403
+ * Validate bootstrap service/env names.
404
+ *
405
+ * @since 1.0.0
406
+ * @category AWS.Bootstrap
407
+ */
408
+ function validateInput(service: string, env: string): void {
409
+ if (!isSimpleName(service)) {
410
+ throw new Error("service must match [a-zA-Z0-9-]+");
411
+ }
412
+ if (!isSimpleName(env)) {
413
+ throw new Error("env must match [a-zA-Z0-9-]+");
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Ensure webhook repo exists when enabled.
419
+ *
420
+ * @since 1.0.0
421
+ * @category AWS.Bootstrap
422
+ */
423
+ function assertWebhookRepo(skipGithubWebhook: boolean, githubRepo: string | undefined): void {
424
+ if (!skipGithubWebhook && !githubRepo) {
425
+ throw new Error("github repo not found from current repo; pass --github-repo");
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Get required stack output value.
431
+ *
432
+ * @since 1.0.0
433
+ * @category AWS.Bootstrap
434
+ */
435
+ function getRequiredOutput(
436
+ outputs: Array<{ OutputKey?: string; OutputValue?: string }>,
437
+ key: string,
438
+ ): string {
439
+ const value = outputs.find((output) => output.OutputKey === key)?.OutputValue;
440
+ if (!value) {
441
+ throw new Error(`Missing CloudFormation output: ${key}`);
442
+ }
443
+ return value;
444
+ }
445
+
446
+ /**
447
+ * Create random webhook secret.
448
+ *
449
+ * @since 1.0.0
450
+ * @category AWS.Bootstrap
451
+ */
452
+ function createWebhookSecret(service: string, env: string): string {
453
+ return `${service}-${env}-${randomBytes(24).toString("hex")}`;
454
+ }
455
+
456
+ /**
457
+ * Parse webhook events CSV as optional list.
458
+ *
459
+ * @since 1.0.0
460
+ * @category AWS.Bootstrap
461
+ */
462
+ function parseOptionalGithubEvents(value?: string): string[] {
463
+ if (!value || value.trim().length === 0) return [];
464
+ const events = value
465
+ .split(",")
466
+ .map((part) => part.trim())
467
+ .filter((part) => part.length > 0);
468
+ if (events.length === 0) return [];
469
+ for (const event of events) {
470
+ if (!/^[a-z0-9_*.-]+$/i.test(event)) {
471
+ throw new Error(`invalid github event: ${event}`);
472
+ }
473
+ }
474
+ return events;
475
+ }
476
+
477
+ /**
478
+ * Resolve final webhook events from explicit and worker-derived values.
479
+ *
480
+ * @since 1.0.0
481
+ * @category AWS.Bootstrap
482
+ */
483
+ export function resolveGithubEvents(explicit: string[], workerEvents: string[]): string[] {
484
+ const merged = new Set<string>();
485
+ for (const event of explicit) {
486
+ merged.add(event);
487
+ }
488
+ for (const event of workerEvents) {
489
+ merged.add(event);
490
+ }
491
+ if (merged.size === 0) {
492
+ return ["*"];
493
+ }
494
+ return [...merged].sort((left, right) => left.localeCompare(right));
495
+ }
496
+
497
+ /**
498
+ * Require optional value.
499
+ *
500
+ * @since 1.0.0
501
+ * @category AWS.Bootstrap
502
+ */
503
+ function requireValue(value: string | undefined, label: string): string {
504
+ if (!value) {
505
+ throw new Error(`missing ${label}`);
506
+ }
507
+ return value;
508
+ }