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