@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,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudFormationClient,
|
|
3
|
+
CreateStackCommand,
|
|
4
|
+
DescribeStackEventsCommand,
|
|
5
|
+
DescribeStacksCommand,
|
|
6
|
+
type Output,
|
|
7
|
+
type StackEvent,
|
|
8
|
+
UpdateStackCommand,
|
|
9
|
+
} from "@aws-sdk/client-cloudformation";
|
|
10
|
+
|
|
11
|
+
const SUCCESS_STATES = new Set(["CREATE_COMPLETE", "UPDATE_COMPLETE"]);
|
|
12
|
+
const FAILURE_STATES = new Set([
|
|
13
|
+
"CREATE_FAILED",
|
|
14
|
+
"ROLLBACK_COMPLETE",
|
|
15
|
+
"ROLLBACK_FAILED",
|
|
16
|
+
"DELETE_FAILED",
|
|
17
|
+
"UPDATE_ROLLBACK_FAILED",
|
|
18
|
+
"UPDATE_ROLLBACK_COMPLETE",
|
|
19
|
+
"UPDATE_FAILED",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export type DeployStackInput = {
|
|
23
|
+
client: CloudFormationClient;
|
|
24
|
+
stackName: string;
|
|
25
|
+
templateBody: string;
|
|
26
|
+
parameters: Record<string, string>;
|
|
27
|
+
timeoutMinutes: number;
|
|
28
|
+
tags?: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DeployStackResult = {
|
|
32
|
+
action: "create" | "update" | "noop";
|
|
33
|
+
status: string;
|
|
34
|
+
outputs: Output[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify CloudFormation stack status.
|
|
39
|
+
*
|
|
40
|
+
* @since 1.0.0
|
|
41
|
+
* @category AWS.CloudFormation
|
|
42
|
+
*/
|
|
43
|
+
export function classifyStackStatus(status: string):
|
|
44
|
+
| "success"
|
|
45
|
+
| "failure"
|
|
46
|
+
| "in-progress" {
|
|
47
|
+
if (SUCCESS_STATES.has(status)) return "success";
|
|
48
|
+
if (FAILURE_STATES.has(status)) return "failure";
|
|
49
|
+
return "in-progress";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create or update stack and wait for terminal status.
|
|
54
|
+
*
|
|
55
|
+
* @since 1.0.0
|
|
56
|
+
* @category AWS.CloudFormation
|
|
57
|
+
*/
|
|
58
|
+
export async function deployStack(
|
|
59
|
+
input: DeployStackInput,
|
|
60
|
+
): Promise<DeployStackResult> {
|
|
61
|
+
const exists = await stackExists(input.client, input.stackName);
|
|
62
|
+
|
|
63
|
+
if (!exists) {
|
|
64
|
+
await input.client.send(
|
|
65
|
+
new CreateStackCommand({
|
|
66
|
+
StackName: input.stackName,
|
|
67
|
+
TemplateBody: input.templateBody,
|
|
68
|
+
Parameters: toCfnParameters(input.parameters),
|
|
69
|
+
Tags: toCfnTags(input.tags),
|
|
70
|
+
Capabilities: ["CAPABILITY_NAMED_IAM"],
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const status = await waitForTerminalStatus(
|
|
75
|
+
input.client,
|
|
76
|
+
input.stackName,
|
|
77
|
+
input.timeoutMinutes,
|
|
78
|
+
);
|
|
79
|
+
const outputs = await getOutputs(input.client, input.stackName);
|
|
80
|
+
return { action: "create", status, outputs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const current = await getStackStatus(input.client, input.stackName);
|
|
84
|
+
if (current.endsWith("_IN_PROGRESS")) {
|
|
85
|
+
throw new Error(`Stack ${input.stackName} currently ${current}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await input.client.send(
|
|
90
|
+
new UpdateStackCommand({
|
|
91
|
+
StackName: input.stackName,
|
|
92
|
+
TemplateBody: input.templateBody,
|
|
93
|
+
Parameters: toCfnParameters(input.parameters),
|
|
94
|
+
Tags: toCfnTags(input.tags),
|
|
95
|
+
Capabilities: ["CAPABILITY_NAMED_IAM"],
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
if (message.includes("No updates are to be performed")) {
|
|
101
|
+
const outputs = await getOutputs(input.client, input.stackName);
|
|
102
|
+
return { action: "noop", status: current, outputs };
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const status = await waitForTerminalStatus(
|
|
108
|
+
input.client,
|
|
109
|
+
input.stackName,
|
|
110
|
+
input.timeoutMinutes,
|
|
111
|
+
);
|
|
112
|
+
const outputs = await getOutputs(input.client, input.stackName);
|
|
113
|
+
return { action: "update", status, outputs };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build short failure summary from stack events.
|
|
118
|
+
*
|
|
119
|
+
* @since 1.0.0
|
|
120
|
+
* @category AWS.CloudFormation
|
|
121
|
+
*/
|
|
122
|
+
export async function getFailureSummary(
|
|
123
|
+
client: CloudFormationClient,
|
|
124
|
+
stackName: string,
|
|
125
|
+
): Promise<string[]> {
|
|
126
|
+
const res = await client.send(
|
|
127
|
+
new DescribeStackEventsCommand({ StackName: stackName }),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const events = (res.StackEvents ?? [])
|
|
131
|
+
.filter((event: StackEvent) => {
|
|
132
|
+
const status = event.ResourceStatus ?? "";
|
|
133
|
+
return status.includes("FAILED") || status.includes("ROLLBACK");
|
|
134
|
+
})
|
|
135
|
+
.slice(0, 10)
|
|
136
|
+
.map((event: StackEvent) => {
|
|
137
|
+
const id = event.LogicalResourceId ?? "unknown";
|
|
138
|
+
const status = event.ResourceStatus ?? "unknown";
|
|
139
|
+
const reason = event.ResourceStatusReason ?? "no reason";
|
|
140
|
+
return `${id}: ${status} - ${reason}`;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return events;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if stack already exists.
|
|
148
|
+
*
|
|
149
|
+
* @since 1.0.0
|
|
150
|
+
* @category AWS.CloudFormation
|
|
151
|
+
*/
|
|
152
|
+
async function stackExists(
|
|
153
|
+
client: CloudFormationClient,
|
|
154
|
+
stackName: string,
|
|
155
|
+
): Promise<boolean> {
|
|
156
|
+
try {
|
|
157
|
+
await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
if (message.includes("does not exist")) return false;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read stack status value.
|
|
168
|
+
*
|
|
169
|
+
* @since 1.0.0
|
|
170
|
+
* @category AWS.CloudFormation
|
|
171
|
+
*/
|
|
172
|
+
async function getStackStatus(
|
|
173
|
+
client: CloudFormationClient,
|
|
174
|
+
stackName: string,
|
|
175
|
+
): Promise<string> {
|
|
176
|
+
const res = await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
177
|
+
const stack = res.Stacks?.[0];
|
|
178
|
+
if (!stack?.StackStatus) throw new Error(`Cannot read stack status: ${stackName}`);
|
|
179
|
+
return stack.StackStatus;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Poll until stack reaches terminal status.
|
|
184
|
+
*
|
|
185
|
+
* @since 1.0.0
|
|
186
|
+
* @category AWS.CloudFormation
|
|
187
|
+
*/
|
|
188
|
+
async function waitForTerminalStatus(
|
|
189
|
+
client: CloudFormationClient,
|
|
190
|
+
stackName: string,
|
|
191
|
+
timeoutMinutes: number,
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
const deadline = Date.now() + timeoutMinutes * 60_000;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const status = await getStackStatus(client, stackName);
|
|
196
|
+
const kind = classifyStackStatus(status);
|
|
197
|
+
if (kind === "success") return status;
|
|
198
|
+
if (kind === "failure") {
|
|
199
|
+
throw new Error(`Stack failed: ${status}`);
|
|
200
|
+
}
|
|
201
|
+
await Bun.sleep(5000);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Timed out waiting for stack ${stackName}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read stack outputs list.
|
|
208
|
+
*
|
|
209
|
+
* @since 1.0.0
|
|
210
|
+
* @category AWS.CloudFormation
|
|
211
|
+
*/
|
|
212
|
+
async function getOutputs(
|
|
213
|
+
client: CloudFormationClient,
|
|
214
|
+
stackName: string,
|
|
215
|
+
): Promise<Output[]> {
|
|
216
|
+
const res = await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
217
|
+
const stack = res.Stacks?.[0];
|
|
218
|
+
return stack?.Outputs ?? [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Convert parameters map to CloudFormation list.
|
|
223
|
+
*
|
|
224
|
+
* @since 1.0.0
|
|
225
|
+
* @category AWS.CloudFormation
|
|
226
|
+
*/
|
|
227
|
+
function toCfnParameters(parameters: Record<string, string>) {
|
|
228
|
+
return Object.entries(parameters).map(([ParameterKey, ParameterValue]) => ({
|
|
229
|
+
ParameterKey,
|
|
230
|
+
ParameterValue,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Convert tags map to CloudFormation list.
|
|
236
|
+
*
|
|
237
|
+
* @since 1.0.0
|
|
238
|
+
* @category AWS.CloudFormation
|
|
239
|
+
*/
|
|
240
|
+
function toCfnTags(tags?: Record<string, string>) {
|
|
241
|
+
if (!tags) return undefined;
|
|
242
|
+
return Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
|
|
243
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ENV = "sandbox";
|
|
4
|
+
const DEFAULT_REGION = "us-east-1";
|
|
5
|
+
|
|
6
|
+
export type DeployDefaults = {
|
|
7
|
+
service: string;
|
|
8
|
+
env: string;
|
|
9
|
+
region: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve default deploy inputs from cwd + env.
|
|
14
|
+
*
|
|
15
|
+
* @since 1.0.0
|
|
16
|
+
* @category AWS.Defaults
|
|
17
|
+
*/
|
|
18
|
+
export function resolveDeployDefaults(
|
|
19
|
+
cwd = process.cwd(),
|
|
20
|
+
env: Record<string, string | undefined> = process.env,
|
|
21
|
+
): DeployDefaults {
|
|
22
|
+
const service =
|
|
23
|
+
toSimpleName(env.SKIPPER_AWS_SERVICE ?? "") ||
|
|
24
|
+
toSimpleName(basename(cwd)) ||
|
|
25
|
+
"skipper";
|
|
26
|
+
const deployEnv =
|
|
27
|
+
toSimpleName(env.SKIPPER_AWS_ENV ?? "") ||
|
|
28
|
+
toSimpleName(env.AWS_PROFILE ?? "") ||
|
|
29
|
+
DEFAULT_ENV;
|
|
30
|
+
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? DEFAULT_REGION;
|
|
31
|
+
return { service, env: deployEnv, region };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate simple slug-like value.
|
|
36
|
+
*
|
|
37
|
+
* @since 1.0.0
|
|
38
|
+
* @category AWS.Defaults
|
|
39
|
+
*/
|
|
40
|
+
export function isSimpleName(value: string): boolean {
|
|
41
|
+
return /^[a-zA-Z0-9-]+$/.test(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse webhook events CSV.
|
|
46
|
+
*
|
|
47
|
+
* @since 1.0.0
|
|
48
|
+
* @category AWS.Defaults
|
|
49
|
+
*/
|
|
50
|
+
export function parseGithubEvents(value?: string): string[] {
|
|
51
|
+
if (!value || value.trim().length === 0) return ["*"];
|
|
52
|
+
const events = value
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((part) => part.trim())
|
|
55
|
+
.filter((part) => part.length > 0);
|
|
56
|
+
if (events.length === 0) return ["*"];
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
if (!/^[a-z0-9_*.-]+$/i.test(event)) {
|
|
59
|
+
throw new Error(`invalid github event: ${event}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return events;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse stack tags CSV.
|
|
67
|
+
*
|
|
68
|
+
* @since 1.0.0
|
|
69
|
+
* @category AWS.Defaults
|
|
70
|
+
*/
|
|
71
|
+
export function parseTags(value?: string): Record<string, string> | undefined {
|
|
72
|
+
if (!value) return undefined;
|
|
73
|
+
const entries = value
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((part) => part.trim())
|
|
76
|
+
.filter((part) => part.length > 0);
|
|
77
|
+
if (entries.length === 0) return undefined;
|
|
78
|
+
const tags: Record<string, string> = {};
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const [key, ...rest] = entry.split("=");
|
|
81
|
+
const cleanKey = key?.trim();
|
|
82
|
+
const cleanValue = rest.join("=").trim();
|
|
83
|
+
if (!cleanKey || cleanValue.length === 0) {
|
|
84
|
+
throw new Error(`invalid tag: ${entry}`);
|
|
85
|
+
}
|
|
86
|
+
tags[cleanKey] = cleanValue;
|
|
87
|
+
}
|
|
88
|
+
return tags;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Normalize value into simple name.
|
|
93
|
+
*
|
|
94
|
+
* @since 1.0.0
|
|
95
|
+
* @category AWS.Defaults
|
|
96
|
+
*/
|
|
97
|
+
function toSimpleName(value: string): string {
|
|
98
|
+
return value
|
|
99
|
+
.trim()
|
|
100
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
101
|
+
.replace(/^-+|-+$/g, "")
|
|
102
|
+
.replace(/--+/g, "-");
|
|
103
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
listWorkerChunkParameterKeys,
|
|
4
|
+
WORKERS_CHUNK_COUNT_PARAM,
|
|
5
|
+
WORKERS_ENCODING_PARAM,
|
|
6
|
+
WORKERS_SCHEMA_VERSION_PARAM,
|
|
7
|
+
WORKERS_SHA256_PARAM,
|
|
8
|
+
} from "../../worker/aws-params.js";
|
|
9
|
+
import type { WorkerGithubEventSubscription } from "../../worker/github-events.js";
|
|
10
|
+
|
|
11
|
+
type JsonMap = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export type BuildDeployTemplateInput = {
|
|
14
|
+
workerSubscriptions: WorkerGithubEventSubscription[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build repo-scoped deploy template JSON string.
|
|
19
|
+
*
|
|
20
|
+
* @since 1.0.0
|
|
21
|
+
* @category AWS.DeployTemplate
|
|
22
|
+
*/
|
|
23
|
+
export function buildDeployTemplate(input: BuildDeployTemplateInput): string {
|
|
24
|
+
const workerSubscriptions = [...input.workerSubscriptions].sort((left, right) =>
|
|
25
|
+
left.workerId.localeCompare(right.workerId),
|
|
26
|
+
);
|
|
27
|
+
const template = {
|
|
28
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
29
|
+
Description: "Skipper repository-scoped EventBridge subscription",
|
|
30
|
+
Parameters: buildParameters(),
|
|
31
|
+
Resources: buildResources(workerSubscriptions),
|
|
32
|
+
Outputs: buildOutputs(workerSubscriptions),
|
|
33
|
+
};
|
|
34
|
+
return JSON.stringify(template, null, 2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build deploy template parameters.
|
|
39
|
+
*
|
|
40
|
+
* @since 1.0.0
|
|
41
|
+
* @category AWS.DeployTemplate
|
|
42
|
+
*/
|
|
43
|
+
function buildParameters(): JsonMap {
|
|
44
|
+
const parameters: JsonMap = {
|
|
45
|
+
ServiceName: { Type: "String" },
|
|
46
|
+
Environment: { Type: "String" },
|
|
47
|
+
RepositoryFullName: { Type: "String" },
|
|
48
|
+
RepositoryPrefix: { Type: "String" },
|
|
49
|
+
EventBusName: { Type: "String" },
|
|
50
|
+
EventSource: { Type: "String" },
|
|
51
|
+
EventDetailType: { Type: "String" },
|
|
52
|
+
EcsClusterArn: { Type: "String" },
|
|
53
|
+
EcsTaskDefinitionArn: { Type: "String" },
|
|
54
|
+
EcsSecurityGroupId: { Type: "String" },
|
|
55
|
+
EcsSubnetIdsCsv: { Type: "String" },
|
|
56
|
+
EcsTaskExecutionRoleArn: { Type: "String" },
|
|
57
|
+
EcsTaskRoleArn: { Type: "String" },
|
|
58
|
+
WebhookSecretParameterName: { Type: "String" },
|
|
59
|
+
GitHubToken: { Type: "String", Default: "", NoEcho: true },
|
|
60
|
+
LambdaCodeS3Bucket: { Type: "String" },
|
|
61
|
+
LambdaCodeS3Key: { Type: "String" },
|
|
62
|
+
[WORKERS_ENCODING_PARAM]: { Type: "String", Default: "" },
|
|
63
|
+
[WORKERS_SHA256_PARAM]: { Type: "String", Default: "" },
|
|
64
|
+
[WORKERS_SCHEMA_VERSION_PARAM]: { Type: "String", Default: "1" },
|
|
65
|
+
[WORKERS_CHUNK_COUNT_PARAM]: { Type: "Number", Default: 0 },
|
|
66
|
+
};
|
|
67
|
+
for (const key of listWorkerChunkParameterKeys()) {
|
|
68
|
+
parameters[key] = { Type: "String", Default: "" };
|
|
69
|
+
}
|
|
70
|
+
return parameters;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build deploy template resources.
|
|
75
|
+
*
|
|
76
|
+
* @since 1.0.0
|
|
77
|
+
* @category AWS.DeployTemplate
|
|
78
|
+
*/
|
|
79
|
+
function buildResources(workerSubscriptions: WorkerGithubEventSubscription[]): JsonMap {
|
|
80
|
+
const resources: JsonMap = {
|
|
81
|
+
...buildSharedResources(),
|
|
82
|
+
};
|
|
83
|
+
for (const subscription of workerSubscriptions) {
|
|
84
|
+
Object.assign(resources, buildWorkerResources(subscription));
|
|
85
|
+
}
|
|
86
|
+
return resources;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build resources shared by all worker Lambda subscriptions.
|
|
91
|
+
*
|
|
92
|
+
* @since 1.0.0
|
|
93
|
+
* @category AWS.DeployTemplate
|
|
94
|
+
*/
|
|
95
|
+
function buildSharedResources(): JsonMap {
|
|
96
|
+
return {
|
|
97
|
+
RepositoryWorkerLambdaRole: {
|
|
98
|
+
Type: "AWS::IAM::Role",
|
|
99
|
+
Properties: {
|
|
100
|
+
AssumeRolePolicyDocument: {
|
|
101
|
+
Version: "2012-10-17",
|
|
102
|
+
Statement: [
|
|
103
|
+
{
|
|
104
|
+
Effect: "Allow",
|
|
105
|
+
Principal: { Service: "lambda.amazonaws.com" },
|
|
106
|
+
Action: "sts:AssumeRole",
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
Policies: [
|
|
111
|
+
{
|
|
112
|
+
PolicyName: "repository-worker-lambda",
|
|
113
|
+
PolicyDocument: {
|
|
114
|
+
Version: "2012-10-17",
|
|
115
|
+
Statement: [
|
|
116
|
+
{
|
|
117
|
+
Effect: "Allow",
|
|
118
|
+
Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
|
|
119
|
+
Resource: {
|
|
120
|
+
"Fn::Sub":
|
|
121
|
+
"arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*:*",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
Effect: "Allow",
|
|
126
|
+
Action: ["ecs:RunTask"],
|
|
127
|
+
Resource: { Ref: "EcsTaskDefinitionArn" },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
Effect: "Allow",
|
|
131
|
+
Action: ["iam:PassRole"],
|
|
132
|
+
Resource: [{ Ref: "EcsTaskExecutionRoleArn" }, { Ref: "EcsTaskRoleArn" }],
|
|
133
|
+
Condition: {
|
|
134
|
+
StringEquals: {
|
|
135
|
+
"iam:PassedToService": "ecs-tasks.amazonaws.com",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
Effect: "Allow",
|
|
141
|
+
Action: ["cloudformation:DescribeStacks"],
|
|
142
|
+
Resource: {
|
|
143
|
+
"Fn::Sub":
|
|
144
|
+
"arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build resources for one worker-specific Lambda subscription.
|
|
158
|
+
*
|
|
159
|
+
* @since 1.0.0
|
|
160
|
+
* @category AWS.DeployTemplate
|
|
161
|
+
*/
|
|
162
|
+
function buildWorkerResources(subscription: WorkerGithubEventSubscription): JsonMap {
|
|
163
|
+
const eventNames = subscription.events
|
|
164
|
+
.map((event) => event.trim())
|
|
165
|
+
.filter((event) => event.length > 0);
|
|
166
|
+
if (eventNames.length === 0) {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
const logicalPrefix = buildWorkerLogicalId(subscription.workerId);
|
|
170
|
+
const logGroupLogicalId = `${logicalPrefix}LambdaLogGroup`;
|
|
171
|
+
const lambdaLogicalId = `${logicalPrefix}LambdaFunction`;
|
|
172
|
+
const eventRuleLogicalId = `${logicalPrefix}EventRule`;
|
|
173
|
+
const permissionLogicalId = `${logicalPrefix}LambdaInvokePermission`;
|
|
174
|
+
const targetId = `worker-${sha1Suffix(subscription.workerId).slice(0, 12)}`;
|
|
175
|
+
return {
|
|
176
|
+
[logGroupLogicalId]: {
|
|
177
|
+
Type: "AWS::Logs::LogGroup",
|
|
178
|
+
Properties: {
|
|
179
|
+
LogGroupName: {
|
|
180
|
+
"Fn::Sub": `/aws/lambda/\${${lambdaLogicalId}}`,
|
|
181
|
+
},
|
|
182
|
+
RetentionInDays: 14,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
[lambdaLogicalId]: {
|
|
186
|
+
Type: "AWS::Lambda::Function",
|
|
187
|
+
Properties: {
|
|
188
|
+
Runtime: "nodejs20.x",
|
|
189
|
+
Handler: "index.handler",
|
|
190
|
+
Role: { "Fn::GetAtt": ["RepositoryWorkerLambdaRole", "Arn"] },
|
|
191
|
+
Timeout: 30,
|
|
192
|
+
MemorySize: 512,
|
|
193
|
+
Code: {
|
|
194
|
+
S3Bucket: { Ref: "LambdaCodeS3Bucket" },
|
|
195
|
+
S3Key: { Ref: "LambdaCodeS3Key" },
|
|
196
|
+
},
|
|
197
|
+
Environment: {
|
|
198
|
+
Variables: {
|
|
199
|
+
ECS_CLUSTER_ARN: { Ref: "EcsClusterArn" },
|
|
200
|
+
ECS_TASK_DEFINITION_ARN: { Ref: "EcsTaskDefinitionArn" },
|
|
201
|
+
ECS_SECURITY_GROUP_ID: { Ref: "EcsSecurityGroupId" },
|
|
202
|
+
ECS_SUBNET_IDS: { Ref: "EcsSubnetIdsCsv" },
|
|
203
|
+
WORKERS_STACK_NAME: { Ref: "AWS::StackName" },
|
|
204
|
+
WORKERS_ENCODING: { Ref: WORKERS_ENCODING_PARAM },
|
|
205
|
+
WORKERS_SHA256: { Ref: WORKERS_SHA256_PARAM },
|
|
206
|
+
WORKERS_SCHEMA_VERSION: { Ref: WORKERS_SCHEMA_VERSION_PARAM },
|
|
207
|
+
WORKERS_CHUNK_COUNT: {
|
|
208
|
+
"Fn::Join": ["", [{ Ref: WORKERS_CHUNK_COUNT_PARAM }]],
|
|
209
|
+
},
|
|
210
|
+
WEBHOOK_SECRET: {
|
|
211
|
+
"Fn::Sub":
|
|
212
|
+
"{{resolve:ssm:${WebhookSecretParameterName}}}",
|
|
213
|
+
},
|
|
214
|
+
GITHUB_TOKEN: { Ref: "GitHubToken" },
|
|
215
|
+
SKIPPER_WORKER_ID: subscription.workerId,
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
[eventRuleLogicalId]: {
|
|
221
|
+
Type: "AWS::Events::Rule",
|
|
222
|
+
Properties: {
|
|
223
|
+
Description: `Skipper worker ${subscription.workerId} repository subscription`,
|
|
224
|
+
EventBusName: { Ref: "EventBusName" },
|
|
225
|
+
State: "ENABLED",
|
|
226
|
+
EventPattern: {
|
|
227
|
+
source: [{ Ref: "EventSource" }],
|
|
228
|
+
"detail-type": [{ Ref: "EventDetailType" }],
|
|
229
|
+
detail: {
|
|
230
|
+
repository: {
|
|
231
|
+
full_name: [{ Ref: "RepositoryFullName" }],
|
|
232
|
+
},
|
|
233
|
+
headers: {
|
|
234
|
+
"x-github-event": eventNames,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
Targets: [
|
|
239
|
+
{
|
|
240
|
+
Arn: { "Fn::GetAtt": [lambdaLogicalId, "Arn"] },
|
|
241
|
+
Id: targetId,
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
[permissionLogicalId]: {
|
|
247
|
+
Type: "AWS::Lambda::Permission",
|
|
248
|
+
Properties: {
|
|
249
|
+
FunctionName: { Ref: lambdaLogicalId },
|
|
250
|
+
Action: "lambda:InvokeFunction",
|
|
251
|
+
Principal: "events.amazonaws.com",
|
|
252
|
+
SourceArn: { "Fn::GetAtt": [eventRuleLogicalId, "Arn"] },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build deploy template outputs.
|
|
260
|
+
*
|
|
261
|
+
* @since 1.0.0
|
|
262
|
+
* @category AWS.DeployTemplate
|
|
263
|
+
*/
|
|
264
|
+
function buildOutputs(workerSubscriptions: WorkerGithubEventSubscription[]): JsonMap {
|
|
265
|
+
return {
|
|
266
|
+
RepositoryFullName: { Value: { Ref: "RepositoryFullName" } },
|
|
267
|
+
WorkerSubscriptionCount: {
|
|
268
|
+
Value: String(workerSubscriptions.length),
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build stable CloudFormation logical id prefix from worker id.
|
|
275
|
+
*
|
|
276
|
+
* @since 1.0.0
|
|
277
|
+
* @category AWS.DeployTemplate
|
|
278
|
+
*/
|
|
279
|
+
function buildWorkerLogicalId(workerId: string): string {
|
|
280
|
+
const base = toPascalCase(workerId).slice(0, 40);
|
|
281
|
+
return `Worker${base}${sha1Suffix(workerId).slice(0, 8)}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Convert kebab/slug string to PascalCase alnum value.
|
|
286
|
+
*
|
|
287
|
+
* @since 1.0.0
|
|
288
|
+
* @category AWS.DeployTemplate
|
|
289
|
+
*/
|
|
290
|
+
function toPascalCase(value: string): string {
|
|
291
|
+
const parts = value.split(/[^a-zA-Z0-9]+/).filter((part) => part.length > 0);
|
|
292
|
+
if (parts.length === 0) {
|
|
293
|
+
return "Worker";
|
|
294
|
+
}
|
|
295
|
+
return parts
|
|
296
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
|
|
297
|
+
.join("");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Build stable sha1 hex suffix.
|
|
302
|
+
*
|
|
303
|
+
* @since 1.0.0
|
|
304
|
+
* @category AWS.DeployTemplate
|
|
305
|
+
*/
|
|
306
|
+
function sha1Suffix(value: string): string {
|
|
307
|
+
return createHash("sha1").update(value).digest("hex");
|
|
308
|
+
}
|