@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,782 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudFormationClient,
|
|
3
|
+
DescribeStacksCommand,
|
|
4
|
+
type Output,
|
|
5
|
+
} from "@aws-sdk/client-cloudformation";
|
|
6
|
+
import {
|
|
7
|
+
CloudWatchLogsClient,
|
|
8
|
+
GetLogEventsCommand,
|
|
9
|
+
} from "@aws-sdk/client-cloudwatch-logs";
|
|
10
|
+
import {
|
|
11
|
+
DescribeTasksCommand,
|
|
12
|
+
ECSClient,
|
|
13
|
+
ListTasksCommand,
|
|
14
|
+
StopTaskCommand,
|
|
15
|
+
type KeyValuePair,
|
|
16
|
+
type Task,
|
|
17
|
+
} from "@aws-sdk/client-ecs";
|
|
18
|
+
import {
|
|
19
|
+
isRecord,
|
|
20
|
+
parseJson,
|
|
21
|
+
readOptionalString,
|
|
22
|
+
} from "../../shared/validation/parse-json.js";
|
|
23
|
+
import { isSimpleName, resolveDeployDefaults } from "./defaults.js";
|
|
24
|
+
import { buildRepoScopedStackName } from "./deploy.js";
|
|
25
|
+
import { resolveGithubRepo, toRepositoryPrefix } from "./github.js";
|
|
26
|
+
|
|
27
|
+
export type VerifyIssueSubscriptionOptions = {
|
|
28
|
+
service?: string;
|
|
29
|
+
env?: string;
|
|
30
|
+
region?: string;
|
|
31
|
+
profile?: string;
|
|
32
|
+
dir?: string;
|
|
33
|
+
githubRepo?: string;
|
|
34
|
+
bootstrapStackName?: string;
|
|
35
|
+
timeoutMinutes?: number;
|
|
36
|
+
pollSeconds?: number;
|
|
37
|
+
issueTitlePrefix?: string;
|
|
38
|
+
keepIssue?: boolean;
|
|
39
|
+
keepTask?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type VerifyIssueSubscriptionContext = {
|
|
43
|
+
rootDir: string;
|
|
44
|
+
service: string;
|
|
45
|
+
env: string;
|
|
46
|
+
region: string;
|
|
47
|
+
repositoryFullName: string;
|
|
48
|
+
bootstrapStackName: string;
|
|
49
|
+
bootstrapStackNameExplicit: boolean;
|
|
50
|
+
deployStackName: string;
|
|
51
|
+
timeoutMs: number;
|
|
52
|
+
pollMs: number;
|
|
53
|
+
issueTitlePrefix: string;
|
|
54
|
+
keepIssue: boolean;
|
|
55
|
+
keepTask: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type BootstrapResources = {
|
|
59
|
+
clusterArn: string;
|
|
60
|
+
logGroupName: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type CreatedIssue = {
|
|
64
|
+
number: string;
|
|
65
|
+
url: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type MatchedIssueTask = {
|
|
69
|
+
taskArn: string;
|
|
70
|
+
logStreamName: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type IssueLogAnalysis = {
|
|
74
|
+
matched: boolean;
|
|
75
|
+
marker?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type GhCreatedIssueResponse = {
|
|
80
|
+
number: number;
|
|
81
|
+
html_url: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run e2e issue-subscription verification.
|
|
86
|
+
*
|
|
87
|
+
* @since 1.0.0
|
|
88
|
+
* @category AWS.VerifyIssue
|
|
89
|
+
*/
|
|
90
|
+
export async function runIssueSubscriptionE2E(
|
|
91
|
+
options: VerifyIssueSubscriptionOptions = {},
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
console.log("Running issue subscription e2e verification...");
|
|
94
|
+
const context = await buildVerifyIssueSubscriptionContext(options);
|
|
95
|
+
console.log(
|
|
96
|
+
`Config repo=${context.repositoryFullName} region=${context.region} bootstrap=${context.bootstrapStackName}`,
|
|
97
|
+
);
|
|
98
|
+
await runWithProfile(options.profile, async () => {
|
|
99
|
+
await executeVerifyIssueSubscription(context);
|
|
100
|
+
});
|
|
101
|
+
console.log("Verification completed");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build normalized e2e verification context.
|
|
106
|
+
*
|
|
107
|
+
* @since 1.0.0
|
|
108
|
+
* @category AWS.VerifyIssue
|
|
109
|
+
*/
|
|
110
|
+
async function buildVerifyIssueSubscriptionContext(
|
|
111
|
+
options: VerifyIssueSubscriptionOptions,
|
|
112
|
+
): Promise<VerifyIssueSubscriptionContext> {
|
|
113
|
+
const rootDir = options.dir ?? process.cwd();
|
|
114
|
+
const defaults = resolveDeployDefaults(rootDir);
|
|
115
|
+
const service = options.service ?? defaults.service;
|
|
116
|
+
const env = options.env ?? defaults.env;
|
|
117
|
+
const region = options.region ?? defaults.region;
|
|
118
|
+
const timeoutMinutes = options.timeoutMinutes ?? 12;
|
|
119
|
+
const pollSeconds = options.pollSeconds ?? 5;
|
|
120
|
+
assertPositiveInteger(timeoutMinutes, "timeoutMinutes");
|
|
121
|
+
assertPositiveInteger(pollSeconds, "pollSeconds");
|
|
122
|
+
if (!isSimpleName(service)) {
|
|
123
|
+
throw new Error("service must match [a-zA-Z0-9-]+");
|
|
124
|
+
}
|
|
125
|
+
if (!isSimpleName(env)) {
|
|
126
|
+
throw new Error("env must match [a-zA-Z0-9-]+");
|
|
127
|
+
}
|
|
128
|
+
const repositoryFullName = await resolveGithubRepo(options.githubRepo, process.env, rootDir);
|
|
129
|
+
if (!repositoryFullName) {
|
|
130
|
+
throw new Error("github repo not found from current repo; pass --github-repo");
|
|
131
|
+
}
|
|
132
|
+
const repositoryPrefix = toRepositoryPrefix(repositoryFullName);
|
|
133
|
+
const bootstrapStackName = options.bootstrapStackName ?? `${service}-${env}-bootstrap`;
|
|
134
|
+
const deployStackName = buildRepoScopedStackName(repositoryPrefix, service, env);
|
|
135
|
+
const issueTitlePrefix = options.issueTitlePrefix?.trim() ?? "[e2e] issue subscription verify";
|
|
136
|
+
if (issueTitlePrefix.length === 0) {
|
|
137
|
+
throw new Error("issue title prefix required");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
rootDir,
|
|
141
|
+
service,
|
|
142
|
+
env,
|
|
143
|
+
region,
|
|
144
|
+
repositoryFullName,
|
|
145
|
+
bootstrapStackName,
|
|
146
|
+
bootstrapStackNameExplicit: options.bootstrapStackName !== undefined,
|
|
147
|
+
deployStackName,
|
|
148
|
+
timeoutMs: timeoutMinutes * 60_000,
|
|
149
|
+
pollMs: pollSeconds * 1000,
|
|
150
|
+
issueTitlePrefix,
|
|
151
|
+
keepIssue: options.keepIssue ?? false,
|
|
152
|
+
keepTask: options.keepTask ?? false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run full e2e verification flow.
|
|
158
|
+
*
|
|
159
|
+
* @since 1.0.0
|
|
160
|
+
* @category AWS.VerifyIssue
|
|
161
|
+
*/
|
|
162
|
+
async function executeVerifyIssueSubscription(
|
|
163
|
+
context: VerifyIssueSubscriptionContext,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
console.log("Resolving bootstrap resources...");
|
|
166
|
+
const cloudformation = new CloudFormationClient({ region: context.region });
|
|
167
|
+
const ecs = new ECSClient({ region: context.region });
|
|
168
|
+
const logs = new CloudWatchLogsClient({ region: context.region });
|
|
169
|
+
const resources = await resolveBootstrapResources(cloudformation, context);
|
|
170
|
+
console.log(`Bootstrap resources resolved: cluster=${resources.clusterArn}`);
|
|
171
|
+
const baselineTaskArns = await listKnownTaskArns(ecs, resources.clusterArn);
|
|
172
|
+
console.log(`Baseline tasks tracked: ${baselineTaskArns.size}`);
|
|
173
|
+
const issue = await createVerificationIssue(context);
|
|
174
|
+
console.log(`Created verification issue: ${issue.url}`);
|
|
175
|
+
console.log(`Using deploy stack scope: ${context.deployStackName}`);
|
|
176
|
+
|
|
177
|
+
let matchedTask: MatchedIssueTask | undefined;
|
|
178
|
+
let verificationError: unknown;
|
|
179
|
+
try {
|
|
180
|
+
matchedTask = await waitForMatchingIssueTask({
|
|
181
|
+
ecs,
|
|
182
|
+
clusterArn: resources.clusterArn,
|
|
183
|
+
baselineTaskArns,
|
|
184
|
+
issueNumber: issue.number,
|
|
185
|
+
timeoutMs: context.timeoutMs,
|
|
186
|
+
pollMs: context.pollMs,
|
|
187
|
+
});
|
|
188
|
+
console.log(`Matched ECS task: ${matchedTask.taskArn}`);
|
|
189
|
+
const logMarker = await waitForIssueFetchEvidence({
|
|
190
|
+
logs,
|
|
191
|
+
logGroupName: resources.logGroupName,
|
|
192
|
+
logStreamName: matchedTask.logStreamName,
|
|
193
|
+
repositoryFullName: context.repositoryFullName,
|
|
194
|
+
issueNumber: issue.number,
|
|
195
|
+
timeoutMs: context.timeoutMs,
|
|
196
|
+
pollMs: context.pollMs,
|
|
197
|
+
});
|
|
198
|
+
console.log(`Issue fetch evidence: ${logMarker}`);
|
|
199
|
+
console.log("E2E issue subscription verification passed");
|
|
200
|
+
} catch (error) {
|
|
201
|
+
verificationError = error;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await cleanupVerificationArtifacts(context, issue, matchedTask, resources.clusterArn, ecs);
|
|
205
|
+
if (verificationError !== undefined) {
|
|
206
|
+
throw verificationError;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve bootstrap resources needed by e2e flow.
|
|
212
|
+
*
|
|
213
|
+
* @since 1.0.0
|
|
214
|
+
* @category AWS.VerifyIssue
|
|
215
|
+
*/
|
|
216
|
+
async function resolveBootstrapResources(
|
|
217
|
+
client: CloudFormationClient,
|
|
218
|
+
context: VerifyIssueSubscriptionContext,
|
|
219
|
+
): Promise<BootstrapResources> {
|
|
220
|
+
const { outputs, stackName } = await resolveBootstrapStackOutputs(client, context);
|
|
221
|
+
if (stackName !== context.bootstrapStackName) {
|
|
222
|
+
console.log(`Bootstrap stack fallback used: ${stackName}`);
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
clusterArn: readRequiredOutput(outputs, "EcsClusterArn", stackName),
|
|
226
|
+
logGroupName: buildTaskLogGroupName(context.service, context.env),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve bootstrap stack outputs with optional name fallback.
|
|
232
|
+
*
|
|
233
|
+
* @since 1.0.0
|
|
234
|
+
* @category AWS.VerifyIssue
|
|
235
|
+
*/
|
|
236
|
+
async function resolveBootstrapStackOutputs(
|
|
237
|
+
client: CloudFormationClient,
|
|
238
|
+
context: VerifyIssueSubscriptionContext,
|
|
239
|
+
): Promise<{ outputs: Output[]; stackName: string }> {
|
|
240
|
+
const primaryOutputs = await tryReadStackOutputs(client, context.bootstrapStackName);
|
|
241
|
+
if (primaryOutputs) {
|
|
242
|
+
return {
|
|
243
|
+
outputs: primaryOutputs,
|
|
244
|
+
stackName: context.bootstrapStackName,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (context.bootstrapStackNameExplicit) {
|
|
248
|
+
throw new Error(`bootstrap stack not found: ${context.bootstrapStackName}; run aws bootstrap`);
|
|
249
|
+
}
|
|
250
|
+
const fallbackStackName = `${context.service}-${context.env}`;
|
|
251
|
+
const fallbackOutputs = await tryReadStackOutputs(client, fallbackStackName);
|
|
252
|
+
if (fallbackOutputs) {
|
|
253
|
+
return {
|
|
254
|
+
outputs: fallbackOutputs,
|
|
255
|
+
stackName: fallbackStackName,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
throw new Error(
|
|
259
|
+
`bootstrap stack not found: ${context.bootstrapStackName} or ${fallbackStackName}; run aws bootstrap`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read stack outputs or return undefined when stack is missing.
|
|
265
|
+
*
|
|
266
|
+
* @since 1.0.0
|
|
267
|
+
* @category AWS.VerifyIssue
|
|
268
|
+
*/
|
|
269
|
+
async function tryReadStackOutputs(
|
|
270
|
+
client: CloudFormationClient,
|
|
271
|
+
stackName: string,
|
|
272
|
+
): Promise<Output[] | undefined> {
|
|
273
|
+
try {
|
|
274
|
+
const response = await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
275
|
+
return response.Stacks?.[0]?.Outputs ?? [];
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Read required stack output value.
|
|
283
|
+
*
|
|
284
|
+
* @since 1.0.0
|
|
285
|
+
* @category AWS.VerifyIssue
|
|
286
|
+
*/
|
|
287
|
+
function readRequiredOutput(outputs: Output[], key: string, stackName: string): string {
|
|
288
|
+
const value = outputs.find((entry) => entry.OutputKey === key)?.OutputValue;
|
|
289
|
+
if (value) return value;
|
|
290
|
+
throw new Error(`Missing ${key} in ${stackName}; re-run aws bootstrap`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Create temporary GitHub issue used for verification.
|
|
295
|
+
*
|
|
296
|
+
* @since 1.0.0
|
|
297
|
+
* @category AWS.VerifyIssue
|
|
298
|
+
*/
|
|
299
|
+
async function createVerificationIssue(
|
|
300
|
+
context: VerifyIssueSubscriptionContext,
|
|
301
|
+
): Promise<CreatedIssue> {
|
|
302
|
+
const runId = Date.now().toString();
|
|
303
|
+
const title = `${context.issueTitlePrefix} ${runId}`;
|
|
304
|
+
const body =
|
|
305
|
+
"Automated e2e verification for issue webhook -> ECS task trigger and issue fetch.";
|
|
306
|
+
const raw = await runGhApi(
|
|
307
|
+
[
|
|
308
|
+
"--method",
|
|
309
|
+
"POST",
|
|
310
|
+
`repos/${context.repositoryFullName}/issues`,
|
|
311
|
+
"-f",
|
|
312
|
+
`title=${title}`,
|
|
313
|
+
"-f",
|
|
314
|
+
`body=${body}`,
|
|
315
|
+
],
|
|
316
|
+
context.rootDir,
|
|
317
|
+
);
|
|
318
|
+
const parsed = parseCreatedIssue(raw);
|
|
319
|
+
return {
|
|
320
|
+
number: String(parsed.number),
|
|
321
|
+
url: parsed.html_url,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Close temporary verification issue.
|
|
327
|
+
*
|
|
328
|
+
* @since 1.0.0
|
|
329
|
+
* @category AWS.VerifyIssue
|
|
330
|
+
*/
|
|
331
|
+
async function closeVerificationIssue(
|
|
332
|
+
context: VerifyIssueSubscriptionContext,
|
|
333
|
+
issue: CreatedIssue,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
await runGhApi(
|
|
336
|
+
[
|
|
337
|
+
"--method",
|
|
338
|
+
"PATCH",
|
|
339
|
+
`repos/${context.repositoryFullName}/issues/${issue.number}`,
|
|
340
|
+
"-f",
|
|
341
|
+
"state=closed",
|
|
342
|
+
],
|
|
343
|
+
context.rootDir,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Execute `gh api` and return stdout.
|
|
349
|
+
*
|
|
350
|
+
* @since 1.0.0
|
|
351
|
+
* @category AWS.VerifyIssue
|
|
352
|
+
*/
|
|
353
|
+
async function runGhApi(args: string[], cwd: string): Promise<string> {
|
|
354
|
+
const process = Bun.spawn(["gh", "api", ...args], {
|
|
355
|
+
cwd,
|
|
356
|
+
stdout: "pipe",
|
|
357
|
+
stderr: "pipe",
|
|
358
|
+
});
|
|
359
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
360
|
+
new Response(process.stdout).text(),
|
|
361
|
+
new Response(process.stderr).text(),
|
|
362
|
+
process.exited,
|
|
363
|
+
]);
|
|
364
|
+
if (exitCode !== 0) {
|
|
365
|
+
throw new Error(stderr.trim() || `gh api failed (${exitCode})`);
|
|
366
|
+
}
|
|
367
|
+
return stdout;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse GitHub issue creation response.
|
|
372
|
+
*
|
|
373
|
+
* @since 1.0.0
|
|
374
|
+
* @category AWS.VerifyIssue
|
|
375
|
+
*/
|
|
376
|
+
function parseCreatedIssue(raw: string): GhCreatedIssueResponse {
|
|
377
|
+
return parseJson(raw, isCreatedIssueResponse, "created issue response");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Validate GitHub issue creation payload.
|
|
382
|
+
*
|
|
383
|
+
* @since 1.0.0
|
|
384
|
+
* @category AWS.VerifyIssue
|
|
385
|
+
*/
|
|
386
|
+
function isCreatedIssueResponse(value: unknown): value is GhCreatedIssueResponse {
|
|
387
|
+
if (!isRecord(value)) return false;
|
|
388
|
+
if (typeof value.number !== "number") return false;
|
|
389
|
+
return typeof readOptionalString(value, "html_url") === "string";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Wait for task matching created issue payload.
|
|
394
|
+
*
|
|
395
|
+
* @since 1.0.0
|
|
396
|
+
* @category AWS.VerifyIssue
|
|
397
|
+
*/
|
|
398
|
+
async function waitForMatchingIssueTask(input: {
|
|
399
|
+
ecs: ECSClient;
|
|
400
|
+
clusterArn: string;
|
|
401
|
+
baselineTaskArns: Set<string>;
|
|
402
|
+
issueNumber: string;
|
|
403
|
+
timeoutMs: number;
|
|
404
|
+
pollMs: number;
|
|
405
|
+
}): Promise<MatchedIssueTask> {
|
|
406
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
407
|
+
while (Date.now() < deadline) {
|
|
408
|
+
const knownTaskArns = await listKnownTaskArns(input.ecs, input.clusterArn);
|
|
409
|
+
const candidateTaskArns = [...knownTaskArns].filter(
|
|
410
|
+
(taskArn) => !input.baselineTaskArns.has(taskArn),
|
|
411
|
+
);
|
|
412
|
+
if (candidateTaskArns.length > 0) {
|
|
413
|
+
const tasks = await describeTasks(input.ecs, input.clusterArn, candidateTaskArns);
|
|
414
|
+
const matched = findMatchingTask(tasks, input.issueNumber);
|
|
415
|
+
if (matched) return matched;
|
|
416
|
+
}
|
|
417
|
+
await Bun.sleep(input.pollMs);
|
|
418
|
+
}
|
|
419
|
+
throw new Error(`timed out waiting for issue task issue=${input.issueNumber}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* List currently known ECS task arns (running + stopped).
|
|
424
|
+
*
|
|
425
|
+
* @since 1.0.0
|
|
426
|
+
* @category AWS.VerifyIssue
|
|
427
|
+
*/
|
|
428
|
+
async function listKnownTaskArns(client: ECSClient, clusterArn: string): Promise<Set<string>> {
|
|
429
|
+
const running = await listAllTaskArns(client, clusterArn, "RUNNING");
|
|
430
|
+
const stopped = await listAllTaskArns(client, clusterArn, "STOPPED");
|
|
431
|
+
return new Set([...running, ...stopped]);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* List all task arns for one ECS desired status.
|
|
436
|
+
*
|
|
437
|
+
* @since 1.0.0
|
|
438
|
+
* @category AWS.VerifyIssue
|
|
439
|
+
*/
|
|
440
|
+
async function listAllTaskArns(
|
|
441
|
+
client: ECSClient,
|
|
442
|
+
clusterArn: string,
|
|
443
|
+
desiredStatus: "RUNNING" | "STOPPED",
|
|
444
|
+
): Promise<string[]> {
|
|
445
|
+
const taskArns: string[] = [];
|
|
446
|
+
let nextToken: string | undefined;
|
|
447
|
+
do {
|
|
448
|
+
const response = await client.send(
|
|
449
|
+
new ListTasksCommand({
|
|
450
|
+
cluster: clusterArn,
|
|
451
|
+
desiredStatus,
|
|
452
|
+
nextToken,
|
|
453
|
+
maxResults: 100,
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
taskArns.push(...(response.taskArns ?? []));
|
|
457
|
+
nextToken = response.nextToken;
|
|
458
|
+
} while (nextToken);
|
|
459
|
+
return taskArns;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Describe ECS tasks by arns, chunked by API limit.
|
|
464
|
+
*
|
|
465
|
+
* @since 1.0.0
|
|
466
|
+
* @category AWS.VerifyIssue
|
|
467
|
+
*/
|
|
468
|
+
async function describeTasks(
|
|
469
|
+
client: ECSClient,
|
|
470
|
+
clusterArn: string,
|
|
471
|
+
taskArns: string[],
|
|
472
|
+
): Promise<Task[]> {
|
|
473
|
+
const tasks: Task[] = [];
|
|
474
|
+
for (let index = 0; index < taskArns.length; index += 100) {
|
|
475
|
+
const chunk = taskArns.slice(index, index + 100);
|
|
476
|
+
const response = await client.send(
|
|
477
|
+
new DescribeTasksCommand({
|
|
478
|
+
cluster: clusterArn,
|
|
479
|
+
tasks: chunk,
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
tasks.push(...(response.tasks ?? []));
|
|
483
|
+
}
|
|
484
|
+
return tasks;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Find issue-opened task from described task set.
|
|
489
|
+
*
|
|
490
|
+
* @since 1.0.0
|
|
491
|
+
* @category AWS.VerifyIssue
|
|
492
|
+
*/
|
|
493
|
+
function findMatchingTask(tasks: Task[], issueNumber: string): MatchedIssueTask | undefined {
|
|
494
|
+
for (const task of tasks) {
|
|
495
|
+
const taskArn = task.taskArn;
|
|
496
|
+
if (!taskArn) continue;
|
|
497
|
+
const environment = readTaskEnvironmentEntries(task);
|
|
498
|
+
if (!isIssueTaskEnvironment(environment, issueNumber)) continue;
|
|
499
|
+
return {
|
|
500
|
+
taskArn,
|
|
501
|
+
logStreamName: buildTaskLogStreamName(taskArn),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Read merged environment entries from task overrides.
|
|
509
|
+
*
|
|
510
|
+
* @since 1.0.0
|
|
511
|
+
* @category AWS.VerifyIssue
|
|
512
|
+
*/
|
|
513
|
+
function readTaskEnvironmentEntries(task: Task): KeyValuePair[] {
|
|
514
|
+
const entries: KeyValuePair[] = [];
|
|
515
|
+
for (const override of task.overrides?.containerOverrides ?? []) {
|
|
516
|
+
entries.push(...(override.environment ?? []));
|
|
517
|
+
}
|
|
518
|
+
return entries;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Check whether task env matches issue opened payload.
|
|
523
|
+
*
|
|
524
|
+
* @since 1.0.0
|
|
525
|
+
* @category AWS.VerifyIssue
|
|
526
|
+
*/
|
|
527
|
+
export function isIssueTaskEnvironment(
|
|
528
|
+
environment: KeyValuePair[],
|
|
529
|
+
issueNumber: string,
|
|
530
|
+
): boolean {
|
|
531
|
+
const values: Record<string, string> = {};
|
|
532
|
+
for (const entry of environment) {
|
|
533
|
+
if (!entry.name || entry.value === undefined) continue;
|
|
534
|
+
values[entry.name] = entry.value;
|
|
535
|
+
}
|
|
536
|
+
return (
|
|
537
|
+
values.GITHUB_EVENT === "issues" &&
|
|
538
|
+
values.GITHUB_ACTION === "opened" &&
|
|
539
|
+
values.GITHUB_ISSUE_NUMBER === issueNumber
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Build ECS webhook log group name.
|
|
545
|
+
*
|
|
546
|
+
* @since 1.0.0
|
|
547
|
+
* @category AWS.VerifyIssue
|
|
548
|
+
*/
|
|
549
|
+
function buildTaskLogGroupName(service: string, env: string): string {
|
|
550
|
+
return `/aws/ecs/${service}-${env}-webhook`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Build ECS log stream name from task arn.
|
|
555
|
+
*
|
|
556
|
+
* @since 1.0.0
|
|
557
|
+
* @category AWS.VerifyIssue
|
|
558
|
+
*/
|
|
559
|
+
function buildTaskLogStreamName(taskArn: string): string {
|
|
560
|
+
const taskId = extractTaskId(taskArn);
|
|
561
|
+
return `ecs/webhook/${taskId}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Extract task id from ECS task arn.
|
|
566
|
+
*
|
|
567
|
+
* @since 1.0.0
|
|
568
|
+
* @category AWS.VerifyIssue
|
|
569
|
+
*/
|
|
570
|
+
function extractTaskId(taskArn: string): string {
|
|
571
|
+
const taskId = taskArn.split("/").at(-1)?.trim();
|
|
572
|
+
if (!taskId) {
|
|
573
|
+
throw new Error(`invalid task arn: ${taskArn}`);
|
|
574
|
+
}
|
|
575
|
+
return taskId;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Wait for issue fetch evidence in task logs.
|
|
580
|
+
*
|
|
581
|
+
* @since 1.0.0
|
|
582
|
+
* @category AWS.VerifyIssue
|
|
583
|
+
*/
|
|
584
|
+
async function waitForIssueFetchEvidence(input: {
|
|
585
|
+
logs: CloudWatchLogsClient;
|
|
586
|
+
logGroupName: string;
|
|
587
|
+
logStreamName: string;
|
|
588
|
+
repositoryFullName: string;
|
|
589
|
+
issueNumber: string;
|
|
590
|
+
timeoutMs: number;
|
|
591
|
+
pollMs: number;
|
|
592
|
+
}): Promise<string> {
|
|
593
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
594
|
+
let nextToken: string | undefined;
|
|
595
|
+
while (Date.now() < deadline) {
|
|
596
|
+
let events: Array<{ message?: string }> = [];
|
|
597
|
+
try {
|
|
598
|
+
const response = await input.logs.send(
|
|
599
|
+
new GetLogEventsCommand({
|
|
600
|
+
logGroupName: input.logGroupName,
|
|
601
|
+
logStreamName: input.logStreamName,
|
|
602
|
+
nextToken,
|
|
603
|
+
startFromHead: nextToken === undefined,
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
events = response.events ?? [];
|
|
607
|
+
nextToken = response.nextForwardToken ?? nextToken;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (isMissingLogStreamError(error)) {
|
|
610
|
+
await Bun.sleep(input.pollMs);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
const messages = events
|
|
616
|
+
.map((event) => event.message)
|
|
617
|
+
.filter((message): message is string => typeof message === "string");
|
|
618
|
+
const analysis = analyzeIssueTaskLogs(
|
|
619
|
+
messages,
|
|
620
|
+
input.repositoryFullName,
|
|
621
|
+
input.issueNumber,
|
|
622
|
+
);
|
|
623
|
+
if (analysis.error) {
|
|
624
|
+
throw new Error(analysis.error);
|
|
625
|
+
}
|
|
626
|
+
if (analysis.matched) {
|
|
627
|
+
return analysis.marker ?? "issue fetch marker detected";
|
|
628
|
+
}
|
|
629
|
+
await Bun.sleep(input.pollMs);
|
|
630
|
+
}
|
|
631
|
+
throw new Error(
|
|
632
|
+
`timed out waiting for issue fetch logs issue=${input.issueNumber} stream=${input.logStreamName}`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Check whether error means missing log stream/group.
|
|
638
|
+
*
|
|
639
|
+
* @since 1.0.0
|
|
640
|
+
* @category AWS.VerifyIssue
|
|
641
|
+
*/
|
|
642
|
+
function isMissingLogStreamError(error: unknown): boolean {
|
|
643
|
+
if (!isRecord(error)) return false;
|
|
644
|
+
const name = readOptionalString(error, "name");
|
|
645
|
+
return name === "ResourceNotFoundException";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Analyze log lines for issue fetch success and known failures.
|
|
650
|
+
*
|
|
651
|
+
* @since 1.0.0
|
|
652
|
+
* @category AWS.VerifyIssue
|
|
653
|
+
*/
|
|
654
|
+
export function analyzeIssueTaskLogs(
|
|
655
|
+
messages: string[],
|
|
656
|
+
repositoryFullName: string,
|
|
657
|
+
issueNumber: string,
|
|
658
|
+
): IssueLogAnalysis {
|
|
659
|
+
const ghMarker = `gh issue view ${issueNumber}`;
|
|
660
|
+
const webMarker = `WebFetch https://github.com/${repositoryFullName}/issues/${issueNumber}`;
|
|
661
|
+
const apiMarker =
|
|
662
|
+
`WebFetch https://api.github.com/repos/${repositoryFullName}/issues/${issueNumber}`;
|
|
663
|
+
for (const rawMessage of messages) {
|
|
664
|
+
const message = stripAnsi(rawMessage);
|
|
665
|
+
if (message.includes("command not found")) {
|
|
666
|
+
return {
|
|
667
|
+
matched: false,
|
|
668
|
+
error: `task runtime command failure: ${message.trim()}`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
if (
|
|
672
|
+
message.includes("gh auth login") ||
|
|
673
|
+
message.includes("GH_TOKEN environment variable")
|
|
674
|
+
) {
|
|
675
|
+
return {
|
|
676
|
+
matched: false,
|
|
677
|
+
error: `task runtime missing GitHub auth: ${message.trim()}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (
|
|
681
|
+
message.includes(ghMarker) ||
|
|
682
|
+
message.includes(webMarker) ||
|
|
683
|
+
message.includes(apiMarker)
|
|
684
|
+
) {
|
|
685
|
+
return {
|
|
686
|
+
matched: true,
|
|
687
|
+
marker: message.trim(),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return { matched: false };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Strip ANSI escape sequences from one log line.
|
|
696
|
+
*
|
|
697
|
+
* @since 1.0.0
|
|
698
|
+
* @category AWS.VerifyIssue
|
|
699
|
+
*/
|
|
700
|
+
function stripAnsi(value: string): string {
|
|
701
|
+
return value.replace(/\u001b\[[0-9;]*m/g, "");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Clean up temporary issue/task artifacts.
|
|
706
|
+
*
|
|
707
|
+
* @since 1.0.0
|
|
708
|
+
* @category AWS.VerifyIssue
|
|
709
|
+
*/
|
|
710
|
+
async function cleanupVerificationArtifacts(
|
|
711
|
+
context: VerifyIssueSubscriptionContext,
|
|
712
|
+
issue: CreatedIssue,
|
|
713
|
+
matchedTask: MatchedIssueTask | undefined,
|
|
714
|
+
clusterArn: string,
|
|
715
|
+
ecs: ECSClient,
|
|
716
|
+
): Promise<void> {
|
|
717
|
+
if (!context.keepTask && matchedTask) {
|
|
718
|
+
try {
|
|
719
|
+
await ecs.send(
|
|
720
|
+
new StopTaskCommand({
|
|
721
|
+
cluster: clusterArn,
|
|
722
|
+
task: matchedTask.taskArn,
|
|
723
|
+
reason: "issue subscription verification complete",
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
console.log(`Stopped verification task: ${matchedTask.taskArn}`);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
console.warn(
|
|
729
|
+
`Failed stopping task ${matchedTask.taskArn}: ${error instanceof Error ? error.message : String(error)}`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (!context.keepIssue) {
|
|
734
|
+
try {
|
|
735
|
+
await closeVerificationIssue(context, issue);
|
|
736
|
+
console.log(`Closed verification issue: ${issue.url}`);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
console.warn(
|
|
739
|
+
`Failed closing issue ${issue.url}: ${error instanceof Error ? error.message : String(error)}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Assert positive integer value.
|
|
747
|
+
*
|
|
748
|
+
* @since 1.0.0
|
|
749
|
+
* @category AWS.VerifyIssue
|
|
750
|
+
*/
|
|
751
|
+
function assertPositiveInteger(value: number, label: string): void {
|
|
752
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
753
|
+
throw new Error(`${label} must be positive integer`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Run body with optional AWS profile override.
|
|
759
|
+
*
|
|
760
|
+
* @since 1.0.0
|
|
761
|
+
* @category AWS.VerifyIssue
|
|
762
|
+
*/
|
|
763
|
+
async function runWithProfile(
|
|
764
|
+
profile: string | undefined,
|
|
765
|
+
run: () => Promise<void>,
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
const previousProfile = process.env.AWS_PROFILE;
|
|
768
|
+
if (profile) {
|
|
769
|
+
process.env.AWS_PROFILE = profile;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
await run();
|
|
773
|
+
} finally {
|
|
774
|
+
if (profile) {
|
|
775
|
+
if (previousProfile === undefined) {
|
|
776
|
+
delete process.env.AWS_PROFILE;
|
|
777
|
+
} else {
|
|
778
|
+
process.env.AWS_PROFILE = previousProfile;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|