@skippercorp/skipper 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }