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