@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,593 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ CloudFormationClient,
8
+ DescribeStacksCommand,
9
+ type Output,
10
+ } from "@aws-sdk/client-cloudformation";
11
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
12
+ import type { Command } from "commander";
13
+ import { parseUnknownJson } from "../../shared/validation/parse-json.js";
14
+ import {
15
+ collectGithubEventSubscriptions,
16
+ collectGithubEventsFromWorkers,
17
+ type WorkerGithubEventSubscription,
18
+ } from "../../worker/github-events.js";
19
+ import { loadWorkers } from "../../worker/load.js";
20
+ import { encodeWorkerManifest } from "../../worker/serialize.js";
21
+ import { deployStack, getFailureSummary } from "./cloudformation.js";
22
+ import { parseTags, resolveDeployDefaults } from "./defaults.js";
23
+ import { buildDeployTemplate } from "./deploy-template.js";
24
+ import { resolveGithubRepo, toRepositoryPrefix } from "./github.js";
25
+
26
+ type DeployOptions = {
27
+ region?: string;
28
+ profile?: string;
29
+ dir?: string;
30
+ stackName?: string;
31
+ bootstrapStackName?: string;
32
+ timeoutMinutes: number;
33
+ dryRunTemplate?: boolean;
34
+ tags?: string;
35
+ githubRepo?: string;
36
+ strictWorkers?: boolean;
37
+ eventBusName?: string;
38
+ eventSource?: string;
39
+ eventDetailType?: string;
40
+ };
41
+
42
+ type DeployContext = {
43
+ rootDir: string;
44
+ service: string;
45
+ env: string;
46
+ region: string;
47
+ stackName: string;
48
+ bootstrapStackName: string;
49
+ repositoryFullName: string;
50
+ repositoryPrefix: string;
51
+ workerCount: number;
52
+ workerIds: string[];
53
+ workerSubscriptions: WorkerGithubEventSubscription[];
54
+ workerManifestByteLength: number;
55
+ workerParameterValues: Record<string, string>;
56
+ workerEvents: string[];
57
+ timeoutMinutes: number;
58
+ tags?: Record<string, string>;
59
+ eventBusName: string;
60
+ eventSource: string;
61
+ eventDetailType: string;
62
+ ecsClusterArn: string;
63
+ ecsTaskDefinitionArn: string;
64
+ ecsTaskExecutionRoleArn: string;
65
+ ecsTaskRoleArn: string;
66
+ ecsSecurityGroupId: string;
67
+ ecsSubnetIdsCsv: string;
68
+ webhookSecretParameterName: string;
69
+ lambdaArtifactsBucketName: string;
70
+ githubToken?: string;
71
+ };
72
+
73
+ type LambdaArtifact = {
74
+ bucket: string;
75
+ key: string;
76
+ sha256: string;
77
+ };
78
+
79
+ const EVENTBRIDGE_LAMBDA_ENTRY = fileURLToPath(
80
+ new URL("./lambda/eventbridge-handler.ts", import.meta.url),
81
+ );
82
+
83
+ /**
84
+ * Register aws deploy command.
85
+ *
86
+ * @since 1.0.0
87
+ * @category AWS.Deploy
88
+ */
89
+ export function registerAwsDeployCommand(program: Command): void {
90
+ program
91
+ .command("deploy")
92
+ .description("Deploy repository-scoped AWS subscription stack")
93
+ .argument("[service]", "Service name (default: current directory)")
94
+ .argument("[env]", "Environment (default: AWS_PROFILE or sandbox)")
95
+ .option(
96
+ "--region <region>",
97
+ "AWS region (default: AWS_REGION/AWS_DEFAULT_REGION/us-east-1)",
98
+ )
99
+ .option("--profile <profile>", "AWS profile")
100
+ .option("--dir <path>", "Repository root directory (default: cwd)")
101
+ .option("--stack-name <name>", "CloudFormation stack name")
102
+ .option("--bootstrap-stack-name <name>", "Bootstrap stack name for shared ingress outputs")
103
+ .option("--event-bus-name <name>", "EventBridge bus name override")
104
+ .option("--event-source <source>", "EventBridge source override")
105
+ .option("--event-detail-type <type>", "EventBridge detail-type override")
106
+ .option("--timeout-minutes <n>", "Wait timeout minutes", parseNumber, 30)
107
+ .option("--tags <k=v,...>", "Stack tags")
108
+ .option(
109
+ "--github-repo <owner/repo>",
110
+ "GitHub repo scope for subscription (default: current git repo)",
111
+ )
112
+ .option("--strict-workers", "Fail when no workers are found")
113
+ .option("--dry-run-template", "Print template and parameters only")
114
+ .action(handleDeployAction);
115
+ }
116
+
117
+ /**
118
+ * Execute deploy command action.
119
+ *
120
+ * @since 1.0.0
121
+ * @category AWS.Deploy
122
+ */
123
+ async function handleDeployAction(
124
+ serviceArg: string | undefined,
125
+ envArg: string | undefined,
126
+ options: DeployOptions,
127
+ ): Promise<void> {
128
+ const context = await buildDeployContext(serviceArg, envArg, options);
129
+ const templateBody = buildDeployTemplate({
130
+ workerSubscriptions: context.workerSubscriptions,
131
+ });
132
+ if (options.dryRunTemplate) {
133
+ printDryRun(templateBody, context);
134
+ return;
135
+ }
136
+ await runWithProfile(options.profile, async () => {
137
+ await executeDeploy(templateBody, context);
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Build normalized deploy context.
143
+ *
144
+ * @since 1.0.0
145
+ * @category AWS.Deploy
146
+ */
147
+ async function buildDeployContext(
148
+ serviceArg: string | undefined,
149
+ envArg: string | undefined,
150
+ options: DeployOptions,
151
+ ): Promise<DeployContext> {
152
+ const rootDir = options.dir ?? process.cwd();
153
+ const defaults = resolveDeployDefaults(rootDir);
154
+ const service = serviceArg ?? defaults.service;
155
+ const env = envArg ?? defaults.env;
156
+ const region = options.region ?? defaults.region;
157
+ const workers = await loadWorkers(rootDir);
158
+ if ((options.strictWorkers ?? false) && workers.length === 0) {
159
+ throw new Error("no workers found in .skipper/worker/*.ts");
160
+ }
161
+ const workerSubscriptions = collectGithubEventSubscriptions(workers);
162
+ const encodedWorkers = encodeWorkerManifest({ workers });
163
+ const repositoryFullName = await resolveGithubRepo(options.githubRepo, process.env, rootDir);
164
+ if (!repositoryFullName) {
165
+ throw new Error("github repo not found from current repo; pass --github-repo");
166
+ }
167
+ const repositoryPrefix = toRepositoryPrefix(repositoryFullName);
168
+ const githubToken = await resolveOptionalGithubToken(rootDir);
169
+ const stackName =
170
+ options.stackName ?? buildRepoScopedStackName(repositoryPrefix, service, env);
171
+ const bootstrapStackName =
172
+ options.bootstrapStackName ?? `${service}-${env}-bootstrap`;
173
+ const shared = await resolveSharedDeployConfig({
174
+ region,
175
+ bootstrapStackName,
176
+ eventBusName: options.eventBusName,
177
+ eventSource: options.eventSource,
178
+ eventDetailType: options.eventDetailType,
179
+ service,
180
+ });
181
+ return {
182
+ rootDir,
183
+ service,
184
+ env,
185
+ region,
186
+ stackName,
187
+ bootstrapStackName,
188
+ repositoryFullName,
189
+ repositoryPrefix,
190
+ workerCount: encodedWorkers.workerCount,
191
+ workerIds: workers.map((worker) => worker.metadata.id),
192
+ workerSubscriptions,
193
+ workerManifestByteLength: encodedWorkers.byteLength,
194
+ workerParameterValues: encodedWorkers.parameterValues,
195
+ workerEvents: collectGithubEventsFromWorkers(workers),
196
+ timeoutMinutes: options.timeoutMinutes,
197
+ tags: parseTags(options.tags),
198
+ eventBusName: shared.eventBusName,
199
+ eventSource: shared.eventSource,
200
+ eventDetailType: shared.eventDetailType,
201
+ ecsClusterArn: shared.ecsClusterArn,
202
+ ecsTaskDefinitionArn: shared.ecsTaskDefinitionArn,
203
+ ecsTaskExecutionRoleArn: shared.ecsTaskExecutionRoleArn,
204
+ ecsTaskRoleArn: shared.ecsTaskRoleArn,
205
+ ecsSecurityGroupId: shared.ecsSecurityGroupId,
206
+ ecsSubnetIdsCsv: shared.ecsSubnetIdsCsv,
207
+ webhookSecretParameterName: shared.webhookSecretParameterName,
208
+ lambdaArtifactsBucketName: shared.lambdaArtifactsBucketName,
209
+ githubToken,
210
+ };
211
+ }
212
+
213
+ type SharedDeployConfigInput = {
214
+ region: string;
215
+ bootstrapStackName: string;
216
+ eventBusName?: string;
217
+ eventSource?: string;
218
+ eventDetailType?: string;
219
+ service: string;
220
+ };
221
+
222
+ type SharedDeployConfig = {
223
+ eventBusName: string;
224
+ eventSource: string;
225
+ eventDetailType: string;
226
+ ecsClusterArn: string;
227
+ ecsTaskDefinitionArn: string;
228
+ ecsTaskExecutionRoleArn: string;
229
+ ecsTaskRoleArn: string;
230
+ ecsSecurityGroupId: string;
231
+ ecsSubnetIdsCsv: string;
232
+ webhookSecretParameterName: string;
233
+ lambdaArtifactsBucketName: string;
234
+ };
235
+
236
+ /**
237
+ * Resolve shared event settings from bootstrap stack and overrides.
238
+ *
239
+ * @since 1.0.0
240
+ * @category AWS.Deploy
241
+ */
242
+ async function resolveSharedDeployConfig(
243
+ input: SharedDeployConfigInput,
244
+ ): Promise<SharedDeployConfig> {
245
+ const outputs = await readStackOutputs(input.region, input.bootstrapStackName);
246
+ const eventBusName =
247
+ input.eventBusName ?? readOutput(outputs, "EventBusName");
248
+ if (!eventBusName) {
249
+ throw new Error(
250
+ `Missing EventBusName; run aws bootstrap for ${input.bootstrapStackName} or pass --event-bus-name`,
251
+ );
252
+ }
253
+ return {
254
+ eventBusName,
255
+ eventSource:
256
+ input.eventSource ??
257
+ readOutput(outputs, "EventSource") ??
258
+ `${input.service}.webhook`,
259
+ eventDetailType:
260
+ input.eventDetailType ??
261
+ readOutput(outputs, "EventDetailType") ??
262
+ "WebhookReceived",
263
+ ecsClusterArn: readRequiredOutput(outputs, "EcsClusterArn", input.bootstrapStackName),
264
+ ecsTaskDefinitionArn: readRequiredOutput(
265
+ outputs,
266
+ "EcsTaskDefinitionArn",
267
+ input.bootstrapStackName,
268
+ ),
269
+ ecsTaskExecutionRoleArn: readRequiredOutput(
270
+ outputs,
271
+ "EcsTaskExecutionRoleArn",
272
+ input.bootstrapStackName,
273
+ ),
274
+ ecsTaskRoleArn: readRequiredOutput(outputs, "EcsTaskRoleArn", input.bootstrapStackName),
275
+ ecsSecurityGroupId: readRequiredOutput(
276
+ outputs,
277
+ "EcsSecurityGroupId",
278
+ input.bootstrapStackName,
279
+ ),
280
+ ecsSubnetIdsCsv: readRequiredOutput(outputs, "EcsSubnetIdsCsv", input.bootstrapStackName),
281
+ webhookSecretParameterName: readRequiredOutput(
282
+ outputs,
283
+ "WebhookSecretParameterName",
284
+ input.bootstrapStackName,
285
+ ),
286
+ lambdaArtifactsBucketName: readRequiredOutput(
287
+ outputs,
288
+ "LambdaArtifactsBucketName",
289
+ input.bootstrapStackName,
290
+ ),
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Read stack outputs for a stack name.
296
+ *
297
+ * @since 1.0.0
298
+ * @category AWS.Deploy
299
+ */
300
+ async function readStackOutputs(region: string, stackName: string): Promise<Output[]> {
301
+ const client = new CloudFormationClient({ region });
302
+ try {
303
+ const response = await client.send(new DescribeStacksCommand({ StackName: stackName }));
304
+ return response.Stacks?.[0]?.Outputs ?? [];
305
+ } catch {
306
+ throw new Error(
307
+ `bootstrap stack not found: ${stackName}; run aws bootstrap`,
308
+ );
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Read optional output value.
314
+ *
315
+ * @since 1.0.0
316
+ * @category AWS.Deploy
317
+ */
318
+ function readOutput(outputs: Output[], key: string): string | undefined {
319
+ return outputs.find((entry) => entry.OutputKey === key)?.OutputValue;
320
+ }
321
+
322
+ /**
323
+ * Read required output value.
324
+ *
325
+ * @since 1.0.0
326
+ * @category AWS.Deploy
327
+ */
328
+ function readRequiredOutput(outputs: Output[], key: string, stackName: string): string {
329
+ const value = readOutput(outputs, key);
330
+ if (value) {
331
+ return value;
332
+ }
333
+ throw new Error(
334
+ `Missing ${key} in ${stackName}; re-run aws bootstrap to refresh shared outputs`,
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Build CloudFormation parameters for repo-scoped deploy template.
340
+ *
341
+ * @since 1.0.0
342
+ * @category AWS.Deploy
343
+ */
344
+ function createDeployTemplateParameters(
345
+ context: DeployContext,
346
+ artifact: LambdaArtifact,
347
+ ): Record<string, string> {
348
+ return {
349
+ ServiceName: context.service,
350
+ Environment: context.env,
351
+ RepositoryFullName: context.repositoryFullName,
352
+ RepositoryPrefix: context.repositoryPrefix,
353
+ EventBusName: context.eventBusName,
354
+ EventSource: context.eventSource,
355
+ EventDetailType: context.eventDetailType,
356
+ EcsClusterArn: context.ecsClusterArn,
357
+ EcsTaskDefinitionArn: context.ecsTaskDefinitionArn,
358
+ EcsTaskExecutionRoleArn: context.ecsTaskExecutionRoleArn,
359
+ EcsTaskRoleArn: context.ecsTaskRoleArn,
360
+ EcsSecurityGroupId: context.ecsSecurityGroupId,
361
+ EcsSubnetIdsCsv: context.ecsSubnetIdsCsv,
362
+ WebhookSecretParameterName: context.webhookSecretParameterName,
363
+ GitHubToken: context.githubToken ?? "",
364
+ LambdaCodeS3Bucket: artifact.bucket,
365
+ LambdaCodeS3Key: artifact.key,
366
+ ...context.workerParameterValues,
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Resolve optional GitHub token from env or gh auth.
372
+ *
373
+ * @since 1.0.0
374
+ * @category AWS.Deploy
375
+ */
376
+ async function resolveOptionalGithubToken(cwd: string): Promise<string | undefined> {
377
+ const tokenFromEnv = process.env.GITHUB_TOKEN?.trim() ?? process.env.GH_TOKEN?.trim() ?? "";
378
+ if (tokenFromEnv.length > 0) {
379
+ return tokenFromEnv;
380
+ }
381
+ const tokenFromGh = await Bun.$`gh auth token`.cwd(cwd).nothrow().text();
382
+ const normalized = tokenFromGh.trim();
383
+ if (normalized.length === 0) {
384
+ return undefined;
385
+ }
386
+ return normalized;
387
+ }
388
+
389
+ /**
390
+ * Print deploy dry-run payload.
391
+ *
392
+ * @since 1.0.0
393
+ * @category AWS.Deploy
394
+ */
395
+ function printDryRun(templateBody: string, context: DeployContext): void {
396
+ console.log(
397
+ JSON.stringify(
398
+ {
399
+ stackName: context.stackName,
400
+ bootstrapStackName: context.bootstrapStackName,
401
+ region: context.region,
402
+ repository: {
403
+ fullName: context.repositoryFullName,
404
+ prefix: context.repositoryPrefix,
405
+ },
406
+ eventBridge: {
407
+ eventBusName: context.eventBusName,
408
+ eventSource: context.eventSource,
409
+ eventDetailType: context.eventDetailType,
410
+ },
411
+ workers: {
412
+ rootDir: context.rootDir,
413
+ workerCount: context.workerCount,
414
+ workerIds: context.workerIds,
415
+ lambdaSubscriptionCount: context.workerSubscriptions.length,
416
+ lambdaSubscriptions: context.workerSubscriptions,
417
+ serializedJsonBytes: context.workerManifestByteLength,
418
+ workerParameterKeys: Object.keys(context.workerParameterValues).sort(),
419
+ events: context.workerEvents,
420
+ },
421
+ ecs: {
422
+ clusterArn: context.ecsClusterArn,
423
+ taskDefinitionArn: context.ecsTaskDefinitionArn,
424
+ taskExecutionRoleArn: context.ecsTaskExecutionRoleArn,
425
+ taskRoleArn: context.ecsTaskRoleArn,
426
+ securityGroupId: context.ecsSecurityGroupId,
427
+ subnetIdsCsv: context.ecsSubnetIdsCsv,
428
+ },
429
+ lambda: {
430
+ webhookSecretParameterName: context.webhookSecretParameterName,
431
+ artifactsBucketName: context.lambdaArtifactsBucketName,
432
+ },
433
+ template: parseUnknownJson(templateBody, "cloudformation template"),
434
+ },
435
+ null,
436
+ 2,
437
+ ),
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Build and upload Lambda artifact for deploy stack.
443
+ *
444
+ * @since 1.0.0
445
+ * @category AWS.Deploy
446
+ */
447
+ async function buildAndUploadLambdaArtifact(context: DeployContext): Promise<LambdaArtifact> {
448
+ const tempDir = await mkdtemp(join(tmpdir(), "skipper-worker-subscription-"));
449
+ const bundleFile = join(tempDir, "index.js");
450
+ const zipFile = join(tempDir, "lambda.zip");
451
+ try {
452
+ await buildLambdaBundle(bundleFile);
453
+ await Bun.$`zip -q -j ${zipFile} ${bundleFile}`;
454
+ const zipBytes = new Uint8Array(await Bun.file(zipFile).arrayBuffer());
455
+ const sha256 = createHash("sha256").update(zipBytes).digest("hex");
456
+ const key = `lambda/worker-subscription/${sha256}.zip`;
457
+ const s3 = new S3Client({ region: context.region });
458
+ await s3.send(
459
+ new PutObjectCommand({
460
+ Bucket: context.lambdaArtifactsBucketName,
461
+ Key: key,
462
+ Body: zipBytes,
463
+ ContentType: "application/zip",
464
+ }),
465
+ );
466
+ return {
467
+ bucket: context.lambdaArtifactsBucketName,
468
+ key,
469
+ sha256,
470
+ };
471
+ } finally {
472
+ await rm(tempDir, { recursive: true, force: true });
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Build EventBridge Lambda bundle to one file.
478
+ *
479
+ * @since 1.0.0
480
+ * @category AWS.Deploy
481
+ */
482
+ async function buildLambdaBundle(outfile: string): Promise<void> {
483
+ await Bun.$`bun build ${EVENTBRIDGE_LAMBDA_ENTRY} --target=node --format=cjs --minify --outfile ${outfile}`;
484
+ }
485
+
486
+ /**
487
+ * Execute deploy flow.
488
+ *
489
+ * @since 1.0.0
490
+ * @category AWS.Deploy
491
+ */
492
+ async function executeDeploy(templateBody: string, context: DeployContext): Promise<void> {
493
+ try {
494
+ const client = new CloudFormationClient({ region: context.region });
495
+ const artifact = await buildAndUploadLambdaArtifact(context);
496
+ const result = await deployStack({
497
+ client,
498
+ stackName: context.stackName,
499
+ templateBody,
500
+ parameters: createDeployTemplateParameters(context, artifact),
501
+ timeoutMinutes: context.timeoutMinutes,
502
+ tags: context.tags,
503
+ });
504
+ console.log(`Stack ${context.stackName} ${result.action === "noop" ? "unchanged" : "deployed"}`);
505
+ for (const output of result.outputs) {
506
+ if (output.OutputKey && output.OutputValue) {
507
+ console.log(`${output.OutputKey}: ${output.OutputValue}`);
508
+ }
509
+ }
510
+ } catch (error) {
511
+ await handleDeployError(error, context.stackName, context.region);
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Handle deploy failure output.
517
+ *
518
+ * @since 1.0.0
519
+ * @category AWS.Deploy
520
+ */
521
+ async function handleDeployError(
522
+ error: unknown,
523
+ stackName: string,
524
+ region: string,
525
+ ): Promise<never> {
526
+ console.error(`Deploy failed: ${error instanceof Error ? error.message : error}`);
527
+ const client = new CloudFormationClient({ region });
528
+ const details = await getFailureSummary(client, stackName).catch(() => []);
529
+ for (const line of details) {
530
+ console.error(`- ${line}`);
531
+ }
532
+ process.exit(1);
533
+ }
534
+
535
+ /**
536
+ * Run deploy body with optional AWS profile override.
537
+ *
538
+ * @since 1.0.0
539
+ * @category AWS.Deploy
540
+ */
541
+ async function runWithProfile(
542
+ profile: string | undefined,
543
+ run: () => Promise<void>,
544
+ ): Promise<void> {
545
+ const previousProfile = process.env.AWS_PROFILE;
546
+ if (profile) {
547
+ process.env.AWS_PROFILE = profile;
548
+ }
549
+ try {
550
+ await run();
551
+ } finally {
552
+ if (!profile) return;
553
+ if (previousProfile === undefined) {
554
+ delete process.env.AWS_PROFILE;
555
+ return;
556
+ }
557
+ process.env.AWS_PROFILE = previousProfile;
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Build default repo-scoped stack name with limit-safe suffix.
563
+ *
564
+ * @since 1.0.0
565
+ * @category AWS.Deploy
566
+ */
567
+ export function buildRepoScopedStackName(
568
+ repositoryPrefix: string,
569
+ service: string,
570
+ env: string,
571
+ ): string {
572
+ const base = `${repositoryPrefix}-${service}-${env}-deploy`;
573
+ if (base.length <= 128) {
574
+ return base;
575
+ }
576
+ const hash = createHash("sha1").update(base).digest("hex").slice(0, 8);
577
+ const trimmed = base.slice(0, 119).replace(/-+$/g, "");
578
+ return `${trimmed}-${hash}`;
579
+ }
580
+
581
+ /**
582
+ * Parse positive number option.
583
+ *
584
+ * @since 1.0.0
585
+ * @category AWS.Deploy
586
+ */
587
+ function parseNumber(value: string): number {
588
+ const parsed = Number.parseInt(value, 10);
589
+ if (Number.isNaN(parsed) || parsed <= 0) {
590
+ throw new Error("timeout must be positive integer");
591
+ }
592
+ return parsed;
593
+ }