carlin 1.49.13 → 1.49.15

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/dist/index.mjs ADDED
@@ -0,0 +1,4555 @@
1
+ import { builtinModules, createRequire } from "node:module";
2
+ import * as path$2 from "node:path";
3
+ import path from "node:path";
4
+ import * as fs$3 from "node:fs";
5
+ import fs, { chmodSync, createReadStream, existsSync, statSync } from "node:fs";
6
+ import yaml from "js-yaml";
7
+ import * as esbuild from "esbuild";
8
+ import AWS from "aws-sdk";
9
+ import { camelCase, constantCase, kebabCase, pascalCase } from "change-case";
10
+ import deepEqual from "deep-equal";
11
+ import deepMerge from "deepmerge";
12
+ import dotenv from "dotenv";
13
+ import findUpSync from "findup-sync";
14
+ import yargs from "yargs";
15
+ import { hideBin } from "yargs/helpers";
16
+ import log from "npmlog";
17
+ import "uglify-js";
18
+ import "prettier";
19
+ import git from "simple-git";
20
+ import * as fs$2 from "fs";
21
+ import fs$1 from "fs";
22
+ import { CloudFormationClient, CreateStackCommand, DeleteStackCommand, DescribeStackEventsCommand, DescribeStackResourceCommand, DescribeStacksCommand, UpdateStackCommand, UpdateTerminationProtectionCommand, ValidateTemplateCommand } from "@aws-sdk/client-cloudformation";
23
+ import { CopyObjectCommand, DeleteObjectsCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
24
+ import { Upload } from "@aws-sdk/lib-storage";
25
+ import { glob } from "glob";
26
+ import mime from "mime-types";
27
+ import * as path$1 from "path";
28
+ import { typescriptConfig } from "@ttoss/config";
29
+ import AdmZip from "adm-zip";
30
+ import { spawn } from "node:child_process";
31
+ //#region \0rolldown/runtime.js
32
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
33
+ //#endregion
34
+ //#region ../read-config-file/src/loadConfig.ts
35
+ const nodeRequire = createRequire(import.meta.url);
36
+ const loadConfig = (entryPoint) => {
37
+ const filename = entryPoint.split("/").pop()?.split(".")[0];
38
+ const entryFileStats = fs.statSync(entryPoint, { bigint: true });
39
+ const entryFileVersion = `${entryFileStats.mtimeNs}-${entryFileStats.size}`;
40
+ const outfile = path.resolve(process.cwd(), "out", `${filename}-${entryFileVersion}.cjs`);
41
+ const result = esbuild.buildSync({
42
+ bundle: true,
43
+ entryPoints: [entryPoint],
44
+ /**
45
+ * ttoss packages cannot be market as external because it'd break the CI.
46
+ * On CI, ttoss packages point to the TS main file, not the compiled
47
+ * ones. See more details here https://github.com/ttoss/ttoss/issues/541.
48
+ */
49
+ external: [],
50
+ format: "cjs",
51
+ outfile,
52
+ platform: "node",
53
+ target: "ES2021",
54
+ treeShaking: true
55
+ });
56
+ if (result.errors.length > 0) {
57
+ console.error("Error building config file: ", filename);
58
+ throw result.errors;
59
+ }
60
+ try {
61
+ const resolvedOutfile = nodeRequire.resolve(outfile);
62
+ delete nodeRequire.cache[resolvedOutfile];
63
+ const config = nodeRequire(resolvedOutfile);
64
+ return config.default || config.config;
65
+ } catch (error) {
66
+ console.error("Failed importing build config file: ", filename);
67
+ throw error;
68
+ }
69
+ };
70
+ //#endregion
71
+ //#region ../read-config-file/src/index.ts
72
+ const readConfigFileSync = ({ configFilePath, options }) => {
73
+ const extension = configFilePath.split(".").pop();
74
+ if (extension === "yaml" || extension === "yml") {
75
+ const file = fs.readFileSync(configFilePath, "utf8");
76
+ return yaml.load(file);
77
+ }
78
+ if (extension === "json") {
79
+ const file = fs.readFileSync(configFilePath, "utf8");
80
+ return JSON.parse(file);
81
+ }
82
+ if (extension === "js") return __require(configFilePath);
83
+ if (extension === "ts") {
84
+ let result = loadConfig(configFilePath);
85
+ if (typeof result === "function") result = result(options);
86
+ return result;
87
+ }
88
+ throw new Error("Unsupported config file extension: " + extension);
89
+ };
90
+ const readConfigFile = async ({ configFilePath, options }) => {
91
+ if (configFilePath.split(".").pop() === "ts") {
92
+ let result = loadConfig(configFilePath);
93
+ if (typeof result === "function") result = result(options);
94
+ result = await Promise.resolve(result);
95
+ return result;
96
+ }
97
+ return readConfigFileSync({
98
+ configFilePath,
99
+ options
100
+ });
101
+ };
102
+ //#endregion
103
+ //#region src/config.ts
104
+ const NAME = "carlin";
105
+ const AWS_DEFAULT_REGION = "us-east-1";
106
+ /**
107
+ * CloudFront triggers can be only in US East (N. Virginia) Region.
108
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-cloudfront-triggers
109
+ */
110
+ const CLOUDFRONT_REGION = "us-east-1";
111
+ /**
112
+ * Default Node.js runtime string.
113
+ */
114
+ const DEFAULT_NODE_RUNTIME = `nodejs24.x`;
115
+ //#endregion
116
+ //#region src/deploy/cicd/ecsTaskReportCommand.ts
117
+ const logPrefix$20 = "cicd-ecs-task-report";
118
+ /**
119
+ * This method create the payload to send to Lambda ECS task report handler.
120
+ *
121
+ * @param param.status execution status.
122
+ */
123
+ const sendEcsTaskReport = async ({ status }) => {
124
+ if (!process.env.ECS_TASK_REPORT_HANDLER_NAME) {
125
+ log.info(logPrefix$20, "ECS_TASK_REPORT_HANDLER_NAME not defined.");
126
+ return;
127
+ }
128
+ const lambda = new AWS.Lambda();
129
+ const payload = { status };
130
+ if (process.env.ECS_TASK_ARN) payload.ecsTaskArn = process.env.ECS_TASK_ARN;
131
+ if (process.env.PIPELINE_NAME) payload.pipelineName = process.env.PIPELINE_NAME;
132
+ await lambda.invokeAsync({
133
+ FunctionName: process.env.ECS_TASK_REPORT_HANDLER_NAME,
134
+ InvokeArgs: JSON.stringify(payload)
135
+ }).promise();
136
+ log.info(logPrefix$20, "Report sent.");
137
+ };
138
+ const options$7 = { status: {
139
+ choices: [
140
+ "Approved",
141
+ "Rejected",
142
+ "MainTagFound"
143
+ ],
144
+ demandOption: true,
145
+ type: "string"
146
+ } };
147
+ /**
148
+ * Used to send report to ECS Task Report Handler Lambda.
149
+ */
150
+ const ecsTaskReportCommand = {
151
+ command: "cicd-ecs-task-report",
152
+ describe: false,
153
+ builder: (yargs) => {
154
+ return yargs.options(options$7);
155
+ },
156
+ handler: async (args) => {
157
+ return sendEcsTaskReport(args);
158
+ }
159
+ };
160
+ //#endregion
161
+ //#region src/utils/addGroupToOptions.ts
162
+ const addGroupToOptions = (options, group) => {
163
+ Object.values(options).forEach((option) => {
164
+ option.group = group;
165
+ });
166
+ return options;
167
+ };
168
+ //#endregion
169
+ //#region src/utils/codeBuild.ts
170
+ const logPrefix$19 = "codebuild";
171
+ const WAIT_TIME = 10 * 1e3;
172
+ /**
173
+ * @param param.name name used to identify the build.
174
+ */
175
+ const waitCodeBuildFinish = async ({ buildId, name }) => {
176
+ const codeBuild = new AWS.CodeBuild();
177
+ let result;
178
+ const checkIfBuildIsFinished = async () => {
179
+ const { builds } = await codeBuild.batchGetBuilds({ ids: [buildId] }).promise();
180
+ return new Promise((resolve, reject) => {
181
+ setTimeout(() => {
182
+ const executedBuild = builds?.find(({ id }) => {
183
+ return id === buildId;
184
+ });
185
+ log.info(logPrefix$19, `Build status of ${name || buildId}: ${executedBuild?.buildStatus}`);
186
+ if (executedBuild && executedBuild.currentPhase === "COMPLETED") {
187
+ if (executedBuild.buildStatus === "SUCCEEDED") resolve(executedBuild);
188
+ else if (["FAILED", "FAILURE"].includes(executedBuild.buildStatus || "")) reject(/* @__PURE__ */ new Error(`Cannot execute build ${buildId}.`));
189
+ }
190
+ resolve(void 0);
191
+ }, WAIT_TIME);
192
+ });
193
+ };
194
+ while (!result) result = await checkIfBuildIsFinished();
195
+ return result;
196
+ };
197
+ const startCodeBuildBuild = async ({ projectName }) => {
198
+ const { build } = await new AWS.CodeBuild().startBuild({ projectName }).promise();
199
+ if (!build) throw new Error(`Cannot start ${projectName} build`);
200
+ return build;
201
+ };
202
+ //#endregion
203
+ //#region src/utils/environmentVariables.ts
204
+ const cache = /* @__PURE__ */ new Map();
205
+ const getEnvVar = (key) => {
206
+ return cache.has(key) && cache.get(key) ? cache.get(key) : void 0;
207
+ };
208
+ const setEnvVar = (key, value) => {
209
+ if (!value) return cache.delete(key);
210
+ return cache.set(key, value);
211
+ };
212
+ //#endregion
213
+ //#region src/utils/exec.ts
214
+ log.heading = "exec";
215
+ //#endregion
216
+ //#region src/utils/getAwsAccountId.ts
217
+ const getAwsAccountId = async () => {
218
+ const { Account } = await new AWS.STS().getCallerIdentity().promise();
219
+ return Account;
220
+ };
221
+ /**
222
+ * Git current branch is used to determine the name of the stack when deploying
223
+ * resources. If we provide a `CARLIN_BRANCH` through `process.env` or by
224
+ * options, these values will be used instead of Git current branch. Example:
225
+ *
226
+ * ```
227
+ * CARLIN_BRANCH=branch-name carlin deploy --destroy
228
+ * carlin deploy --destroy --branch=branch-name
229
+ * ```
230
+ *
231
+ * This parameters is useful when you need to delete a deployment related to
232
+ * some branch but such branch has already beed deleted.
233
+ */
234
+ const getCurrentBranch = async () => {
235
+ try {
236
+ if (getEnvVar("BRANCH")) return getEnvVar("BRANCH");
237
+ const { current } = await git().branch();
238
+ return current || "";
239
+ } catch (err) {
240
+ return "";
241
+ }
242
+ };
243
+ //#endregion
244
+ //#region src/utils/getEnvironment.ts
245
+ const getEnvironment = () => {
246
+ return getEnvVar("ENVIRONMENT");
247
+ };
248
+ //#endregion
249
+ //#region src/utils/getIamPath.ts
250
+ const getIamPath = () => `/${NAME}/`;
251
+ //#endregion
252
+ //#region src/utils/packageJson.ts
253
+ const readPackageJson = () => {
254
+ const packageJsonDir = findUpSync("package.json");
255
+ if (!packageJsonDir) return {};
256
+ return JSON.parse(fs$1.readFileSync(packageJsonDir).toString());
257
+ };
258
+ const getPackageJsonProperty = ({ property }) => {
259
+ try {
260
+ return readPackageJson()[property];
261
+ } catch {
262
+ return "";
263
+ }
264
+ };
265
+ const getPackageName = () => {
266
+ return getPackageJsonProperty({ property: "name" });
267
+ };
268
+ const getPackageVersion = () => {
269
+ return getPackageJsonProperty({ property: "version" });
270
+ };
271
+ //#endregion
272
+ //#region src/utils/getProjectName.ts
273
+ /**
274
+ * This variable is used to determine the name of the whole project. If the
275
+ * project is a monorepo, the project name is considered as the
276
+ * [scope](https://docs.npmjs.com/cli/v7/using-npm/scope) of the `package.json`
277
+ * name property. If isn't a monorepo, is considered the package name.
278
+ *
279
+ * This variable is used to set some properties on CloudFormation tags and
280
+ * defining the name of some stacks, for instance, the CICD stack.
281
+ */
282
+ const getProjectName = () => {
283
+ if (getEnvVar("PROJECT")) return getEnvVar("PROJECT");
284
+ const name = getPackageName();
285
+ /**
286
+ * This case happens when user executes `carlin` outside of project.
287
+ * Even commands like `carlin --help` raise an error.
288
+ */
289
+ if (!name) return "";
290
+ try {
291
+ return pascalCase(name.split(/[@/]/)[1]);
292
+ } catch (err) {
293
+ return pascalCase(name);
294
+ }
295
+ };
296
+ //#endregion
297
+ //#region src/utils/spawn.ts
298
+ log.heading = "exec";
299
+ //#endregion
300
+ //#region src/deploy/addDefaults.cloudformation.ts
301
+ const addDefaultsParametersAndTagsToParams = async (params) => {
302
+ const branchName = await getCurrentBranch();
303
+ const environment = await getEnvironment();
304
+ const packageName = await getPackageName();
305
+ const packageVersion = await getPackageVersion();
306
+ const projectName = await getProjectName();
307
+ /**
308
+ * https://docs.aws.amazon.com/directoryservice/latest/devguide/API_Tag.html
309
+ */
310
+ const tagValuePattern = /[^a-zA-Z0-9_.:/=+\-@]/g;
311
+ return {
312
+ ...params,
313
+ Parameters: [
314
+ ...params.Parameters || [],
315
+ ...environment ? [{
316
+ ParameterKey: "Environment",
317
+ ParameterValue: environment
318
+ }] : [],
319
+ {
320
+ ParameterKey: "Project",
321
+ ParameterValue: projectName
322
+ }
323
+ ],
324
+ Tags: [
325
+ ...params.Tags || [],
326
+ {
327
+ Key: "Branch",
328
+ Value: branchName
329
+ },
330
+ ...environment ? [{
331
+ Key: "Environment",
332
+ Value: environment
333
+ }] : [],
334
+ {
335
+ Key: "Package",
336
+ Value: packageName
337
+ },
338
+ {
339
+ Key: "Project",
340
+ Value: projectName
341
+ },
342
+ {
343
+ Key: "Version",
344
+ Value: packageVersion
345
+ }
346
+ ].filter(({ Value }) => {
347
+ return !!Value;
348
+ }).map(({ Key, Value }) => {
349
+ return {
350
+ Key,
351
+ Value: Value.replace(tagValuePattern, "")
352
+ };
353
+ })
354
+ };
355
+ };
356
+ const addDefaultParametersToTemplate = async (template) => {
357
+ const [environment, projectName] = await Promise.all([getEnvironment(), getProjectName()]);
358
+ const newParameters = { Project: {
359
+ Default: projectName,
360
+ Type: "String"
361
+ } };
362
+ if (environment) newParameters.Environment = {
363
+ Default: environment,
364
+ Type: "String"
365
+ };
366
+ template.Parameters = {
367
+ ...newParameters,
368
+ ...template.Parameters
369
+ };
370
+ };
371
+ const addLogGroupToResources = (template) => {
372
+ const { Resources } = template;
373
+ const resourcesEntries = Object.entries(Resources);
374
+ for (const [key, resource] of resourcesEntries) if (["AWS::Lambda::Function", "AWS::Serverless::Function"].includes(resource.Type)) {
375
+ if (!resourcesEntries.find(([, resource2]) => {
376
+ return JSON.stringify(resource2.Properties?.LogGroupName?.["Fn::Join"] || "").includes(key);
377
+ })) Resources[`${key}LogsLogGroup`] = {
378
+ Type: "AWS::Logs::LogGroup",
379
+ DeletionPolicy: "Delete",
380
+ Properties: {
381
+ LogGroupName: { "Fn::Join": ["/", ["/aws/lambda", { Ref: key }]] },
382
+ RetentionInDays: 14
383
+ }
384
+ };
385
+ }
386
+ };
387
+ const addEnvironmentsToLambdaResources = async (template) => {
388
+ const environment = getEnvironment();
389
+ const { Resources } = template;
390
+ const resourcesEntries = Object.entries(Resources);
391
+ for (const [, resource] of resourcesEntries) if (resource.Type === "AWS::Lambda::Function") {
392
+ if (!resource.Properties) resource.Properties = {};
393
+ /**
394
+ * Lambda@Edege does not support environment variables.
395
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-lambda-function-configuration
396
+ * Then every function that has "Lambda@Edge" in its description will not
397
+ * have the variables passed to Environment.Variables.
398
+ */
399
+ if ((resource.Properties.Description || "").includes("Lambda@Edge")) continue;
400
+ if (!environment) continue;
401
+ if (!resource.Properties.Environment) resource.Properties.Environment = {};
402
+ if (!resource.Properties.Environment.Variables) resource.Properties.Environment.Variables = {};
403
+ resource.Properties.Environment.Variables.ENVIRONMENT = environment;
404
+ }
405
+ };
406
+ const CRITICAL_RESOURCES_TYPES = ["AWS::Cognito::UserPool", "AWS::DynamoDB::Table"];
407
+ /**
408
+ * Generally, critical resources are those that contain user data, such as
409
+ * Amazon Cognito user pools or DynamoDB tables. If you delete these resources,
410
+ * you might lose user data that cannot be recovered.
411
+ */
412
+ const addRetainToCriticalResources = async (template) => {
413
+ const environment = getEnvironment();
414
+ for (const [, resource] of Object.entries(template.Resources)) if (CRITICAL_RESOURCES_TYPES.includes(resource.Type)) {
415
+ if (!resource.DeletionPolicy && environment) resource.DeletionPolicy = "Retain";
416
+ }
417
+ };
418
+ /**
419
+ * Base URL for the AWS AppSync Console page.
420
+ * Format: https://console.aws.amazon.com/appsync/home?region=<region>#/<apiId>/v1/home
421
+ */
422
+ const AWS_APPSYNC_CONSOLE_BASE_URL = "https://console.aws.amazon.com/appsync/home?region=";
423
+ const addAppSyncApiOutputs = async (template) => {
424
+ for (const [key, resource] of Object.entries(template.Resources)) if (resource.Type === "AWS::AppSync::GraphQLApi") template.Outputs = {
425
+ AppSyncApiArn: {
426
+ Description: `Automatically added by ${NAME}`,
427
+ Value: { "Fn::GetAtt": [key, "Arn"] }
428
+ },
429
+ AppSyncConsoleUrl: {
430
+ Description: `Automatically added by ${NAME}`,
431
+ Value: { "Fn::Join": ["", [
432
+ AWS_APPSYNC_CONSOLE_BASE_URL,
433
+ { Ref: "AWS::Region" },
434
+ "#/",
435
+ { "Fn::GetAtt": [key, "ApiId"] },
436
+ "/v1/home"
437
+ ]] }
438
+ },
439
+ ...template.Outputs
440
+ };
441
+ };
442
+ const addDefaults = async ({ params, template }) => {
443
+ const newTemplate = JSON.parse(JSON.stringify(template));
444
+ await addDefaultParametersToTemplate(newTemplate);
445
+ await addLogGroupToResources(newTemplate);
446
+ await addEnvironmentsToLambdaResources(newTemplate);
447
+ await addAppSyncApiOutputs(newTemplate);
448
+ await addRetainToCriticalResources(newTemplate);
449
+ return {
450
+ params: await addDefaultsParametersAndTagsToParams(params),
451
+ template: newTemplate
452
+ };
453
+ };
454
+ //#endregion
455
+ //#region src/deploy/baseStack/config.ts
456
+ const pascalCaseName = pascalCase(NAME);
457
+ const BASE_STACK_NAME = `${pascalCaseName}BaseStack`;
458
+ const BASE_STACK_BUCKET_TEMPLATES_FOLDER = "cloudformation-templates";
459
+ /**
460
+ * S3 Bucket.
461
+ */
462
+ const BASE_STACK_BUCKET_LOGICAL_NAME = `${pascalCaseName}Bucket`;
463
+ const BASE_STACK_BUCKET_NAME_EXPORTED_NAME = `${pascalCaseName}BucketNameExportedName`;
464
+ /**
465
+ * CloudFront.
466
+ */
467
+ const BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_LOGICAL_NAME = `${pascalCaseName}CloudFrontFunctionAppendIndexHtml`;
468
+ const BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_ARN = `${pascalCaseName}CloudFrontFunctionAppendIndexHtmlArn`;
469
+ const BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_ARN_EXPORTED_NAME = `${pascalCaseName}CloudFrontFunctionAppendIndexHtmlArnExportedName`;
470
+ /**
471
+ * Lambda image builder.
472
+ */
473
+ const BASE_STACK_LAMBDA_IMAGE_BUILDER_LOGICAL_NAME = `${pascalCaseName}LambdaImageBuilder`;
474
+ const BASE_STACK_LAMBDA_IMAGE_BUILDER_EXPORTED_NAME = `${pascalCaseName}LambdaImageBuilderExportedName`;
475
+ /**
476
+ * Lambda layer builder.
477
+ */
478
+ const BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME = `${pascalCaseName}LambdaLayerBuilder`;
479
+ /**
480
+ * VPC
481
+ */
482
+ const BASE_STACK_VPC_ID_EXPORTED_NAME = `${pascalCaseName}VPCIDExportedName`;
483
+ const BASE_STACK_VPC_DEFAULT_SECURITY_GROUP_EXPORTED_NAME = `${pascalCaseName}DefaultSecurityGroupExportedName`;
484
+ const BASE_STACK_VPC_PUBLIC_SUBNET_0_EXPORTED_NAME = `${pascalCaseName}VPCPublicSubnet0ExportedName`;
485
+ const BASE_STACK_VPC_PUBLIC_SUBNET_1_EXPORTED_NAME = `${pascalCaseName}VPCPublicSubnet1ExportedName`;
486
+ const BASE_STACK_VPC_PUBLIC_SUBNET_2_EXPORTED_NAME = `${pascalCaseName}VPCPublicSubnet2ExportedName`;
487
+ //#endregion
488
+ //#region src/deploy/baseStack/getBaseStackResource.ts
489
+ const getBaseStackOutput = async (outputKey) => {
490
+ return (await getStackOutput({
491
+ stackName: BASE_STACK_NAME,
492
+ outputKey
493
+ })).OutputValue;
494
+ };
495
+ const resourcesKeys = {
496
+ BASE_STACK_BUCKET_LOGICAL_NAME,
497
+ BASE_STACK_LAMBDA_IMAGE_BUILDER_LOGICAL_NAME,
498
+ BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME
499
+ };
500
+ const resources = {};
501
+ const getBaseStackResource = async (resource) => {
502
+ if (!resources[resource]) resources[resource] = await getBaseStackOutput(resourcesKeys[resource]);
503
+ return resources[resource];
504
+ };
505
+ //#endregion
506
+ //#region src/deploy/config.ts
507
+ /**
508
+ * Besides saving the deploy information in the `$STACK_NAME.json` file,
509
+ * we also save in a "latest-deploy.json" file to be used by tests and other
510
+ * packages that need to know the latest deploy information.
511
+ */
512
+ const LATEST_DEPLOY_OUTPUTS_FILENAME = "latest-deploy.json";
513
+ //#endregion
514
+ //#region src/deploy/s3.ts
515
+ const logPrefix$18 = "s3";
516
+ /**
517
+ * S3 client cache to avoid creating multiple clients.
518
+ * Each client is created with different parameters.
519
+ */
520
+ const s3Clients = {};
521
+ const s3 = () => {
522
+ const s3ClientConfig = { region: getEnvVar("REGION") };
523
+ const key = JSON.stringify(s3ClientConfig);
524
+ if (!s3Clients[key]) s3Clients[key] = new S3Client(s3ClientConfig);
525
+ return s3Clients[key];
526
+ };
527
+ const getBucketKeyUrl = ({ bucket, key }) => {
528
+ return `https://s3.amazonaws.com/${bucket}/${key}`;
529
+ };
530
+ const uploadFileToS3 = async ({ bucket, contentType, file, filePath, key }) => {
531
+ if (!file && !filePath) throw new Error("file or filePath must be defined");
532
+ const params = {
533
+ Bucket: bucket,
534
+ Key: key.split(path.sep).join("/")
535
+ };
536
+ if (file) {
537
+ params.ContentType = contentType;
538
+ params.Body = file;
539
+ } else if (filePath) {
540
+ const readFile = await fs.promises.readFile(filePath);
541
+ params.ContentType = contentType || mime.contentType(path.extname(filePath)) || void 0;
542
+ params.Body = Buffer.from(readFile);
543
+ }
544
+ const result = await new Upload({
545
+ client: s3(),
546
+ params
547
+ }).done();
548
+ return {
549
+ bucket: result.Bucket,
550
+ key: result.Key,
551
+ versionId: result.VersionId,
552
+ url: getBucketKeyUrl({
553
+ bucket: result.Bucket,
554
+ key: result.Key
555
+ })
556
+ };
557
+ };
558
+ /**
559
+ * Get all files inside $directory.
560
+ */
561
+ const getAllFilesInsideADirectory = async ({ directory }) => {
562
+ return (await glob(`${directory}/**/*`)).filter((item) => {
563
+ return fs.lstatSync(item).isFile();
564
+ });
565
+ };
566
+ /**
567
+ * Docusaurus 2 has a 404.html file in the root of the build folder. This
568
+ * function copies it to 404/index.html so that it can be served by S3 and
569
+ * CloudFront.
570
+ */
571
+ const copyRoot404To404Index = async ({ bucket }) => {
572
+ try {
573
+ const headCommand = new HeadObjectCommand({
574
+ Bucket: bucket,
575
+ Key: "404.html"
576
+ });
577
+ if (await s3().send(headCommand).catch(() => {
578
+ return false;
579
+ })) {
580
+ const copyCommand = new CopyObjectCommand({
581
+ Bucket: bucket,
582
+ CopySource: `${bucket}/404.html`,
583
+ Key: "404/index.html"
584
+ });
585
+ await s3().send(copyCommand);
586
+ }
587
+ } catch (error) {
588
+ log.error(logPrefix$18, `Cannot copy 404.html to 404/index.html`);
589
+ throw error;
590
+ }
591
+ };
592
+ const uploadDirectoryToS3 = async ({ bucket, bucketKey = "", directory }) => {
593
+ log.info(logPrefix$18, `Uploading directory ${directory}/ to ${bucket}/${bucketKey}...`);
594
+ const allFiles = await getAllFilesInsideADirectory({ directory });
595
+ /**
596
+ * If the folder has no files (the folder name may be wrong), thrown an
597
+ * error. Discovered at #16 https://github.com/ttoss/carlin/issues/16.
598
+ */
599
+ if (allFiles.length === 0) throw new Error(`Directory ${directory}/ has no files.`);
600
+ const numberOfGroups = Math.ceil(allFiles.length / 63);
601
+ /**
602
+ * Divide all files and create "numberOfGroups" groups of files whose max
603
+ * length is GROUP_MAX_LENGTH.
604
+ */
605
+ const aoaOfFiles = allFiles.reduce((acc, file, index) => {
606
+ const groupIndex = index % numberOfGroups;
607
+ if (!acc[groupIndex]) acc[groupIndex] = [];
608
+ acc[index % numberOfGroups].push(file);
609
+ return acc;
610
+ }, []);
611
+ for (const [index, groupOfFiles] of aoaOfFiles.entries()) {
612
+ log.info(logPrefix$18, `Uploading group ${index + 1}/${aoaOfFiles.length}...`);
613
+ await Promise.all(groupOfFiles.map((file) => {
614
+ return uploadFileToS3({
615
+ bucket,
616
+ key: path.join(bucketKey, path.relative(directory, file)),
617
+ filePath: file
618
+ });
619
+ }));
620
+ }
621
+ };
622
+ const emptyS3Directory = async ({ bucket, directory = "" }) => {
623
+ log.info(logPrefix$18, `${bucket}/${directory} will be empty`);
624
+ try {
625
+ const listCommand = new ListObjectsV2Command({
626
+ Bucket: bucket,
627
+ Prefix: directory
628
+ });
629
+ const { Contents, IsTruncated } = await s3().send(listCommand);
630
+ if (Contents && Contents.length > 0) {
631
+ /**
632
+ * Get object versions
633
+ */
634
+ const objectsPromises = Contents.filter(({ Key }) => {
635
+ return !!Key;
636
+ }).map(async ({ Key }) => {
637
+ const listVersionsCommand = new ListObjectVersionsCommand({
638
+ Bucket: bucket,
639
+ Prefix: Key
640
+ });
641
+ const { Versions = [] } = await s3().send(listVersionsCommand);
642
+ return {
643
+ Key,
644
+ Versions: Versions.map(({ VersionId }) => {
645
+ return VersionId || void 0;
646
+ })
647
+ };
648
+ });
649
+ const objectsWithVersionsIds = (await Promise.all(objectsPromises)).reduce((acc, { Key, Versions }) => {
650
+ const objectWithVersionsIds = Versions.map((VersionId) => {
651
+ return {
652
+ Key,
653
+ VersionId
654
+ };
655
+ });
656
+ return [...acc, ...objectWithVersionsIds];
657
+ }, []);
658
+ /**
659
+ * Batch delete operations in groups of 1000 (AWS limit)
660
+ * https://stackoverflow.com/a/61474768
661
+ */
662
+ const BATCH_SIZE = 1e3;
663
+ for (let i = 0; i < objectsWithVersionsIds.length; i += BATCH_SIZE) {
664
+ const deleteCommand = new DeleteObjectsCommand({
665
+ Bucket: bucket,
666
+ Delete: { Objects: objectsWithVersionsIds.slice(i, i + BATCH_SIZE) }
667
+ });
668
+ const result = await s3().send(deleteCommand);
669
+ if (result.Errors && result.Errors.length > 0) {
670
+ const firstError = result.Errors[0];
671
+ throw new Error(`Error deleting objects from ${bucket}/${directory}: ${JSON.stringify(firstError)}`);
672
+ }
673
+ }
674
+ }
675
+ /**
676
+ * Truncated is files that exists but weren't listed from S3 API.
677
+ */
678
+ if (IsTruncated) await emptyS3Directory({
679
+ bucket,
680
+ directory
681
+ });
682
+ log.info(logPrefix$18, `${bucket}/${directory} is empty.`);
683
+ } catch (error) {
684
+ log.error(logPrefix$18, `Cannot empty ${bucket}/${directory}.`);
685
+ throw error;
686
+ }
687
+ };
688
+ /**
689
+ * Delete old S3 files based on retention period.
690
+ * Files older than the specified number of days will be deleted.
691
+ */
692
+ const deleteOldS3Files = async ({ bucket, continuationToken, directory = "", retentionDays, totalDeleted = 0 }) => {
693
+ if (!continuationToken) log.info(logPrefix$18, `Deleting files older than ${retentionDays} days from ${bucket}/${directory}...`);
694
+ try {
695
+ const listCommand = new ListObjectsV2Command({
696
+ Bucket: bucket,
697
+ Prefix: directory,
698
+ ContinuationToken: continuationToken
699
+ });
700
+ const { Contents, IsTruncated, NextContinuationToken } = await s3().send(listCommand);
701
+ let deletedCount = 0;
702
+ if (Contents && Contents.length > 0) {
703
+ const now = /* @__PURE__ */ new Date();
704
+ const retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
705
+ const oldFiles = Contents.filter(({ Key, LastModified }) => {
706
+ if (!Key || !LastModified) return false;
707
+ return now.getTime() - LastModified.getTime() > retentionMs;
708
+ }).map(({ Key }) => {
709
+ return Key;
710
+ });
711
+ if (oldFiles.length > 0) {
712
+ /**
713
+ * Batch delete operations in groups of 1000 (AWS limit)
714
+ */
715
+ const BATCH_SIZE = 1e3;
716
+ for (let i = 0; i < oldFiles.length; i += BATCH_SIZE) {
717
+ const deleteCommand = new DeleteObjectsCommand({
718
+ Bucket: bucket,
719
+ Delete: { Objects: oldFiles.slice(i, i + BATCH_SIZE).map((Key) => {
720
+ return { Key };
721
+ }) }
722
+ });
723
+ const result = await s3().send(deleteCommand);
724
+ if (result.Errors && result.Errors.length > 0) {
725
+ const firstError = result.Errors[0];
726
+ throw new Error(`Error deleting old files from ${bucket}/${directory}: ${JSON.stringify(firstError)}`);
727
+ }
728
+ }
729
+ deletedCount = oldFiles.length;
730
+ }
731
+ }
732
+ /**
733
+ * Handle pagination if results were truncated
734
+ */
735
+ if (IsTruncated && NextContinuationToken) return await deleteOldS3Files({
736
+ bucket,
737
+ continuationToken: NextContinuationToken,
738
+ directory,
739
+ retentionDays,
740
+ totalDeleted: totalDeleted + deletedCount
741
+ });
742
+ const finalTotal = totalDeleted + deletedCount;
743
+ if (finalTotal === 0) log.info(logPrefix$18, `No files older than ${retentionDays} days found in ${bucket}/${directory}`);
744
+ else log.info(logPrefix$18, `Deleted ${finalTotal} old files from ${bucket}/${directory}`);
745
+ return finalTotal;
746
+ } catch (error) {
747
+ log.error(logPrefix$18, `Cannot delete old files from ${bucket}/${directory}.`);
748
+ throw error;
749
+ }
750
+ };
751
+ //#endregion
752
+ //#region src/deploy/cloudformation.core.ts
753
+ const logPrefix$17 = "cloudformation";
754
+ log.addLevel("event", 1e4, { fg: "yellow" });
755
+ log.addLevel("output", 1e4, { fg: "blue" });
756
+ /**
757
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-limits.html
758
+ */
759
+ const TEMPLATE_BODY_MAX_SIZE = 51200;
760
+ const isTemplateBodyGreaterThanMaxSize = (template) => {
761
+ return Buffer.byteLength(JSON.stringify(template), "utf8") >= TEMPLATE_BODY_MAX_SIZE;
762
+ };
763
+ /**
764
+ * Update CloudFormation template to base stack bucket.
765
+ * @param input.stackName: CloudFormation stack name.
766
+ * @param input.template: CloudFormation template.
767
+ */
768
+ const uploadTemplateToBaseStackBucket = async ({ stackName, template }) => {
769
+ const { url } = await uploadFileToS3({
770
+ bucket: await getBaseStackResource("BASE_STACK_BUCKET_LOGICAL_NAME"),
771
+ contentType: "application/json",
772
+ key: `${BASE_STACK_BUCKET_TEMPLATES_FOLDER}/${stackName}.json`,
773
+ file: Buffer.from(JSON.stringify(template, null, 2))
774
+ });
775
+ return { url };
776
+ };
777
+ /**
778
+ * CloudFormation client cache to avoid creating multiple clients.
779
+ * Each client is created with different parameters.
780
+ */
781
+ const cloudFormationClients = {};
782
+ const cloudformation = () => {
783
+ const cloudFormationClientConfig = {
784
+ apiVersion: "2010-05-15",
785
+ region: getEnvVar("REGION")
786
+ };
787
+ const key = JSON.stringify(cloudFormationClientConfig);
788
+ if (!cloudFormationClients[key]) cloudFormationClients[key] = new CloudFormationClient({
789
+ apiVersion: "2010-05-15",
790
+ region: getEnvVar("REGION")
791
+ });
792
+ return cloudFormationClients[key];
793
+ };
794
+ const cloudFormationV2 = () => {
795
+ return new AWS.CloudFormation({ apiVersion: "2010-05-15" });
796
+ };
797
+ const describeStacks = async ({ stackName } = {}) => {
798
+ const { Stacks } = await cloudformation().send(new DescribeStacksCommand({ StackName: stackName }));
799
+ return Stacks;
800
+ };
801
+ const describeStackResource = async (input) => {
802
+ return cloudformation().send(new DescribeStackResourceCommand(input));
803
+ };
804
+ const doesStackExist = async ({ stackName }) => {
805
+ log.info(logPrefix$17, `Checking if stack ${stackName} already exists...`);
806
+ try {
807
+ await describeStacks({ stackName });
808
+ log.info(logPrefix$17, `Stack ${stackName} already exists.`);
809
+ return true;
810
+ } catch (error) {
811
+ if (error.Code === "ValidationError") {
812
+ log.info(logPrefix$17, `Stack ${stackName} does not exist.`);
813
+ return false;
814
+ }
815
+ throw error;
816
+ }
817
+ };
818
+ const describeStackEvents = async ({ stackName }) => {
819
+ log.error(logPrefix$17, "Stack events:");
820
+ const { StackEvents } = await cloudformation().send(new DescribeStackEventsCommand({ StackName: stackName }));
821
+ const events = (StackEvents || []).filter(({ Timestamp }) => {
822
+ return Date.now() - Number(Timestamp) < 600 * 1e3;
823
+ }).filter(({ ResourceStatusReason }) => {
824
+ return ResourceStatusReason;
825
+ }).reverse();
826
+ for (const { LogicalResourceId, ResourceStatusReason } of events) log.event(LogicalResourceId, ResourceStatusReason);
827
+ return events;
828
+ };
829
+ const describeStack = async ({ stackName }) => {
830
+ const stacks = await describeStacks({ stackName });
831
+ if (!stacks) throw new Error(`Stack ${stackName} not found and cannot be described.`);
832
+ return stacks[0];
833
+ };
834
+ const getStackOutput = async ({ stackName, outputKey }) => {
835
+ const { Outputs = [] } = await describeStack({ stackName });
836
+ const output = Outputs?.find(({ OutputKey }) => {
837
+ return OutputKey === outputKey;
838
+ });
839
+ if (!output) throw new Error(`Output ${outputKey} doesn't exist on ${stackName} stack`);
840
+ return output;
841
+ };
842
+ const saveEnvironmentOutput = async ({ outputs, stackName }) => {
843
+ const envFile = {
844
+ stackName,
845
+ environment: getEnvironment(),
846
+ projectName: getProjectName(),
847
+ packageName: getPackageName()
848
+ };
849
+ envFile.outputs = outputs.reduce((acc, output) => {
850
+ if (!output.OutputKey || !output) return acc;
851
+ return {
852
+ ...acc,
853
+ [output.OutputKey]: output
854
+ };
855
+ }, {});
856
+ const dotCarlinFolderPath = path$2.join(process.cwd(), ".carlin");
857
+ if (!fs$3.existsSync(dotCarlinFolderPath)) await fs$3.promises.mkdir(dotCarlinFolderPath);
858
+ const filePath = path$2.join(dotCarlinFolderPath, `${stackName}.json`);
859
+ await fs$3.promises.writeFile(filePath, JSON.stringify(envFile, null, 2));
860
+ const latestFilePath = path$2.join(dotCarlinFolderPath, LATEST_DEPLOY_OUTPUTS_FILENAME);
861
+ await fs$3.promises.writeFile(latestFilePath, JSON.stringify(envFile, null, 2));
862
+ };
863
+ /**
864
+ * After deployment, Carlin prints the outputs defined in your CloudFormation
865
+ * template and saves them in two files:
866
+ *
867
+ * 1. `.carlin/$STACK_NAME.json` file.
868
+ * 1. `.carlin/latest-deploy.json` file.
869
+ *
870
+ * _Note: The `.carlin` folder is created in the root of your project._
871
+ *
872
+ * The `latest-deploy.json` file is used by tests and other packages that need
873
+ * to access the outputs of the last deployment, but don't have access to the
874
+ * stack name. It's useful for end-to-end tests that need to access the outputs
875
+ * of the last deployment and test the application.
876
+ */
877
+ const printStackOutputsAfterDeploy = async ({ stackName }) => {
878
+ const { EnableTerminationProtection, StackName, Outputs = [] } = await describeStack({ stackName });
879
+ await saveEnvironmentOutput({
880
+ stackName,
881
+ outputs: Outputs
882
+ });
883
+ log.output("Describe Stack");
884
+ log.output("StackName", StackName);
885
+ log.output("EnableTerminationProtection", EnableTerminationProtection);
886
+ for (const { OutputKey, OutputValue, Description, ExportName } of Outputs) log.output(`${OutputKey}`, [
887
+ "",
888
+ `OutputKey: ${OutputKey}`,
889
+ `OutputValue: ${OutputValue}`,
890
+ `Description: ${Description}`,
891
+ `ExportName: ${ExportName}`,
892
+ ""
893
+ ].join("\n"));
894
+ };
895
+ const deleteStack = async ({ stackName }) => {
896
+ log.info(logPrefix$17, `Deleting stack ${stackName}...`);
897
+ await cloudformation().send(new DeleteStackCommand({ StackName: stackName }));
898
+ try {
899
+ await cloudFormationV2().waitFor("stackDeleteComplete", { StackName: stackName }).promise();
900
+ } catch (error) {
901
+ log.error(logPrefix$17, `An error occurred when deleting stack ${stackName}.`);
902
+ await describeStackEvents({ stackName });
903
+ throw error;
904
+ }
905
+ log.info(logPrefix$17, `Stack ${stackName} deleted.`);
906
+ };
907
+ const createStack = async ({ params }) => {
908
+ const { StackName: stackName = "" } = params;
909
+ log.info(logPrefix$17, `Creating stack ${stackName}...`);
910
+ await cloudformation().send(new CreateStackCommand(params));
911
+ try {
912
+ await cloudFormationV2().waitFor("stackCreateComplete", { StackName: stackName }).promise();
913
+ } catch (error) {
914
+ log.error(logPrefix$17, `An error occurred when creating stack ${stackName}.`);
915
+ await describeStackEvents({ stackName });
916
+ await deleteStack({ stackName });
917
+ throw error;
918
+ }
919
+ log.info(logPrefix$17, `Stack ${stackName} was created.`);
920
+ };
921
+ const updateStack = async ({ params }) => {
922
+ const { StackName: stackName = "" } = params;
923
+ log.info(logPrefix$17, `Updating stack ${stackName}...`);
924
+ try {
925
+ await cloudformation().send(new UpdateStackCommand(params));
926
+ await cloudFormationV2().waitFor("stackUpdateComplete", { StackName: stackName }).promise();
927
+ } catch (error) {
928
+ if (error.message === "No updates are to be performed.") {
929
+ log.info(logPrefix$17, error.message);
930
+ return;
931
+ }
932
+ log.error(logPrefix$17, "An error occurred when updating stack.");
933
+ await describeStackEvents({ stackName });
934
+ throw error;
935
+ }
936
+ log.info(logPrefix$17, `Stack ${stackName} was updated.`);
937
+ };
938
+ const enableTerminationProtection = async ({ stackName }) => {
939
+ log.info(logPrefix$17, `Enabling termination protection...`);
940
+ try {
941
+ await cloudformation().send(new UpdateTerminationProtectionCommand({
942
+ EnableTerminationProtection: true,
943
+ StackName: stackName
944
+ }));
945
+ } catch (error) {
946
+ log.error(logPrefix$17, "An error occurred when enabling termination protection");
947
+ throw error;
948
+ }
949
+ };
950
+ [
951
+ "ts",
952
+ "js",
953
+ "yaml",
954
+ "yml",
955
+ "json"
956
+ ].map((extension) => {
957
+ return `src/cloudformation.${extension}`;
958
+ });
959
+ /**
960
+ * 1. Add defaults to CloudFormation template and parameters.
961
+ * 1. Check is CloudFormation template body is greater than max size limit.
962
+ * 1. If is greater, upload to S3 base stack.
963
+ * 1. If stack exists, update the stack, else create a new stack.
964
+ * 1. If `terminationProtection` option is true or `environment` is defined,
965
+ * then stack termination protection will be enabled.
966
+ */
967
+ const deploy = async ({ terminationProtection = false, ...paramsAndTemplate }) => {
968
+ const { params, template } = await addDefaults(paramsAndTemplate);
969
+ const stackName = params.StackName;
970
+ if (!stackName) throw new Error("StackName is required");
971
+ delete params.TemplateBody;
972
+ delete params.TemplateURL;
973
+ if (isTemplateBodyGreaterThanMaxSize(template)) {
974
+ const { url } = await uploadTemplateToBaseStackBucket({
975
+ stackName,
976
+ template
977
+ });
978
+ params.TemplateURL = url;
979
+ } else params.TemplateBody = JSON.stringify(template);
980
+ /**
981
+ * CAPABILITY_AUTO_EXPAND allows serverless transform.
982
+ */
983
+ params.Capabilities = [
984
+ "CAPABILITY_AUTO_EXPAND",
985
+ "CAPABILITY_IAM",
986
+ "CAPABILITY_NAMED_IAM"
987
+ ];
988
+ if (await doesStackExist({ stackName })) await updateStack({ params });
989
+ else await createStack({ params });
990
+ if (terminationProtection || !!getEnvironment()) await enableTerminationProtection({ stackName });
991
+ await printStackOutputsAfterDeploy({ stackName });
992
+ return describeStack({ stackName });
993
+ };
994
+ const canDestroyStack = async ({ stackName }) => {
995
+ const { EnableTerminationProtection } = await describeStack({ stackName });
996
+ if (EnableTerminationProtection) return false;
997
+ return true;
998
+ };
999
+ const validateTemplate = async ({ stackName, template }) => {
1000
+ const validateTemplateCommandInput = {};
1001
+ if (isTemplateBodyGreaterThanMaxSize(template)) {
1002
+ const { url } = await uploadTemplateToBaseStackBucket({
1003
+ stackName,
1004
+ template
1005
+ });
1006
+ validateTemplateCommandInput.TemplateURL = url;
1007
+ } else validateTemplateCommandInput.TemplateBody = JSON.stringify(template);
1008
+ await cloudformation().send(new ValidateTemplateCommand(validateTemplateCommandInput));
1009
+ };
1010
+ //#endregion
1011
+ //#region src/deploy/stackName.ts
1012
+ /**
1013
+ * Used by CLI set stack name when it is defined.
1014
+ */
1015
+ const setPreDefinedStackName = (stackName) => {
1016
+ setEnvVar("STACK_NAME", stackName);
1017
+ };
1018
+ const limitStackName = (stackName) => {
1019
+ return `${stackName}`.substring(0, 100);
1020
+ };
1021
+ /**
1022
+ * Sanitizes a stack name to satisfy the CloudFormation constraint:
1023
+ * `[a-zA-Z][-a-zA-Z0-9]*`
1024
+ *
1025
+ * Steps:
1026
+ * 1. Normalize Unicode (NFKD) to decompose accented characters (e.g. ç → c + combining cedilla).
1027
+ * 2. Strip combining diacritical marks so the base letters remain.
1028
+ * 3. Replace any remaining characters that are not letters, digits, or hyphens with a hyphen.
1029
+ * 4. Collapse consecutive hyphens into a single hyphen.
1030
+ * 5. Strip leading and trailing hyphens.
1031
+ * 6. If the result is empty, use `Stack` as a fallback.
1032
+ * 7. If the result does not start with a letter, prefix it with `Stack-`.
1033
+ */
1034
+ const sanitizeStackName = (stackName) => {
1035
+ const sanitized = stackName.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
1036
+ if (!sanitized) return "Stack";
1037
+ if (!/^[a-zA-Z]/.test(sanitized)) return `Stack-${sanitized}`;
1038
+ return sanitized;
1039
+ };
1040
+ /**
1041
+ * If stack name isn't previously defined, the name will be created accordingly
1042
+ * with the following rules:
1043
+ *
1044
+ * 1. The name has to parts.
1045
+ *
1046
+ * 1. The first part is defined by the package.json name, if it is defined.
1047
+ * Else, it'll be a random name starting with the string "Stack-", e.g. **Stack-96830**.
1048
+ *
1049
+ * 1. The second part will be defined by, whichever is defined first:
1050
+ * 1. environment,
1051
+ * 1. [branch name](https://carlin.ttoss.dev/docs/CLI#branchbranch_name) in param-case,
1052
+ * 1. `undefined`.
1053
+ *
1054
+ * Example:
1055
+ *
1056
+ * | Case | Package Name | Environment | Branch Name | `--stack-name` | Stack Name |
1057
+ * | ---- | ------------ | ----------- | ---------- | -------------- | ---------- |
1058
+ * | #1 | @package/name | Production | main | MyStackName | **MyStackName** |
1059
+ * | #2 | @package/name | Production | main | | **PackageName-Production** |
1060
+ * | #3 | @package/name | | main | | **PackageName-main** |
1061
+ * | #4 | @package/name | | | | **PackageName** |
1062
+ * | #5 | | Production | main | | **Stack-96820-Production** |
1063
+ * | #6 | | | main | | **Stack-96820-main** |
1064
+ * | #7 | | | | | **Stack-96820** |
1065
+ *
1066
+ * CAUTION!!!
1067
+ *
1068
+ * This method is a BREAKING CHANGE for **carlin**, I hope we never have to
1069
+ * change this algorithm, ever. Stack name is how we track the stacks on AWS.
1070
+ * Suppose we change this algorithm. If we perform an update or destroy
1071
+ * operation, **carlin** will create another stack or do nothing because the
1072
+ * old stack won't be found due to stack name changing.
1073
+ *
1074
+ */
1075
+ const getStackName = async () => {
1076
+ if (getEnvVar("STACK_NAME")) return getEnvVar("STACK_NAME");
1077
+ const [currentBranch, environment, packageName] = await Promise.all([
1078
+ getCurrentBranch(),
1079
+ getEnvironment(),
1080
+ getPackageName()
1081
+ ]);
1082
+ return limitStackName(sanitizeStackName([packageName ? pascalCase(packageName) : `Stack-${Math.round(Math.random() * 1e5)}`, (() => {
1083
+ if (environment) return environment;
1084
+ if (currentBranch) return kebabCase(currentBranch);
1085
+ })()].filter((word) => {
1086
+ return !!word;
1087
+ }).join("-")));
1088
+ };
1089
+ //#endregion
1090
+ //#region src/deploy/utils.ts
1091
+ const deployErrorLogs = ({ error, logPrefix }) => {
1092
+ log.error(logPrefix, `An error occurred. Cannot deploy ${logPrefix}.`);
1093
+ log.error(logPrefix, "Error message: %j", error?.message);
1094
+ };
1095
+ const handleDeployError = ({ error, logPrefix }) => {
1096
+ deployErrorLogs({
1097
+ error,
1098
+ logPrefix
1099
+ });
1100
+ process.exit(1);
1101
+ };
1102
+ /**
1103
+ * @param param.stackName acts as a default stack name.
1104
+ */
1105
+ const handleDeployInitialization = async ({ logPrefix, stackName: preDefinedStackName }) => {
1106
+ log.info(logPrefix, `Starting deploy ${logPrefix}...`);
1107
+ if (preDefinedStackName) setPreDefinedStackName(preDefinedStackName);
1108
+ const stackName = await getStackName();
1109
+ log.info(logPrefix, `stackName: ${stackName}`);
1110
+ return { stackName };
1111
+ };
1112
+ //#endregion
1113
+ //#region src/deploy/baseStack/getBucketTemplate.ts
1114
+ const getBucketTemplate = () => {
1115
+ return {
1116
+ AWSTemplateFormatVersion: "2010-09-09",
1117
+ Resources: { [BASE_STACK_BUCKET_LOGICAL_NAME]: {
1118
+ Type: "AWS::S3::Bucket",
1119
+ DeletionPolicy: "Retain",
1120
+ Properties: {
1121
+ LifecycleConfiguration: { Rules: [{
1122
+ ExpirationInDays: 1,
1123
+ Prefix: BASE_STACK_BUCKET_TEMPLATES_FOLDER,
1124
+ Status: "Enabled"
1125
+ }, {
1126
+ NoncurrentVersionExpirationInDays: 3,
1127
+ Status: "Enabled"
1128
+ }] },
1129
+ /**
1130
+ * This is necessary because if we update Lambda code without change
1131
+ * CloudFormation template, the Lambda will not be updated.
1132
+ */
1133
+ VersioningConfiguration: { Status: "Enabled" }
1134
+ }
1135
+ } },
1136
+ Outputs: { [BASE_STACK_BUCKET_LOGICAL_NAME]: {
1137
+ Value: { Ref: BASE_STACK_BUCKET_LOGICAL_NAME },
1138
+ Export: { Name: BASE_STACK_BUCKET_NAME_EXPORTED_NAME }
1139
+ } }
1140
+ };
1141
+ };
1142
+ //#endregion
1143
+ //#region src/deploy/baseStack/getCloudFrontTemplate.ts
1144
+ /**
1145
+ * https://juffalow.com/blog/other/how-to-deploy-docusaurus-page-using-aws-s3-and-cloudfront#cloudfront-functions
1146
+ */
1147
+ const functionCode = `function handler(event) {
1148
+ var request = event.request;
1149
+ var uri = request.uri;
1150
+ if (uri.endsWith('/')) {
1151
+ request.uri += 'index.html';
1152
+ } else if (!uri.includes('.')) {
1153
+ request.uri += '/index.html';
1154
+ }
1155
+ return request;
1156
+ }`;
1157
+ const getCloudFrontTemplate$1 = () => {
1158
+ return {
1159
+ AWSTemplateFormatVersion: "2010-09-09",
1160
+ Resources: { [BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_LOGICAL_NAME]: {
1161
+ Type: "AWS::CloudFront::Function",
1162
+ Properties: {
1163
+ Name: "AppendIndexDotHtml",
1164
+ FunctionConfig: {
1165
+ Comment: "Append index.html to the request URI",
1166
+ Runtime: "cloudfront-js-2.0"
1167
+ },
1168
+ FunctionCode: functionCode,
1169
+ AutoPublish: true
1170
+ }
1171
+ } },
1172
+ Outputs: { [BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_ARN]: {
1173
+ Value: { "Fn::GetAtt": [BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_LOGICAL_NAME, "FunctionMetadata.FunctionARN"] },
1174
+ Export: { Name: BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_ARN_EXPORTED_NAME }
1175
+ } }
1176
+ };
1177
+ };
1178
+ //#endregion
1179
+ //#region src/deploy/baseStack/getLambdaImageBuilderTemplate.ts
1180
+ const getLambdaImageBuilderTemplate = () => {
1181
+ const CODE_BUILD_PROJECT_LOGS_LOGICAL_ID = "CodeBuildProjectLogsLogGroup";
1182
+ const CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID = "ImageCodeBuildProjectIAMRole";
1183
+ return {
1184
+ AWSTemplateFormatVersion: "2010-09-09",
1185
+ Resources: {
1186
+ [CODE_BUILD_PROJECT_LOGS_LOGICAL_ID]: {
1187
+ Type: "AWS::Logs::LogGroup",
1188
+ DeletionPolicy: "Delete",
1189
+ Properties: {}
1190
+ },
1191
+ [CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID]: {
1192
+ Type: "AWS::IAM::Role",
1193
+ Properties: {
1194
+ AssumeRolePolicyDocument: {
1195
+ Version: "2012-10-17",
1196
+ Statement: [{
1197
+ Effect: "Allow",
1198
+ Principal: { Service: "codebuild.amazonaws.com" },
1199
+ Action: "sts:AssumeRole"
1200
+ }]
1201
+ },
1202
+ Path: getIamPath(),
1203
+ Policies: [{
1204
+ PolicyName: `${CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID}Policy`,
1205
+ PolicyDocument: {
1206
+ Version: "2012-10-17",
1207
+ Statement: [
1208
+ {
1209
+ Effect: "Allow",
1210
+ Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
1211
+ Resource: "*"
1212
+ },
1213
+ {
1214
+ Effect: "Allow",
1215
+ Action: ["ecr:GetAuthorizationToken"],
1216
+ Resource: "*"
1217
+ },
1218
+ {
1219
+ Effect: "Allow",
1220
+ Action: [
1221
+ "ecr:BatchCheckLayerAvailability",
1222
+ "ecr:CompleteLayerUpload",
1223
+ "ecr:InitiateLayerUpload",
1224
+ "ecr:PutImage",
1225
+ "ecr:UploadLayerPart"
1226
+ ],
1227
+ Resource: "*"
1228
+ },
1229
+ {
1230
+ Effect: "Allow",
1231
+ Action: "s3:GetObject",
1232
+ Resource: [{ "Fn::Sub": ["arn:aws:s3:::${BucketName}/*", { BucketName: { Ref: BASE_STACK_BUCKET_LOGICAL_NAME } }] }]
1233
+ }
1234
+ ]
1235
+ }
1236
+ }]
1237
+ }
1238
+ },
1239
+ [BASE_STACK_LAMBDA_IMAGE_BUILDER_LOGICAL_NAME]: {
1240
+ Type: "AWS::CodeBuild::Project",
1241
+ Properties: {
1242
+ Artifacts: { Type: "NO_ARTIFACTS" },
1243
+ Cache: {
1244
+ Location: "LOCAL",
1245
+ Modes: ["LOCAL_DOCKER_LAYER_CACHE"],
1246
+ Type: "LOCAL"
1247
+ },
1248
+ Description: "Create Lambda image.",
1249
+ Environment: {
1250
+ ComputeType: "BUILD_GENERAL1_SMALL",
1251
+ EnvironmentVariables: [
1252
+ {
1253
+ Name: "AWS_ACCOUNT_ID",
1254
+ Value: { Ref: "AWS::AccountId" }
1255
+ },
1256
+ {
1257
+ Name: "AWS_REGION",
1258
+ Value: { Ref: "AWS::Region" }
1259
+ },
1260
+ {
1261
+ Name: "IMAGE_TAG",
1262
+ Value: "latest"
1263
+ },
1264
+ {
1265
+ Name: "LAMBDA_EXTERNALS",
1266
+ Value: ""
1267
+ }
1268
+ ],
1269
+ Image: "aws/codebuild/standard:3.0",
1270
+ ImagePullCredentialsType: "CODEBUILD",
1271
+ PrivilegedMode: true,
1272
+ Type: "LINUX_CONTAINER"
1273
+ },
1274
+ LogsConfig: { CloudWatchLogs: {
1275
+ Status: "ENABLED",
1276
+ GroupName: { Ref: CODE_BUILD_PROJECT_LOGS_LOGICAL_ID }
1277
+ } },
1278
+ ServiceRole: { "Fn::GetAtt": [CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID, "Arn"] },
1279
+ Source: {
1280
+ BuildSpec: yaml.dump({
1281
+ version: "0.2",
1282
+ phases: {
1283
+ install: { commands: [
1284
+ "echo install started on `date`",
1285
+ "npm init -y",
1286
+ "npm install --save --package-lock-only --no-package-lock $LAMBDA_EXTERNALS",
1287
+ "ls"
1288
+ ] },
1289
+ pre_build: { commands: ["echo pre_build started on `date`", "$(aws ecr get-login --no-include-email --region $AWS_REGION)"] },
1290
+ build: { commands: [
1291
+ "echo build started on `date`",
1292
+ "echo Building the repository image...",
1293
+ "echo \"$DOCKERFILE\" > Dockerfile",
1294
+ "docker build -t $REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile .",
1295
+ "docker tag $REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG"
1296
+ ] },
1297
+ post_build: { commands: [
1298
+ "echo post_build completed on `date`",
1299
+ "echo Pushing the repository image...",
1300
+ "docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG"
1301
+ ] }
1302
+ }
1303
+ }),
1304
+ Type: "NO_SOURCE"
1305
+ },
1306
+ TimeoutInMinutes: 60
1307
+ }
1308
+ }
1309
+ },
1310
+ Outputs: { [BASE_STACK_LAMBDA_IMAGE_BUILDER_LOGICAL_NAME]: {
1311
+ Value: { Ref: BASE_STACK_LAMBDA_IMAGE_BUILDER_LOGICAL_NAME },
1312
+ Export: { Name: BASE_STACK_LAMBDA_IMAGE_BUILDER_EXPORTED_NAME }
1313
+ } }
1314
+ };
1315
+ };
1316
+ //#endregion
1317
+ //#region src/deploy/runtime.ts
1318
+ /**
1319
+ * Get the Node.js version number from runtime string.
1320
+ * @param options - Configuration options
1321
+ * @param options.runtime - The runtime string (e.g., 'nodejs20.x')
1322
+ * @returns The version number (e.g., '20')
1323
+ */
1324
+ const getNodeVersion = ({ runtime = DEFAULT_NODE_RUNTIME } = {}) => {
1325
+ return runtime.replace("nodejs", "").replace(".x", "");
1326
+ };
1327
+ //#endregion
1328
+ //#region src/deploy/baseStack/getLambdaLayerBuilderTemplate.ts
1329
+ const CODE_BUILD_PROJECT_LOGS_GROUP_LOGICAL_ID = `${BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME}LogsLogGroup`;
1330
+ const CODE_BUILD_PROJECT_IAM_ROLE_LOGICAL_ID = `${BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME}Role`;
1331
+ /**
1332
+ * https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html
1333
+ */
1334
+ const getBuildSpec = ({ runtime } = {}) => {
1335
+ return `
1336
+ version: 0.2
1337
+ phases:
1338
+ install:
1339
+ runtime-versions:
1340
+ nodejs: ${runtime ? getNodeVersion({ runtime }) : "24"}
1341
+ commands:
1342
+ - npm i --no-bin-links --no-optional --no-package-lock --no-save --no-shrinkwrap $PACKAGE_NAME
1343
+ - mkdir nodejs
1344
+ - mv node_modules nodejs/node_modules
1345
+ artifacts:
1346
+ files:
1347
+ - nodejs/**/*
1348
+ name: $PACKAGE_NAME.zip
1349
+ `.trim();
1350
+ };
1351
+ /**
1352
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codebuild-project.html
1353
+ */
1354
+ const getLambdaLayerBuilderTemplate = ({ runtime } = {}) => {
1355
+ return {
1356
+ Resources: {
1357
+ [CODE_BUILD_PROJECT_IAM_ROLE_LOGICAL_ID]: {
1358
+ Type: "AWS::IAM::Role",
1359
+ Properties: {
1360
+ AssumeRolePolicyDocument: {
1361
+ Version: "2012-10-17",
1362
+ Statement: [{
1363
+ Effect: "Allow",
1364
+ Principal: { Service: ["codebuild.amazonaws.com"] },
1365
+ Action: ["sts:AssumeRole"]
1366
+ }]
1367
+ },
1368
+ Path: getIamPath(),
1369
+ Policies: [{
1370
+ PolicyName: `${CODE_BUILD_PROJECT_IAM_ROLE_LOGICAL_ID}Policy`,
1371
+ PolicyDocument: {
1372
+ Version: "2012-10-17",
1373
+ Statement: [{
1374
+ Effect: "Allow",
1375
+ Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
1376
+ Resource: "*"
1377
+ }, {
1378
+ Effect: "Allow",
1379
+ Action: ["s3:*"],
1380
+ Resource: [{ "Fn::Sub": ["arn:aws:s3:::${BucketName}", { BucketName: { Ref: BASE_STACK_BUCKET_LOGICAL_NAME } }] }, { "Fn::Sub": ["arn:aws:s3:::${BucketName}/*", { BucketName: { Ref: BASE_STACK_BUCKET_LOGICAL_NAME } }] }]
1381
+ }]
1382
+ }
1383
+ }]
1384
+ }
1385
+ },
1386
+ [CODE_BUILD_PROJECT_LOGS_GROUP_LOGICAL_ID]: {
1387
+ Type: "AWS::Logs::LogGroup",
1388
+ DeletionPolicy: "Delete",
1389
+ Properties: {}
1390
+ },
1391
+ [BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME]: {
1392
+ Type: "AWS::CodeBuild::Project",
1393
+ Properties: {
1394
+ Artifacts: {
1395
+ Location: { Ref: BASE_STACK_BUCKET_LOGICAL_NAME },
1396
+ NamespaceType: "NONE",
1397
+ OverrideArtifactName: true,
1398
+ Packaging: "ZIP",
1399
+ Path: "lambda-layers/packages",
1400
+ Type: "S3"
1401
+ },
1402
+ Environment: {
1403
+ ComputeType: "BUILD_GENERAL1_SMALL",
1404
+ /**
1405
+ * Image should match the runtime of the buildspec.
1406
+ * https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html
1407
+ */
1408
+ Image: "aws/codebuild/standard:7.0",
1409
+ Type: "LINUX_CONTAINER"
1410
+ },
1411
+ LogsConfig: { CloudWatchLogs: {
1412
+ GroupName: { Ref: `${CODE_BUILD_PROJECT_LOGS_GROUP_LOGICAL_ID}` },
1413
+ Status: "ENABLED"
1414
+ } },
1415
+ ServiceRole: { "Fn::GetAtt": `${CODE_BUILD_PROJECT_IAM_ROLE_LOGICAL_ID}.Arn` },
1416
+ Source: {
1417
+ BuildSpec: getBuildSpec({ runtime }),
1418
+ Type: "NO_SOURCE"
1419
+ }
1420
+ }
1421
+ }
1422
+ },
1423
+ Outputs: { [BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME]: { Value: { Ref: BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME } } }
1424
+ };
1425
+ };
1426
+ //#endregion
1427
+ //#region src/deploy/baseStack/getVpcTemplate.ts
1428
+ const getVpcTemplate = () => {
1429
+ const vpcName = `${pascalCase(NAME)}VPC`;
1430
+ const EC2_INTERNET_GATEWAY_LOGICAL_ID = "EC2InternetGateway";
1431
+ const EC2_ROUTE_TABLE_LOGICAL_ID = "EC2RouteTable";
1432
+ const EC2_VPC_LOGICAL_ID = "EC2VCP";
1433
+ const template = {
1434
+ AWSTemplateFormatVersion: "2010-09-09",
1435
+ Mappings: { CidrMappings: { VPC: { CIDR: "10.0.0.0/16" } } },
1436
+ Resources: {
1437
+ [EC2_VPC_LOGICAL_ID]: {
1438
+ Type: "AWS::EC2::VPC",
1439
+ Properties: {
1440
+ CidrBlock: { "Fn::FindInMap": [
1441
+ "CidrMappings",
1442
+ "VPC",
1443
+ "CIDR"
1444
+ ] },
1445
+ EnableDnsHostnames: true,
1446
+ EnableDnsSupport: true,
1447
+ Tags: [{
1448
+ Key: "Name",
1449
+ Value: vpcName
1450
+ }]
1451
+ }
1452
+ },
1453
+ [EC2_INTERNET_GATEWAY_LOGICAL_ID]: {
1454
+ Type: "AWS::EC2::InternetGateway",
1455
+ Properties: {}
1456
+ },
1457
+ EC2VPCGatewayAttachment: {
1458
+ Type: "AWS::EC2::VPCGatewayAttachment",
1459
+ Properties: {
1460
+ InternetGatewayId: { Ref: EC2_INTERNET_GATEWAY_LOGICAL_ID },
1461
+ VpcId: { Ref: EC2_VPC_LOGICAL_ID }
1462
+ }
1463
+ },
1464
+ [EC2_ROUTE_TABLE_LOGICAL_ID]: {
1465
+ Type: "AWS::EC2::RouteTable",
1466
+ Properties: {
1467
+ Tags: [{
1468
+ Key: "Name",
1469
+ Value: { "Fn::Join": [" ", [
1470
+ vpcName,
1471
+ "-",
1472
+ EC2_ROUTE_TABLE_LOGICAL_ID
1473
+ ]] }
1474
+ }],
1475
+ VpcId: { Ref: EC2_VPC_LOGICAL_ID }
1476
+ }
1477
+ },
1478
+ EC2Route: {
1479
+ Type: "AWS::EC2::Route",
1480
+ Properties: {
1481
+ DestinationCidrBlock: "0.0.0.0/0",
1482
+ GatewayId: { Ref: EC2_INTERNET_GATEWAY_LOGICAL_ID },
1483
+ RouteTableId: { Ref: EC2_ROUTE_TABLE_LOGICAL_ID }
1484
+ }
1485
+ }
1486
+ },
1487
+ Outputs: {
1488
+ VPCId: {
1489
+ Value: { Ref: EC2_VPC_LOGICAL_ID },
1490
+ Export: { Name: BASE_STACK_VPC_ID_EXPORTED_NAME }
1491
+ },
1492
+ VPCDefaultSecurityGroup: {
1493
+ Value: { "Fn::GetAtt": [EC2_VPC_LOGICAL_ID, "DefaultSecurityGroup"] },
1494
+ Export: { Name: BASE_STACK_VPC_DEFAULT_SECURITY_GROUP_EXPORTED_NAME }
1495
+ }
1496
+ }
1497
+ };
1498
+ for (const [index, publicSubnetExportedName] of [
1499
+ BASE_STACK_VPC_PUBLIC_SUBNET_0_EXPORTED_NAME,
1500
+ BASE_STACK_VPC_PUBLIC_SUBNET_1_EXPORTED_NAME,
1501
+ BASE_STACK_VPC_PUBLIC_SUBNET_2_EXPORTED_NAME
1502
+ ].entries()) {
1503
+ const publicSubnetLogicalId = `PublicSubnet${index}EC2Subnet`;
1504
+ const publicSubnetCidrMappings = `PublicSubnet${index}`;
1505
+ if (!template.Mappings) template.Mappings = {};
1506
+ template.Mappings.CidrMappings[publicSubnetCidrMappings] = { CIDR: `10.0.${index}.0/24` };
1507
+ template.Resources[publicSubnetLogicalId] = {
1508
+ Type: "AWS::EC2::Subnet",
1509
+ Properties: {
1510
+ AvailabilityZone: { "Fn::Select": [index, { "Fn::GetAZs": { Ref: "AWS::Region" } }] },
1511
+ CidrBlock: { "Fn::FindInMap": [
1512
+ "CidrMappings",
1513
+ publicSubnetCidrMappings,
1514
+ "CIDR"
1515
+ ] },
1516
+ MapPublicIpOnLaunch: true,
1517
+ Tags: [{
1518
+ Key: "Name",
1519
+ Value: { "Fn::Join": [" ", [
1520
+ EC2_VPC_LOGICAL_ID,
1521
+ "-",
1522
+ publicSubnetLogicalId
1523
+ ]] }
1524
+ }],
1525
+ VpcId: { Ref: EC2_VPC_LOGICAL_ID }
1526
+ }
1527
+ };
1528
+ template.Resources[`PublicSubnet${index}EC2SubnetRouteTableAssociation`] = {
1529
+ Type: "AWS::EC2::SubnetRouteTableAssociation",
1530
+ Properties: {
1531
+ RouteTableId: { Ref: EC2_ROUTE_TABLE_LOGICAL_ID },
1532
+ SubnetId: { Ref: publicSubnetLogicalId }
1533
+ }
1534
+ };
1535
+ if (!template.Outputs) template.Outputs = {};
1536
+ template.Outputs[publicSubnetLogicalId] = {
1537
+ Value: { Ref: publicSubnetLogicalId },
1538
+ Export: { Name: publicSubnetExportedName }
1539
+ };
1540
+ }
1541
+ return template;
1542
+ };
1543
+ //#endregion
1544
+ //#region src/deploy/baseStack/deployBaseStack.ts
1545
+ const logPrefix$16 = "base-stack";
1546
+ const baseStackTemplate = deepMerge.all([
1547
+ getBucketTemplate(),
1548
+ getCloudFrontTemplate$1(),
1549
+ getLambdaImageBuilderTemplate(),
1550
+ getLambdaLayerBuilderTemplate(),
1551
+ getVpcTemplate()
1552
+ ]);
1553
+ /**
1554
+ * Base Stack is a set of auxiliary resources that will be used to help at the
1555
+ * deployment time. The resources that will be created are listed below.
1556
+ *
1557
+ * - **S3 bucket**. Deployment may need an auxiliary bucket to succeed. For
1558
+ * instance, to deploy resources that contain a
1559
+ * [Lambda](https://carlin.ttoss.dev/docs/commands/deploy#lambda), we need a S3
1560
+ * bucket to upload the zipped code. Or if the CloudFormation template has a
1561
+ * size greater than [the limit](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-limits.html),
1562
+ * we need to upload the template to a S3 bucket in order to create/update the
1563
+ * stack.
1564
+ *
1565
+ * - **CloudFront function**. This resource is used to append the `index.html`
1566
+ * to the request URI. This is useful when deploying a [Docusaurus](https://docusaurus.io/)
1567
+ * website, for example.
1568
+ *
1569
+ * - **Lambda Layer builder**. This resource is a CodeBuild project that is
1570
+ * used to create Lambda Layers when [--lambda-externals](/docs/api-reference/deploy#lambda-externals)
1571
+ * has values.
1572
+ *
1573
+ * - **Lambda Image builder**. This resource is a CodeBuild project that builds
1574
+ * Docker Images if Lambda is going to use them.
1575
+ *
1576
+ * - **VPC**. This resource is used when some network infrastructure is
1577
+ * required. For example, CICD needs a VPC to execute the [Fargate](https://aws.amazon.com/fargate/)
1578
+ * operations.
1579
+ */
1580
+ const deployBaseStack = async () => {
1581
+ try {
1582
+ const { stackName } = await handleDeployInitialization({
1583
+ logPrefix: logPrefix$16,
1584
+ stackName: BASE_STACK_NAME
1585
+ });
1586
+ await deploy({
1587
+ template: baseStackTemplate,
1588
+ params: { StackName: stackName },
1589
+ terminationProtection: true
1590
+ });
1591
+ } catch (error) {
1592
+ handleDeployError({
1593
+ error,
1594
+ logPrefix: logPrefix$16
1595
+ });
1596
+ }
1597
+ };
1598
+ //#endregion
1599
+ //#region src/deploy/baseStack/command.ts
1600
+ const deployBaseStackCommand = {
1601
+ command: "base-stack",
1602
+ describe: "Create base resources.",
1603
+ handler: deployBaseStack
1604
+ };
1605
+ //#endregion
1606
+ //#region src/deploy/cicd/config.ts
1607
+ const ECS_TASK_DEFAULT_CPU = "2048";
1608
+ const ECS_TASK_DEFAULT_MEMORY = "4096";
1609
+ const PIPELINE_ECS_TASK_EXECUTION_STAGE_NAME = `PipelineRunECSTasksStage`;
1610
+ const PIPELINE_ECS_TASK_EXECUTION_MANUAL_APPROVAL_ACTION_NAME = `PipelineRunECSTasksApproval`;
1611
+ //#endregion
1612
+ //#region src/deploy/cicd/command.options.ts
1613
+ const options$6 = {
1614
+ cpu: { type: "string" },
1615
+ memory: { type: "string" },
1616
+ pipelines: {
1617
+ choices: [
1618
+ "pr",
1619
+ "main",
1620
+ "tag"
1621
+ ],
1622
+ coerce: (values) => {
1623
+ return values.map((value) => {
1624
+ return camelCase(value);
1625
+ });
1626
+ },
1627
+ default: [],
1628
+ description: "Pipelines that will be implemented with the CICD stack.",
1629
+ type: "array"
1630
+ },
1631
+ "update-repository": {
1632
+ alias: ["ur"],
1633
+ description: "Determine if the repository image will be updated.",
1634
+ default: true,
1635
+ type: "boolean"
1636
+ },
1637
+ "ssh-key": {
1638
+ demandOption: true,
1639
+ type: "string"
1640
+ },
1641
+ "ssh-url": {
1642
+ demandOption: true,
1643
+ type: "string"
1644
+ },
1645
+ "slack-webhook-url": { type: "string" },
1646
+ /**
1647
+ * This option has the format:
1648
+ *
1649
+ * ```ts
1650
+ * Array<{
1651
+ * name: string,
1652
+ * value: string,
1653
+ * }>
1654
+ * ```
1655
+ */
1656
+ "task-environment": {
1657
+ alias: ["te"],
1658
+ default: [],
1659
+ describe: "A list of environment variables that will be passed to the ECS container task.",
1660
+ type: "array"
1661
+ }
1662
+ };
1663
+ const getCicdConfig = () => {
1664
+ const { parsed } = yargs(hideBin(process.argv)).config();
1665
+ if (!parsed) return false;
1666
+ const { argv } = parsed;
1667
+ return Object.keys(options$6).reduce((acc, key) => {
1668
+ const value = argv[key];
1669
+ if (value) acc[key] = value;
1670
+ return acc;
1671
+ }, {});
1672
+ };
1673
+ //#endregion
1674
+ //#region src/deploy/cicd/getTriggerPipelineObjectKey.ts
1675
+ /**
1676
+ * The file with this key inside the source S3 key of main and tag pipelines
1677
+ * will trigger those pipelines.
1678
+ */
1679
+ const getTriggerPipelinesObjectKey = ({ prefix, pipeline }) => {
1680
+ return `${prefix}/${pipeline}.zip`;
1681
+ };
1682
+ //#endregion
1683
+ //#region src/deploy/cicd/cicd.template.ts
1684
+ const API_LOGICAL_ID = "ApiV1ServerlessApi";
1685
+ const CODE_BUILD_PROJECT_LOGS_LOGICAL_ID = "RepositoryImageCodeBuildProjectLogsLogGroup";
1686
+ const CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID = "RepositoryImageCodeBuildProjectIAMRole";
1687
+ const ECR_REPOSITORY_LOGICAL_ID = "RepositoryECRRepository";
1688
+ const FUNCTION_IAM_ROLE_LOGICAL_ID = "ApiV1ServerlessFunctionIAMRole";
1689
+ const ECS_TASK_REPORT_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID = "EcsTaskReportHandler";
1690
+ const PROCESS_ENV_REPOSITORY_IMAGE_CODE_BUILD_PROJECT_NAME = "REPOSITORY_IMAGE_CODE_BUILD_PROJECT_NAME";
1691
+ const REPOSITORY_ECS_TASK_CONTAINER_NAME = "RepositoryECSTaskContainerName";
1692
+ const REPOSITORY_ECS_TASK_DEFINITION_LOGICAL_ID = "RepositoryECSTaskDefinition";
1693
+ const REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID = "RepositoryImageCodeBuildProject";
1694
+ const REPOSITORY_TASKS_ECS_CLUSTER_LOGICAL_ID = "RepositoryTasksECSCluster";
1695
+ const REPOSITORY_TASKS_ECS_CLUSTER_LOGS_LOG_GROUP_LOGICAL_ID = "RepositoryTasksECSClusterLogsLogGroup";
1696
+ const REPOSITORY_TASKS_ECS_TASK_DEFINITION_EXECUTION_ROLE_LOGICAL_ID = "RepositoryTasksECSTaskDefinitionExecutionRoleIAMRole";
1697
+ const REPOSITORY_TASKS_ECS_TASK_DEFINITION_TASK_ROLE_LOGICAL_ID = "RepositoryTasksECSTaskDefinitionTaskRoleIAMRole";
1698
+ const PIPELINES_ARTIFACT_STORE_S3_BUCKET_LOGICAL_ID = "PipelinesArtifactStoreS3Bucket";
1699
+ const PIPELINES_ROLE_LOGICAL_ID = "PipelinesMainIAMRole";
1700
+ const PIPELINES_MAIN_LOGICAL_ID = "PipelinesMainCodePipeline";
1701
+ const PIPELINES_TAG_LOGICAL_ID = "PipelinesTagCodePipeline";
1702
+ const PIPELINES_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID = "PipelinesHandlerLambdaFunction";
1703
+ const IMAGE_UPDATER_SCHEDULE_SERVERLESS_FUNCTION_LOGICAL_ID = "ImageUpdaterScheduleServerlessFunction";
1704
+ /**
1705
+ * An [AWS CodeBuild](https://aws.amazon.com/codebuild/) project is created
1706
+ * to build (create and update) repository images. It uses a
1707
+ * [BUILD\_GENERAL1\_SMALL environment compute type](https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-compute-types.html)
1708
+ * with Linux as operational system to build the image.
1709
+ */
1710
+ const getRepositoryImageBuilder = () => {
1711
+ return {
1712
+ Type: "AWS::CodeBuild::Project",
1713
+ Properties: {
1714
+ Artifacts: { Type: "NO_ARTIFACTS" },
1715
+ Cache: {
1716
+ Location: "LOCAL",
1717
+ Modes: ["LOCAL_DOCKER_LAYER_CACHE"],
1718
+ Type: "LOCAL"
1719
+ },
1720
+ Description: "Create repository image.",
1721
+ Environment: {
1722
+ ComputeType: "BUILD_GENERAL1_SMALL",
1723
+ EnvironmentVariables: [
1724
+ {
1725
+ Name: "AWS_ACCOUNT_ID",
1726
+ Value: { Ref: "AWS::AccountId" }
1727
+ },
1728
+ {
1729
+ Name: "AWS_REGION",
1730
+ Value: { Ref: "AWS::Region" }
1731
+ },
1732
+ {
1733
+ Name: "DOCKERFILE",
1734
+ Value: { "Fn::Sub": [
1735
+ "FROM public.ecr.aws/ubuntu/ubuntu:20.04_stable",
1736
+ "ENV DEBIAN_FRONTEND noninteractive",
1737
+ "RUN apt-get update --fix-missing",
1738
+ "RUN apt-get install -y curl",
1739
+ "RUN apt-get install -y git",
1740
+ "RUN apt-get install -y jq",
1741
+ `RUN curl -fsSL https://deb.nodesource.com/setup_${getNodeVersion({ runtime: DEFAULT_NODE_RUNTIME })}.x | bash -`,
1742
+ "RUN apt-get install -y nodejs",
1743
+ "RUN apt-get clean",
1744
+ "RUN npm install -g yarn",
1745
+ "RUN yarn global add carlin",
1746
+ "RUN git config --global user.name carlin",
1747
+ "RUN git config --global user.email carlin@ttoss.dev",
1748
+ "RUN mkdir /root/.ssh/",
1749
+ "COPY ./id_rsa /root/.ssh/id_rsa",
1750
+ "RUN chmod 600 /root/.ssh/id_rsa",
1751
+ "RUN touch /root/.ssh/known_hosts",
1752
+ "RUN ssh-keyscan github.com >> /root/.ssh/known_hosts",
1753
+ "COPY . /home",
1754
+ "WORKDIR /home/repository",
1755
+ "RUN mkdir -p /home/yarn-cache",
1756
+ "RUN yarn config set cache-folder /home/yarn-cache",
1757
+ "RUN yarn install",
1758
+ "RUN git checkout -- yarn.lock"
1759
+ ].join("\n") }
1760
+ },
1761
+ {
1762
+ Name: "IMAGE_TAG",
1763
+ Value: "latest"
1764
+ },
1765
+ {
1766
+ Name: "REPOSITORY_ECR_REPOSITORY",
1767
+ Value: { Ref: ECR_REPOSITORY_LOGICAL_ID }
1768
+ },
1769
+ {
1770
+ Name: "SSH_KEY",
1771
+ Value: { Ref: "SSHKey" }
1772
+ },
1773
+ {
1774
+ Name: "SSH_URL",
1775
+ Value: { Ref: "SSHUrl" }
1776
+ }
1777
+ ],
1778
+ Image: "aws/codebuild/standard:3.0",
1779
+ ImagePullCredentialsType: "CODEBUILD",
1780
+ /**
1781
+ * Enables running the Docker daemon inside a Docker container. Set to
1782
+ * true only if the build project is used to build Docker images.
1783
+ * Otherwise, a build that attempts to interact with the Docker daemon
1784
+ * fails. The default setting is false."
1785
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-codebuild-project-environment.html#cfn-codebuild-project-environment-privilegedmode
1786
+ */
1787
+ PrivilegedMode: true,
1788
+ Type: "LINUX_CONTAINER"
1789
+ },
1790
+ LogsConfig: { CloudWatchLogs: {
1791
+ Status: "ENABLED",
1792
+ GroupName: { Ref: CODE_BUILD_PROJECT_LOGS_LOGICAL_ID }
1793
+ } },
1794
+ ServiceRole: { "Fn::GetAtt": [CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID, "Arn"] },
1795
+ Source: {
1796
+ BuildSpec: yaml.dump({
1797
+ version: "0.2",
1798
+ phases: {
1799
+ install: { commands: [
1800
+ "echo install started on `date`",
1801
+ `echo "$SSH_KEY" > ~/.ssh/id_rsa`,
1802
+ "chmod 600 ~/.ssh/id_rsa",
1803
+ "rm -rf repository",
1804
+ "git clone $SSH_URL repository",
1805
+ "cd repository",
1806
+ "ls"
1807
+ ] },
1808
+ pre_build: { commands: ["echo pre_build started on `date`"] },
1809
+ build: { commands: [
1810
+ "echo build started on `date`",
1811
+ "$(aws ecr get-login --no-include-email --region $AWS_REGION)",
1812
+ "echo Building the repository image...",
1813
+ "cd ../",
1814
+ "cp ~/.ssh/id_rsa .",
1815
+ "echo \"$DOCKERFILE\" > Dockerfile",
1816
+ "cat Dockerfile",
1817
+ "docker build -t $REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile .",
1818
+ "docker tag $REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG",
1819
+ "echo Pushing the repository image...",
1820
+ "docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPOSITORY_ECR_REPOSITORY:$IMAGE_TAG"
1821
+ ] },
1822
+ post_build: { commands: ["echo post_build completed on `date`"] }
1823
+ }
1824
+ }),
1825
+ Type: "NO_SOURCE"
1826
+ },
1827
+ TimeoutInMinutes: 15
1828
+ }
1829
+ };
1830
+ };
1831
+ /**
1832
+ * This variable is used inside GitHub webhooks to identify the object key
1833
+ * prefix of the file that triggers the pipelines.
1834
+ */
1835
+ const triggerPipelinesObjectKeyPrefix = [
1836
+ "cicd",
1837
+ "pipelines",
1838
+ "triggers",
1839
+ getProjectName()
1840
+ ].join("/");
1841
+ const getCicdTemplate = ({ pipelines = [], cpu = ECS_TASK_DEFAULT_CPU, memory = ECS_TASK_DEFAULT_MEMORY, s3, slackWebhookUrl, taskEnvironment = [] }) => {
1842
+ const resources = {};
1843
+ const executeEcsTaskVariables = {
1844
+ ECS_CLUSTER_ARN: { "Fn::GetAtt": [REPOSITORY_TASKS_ECS_CLUSTER_LOGICAL_ID, "Arn"] },
1845
+ ECS_CONTAINER_NAME: REPOSITORY_ECS_TASK_CONTAINER_NAME,
1846
+ ECS_TASK_DEFINITION: { Ref: REPOSITORY_ECS_TASK_DEFINITION_LOGICAL_ID },
1847
+ VPC_SECURITY_GROUP: { "Fn::ImportValue": BASE_STACK_VPC_DEFAULT_SECURITY_GROUP_EXPORTED_NAME },
1848
+ VPC_PUBLIC_SUBNET_0: { "Fn::ImportValue": BASE_STACK_VPC_PUBLIC_SUBNET_0_EXPORTED_NAME },
1849
+ VPC_PUBLIC_SUBNET_1: { "Fn::ImportValue": BASE_STACK_VPC_PUBLIC_SUBNET_1_EXPORTED_NAME },
1850
+ VPC_PUBLIC_SUBNET_2: { "Fn::ImportValue": BASE_STACK_VPC_PUBLIC_SUBNET_2_EXPORTED_NAME },
1851
+ ECS_TASK_REPORT_HANDLER_NAME: { Ref: ECS_TASK_REPORT_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID }
1852
+ };
1853
+ /**
1854
+ * The algorithm will clone the repository and will create a Docker image
1855
+ * to be used to perform your commands. [Yarn cache](https://classic.yarnpkg.com/en/docs/cli/cache/)
1856
+ * will also be saved together with the code to reduce packages installation
1857
+ * time. The created image will be pushed to [Amazon Elastic Container Registry](https://aws.amazon.com/ecr/).
1858
+ * with a defined expiration rule is also defined. The registry only keeps
1859
+ * the latest image.
1860
+ */
1861
+ const getEcrRepositoryResource = () => {
1862
+ return {
1863
+ Type: "AWS::ECR::Repository",
1864
+ Properties: { LifecyclePolicy: { LifecyclePolicyText: JSON.stringify({ rules: [{
1865
+ rulePriority: 1,
1866
+ description: "Only keep the latest image",
1867
+ selection: {
1868
+ tagStatus: "any",
1869
+ countType: "imageCountMoreThan",
1870
+ countNumber: 1
1871
+ },
1872
+ action: { type: "expire" }
1873
+ }] }, null, 2) } }
1874
+ };
1875
+ };
1876
+ resources[ECR_REPOSITORY_LOGICAL_ID] = getEcrRepositoryResource();
1877
+ const commonFunctionProperties = {
1878
+ CodeUri: {
1879
+ Bucket: s3.bucket,
1880
+ Key: s3.key,
1881
+ Version: s3.versionId
1882
+ },
1883
+ Role: { "Fn::GetAtt": [FUNCTION_IAM_ROLE_LOGICAL_ID, "Arn"] },
1884
+ Runtime: DEFAULT_NODE_RUNTIME,
1885
+ Timeout: 60
1886
+ };
1887
+ /**
1888
+ * CodeBuild
1889
+ */
1890
+ (() => {
1891
+ resources[CODE_BUILD_PROJECT_LOGS_LOGICAL_ID] = {
1892
+ Type: "AWS::Logs::LogGroup",
1893
+ DeletionPolicy: "Delete",
1894
+ Properties: {}
1895
+ };
1896
+ resources[CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID] = {
1897
+ Type: "AWS::IAM::Role",
1898
+ Properties: {
1899
+ AssumeRolePolicyDocument: {
1900
+ Version: "2012-10-17",
1901
+ Statement: [{
1902
+ Effect: "Allow",
1903
+ Principal: { Service: "codebuild.amazonaws.com" },
1904
+ Action: "sts:AssumeRole"
1905
+ }]
1906
+ },
1907
+ Path: getIamPath(),
1908
+ Policies: [{
1909
+ PolicyName: `${CODE_BUILD_PROJECT_SERVICE_ROLE_LOGICAL_ID}Policy`,
1910
+ PolicyDocument: {
1911
+ Version: "2012-10-17",
1912
+ Statement: [
1913
+ {
1914
+ Effect: "Allow",
1915
+ Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
1916
+ Resource: "*"
1917
+ },
1918
+ {
1919
+ Effect: "Allow",
1920
+ Action: ["ecr:GetAuthorizationToken"],
1921
+ Resource: "*"
1922
+ },
1923
+ {
1924
+ Effect: "Allow",
1925
+ Action: [
1926
+ "ecr:BatchCheckLayerAvailability",
1927
+ "ecr:CompleteLayerUpload",
1928
+ "ecr:InitiateLayerUpload",
1929
+ "ecr:PutImage",
1930
+ "ecr:UploadLayerPart"
1931
+ ],
1932
+ Resource: { "Fn::GetAtt": [ECR_REPOSITORY_LOGICAL_ID, "Arn"] }
1933
+ }
1934
+ ]
1935
+ }
1936
+ }]
1937
+ }
1938
+ };
1939
+ resources[REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID] = getRepositoryImageBuilder();
1940
+ const cicdConfig = {
1941
+ ...getCicdConfig(),
1942
+ "ssh-key": "/root/.ssh/id_rsa",
1943
+ environment: getEnvironment()
1944
+ };
1945
+ resources[IMAGE_UPDATER_SCHEDULE_SERVERLESS_FUNCTION_LOGICAL_ID] = {
1946
+ Type: "AWS::Serverless::Function",
1947
+ Properties: {
1948
+ ...commonFunctionProperties,
1949
+ Events: { Schedule: {
1950
+ Type: "Schedule",
1951
+ Properties: { Schedule: "rate(7 days)" }
1952
+ } },
1953
+ Environment: { Variables: {
1954
+ [PROCESS_ENV_REPOSITORY_IMAGE_CODE_BUILD_PROJECT_NAME]: { Ref: REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID },
1955
+ CICD_CONFIG: JSON.stringify(cicdConfig),
1956
+ ...executeEcsTaskVariables
1957
+ } },
1958
+ Handler: "index.imageUpdaterScheduleHandler"
1959
+ }
1960
+ };
1961
+ })();
1962
+ const createApiResources = () => {
1963
+ resources[API_LOGICAL_ID] = {
1964
+ Type: "AWS::Serverless::Api",
1965
+ Properties: {
1966
+ Auth: { ApiKeyRequired: false },
1967
+ StageName: "v1"
1968
+ }
1969
+ };
1970
+ resources[FUNCTION_IAM_ROLE_LOGICAL_ID] = {
1971
+ Type: "AWS::IAM::Role",
1972
+ Properties: {
1973
+ AssumeRolePolicyDocument: {
1974
+ Version: "2012-10-17",
1975
+ Statement: [{
1976
+ Effect: "Allow",
1977
+ Principal: { Service: "lambda.amazonaws.com" },
1978
+ Action: ["sts:AssumeRole"]
1979
+ }]
1980
+ },
1981
+ ManagedPolicyArns: ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"],
1982
+ Path: getIamPath(),
1983
+ Policies: [{
1984
+ PolicyName: `${FUNCTION_IAM_ROLE_LOGICAL_ID}Policy`,
1985
+ PolicyDocument: {
1986
+ Version: "2012-10-17",
1987
+ Statement: [
1988
+ {
1989
+ Effect: "Allow",
1990
+ Action: ["codebuild:StartBuild"],
1991
+ Resource: { "Fn::GetAtt": [REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID, "Arn"] }
1992
+ },
1993
+ {
1994
+ Effect: "Allow",
1995
+ Action: ["iam:PassRole"],
1996
+ Resource: [{ "Fn::GetAtt": [REPOSITORY_TASKS_ECS_TASK_DEFINITION_EXECUTION_ROLE_LOGICAL_ID, "Arn"] }, { "Fn::GetAtt": [REPOSITORY_TASKS_ECS_TASK_DEFINITION_TASK_ROLE_LOGICAL_ID, "Arn"] }]
1997
+ },
1998
+ {
1999
+ Effect: "Allow",
2000
+ Action: ["ecs:DescribeTasks"],
2001
+ Resource: "*"
2002
+ },
2003
+ {
2004
+ Effect: "Allow",
2005
+ Action: ["ecs:RunTask"],
2006
+ Resource: [{ Ref: REPOSITORY_ECS_TASK_DEFINITION_LOGICAL_ID }]
2007
+ },
2008
+ {
2009
+ Action: [
2010
+ "codepipeline:PutApprovalResult",
2011
+ "codepipeline:GetJobDetails",
2012
+ "codepipeline:GetPipelineState",
2013
+ "codepipeline:PutJobSuccessResult",
2014
+ "codepipeline:PutJobFailureResult"
2015
+ ],
2016
+ Effect: "Allow",
2017
+ Resource: "*"
2018
+ },
2019
+ {
2020
+ Action: "s3:*",
2021
+ Effect: "Allow",
2022
+ Resource: { "Fn::Sub": [`arn:aws:s3:::\${BucketName}/${triggerPipelinesObjectKeyPrefix}*`, { BucketName: { "Fn::ImportValue": BASE_STACK_BUCKET_NAME_EXPORTED_NAME } }] }
2023
+ }
2024
+ ]
2025
+ }
2026
+ }]
2027
+ }
2028
+ };
2029
+ /**
2030
+ * Called after ECS task execution success or failure.
2031
+ */
2032
+ resources[ECS_TASK_REPORT_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID] = {
2033
+ Type: "AWS::Serverless::Function",
2034
+ Properties: {
2035
+ ...commonFunctionProperties,
2036
+ Environment: { Variables: {
2037
+ ECS_TASK_LOGS_LOG_GROUP: { Ref: REPOSITORY_TASKS_ECS_CLUSTER_LOGS_LOG_GROUP_LOGICAL_ID },
2038
+ ECS_TASK_CONTAINER_NAME: REPOSITORY_ECS_TASK_CONTAINER_NAME,
2039
+ SLACK_WEBHOOK_URL: slackWebhookUrl
2040
+ } },
2041
+ Handler: "index.ecsTaskReportHandler"
2042
+ }
2043
+ };
2044
+ resources.CicdApiV1ServerlessFunction = {
2045
+ Type: "AWS::Serverless::Function",
2046
+ Properties: {
2047
+ ...commonFunctionProperties,
2048
+ Events: { ApiEvent: {
2049
+ Type: "Api",
2050
+ Properties: {
2051
+ Method: "POST",
2052
+ Path: "/cicd",
2053
+ RestApiId: { Ref: API_LOGICAL_ID }
2054
+ }
2055
+ } },
2056
+ Environment: { Variables: {
2057
+ [PROCESS_ENV_REPOSITORY_IMAGE_CODE_BUILD_PROJECT_NAME]: { Ref: REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID },
2058
+ ...executeEcsTaskVariables
2059
+ } },
2060
+ Handler: "index.cicdApiV1Handler"
2061
+ }
2062
+ };
2063
+ resources.GitHubWebhooksApiV1ServerlessFunction = {
2064
+ Type: "AWS::Serverless::Function",
2065
+ Properties: {
2066
+ ...commonFunctionProperties,
2067
+ Events: { ApiEvent: {
2068
+ Type: "Api",
2069
+ Properties: {
2070
+ Method: "POST",
2071
+ Path: "/github/webhooks",
2072
+ RestApiId: { Ref: API_LOGICAL_ID }
2073
+ }
2074
+ } },
2075
+ Environment: { Variables: {
2076
+ BASE_STACK_BUCKET_NAME: { "Fn::ImportValue": BASE_STACK_BUCKET_NAME_EXPORTED_NAME },
2077
+ TRIGGER_PIPELINES_OBJECT_KEY_PREFIX: triggerPipelinesObjectKeyPrefix,
2078
+ PIPELINES_JSON: JSON.stringify(pipelines),
2079
+ ...executeEcsTaskVariables
2080
+ } },
2081
+ Handler: "index.githubWebhooksApiV1Handler"
2082
+ }
2083
+ };
2084
+ };
2085
+ createApiResources();
2086
+ /**
2087
+ * ECS
2088
+ */
2089
+ (() => {
2090
+ resources[REPOSITORY_TASKS_ECS_CLUSTER_LOGICAL_ID] = {
2091
+ Type: "AWS::ECS::Cluster",
2092
+ Properties: {}
2093
+ };
2094
+ resources[REPOSITORY_TASKS_ECS_CLUSTER_LOGS_LOG_GROUP_LOGICAL_ID] = {
2095
+ Type: "AWS::Logs::LogGroup",
2096
+ DeletionPolicy: "Delete",
2097
+ Properties: {}
2098
+ };
2099
+ /**
2100
+ * Used to start the container.
2101
+ */
2102
+ resources[REPOSITORY_TASKS_ECS_TASK_DEFINITION_EXECUTION_ROLE_LOGICAL_ID] = {
2103
+ Type: "AWS::IAM::Role",
2104
+ Properties: {
2105
+ AssumeRolePolicyDocument: {
2106
+ Version: "2012-10-17",
2107
+ Statement: [{
2108
+ Effect: "Allow",
2109
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
2110
+ Action: "sts:AssumeRole"
2111
+ }]
2112
+ },
2113
+ ManagedPolicyArns: ["arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"],
2114
+ Path: getIamPath()
2115
+ }
2116
+ };
2117
+ /**
2118
+ * Used inside de container execution.
2119
+ */
2120
+ resources[REPOSITORY_TASKS_ECS_TASK_DEFINITION_TASK_ROLE_LOGICAL_ID] = {
2121
+ Type: "AWS::IAM::Role",
2122
+ Properties: {
2123
+ AssumeRolePolicyDocument: {
2124
+ Version: "2012-10-17",
2125
+ Statement: [{
2126
+ Effect: "Allow",
2127
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
2128
+ Action: "sts:AssumeRole"
2129
+ }]
2130
+ },
2131
+ ManagedPolicyArns: ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"],
2132
+ Path: getIamPath(),
2133
+ /**
2134
+ * TODO: improve the policies rules.
2135
+ */
2136
+ Policies: [{
2137
+ PolicyName: `${REPOSITORY_TASKS_ECS_TASK_DEFINITION_TASK_ROLE_LOGICAL_ID}Policy`,
2138
+ PolicyDocument: {
2139
+ Version: "2012-10-17",
2140
+ Statement: [{
2141
+ Effect: "Allow",
2142
+ Action: ["*"],
2143
+ Resource: "*"
2144
+ }]
2145
+ }
2146
+ }]
2147
+ }
2148
+ };
2149
+ resources[REPOSITORY_ECS_TASK_DEFINITION_LOGICAL_ID] = {
2150
+ Type: "AWS::ECS::TaskDefinition",
2151
+ Properties: {
2152
+ ContainerDefinitions: [{
2153
+ Environment: [
2154
+ {
2155
+ /**
2156
+ * https://docs.aws.amazon.com/AmazonECS/latest/developerguide/container-metadata.html#enable-metadata
2157
+ */
2158
+ Name: "ECS_ENABLE_CONTAINER_METADATA",
2159
+ Value: "true"
2160
+ },
2161
+ {
2162
+ Name: "CI",
2163
+ Value: "true"
2164
+ },
2165
+ ...taskEnvironment.map((te) => {
2166
+ return {
2167
+ Name: te.name,
2168
+ Value: te.value
2169
+ };
2170
+ })
2171
+ ],
2172
+ Image: { "Fn::Sub": ["${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${RepositoryECR}:latest", { RepositoryECR: { Ref: ECR_REPOSITORY_LOGICAL_ID } }] },
2173
+ LogConfiguration: {
2174
+ LogDriver: "awslogs",
2175
+ Options: {
2176
+ "awslogs-group": { Ref: REPOSITORY_TASKS_ECS_CLUSTER_LOGS_LOG_GROUP_LOGICAL_ID },
2177
+ "awslogs-region": { Ref: "AWS::Region" },
2178
+ "awslogs-stream-prefix": "ecs"
2179
+ }
2180
+ },
2181
+ Name: REPOSITORY_ECS_TASK_CONTAINER_NAME
2182
+ }],
2183
+ Cpu: cpu,
2184
+ ExecutionRoleArn: { "Fn::GetAtt": [REPOSITORY_TASKS_ECS_TASK_DEFINITION_EXECUTION_ROLE_LOGICAL_ID, "Arn"] },
2185
+ Memory: memory,
2186
+ NetworkMode: "awsvpc",
2187
+ RequiresCompatibilities: ["FARGATE"],
2188
+ TaskRoleArn: { "Fn::GetAtt": [REPOSITORY_TASKS_ECS_TASK_DEFINITION_TASK_ROLE_LOGICAL_ID, "Arn"] }
2189
+ }
2190
+ };
2191
+ })();
2192
+ /**
2193
+ * Pipelines
2194
+ */
2195
+ if (pipelines.includes("main") || pipelines.includes("tag")) {
2196
+ resources[PIPELINES_ARTIFACT_STORE_S3_BUCKET_LOGICAL_ID] = {
2197
+ Type: "AWS::S3::Bucket",
2198
+ Properties: { LifecycleConfiguration: { Rules: [{
2199
+ /**
2200
+ * We won't use the artifacts forever.
2201
+ */
2202
+ ExpirationInDays: 7,
2203
+ Status: "Enabled"
2204
+ }] } }
2205
+ };
2206
+ resources[PIPELINES_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID] = {
2207
+ Type: "AWS::Lambda::Function",
2208
+ Properties: {
2209
+ Code: {
2210
+ S3Bucket: s3.bucket,
2211
+ S3Key: s3.key,
2212
+ S3ObjectVersion: s3.versionId
2213
+ },
2214
+ Environment: { Variables: { ...executeEcsTaskVariables } },
2215
+ Handler: "index.pipelinesHandler",
2216
+ MemorySize: 128,
2217
+ Role: { "Fn::GetAtt": [FUNCTION_IAM_ROLE_LOGICAL_ID, "Arn"] },
2218
+ Runtime: DEFAULT_NODE_RUNTIME,
2219
+ Timeout: 60
2220
+ }
2221
+ };
2222
+ resources[PIPELINES_ROLE_LOGICAL_ID] = {
2223
+ Type: "AWS::IAM::Role",
2224
+ Properties: {
2225
+ AssumeRolePolicyDocument: {
2226
+ Version: "2012-10-17",
2227
+ Statement: [{
2228
+ Effect: "Allow",
2229
+ Principal: { Service: "codepipeline.amazonaws.com" },
2230
+ Action: "sts:AssumeRole"
2231
+ }]
2232
+ },
2233
+ ManagedPolicyArns: [],
2234
+ Path: getIamPath(),
2235
+ Policies: [{
2236
+ PolicyName: `${PIPELINES_ROLE_LOGICAL_ID}Policy`,
2237
+ PolicyDocument: {
2238
+ Version: "2012-10-17",
2239
+ Statement: [
2240
+ {
2241
+ Effect: "Allow",
2242
+ Action: "lambda:InvokeFunction",
2243
+ Resource: [{ "Fn::GetAtt": [PIPELINES_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID, "Arn"] }]
2244
+ },
2245
+ {
2246
+ Effect: "Allow",
2247
+ Action: "s3:*",
2248
+ Resource: [{ "Fn::GetAtt": [PIPELINES_ARTIFACT_STORE_S3_BUCKET_LOGICAL_ID, "Arn"] }, { "Fn::Sub": `arn:aws:s3:::\${${PIPELINES_ARTIFACT_STORE_S3_BUCKET_LOGICAL_ID}}/*` }]
2249
+ },
2250
+ {
2251
+ Effect: "Allow",
2252
+ Action: "s3:*",
2253
+ Resource: { "Fn::Sub": [`arn:aws:s3:::\${BucketName}/${triggerPipelinesObjectKeyPrefix}*`, { BucketName: { "Fn::ImportValue": BASE_STACK_BUCKET_NAME_EXPORTED_NAME } }] }
2254
+ },
2255
+ {
2256
+ Effect: "Allow",
2257
+ Action: ["s3:Get*", "s3:List*"],
2258
+ Resource: { "Fn::Sub": [`arn:aws:s3:::\${BucketName}`, { BucketName: { "Fn::ImportValue": BASE_STACK_BUCKET_NAME_EXPORTED_NAME } }] }
2259
+ }
2260
+ ]
2261
+ }
2262
+ }]
2263
+ }
2264
+ };
2265
+ const getCodePipelinePipeline = (pipeline) => {
2266
+ const pipelinePascalCase = pascalCase(pipeline);
2267
+ const pipelineS3SourceOutputName = `Pipeline${pipelinePascalCase}S3SourceOutput`;
2268
+ return {
2269
+ Type: "AWS::CodePipeline::Pipeline",
2270
+ Properties: {
2271
+ ArtifactStore: {
2272
+ Location: { Ref: PIPELINES_ARTIFACT_STORE_S3_BUCKET_LOGICAL_ID },
2273
+ Type: "S3"
2274
+ },
2275
+ RestartExecutionOnUpdate: false,
2276
+ RoleArn: { "Fn::GetAtt": [PIPELINES_ROLE_LOGICAL_ID, "Arn"] },
2277
+ Stages: [{
2278
+ Actions: [{
2279
+ ActionTypeId: {
2280
+ Category: "Source",
2281
+ Owner: "AWS",
2282
+ Provider: "S3",
2283
+ Version: 1
2284
+ },
2285
+ Configuration: {
2286
+ S3Bucket: { "Fn::ImportValue": BASE_STACK_BUCKET_NAME_EXPORTED_NAME },
2287
+ S3ObjectKey: getTriggerPipelinesObjectKey({
2288
+ prefix: triggerPipelinesObjectKeyPrefix,
2289
+ pipeline
2290
+ })
2291
+ },
2292
+ Name: `Pipeline${pipelinePascalCase}S3SourceAction`,
2293
+ OutputArtifacts: [{ Name: pipelineS3SourceOutputName }]
2294
+ }],
2295
+ Name: `Pipeline${pipelinePascalCase}S3SourceStage`
2296
+ }, {
2297
+ Actions: [{
2298
+ ActionTypeId: {
2299
+ Category: "Invoke",
2300
+ Owner: "AWS",
2301
+ Provider: "Lambda",
2302
+ Version: 1
2303
+ },
2304
+ Configuration: {
2305
+ FunctionName: { Ref: PIPELINES_HANDLER_LAMBDA_FUNCTION_LOGICAL_ID },
2306
+ UserParameters: (() => {
2307
+ return pipeline;
2308
+ })()
2309
+ },
2310
+ InputArtifacts: [{ Name: pipelineS3SourceOutputName }],
2311
+ Name: `Pipeline${pipelinePascalCase}RunECSTasksAction`
2312
+ }, {
2313
+ ActionTypeId: {
2314
+ Category: "Approval",
2315
+ Owner: "AWS",
2316
+ Provider: "Manual",
2317
+ Version: 1
2318
+ },
2319
+ Name: PIPELINE_ECS_TASK_EXECUTION_MANUAL_APPROVAL_ACTION_NAME
2320
+ }],
2321
+ Name: PIPELINE_ECS_TASK_EXECUTION_STAGE_NAME
2322
+ }]
2323
+ }
2324
+ };
2325
+ };
2326
+ if (pipelines.includes("main")) resources[PIPELINES_MAIN_LOGICAL_ID] = getCodePipelinePipeline("main");
2327
+ if (pipelines.includes("tag")) resources[PIPELINES_TAG_LOGICAL_ID] = getCodePipelinePipeline("tag");
2328
+ }
2329
+ return {
2330
+ AWSTemplateFormatVersion: "2010-09-09",
2331
+ Transform: "AWS::Serverless-2016-10-31",
2332
+ Resources: resources,
2333
+ Parameters: {
2334
+ SSHKey: {
2335
+ NoEcho: true,
2336
+ Type: "String"
2337
+ },
2338
+ SSHUrl: { Type: "String" }
2339
+ },
2340
+ Outputs: {
2341
+ [REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID]: { Value: { Ref: REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID } },
2342
+ ApiV1Endpoint: {
2343
+ Description: "CICD API v1 stage endpoint.",
2344
+ Value: { "Fn::Sub": `https://\${${API_LOGICAL_ID}}.execute-api.\${AWS::Region}.amazonaws.com/v1/` }
2345
+ }
2346
+ }
2347
+ };
2348
+ };
2349
+ //#endregion
2350
+ //#region src/deploy/lambda/buildLambdaCode.ts
2351
+ const logPrefix$15 = "lambda";
2352
+ /**
2353
+ * Carlin builds the Lambda code using esbuild. It can build the code as ESM or
2354
+ * CJS format. When building as ESM, it will split the code into multiple files,
2355
+ * which reduces the payload size when deploying the Lambda function. The file
2356
+ * extension of the output files will be `.mjs` for ESM and `.cjs` for CJS.
2357
+ */
2358
+ const buildLambdaCode = async ({ lambdaEntryPoints, lambdaEntryPointsBaseDir = ".", lambdaExternal = [], lambdaFormat = "esm", lambdaOutdir }) => {
2359
+ log.info(logPrefix$15, "Building Lambda single file...");
2360
+ /**
2361
+ * Remove the output directory if it exists to not mix old files with the
2362
+ * new ones.
2363
+ */
2364
+ if (fs.existsSync(lambdaOutdir)) fs.rmSync(lambdaOutdir, { recursive: true });
2365
+ const entryPoints = lambdaEntryPoints.map((entryPoint) => {
2366
+ return path.resolve(process.cwd(), lambdaEntryPointsBaseDir, entryPoint);
2367
+ });
2368
+ const { errors } = esbuild.buildSync({
2369
+ banner: { js: "// Powered by carlin (https://ttoss.dev/docs/carlin/)" },
2370
+ bundle: true,
2371
+ entryPoints,
2372
+ external: [
2373
+ "@aws-sdk/*",
2374
+ ...builtinModules,
2375
+ ...lambdaExternal
2376
+ ],
2377
+ /**
2378
+ * Some packages as `graphql` are not compatible with ESM yet.
2379
+ * https://github.com/graphql/graphql-js/issues/3603
2380
+ */
2381
+ format: lambdaFormat,
2382
+ /**
2383
+ * https://esbuild.github.io/api/#minify
2384
+ */
2385
+ minifySyntax: true,
2386
+ platform: "node",
2387
+ splitting: lambdaFormat === "esm",
2388
+ outbase: path.join(process.cwd(), lambdaEntryPointsBaseDir),
2389
+ outdir: path.join(process.cwd(), lambdaOutdir),
2390
+ outExtension: { ".js": lambdaFormat === "esm" ? ".mjs" : ".cjs" },
2391
+ target: typescriptConfig.target,
2392
+ treeShaking: true
2393
+ });
2394
+ if (errors.length > 0) throw errors;
2395
+ };
2396
+ //#endregion
2397
+ //#region src/deploy/lambdaLayer/getPackageLambdaLayerStackName.ts
2398
+ const lambdaLayerStackNamePrefix = `LambdaLayer`;
2399
+ const getPackageLambdaLayerStackName = (packageName) => {
2400
+ const [scopedName, version] = packageName.split("@").filter((part) => {
2401
+ return !!part;
2402
+ });
2403
+ return [
2404
+ lambdaLayerStackNamePrefix,
2405
+ pascalCase(scopedName),
2406
+ version.replace(/[^0-9.]/g, "").replace(/\./g, "-")
2407
+ ].join("-");
2408
+ };
2409
+ //#endregion
2410
+ //#region src/deploy/lambdaLayer/deployLambdaLayer.ts
2411
+ const logPrefix$14 = "lambda-layer";
2412
+ const createLambdaLayerZipFile = async ({ codeBuildProjectName, packageName }) => {
2413
+ log.info(logPrefix$14, `Creating zip file for package ${packageName}...`);
2414
+ const { build } = await new AWS.CodeBuild().startBuild({
2415
+ environmentVariablesOverride: [{
2416
+ name: "PACKAGE_NAME",
2417
+ value: packageName
2418
+ }],
2419
+ projectName: codeBuildProjectName
2420
+ }).promise();
2421
+ if (!build?.id) throw new Error("Cannot start build.");
2422
+ const result = await waitCodeBuildFinish({
2423
+ buildId: build.id,
2424
+ name: packageName
2425
+ });
2426
+ if (result.artifacts?.location) {
2427
+ const location = result.artifacts.location.split("/");
2428
+ const bucket = location.shift()?.replace("arn:aws:s3:::", "");
2429
+ if (!bucket) throw new Error("Cannot retrieve bucket name.");
2430
+ return {
2431
+ bucket,
2432
+ key: location.join("/")
2433
+ };
2434
+ }
2435
+ throw new Error(`Cannot get artifact location for package ${packageName}`);
2436
+ };
2437
+ /**
2438
+ * The CloudFormation template created to deploy a Lambda Layer.
2439
+ *
2440
+ * - The Layer name is the same as the Stack name.
2441
+ */
2442
+ const getLambdaLayerTemplate = ({ bucket, key, packageName, runtime }) => {
2443
+ const description = packageName.substring(0, 256);
2444
+ return {
2445
+ AWSTemplateFormatVersion: "2010-09-09",
2446
+ Resources: { LambdaLayer: {
2447
+ Type: "AWS::Lambda::LayerVersion",
2448
+ Properties: {
2449
+ CompatibleRuntimes: [runtime || DEFAULT_NODE_RUNTIME],
2450
+ Content: {
2451
+ S3Bucket: bucket,
2452
+ S3Key: key
2453
+ },
2454
+ Description: description,
2455
+ LayerName: { Ref: "AWS::StackName" }
2456
+ }
2457
+ } },
2458
+ Outputs: { LambdaLayerVersion: {
2459
+ Description: description,
2460
+ Value: { Ref: "LambdaLayer" },
2461
+ Export: { Name: { Ref: "AWS::StackName" } }
2462
+ } }
2463
+ };
2464
+ };
2465
+ const getPackagesThatAreNotDeployed = async ({ packages }) => {
2466
+ return (await Promise.all(packages.map(async (packageName) => {
2467
+ return await doesStackExist({ stackName: getPackageLambdaLayerStackName(packageName) }) ? "" : packageName;
2468
+ }))).filter((packageName) => {
2469
+ return !!packageName;
2470
+ });
2471
+ };
2472
+ const deployLambdaLayer = async ({ packages, deployIfExists = true, runtime }) => {
2473
+ try {
2474
+ const packagesToBeDeployed = deployIfExists ? packages : await getPackagesThatAreNotDeployed({ packages });
2475
+ if (packagesToBeDeployed.length === 0) return;
2476
+ const codeBuildProjectName = await getBaseStackResource("BASE_STACK_LAMBDA_LAYER_BUILDER_LOGICAL_NAME");
2477
+ if (!codeBuildProjectName) throw new Error("Cannot deploy lambda-layer because AWS CodeBuild project doesn't exist.");
2478
+ const deployLambdaLayerSinglePackage = async (packageName) => {
2479
+ try {
2480
+ const { bucket, key } = await createLambdaLayerZipFile({
2481
+ codeBuildProjectName,
2482
+ packageName
2483
+ });
2484
+ await deploy({
2485
+ template: getLambdaLayerTemplate({
2486
+ packageName,
2487
+ bucket,
2488
+ key,
2489
+ runtime
2490
+ }),
2491
+ terminationProtection: true,
2492
+ params: { StackName: getPackageLambdaLayerStackName(packageName) }
2493
+ });
2494
+ } catch (error) {
2495
+ handleDeployError({
2496
+ error,
2497
+ logPrefix: logPrefix$14
2498
+ });
2499
+ }
2500
+ };
2501
+ await Promise.all(packagesToBeDeployed.map((packageName) => {
2502
+ return deployLambdaLayerSinglePackage(packageName);
2503
+ }));
2504
+ } catch (error) {
2505
+ handleDeployError({
2506
+ error,
2507
+ logPrefix: logPrefix$14
2508
+ });
2509
+ }
2510
+ };
2511
+ //#endregion
2512
+ //#region src/deploy/lambda/deployLambdaLayers.ts
2513
+ const logPrefix$13 = "lambda";
2514
+ const deployLambdaLayers = async ({ lambdaExternal = [] }) => {
2515
+ if (lambdaExternal.length === 0) return;
2516
+ log.info(logPrefix$13, `--lambda-externals [${lambdaExternal.join(", ")}] was found. Creating other layers...`);
2517
+ const { dependencies = {} } = (() => {
2518
+ try {
2519
+ return JSON.parse(fs$2.readFileSync(path$1.resolve(process.cwd(), "package.json"), "utf8"));
2520
+ } catch (err) {
2521
+ log.error(logPrefix$13, "Cannot read package.json. Error message: %j", err.message);
2522
+ return {};
2523
+ }
2524
+ })();
2525
+ await deployLambdaLayer({
2526
+ packages: lambdaExternal.map((external) => {
2527
+ try {
2528
+ return `${external}@${dependencies[external].replace(/(~|\^)/g, "")}`;
2529
+ } catch {
2530
+ throw new Error(`Cannot find ${external} on package.json dependencies.`);
2531
+ }
2532
+ }),
2533
+ deployIfExists: false
2534
+ });
2535
+ };
2536
+ //#endregion
2537
+ //#region src/deploy/lambda/uploadCodeToECR.ts
2538
+ new AWS.CodeBuild({ region: AWS_DEFAULT_REGION });
2539
+ const uploadCodeToECR = async ({ bucket, key, lambdaExternal, lambdaDockerfile }) => {
2540
+ throw new Error("uploadCodeToECR not finished yet.");
2541
+ };
2542
+ //#endregion
2543
+ //#region src/deploy/lambda/uploadCodeToS3.ts
2544
+ const logPrefix$12 = "lambda";
2545
+ const zipFileName = "lambda.zip";
2546
+ const uploadCodeToS3 = async ({ stackName, lambdaOutdir }) => {
2547
+ log.info(logPrefix$12, `Uploading code to S3...`);
2548
+ const zip = new AdmZip();
2549
+ const zipFile = `${lambdaOutdir}/${zipFileName}`;
2550
+ /**
2551
+ * Check if the zip file already exists and delete it before creating a new.
2552
+ */
2553
+ if (fs.existsSync(zipFile)) await fs.promises.rm(zipFile);
2554
+ /**
2555
+ * Zip entire directory.
2556
+ */
2557
+ zip.addLocalFolder(lambdaOutdir);
2558
+ zip.writeZip(`${lambdaOutdir}/${zipFileName}`);
2559
+ return uploadFileToS3({
2560
+ bucket: await getBaseStackResource("BASE_STACK_BUCKET_LOGICAL_NAME"),
2561
+ contentType: "application/zip",
2562
+ key: `lambdas/${stackName}/${zipFileName}`,
2563
+ file: zip.toBuffer()
2564
+ });
2565
+ };
2566
+ //#endregion
2567
+ //#region src/deploy/lambda/deployLambdaCode.ts
2568
+ const logPrefix$11 = "lambda";
2569
+ const deployLambdaCode = async ({ lambdaDockerfile, lambdaExternal = [], lambdaImage, lambdaEntryPoints, lambdaEntryPointsBaseDir = "src", lambdaFormat, lambdaOutdir = "out", stackName }) => {
2570
+ if (!lambdaEntryPoints.length) return {};
2571
+ log.info(logPrefix$11, "Deploying Lambda code...");
2572
+ for (const entryPoint of lambdaEntryPoints) {
2573
+ const entryPointPath = path.resolve(lambdaEntryPointsBaseDir, entryPoint);
2574
+ if (!fs.existsSync(entryPointPath)) throw new Error(`Entry point ${entryPointPath} does not exist.`);
2575
+ }
2576
+ await buildLambdaCode({
2577
+ lambdaExternal,
2578
+ lambdaEntryPoints,
2579
+ lambdaEntryPointsBaseDir,
2580
+ lambdaFormat,
2581
+ lambdaOutdir
2582
+ });
2583
+ const { bucket, key, versionId } = await uploadCodeToS3({
2584
+ stackName,
2585
+ lambdaOutdir
2586
+ });
2587
+ if (!lambdaImage) {
2588
+ await deployLambdaLayers({ lambdaExternal });
2589
+ return {
2590
+ bucket,
2591
+ key,
2592
+ versionId
2593
+ };
2594
+ }
2595
+ const { imageUri } = await uploadCodeToECR({
2596
+ bucket,
2597
+ key,
2598
+ versionId,
2599
+ lambdaDockerfile,
2600
+ lambdaExternal
2601
+ });
2602
+ return { imageUri };
2603
+ };
2604
+ //#endregion
2605
+ //#region src/deploy/cicd/getCicdStackName.ts
2606
+ const getCicdStackName = () => {
2607
+ return pascalCase([
2608
+ NAME,
2609
+ "Cicd",
2610
+ getProjectName()
2611
+ ].join(" "));
2612
+ };
2613
+ //#endregion
2614
+ //#region src/deploy/cicd/deployCicd.ts
2615
+ const logPrefix$10 = "cicd";
2616
+ const getLambdaInput = (extension) => {
2617
+ return path$1.resolve(__dirname, `lambdas/index.${extension}`);
2618
+ };
2619
+ const deployCicdLambdas = async ({ stackName }) => {
2620
+ const s3 = await deployLambdaCode({
2621
+ lambdaEntryPoints: [(() => {
2622
+ /**
2623
+ * This case happens when carlin command is executed when the package is
2624
+ * built.
2625
+ */
2626
+ if (fs$2.existsSync(getLambdaInput("js"))) return getLambdaInput("js");
2627
+ /**
2628
+ * The package isn't built.
2629
+ */
2630
+ if (fs$2.existsSync(getLambdaInput("ts"))) return getLambdaInput("ts");
2631
+ throw new Error("Cannot read CICD lambdas file.");
2632
+ })()],
2633
+ lambdaExternal: [],
2634
+ /**
2635
+ * Needs stackName to define the S3 key.
2636
+ */
2637
+ stackName
2638
+ });
2639
+ if (!s3 || !s3.bucket) throw new Error("Cannot retrieve bucket in which Lambda code was deployed.");
2640
+ return s3;
2641
+ };
2642
+ const waitRepositoryImageUpdate = async ({ stackName }) => {
2643
+ log.info(logPrefix$10, "Starting repository image update...");
2644
+ const { OutputValue: projectName } = await getStackOutput({
2645
+ stackName,
2646
+ outputKey: REPOSITORY_IMAGE_CODE_BUILD_PROJECT_LOGICAL_ID
2647
+ });
2648
+ if (!projectName) throw new Error(`Cannot retrieve repository image CodeBuild project name.`);
2649
+ const build = await startCodeBuildBuild({ projectName });
2650
+ if (build.id) await waitCodeBuildFinish({
2651
+ buildId: build.id,
2652
+ name: stackName
2653
+ });
2654
+ };
2655
+ const deployCicd = async ({ cpu, memory, pipelines, updateRepository, slackWebhookUrl, sshKey, sshUrl, taskEnvironment }) => {
2656
+ try {
2657
+ const { stackName } = await handleDeployInitialization({
2658
+ logPrefix: logPrefix$10,
2659
+ stackName: getCicdStackName()
2660
+ });
2661
+ await deploy({
2662
+ template: getCicdTemplate({
2663
+ cpu,
2664
+ memory,
2665
+ pipelines,
2666
+ s3: await deployCicdLambdas({ stackName }),
2667
+ slackWebhookUrl,
2668
+ taskEnvironment
2669
+ }),
2670
+ params: {
2671
+ StackName: stackName,
2672
+ Parameters: [{
2673
+ ParameterKey: "SSHUrl",
2674
+ ParameterValue: sshUrl
2675
+ }, {
2676
+ ParameterKey: "SSHKey",
2677
+ ParameterValue: sshKey
2678
+ }]
2679
+ },
2680
+ terminationProtection: true
2681
+ });
2682
+ if (updateRepository) await waitRepositoryImageUpdate({ stackName });
2683
+ } catch (error) {
2684
+ handleDeployError({
2685
+ error,
2686
+ logPrefix: logPrefix$10
2687
+ });
2688
+ }
2689
+ };
2690
+ //#endregion
2691
+ //#region src/deploy/cicd/readSSHKey.ts
2692
+ /**
2693
+ * Created to allow mocking.
2694
+ */
2695
+ const readSSHKey = (dir) => {
2696
+ return fs$2.readFileSync(dir, "utf-8");
2697
+ };
2698
+ //#endregion
2699
+ //#region src/deploy/cicd/command.ts
2700
+ const logPrefix$9 = "deploy-cicd";
2701
+ const deployCicdCommand = {
2702
+ command: "cicd",
2703
+ describe: "Deploy CICD.",
2704
+ builder: (yargs) => {
2705
+ return yargs.options(addGroupToOptions(options$6, "Deploy CICD Options"));
2706
+ },
2707
+ handler: ({ destroy, ...rest }) => {
2708
+ if (destroy) log.info(logPrefix$9, `${NAME} doesn't destroy CICD stack.`);
2709
+ else deployCicd({
2710
+ ...rest,
2711
+ sshKey: readSSHKey(rest["ssh-key"])
2712
+ });
2713
+ }
2714
+ };
2715
+ //#endregion
2716
+ //#region ../cloudformation/src/cloudFormationYamlTemplate.ts
2717
+ const cloudFormationTypes = [
2718
+ {
2719
+ tag: "!Equals",
2720
+ options: {
2721
+ kind: "sequence",
2722
+ construct: (data) => {
2723
+ return { "Fn::Equals": data };
2724
+ }
2725
+ }
2726
+ },
2727
+ {
2728
+ tag: "!FindInMap",
2729
+ options: {
2730
+ kind: "sequence",
2731
+ construct: (data) => {
2732
+ return { "Fn::FindInMap": data };
2733
+ }
2734
+ }
2735
+ },
2736
+ {
2737
+ tag: "!GetAtt",
2738
+ options: {
2739
+ kind: "scalar",
2740
+ construct: (data) => {
2741
+ return { "Fn::GetAtt": data.split(".") };
2742
+ }
2743
+ }
2744
+ },
2745
+ {
2746
+ tag: "!GetAtt",
2747
+ options: {
2748
+ kind: "sequence",
2749
+ construct: (data) => {
2750
+ return { "Fn::GetAtt": data };
2751
+ }
2752
+ }
2753
+ },
2754
+ {
2755
+ tag: "!If",
2756
+ options: {
2757
+ kind: "sequence",
2758
+ construct: (data) => {
2759
+ return { "Fn::If": data };
2760
+ }
2761
+ }
2762
+ },
2763
+ {
2764
+ tag: "!ImportValue",
2765
+ options: {
2766
+ kind: "scalar",
2767
+ construct: (data) => {
2768
+ return { "Fn::ImportValue": data };
2769
+ }
2770
+ }
2771
+ },
2772
+ {
2773
+ tag: "!Join",
2774
+ options: {
2775
+ kind: "sequence",
2776
+ construct: (data) => {
2777
+ return { "Fn::Join": data };
2778
+ }
2779
+ }
2780
+ },
2781
+ {
2782
+ tag: "!Not",
2783
+ options: {
2784
+ kind: "sequence",
2785
+ construct: (data) => {
2786
+ return { "Fn::Not": data };
2787
+ }
2788
+ }
2789
+ },
2790
+ {
2791
+ tag: "!Ref",
2792
+ options: {
2793
+ kind: "scalar",
2794
+ construct: (data) => {
2795
+ return { Ref: data };
2796
+ }
2797
+ }
2798
+ },
2799
+ {
2800
+ tag: "!Sub",
2801
+ options: {
2802
+ kind: "scalar",
2803
+ construct: (data) => {
2804
+ return { "Fn::Sub": data };
2805
+ }
2806
+ }
2807
+ },
2808
+ {
2809
+ tag: "!Sub",
2810
+ options: {
2811
+ kind: "sequence",
2812
+ construct: (data) => {
2813
+ return { "Fn::Sub": data };
2814
+ }
2815
+ }
2816
+ }
2817
+ ];
2818
+ const getYamlTypes = (tagAndTypeArr) => {
2819
+ return tagAndTypeArr.map(({ tag, options }) => {
2820
+ return new yaml.Type(tag, options);
2821
+ });
2822
+ };
2823
+ /**
2824
+ * Transform CloudFormation directives in objects. For example, transform
2825
+ * !Ref Something in { Ref: Something }.
2826
+ */
2827
+ const getSchema = (tagAndTypeArr = []) => {
2828
+ return yaml.DEFAULT_SCHEMA.extend(getYamlTypes([...tagAndTypeArr, ...cloudFormationTypes]));
2829
+ };
2830
+ /**
2831
+ * Transform YAML string in JSON object.
2832
+ *
2833
+ * @param template template in String format.
2834
+ * @param tagAndTypeArr YAML types.
2835
+ * @returns JSON template.
2836
+ */
2837
+ const loadCloudFormationTemplate = (template, tagAndTypeArr = []) => {
2838
+ return yaml.load(template, { schema: getSchema(tagAndTypeArr) });
2839
+ };
2840
+ //#endregion
2841
+ //#region ../cloudformation/src/readCloudFormationYamlTemplate.ts
2842
+ const getTypes = () => {
2843
+ return [{
2844
+ tag: `!SubString`,
2845
+ options: {
2846
+ kind: "scalar",
2847
+ construct: (filePath) => {
2848
+ return fs$3.readFileSync(path$2.resolve(process.cwd(), filePath)).toString();
2849
+ }
2850
+ }
2851
+ }];
2852
+ };
2853
+ /**
2854
+ * CloudFormation
2855
+ * @param param0
2856
+ */
2857
+ const readCloudFormationYamlTemplate = ({ templatePath }) => {
2858
+ const parsed = loadCloudFormationTemplate(fs$3.readFileSync(templatePath).toString(), getTypes());
2859
+ if (!parsed || typeof parsed === "string") throw new Error("Cannot parse CloudFormation template.");
2860
+ return parsed;
2861
+ };
2862
+ //#endregion
2863
+ //#region ../cloudformation/src/findAndReadCloudFormationTemplate.ts
2864
+ const defaultTemplatePaths$1 = [
2865
+ "ts",
2866
+ "js",
2867
+ "yaml",
2868
+ "yml",
2869
+ "json"
2870
+ ].map((extension) => {
2871
+ return `./src/cloudformation.${extension}`;
2872
+ });
2873
+ const findAndReadCloudFormationTemplate = async ({ templatePath: defaultTemplatePath, options = {} }) => {
2874
+ const templatePath = defaultTemplatePath || defaultTemplatePaths$1.reduce((acc, cur) => {
2875
+ if (acc) return acc;
2876
+ return fs$3.existsSync(path$2.resolve(process.cwd(), cur)) ? cur : acc;
2877
+ }, "");
2878
+ if (!templatePath) throw new Error("Cannot find a CloudFormation template.");
2879
+ const extension = templatePath?.split(".").pop();
2880
+ /**
2881
+ * We need to read Yaml first because CloudFormation specific tags aren't
2882
+ * recognized when parsing a simple Yaml file. I.e., a possible error:
2883
+ * "Error message: "unknown tag !<!Ref> at line 21, column 34:\n"
2884
+ */
2885
+ if (["yaml", "yml"].includes(extension)) return readCloudFormationYamlTemplate({ templatePath });
2886
+ return readConfigFile({
2887
+ configFilePath: path$2.resolve(process.cwd(), templatePath),
2888
+ options
2889
+ });
2890
+ };
2891
+ //#endregion
2892
+ //#region src/deploy/lambda/getLambdaEntryPointsFromTemplate.ts
2893
+ const getLambdaEntryPointsFromTemplate = (template) => {
2894
+ return Object.keys(template.Resources).filter((key) => {
2895
+ return ["AWS::Lambda::Function", "AWS::Serverless::Function"].includes(template.Resources[key].Type);
2896
+ }).map((key) => {
2897
+ return template.Resources[key].Properties?.Handler;
2898
+ }).filter((handler) => {
2899
+ return !!handler;
2900
+ }).map((handler) => {
2901
+ return handler.split(".")[0] + ".ts";
2902
+ });
2903
+ };
2904
+ //#endregion
2905
+ //#region src/deploy/cloudformation.ts
2906
+ const logPrefix$8 = "cloudformation";
2907
+ log.addLevel("event", 1e4, { fg: "yellow" });
2908
+ log.addLevel("output", 1e4, { fg: "blue" });
2909
+ [
2910
+ "ts",
2911
+ "js",
2912
+ "yaml",
2913
+ "yml",
2914
+ "json"
2915
+ ].map((extension) => {
2916
+ return `./src/cloudformation.${extension}`;
2917
+ });
2918
+ /**
2919
+ * When you use a method to generate your CloudFormation template, you can
2920
+ * retrieve the options from the CLI plus the following variables after CLI
2921
+ * validations and middlewares logic:
2922
+ *
2923
+ * - `stackName`
2924
+ * - `environment`
2925
+ * - `packageName`
2926
+ * - `projectName`
2927
+ *
2928
+ * For example, in your `cloudformation.ts` file:
2929
+ *
2930
+ * ```typescript
2931
+ * export default async ({ environment, region, stackName }) => {
2932
+ * // Do something with CLI options and the variables above.
2933
+ * }
2934
+ * ```
2935
+ */
2936
+ const getCloudformationTemplateOptions = ({ cliOptions, stackName }) => {
2937
+ return {
2938
+ ...cliOptions,
2939
+ stackName,
2940
+ environment: getEnvironment(),
2941
+ packageName: getPackageName(),
2942
+ projectName: getProjectName()
2943
+ };
2944
+ };
2945
+ const deployCloudFormation = async (cliOptions) => {
2946
+ try {
2947
+ const { lambdaDockerfile, lambdaEntryPoints, lambdaEntryPointsBaseDir, lambdaImage, lambdaExternal, lambdaFormat, lambdaOutdir, parameters, template, templatePath } = cliOptions;
2948
+ const { stackName } = await handleDeployInitialization({ logPrefix: logPrefix$8 });
2949
+ const cloudFormationTemplate = await (async () => {
2950
+ if (template) return { ...template };
2951
+ return findAndReadCloudFormationTemplate({
2952
+ templatePath,
2953
+ options: getCloudformationTemplateOptions({
2954
+ stackName,
2955
+ cliOptions
2956
+ })
2957
+ });
2958
+ })();
2959
+ /**
2960
+ * Add Parameters passed on CLI to CloudFormation template if they don't exist.
2961
+ * Also, automatically add the Type of the parameter.
2962
+ */
2963
+ if (parameters) for (const parameter of parameters) {
2964
+ if (cloudFormationTemplate.Parameters?.[parameter.key]) continue;
2965
+ if (!cloudFormationTemplate.Parameters) cloudFormationTemplate.Parameters = {};
2966
+ const type = (() => {
2967
+ if (typeof parameter.value === "string") return "String";
2968
+ if (typeof parameter.value === "number") return "Number";
2969
+ throw new Error(`Parameter assertion failed. Parameter ${parameter.key} value ${parameter.value} is not mapped.`);
2970
+ })();
2971
+ cloudFormationTemplate.Parameters[parameter.key] = { Type: type };
2972
+ }
2973
+ await validateTemplate({
2974
+ stackName,
2975
+ template: cloudFormationTemplate
2976
+ });
2977
+ const params = {
2978
+ StackName: stackName,
2979
+ Parameters: parameters?.map((parameter) => {
2980
+ return {
2981
+ ParameterKey: parameter.key,
2982
+ ParameterValue: parameter.value,
2983
+ UsePreviousValue: parameter.usePreviousValue,
2984
+ ResolvedValue: parameter.resolvedValue
2985
+ };
2986
+ }) || []
2987
+ };
2988
+ const deployCloudFormationDeployLambdaCode = async () => {
2989
+ const response = await deployLambdaCode({
2990
+ lambdaDockerfile,
2991
+ lambdaExternal,
2992
+ lambdaEntryPoints: (() => {
2993
+ if (lambdaEntryPoints && lambdaEntryPoints.length > 0) return lambdaEntryPoints;
2994
+ return getLambdaEntryPointsFromTemplate(cloudFormationTemplate);
2995
+ })(),
2996
+ lambdaEntryPointsBaseDir,
2997
+ lambdaFormat,
2998
+ lambdaImage,
2999
+ lambdaOutdir,
3000
+ stackName
3001
+ });
3002
+ if (response) {
3003
+ const { bucket, key, versionId, imageUri } = response;
3004
+ if (imageUri) {
3005
+ cloudFormationTemplate.Parameters = {
3006
+ LambdaImageUri: { Type: "String" },
3007
+ ...cloudFormationTemplate.Parameters
3008
+ };
3009
+ params.Parameters.push({
3010
+ ParameterKey: "LambdaImageUri",
3011
+ ParameterValue: imageUri
3012
+ });
3013
+ } else if (bucket && key && versionId) {
3014
+ /**
3015
+ * Add Parameters to CloudFormation template.
3016
+ */
3017
+ cloudFormationTemplate.Parameters = {
3018
+ LambdaS3Bucket: { Type: "String" },
3019
+ LambdaS3Key: { Type: "String" },
3020
+ LambdaS3ObjectVersion: { Type: "String" },
3021
+ ...cloudFormationTemplate.Parameters
3022
+ };
3023
+ /**
3024
+ * Add S3Bucket and S3Key to params.
3025
+ */
3026
+ params.Parameters.push(
3027
+ {
3028
+ ParameterKey: "LambdaS3Bucket",
3029
+ ParameterValue: bucket
3030
+ },
3031
+ {
3032
+ ParameterKey: "LambdaS3Key",
3033
+ ParameterValue: key
3034
+ },
3035
+ /**
3036
+ * Used by CloudFormation AWS::Lambda::Function
3037
+ * @see {@link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html}
3038
+ * and by CloudFormation AWS::Serverless::Function
3039
+ * @see {@link https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-functioncode.html}
3040
+ */
3041
+ {
3042
+ ParameterKey: "LambdaS3ObjectVersion",
3043
+ ParameterValue: versionId
3044
+ }
3045
+ );
3046
+ /**
3047
+ * Add `Code` property to every AWS::Lambda::Function resource or
3048
+ * `CodeUri` property to every AWS::Serverless::Function resource if
3049
+ * they are NOT already defined.
3050
+ */
3051
+ for (const key of Object.keys(cloudFormationTemplate.Resources)) {
3052
+ const resource = cloudFormationTemplate.Resources[key];
3053
+ if (resource.Type === "AWS::Lambda::Function") {
3054
+ if (!resource.Properties?.Code) resource.Properties = {
3055
+ ...resource.Properties,
3056
+ Code: {
3057
+ S3Bucket: { Ref: "LambdaS3Bucket" },
3058
+ S3Key: { Ref: "LambdaS3Key" },
3059
+ S3ObjectVersion: { Ref: "LambdaS3ObjectVersion" }
3060
+ }
3061
+ };
3062
+ }
3063
+ if (resource.Type === "AWS::Serverless::Function") {
3064
+ if (!resource.Properties?.CodeUri) resource.Properties = {
3065
+ ...resource.Properties,
3066
+ CodeUri: {
3067
+ Bucket: { Ref: "LambdaS3Bucket" },
3068
+ Key: { Ref: "LambdaS3Key" },
3069
+ Version: { Ref: "LambdaS3ObjectVersion" }
3070
+ }
3071
+ };
3072
+ }
3073
+ }
3074
+ }
3075
+ }
3076
+ };
3077
+ await deployCloudFormationDeployLambdaCode();
3078
+ return await deploy({
3079
+ params,
3080
+ template: cloudFormationTemplate
3081
+ });
3082
+ } catch (error) {
3083
+ return handleDeployError({
3084
+ error,
3085
+ logPrefix: logPrefix$8
3086
+ });
3087
+ }
3088
+ };
3089
+ const emptyStackBuckets = async ({ stackName }) => {
3090
+ const buckets = [];
3091
+ await (async ({ nextToken }) => {
3092
+ const { StackResourceSummaries } = await cloudFormationV2().listStackResources({
3093
+ StackName: stackName,
3094
+ NextToken: nextToken
3095
+ }).promise();
3096
+ for (const { ResourceType, PhysicalResourceId } of StackResourceSummaries || []) if (ResourceType === "AWS::S3::Bucket" && PhysicalResourceId) buckets.push(PhysicalResourceId);
3097
+ })({});
3098
+ return Promise.all(buckets.map((bucket) => {
3099
+ return emptyS3Directory({ bucket });
3100
+ }));
3101
+ };
3102
+ /**
3103
+ * 1. Check if `environment` is defined. If defined, do nothing. It doesn't
3104
+ * destroy stacks with defined `environment`.
3105
+ * 1. Check if termination protection is disabled.
3106
+ * 1. Empty all buckets in the stack (if any).
3107
+ * 1. Delete the stack.
3108
+ */
3109
+ const destroy = async ({ stackName }) => {
3110
+ const environment = getEnvironment();
3111
+ if (environment) {
3112
+ log.info(logPrefix$8, `Cannot destroy stack when environment (${environment}) is defined.`);
3113
+ return;
3114
+ }
3115
+ if (!await doesStackExist({ stackName })) {
3116
+ log.info(logPrefix$8, `Stack ${stackName} doesn't exist.`);
3117
+ return;
3118
+ }
3119
+ if (!await canDestroyStack({ stackName })) {
3120
+ const message = `Stack ${stackName} cannot be destroyed while TerminationProtection is enabled.`;
3121
+ throw new Error(message);
3122
+ }
3123
+ try {
3124
+ await emptyStackBuckets({ stackName });
3125
+ } catch (error) {
3126
+ log.warn(logPrefix$8, `Failed to empty buckets for stack ${stackName}: ${error?.message || error}. Proceeding with stack deletion.`);
3127
+ }
3128
+ await deleteStack({ stackName });
3129
+ };
3130
+ const destroyCloudFormation = async ({ stackName: defaultStackName } = {}) => {
3131
+ try {
3132
+ log.info(logPrefix$8, "CAUTION! Starting CloudFormation destroy...");
3133
+ const stackName = defaultStackName || await getStackName();
3134
+ log.info(logPrefix$8, `stackName: ${stackName}`);
3135
+ await destroy({ stackName });
3136
+ } catch (error) {
3137
+ handleDeployError({
3138
+ error,
3139
+ logPrefix: logPrefix$8
3140
+ });
3141
+ }
3142
+ };
3143
+ //#endregion
3144
+ //#region src/deploy/lambdaLayer/command.ts
3145
+ const logPrefix$7 = "deploy-lambda-layer";
3146
+ /**
3147
+ * https://stackoverflow.com/a/64880672/8786986
3148
+ */
3149
+ const packageNameRegex = /@[~^]?([\dvx*]+(?:[-.](?:[\dx*]+|alpha|beta))*)/;
3150
+ const options$5 = { packages: {
3151
+ array: true,
3152
+ describe: `NPM packages' names to be deployed as Lambda Layers. It must follow the format: ${packageNameRegex.toString()}.`,
3153
+ required: true,
3154
+ type: "string"
3155
+ } };
3156
+ const deployLambdaLayerCommand = {
3157
+ command: "lambda-layer",
3158
+ describe: "Deploy Lambda Layer.",
3159
+ builder: (yargs) => {
3160
+ return yargs.options(addGroupToOptions(options$5, "Deploy Lambda Layer Options")).check(({ packages }) => {
3161
+ const invalidPackages = packages.map((packageName) => {
3162
+ return packageNameRegex.test(packageName) ? void 0 : packageName;
3163
+ }).filter((packageName) => {
3164
+ return !!packageName;
3165
+ });
3166
+ if (invalidPackages.length > 0) throw new Error(`Some package names are invalid: ${invalidPackages.join(", ")}. The package must follow the pattern: ${packageNameRegex.toString()}.`);
3167
+ else return true;
3168
+ });
3169
+ },
3170
+ handler: ({ destroy, lambdaRuntime, ...rest }) => {
3171
+ if (destroy) log.info(logPrefix$7, `${NAME} doesn't destroy lambda layers.`);
3172
+ else deployLambdaLayer({
3173
+ ...rest,
3174
+ runtime: lambdaRuntime
3175
+ });
3176
+ }
3177
+ };
3178
+ //#endregion
3179
+ //#region src/deploy/readDockerfile.ts
3180
+ /**
3181
+ * This method was created because fs.readFileSync cannot be mocked.
3182
+ */
3183
+ const readDockerfile = (dockerfilePath) => {
3184
+ try {
3185
+ return fs$2.readFileSync(path$1.join(process.cwd(), dockerfilePath), "utf8");
3186
+ } catch {
3187
+ return "";
3188
+ }
3189
+ };
3190
+ //#endregion
3191
+ //#region src/deploy/reportToGitHubPR.ts
3192
+ const logPrefix$6 = "report";
3193
+ const getGitHubErrorMessage = async (response) => {
3194
+ try {
3195
+ const body = await response.json();
3196
+ if (body.message) return `${response.status} ${response.statusText} - ${body.message}`;
3197
+ } catch {}
3198
+ return `${response.status} ${response.statusText}`;
3199
+ };
3200
+ const GITHUB_PR_COMMENT_MARKER = "<!-- carlin-deploy-outputs -->";
3201
+ const readAllDeployFiles = async () => {
3202
+ const files = await glob("**/.carlin/*.json", {
3203
+ absolute: true,
3204
+ ignore: ["**/node_modules/**", `**/.carlin/${LATEST_DEPLOY_OUTPUTS_FILENAME}`]
3205
+ });
3206
+ const results = [];
3207
+ for (const file of files) try {
3208
+ const raw = await fs$3.promises.readFile(file, "utf-8");
3209
+ const content = JSON.parse(raw);
3210
+ if (content.stackName && content.outputs) results.push(content);
3211
+ } catch {
3212
+ log.warn(logPrefix$6, `Could not read deploy file: ${path$2.basename(file)}`);
3213
+ }
3214
+ return results.sort((a, b) => {
3215
+ return a.packageName.localeCompare(b.packageName);
3216
+ });
3217
+ };
3218
+ const buildMarkdownComment = (deploys) => {
3219
+ const header = `${GITHUB_PR_COMMENT_MARKER}\n\n## Deploy Outputs\n`;
3220
+ if (deploys.length === 0) return `${header}\nNo deploy outputs found.`;
3221
+ return `${header}\n${[
3222
+ "| Package | Stack | Output Key | Output Value |",
3223
+ "|---------|-------|------------|--------------|",
3224
+ ...deploys.flatMap(({ packageName, stackName, outputs }) => {
3225
+ return Object.values(outputs).map(({ OutputKey, OutputValue }) => {
3226
+ return `| \`${packageName}\` | \`${stackName}\` | \`${OutputKey}\` | ${OutputValue} |`;
3227
+ });
3228
+ })
3229
+ ].join("\n")}`;
3230
+ };
3231
+ const getPrNumber = async ({ branch, repo, token }) => {
3232
+ const [owner] = repo.split("/");
3233
+ const response = await fetch(`https://api.github.com/repos/${repo}/pulls?head=${owner}:${branch}&state=open&per_page=1`, { headers: {
3234
+ Accept: "application/vnd.github+json",
3235
+ Authorization: `Bearer ${token}`,
3236
+ "X-GitHub-Api-Version": "2022-11-28"
3237
+ } });
3238
+ if (!response.ok) throw new Error(`GitHub API error fetching PR: ${await getGitHubErrorMessage(response)}`);
3239
+ const prs = await response.json();
3240
+ if (prs.length === 0) throw new Error(`No open PR found for branch: ${branch}`);
3241
+ return prs[0].number;
3242
+ };
3243
+ const findExistingComment = async ({ prNumber, repo, token }) => {
3244
+ const response = await fetch(`https://api.github.com/repos/${repo}/issues/${prNumber}/comments?per_page=100`, { headers: {
3245
+ Accept: "application/vnd.github+json",
3246
+ Authorization: `Bearer ${token}`,
3247
+ "X-GitHub-Api-Version": "2022-11-28"
3248
+ } });
3249
+ if (!response.ok) throw new Error(`GitHub API error fetching comments: ${await getGitHubErrorMessage(response)}`);
3250
+ return (await response.json()).find(({ body }) => {
3251
+ return body.includes(GITHUB_PR_COMMENT_MARKER);
3252
+ });
3253
+ };
3254
+ const createOrUpdateComment = async ({ body, existingCommentId, prNumber, repo, token }) => {
3255
+ const url = existingCommentId ? `https://api.github.com/repos/${repo}/issues/comments/${existingCommentId}` : `https://api.github.com/repos/${repo}/issues/${prNumber}/comments`;
3256
+ const method = existingCommentId ? "PATCH" : "POST";
3257
+ const response = await fetch(url, {
3258
+ body: JSON.stringify({ body }),
3259
+ headers: {
3260
+ Accept: "application/vnd.github+json",
3261
+ Authorization: `Bearer ${token}`,
3262
+ "Content-Type": "application/json",
3263
+ "X-GitHub-Api-Version": "2022-11-28"
3264
+ },
3265
+ method
3266
+ });
3267
+ if (!response.ok) throw new Error(`GitHub API error ${method} comment: ${await getGitHubErrorMessage(response)}`);
3268
+ };
3269
+ const reportToGitHubPR = async () => {
3270
+ const token = process.env.GH_TOKEN;
3271
+ const repo = process.env.GITHUB_REPOSITORY;
3272
+ const branch = process.env.CARLIN_BRANCH;
3273
+ if (!token) throw new Error("GH_TOKEN environment variable is required for --channel=github-pr");
3274
+ if (!repo) throw new Error("GITHUB_REPOSITORY environment variable is required for --channel=github-pr");
3275
+ if (!branch) throw new Error("CARLIN_BRANCH environment variable is required for --channel=github-pr");
3276
+ log.info(logPrefix$6, "Reading deploy outputs from workspace...");
3277
+ const deploys = await readAllDeployFiles();
3278
+ log.info(logPrefix$6, `Found ${deploys.length} deploy file(s).`);
3279
+ const prNumber = await getPrNumber({
3280
+ branch,
3281
+ repo,
3282
+ token
3283
+ });
3284
+ log.info(logPrefix$6, `Reporting to PR #${prNumber}...`);
3285
+ const body = buildMarkdownComment(deploys);
3286
+ const existingComment = await findExistingComment({
3287
+ prNumber,
3288
+ repo,
3289
+ token
3290
+ });
3291
+ await createOrUpdateComment({
3292
+ body,
3293
+ existingCommentId: existingComment?.id,
3294
+ prNumber,
3295
+ repo,
3296
+ token
3297
+ });
3298
+ log.info(logPrefix$6, existingComment ? "PR comment updated." : "PR comment created.");
3299
+ };
3300
+ //#endregion
3301
+ //#region src/deploy/staticApp/findDefaultBuildFolder.ts
3302
+ /**
3303
+ * Fixes #20 https://github.com/ttoss/carlin/issues/20
3304
+ */
3305
+ const defaultBuildFolders = [
3306
+ "build",
3307
+ "out",
3308
+ "storybook-static",
3309
+ "dist"
3310
+ ];
3311
+ const findDefaultBuildFolder = async () => {
3312
+ return (await Promise.all(defaultBuildFolders.map(async (directory) => {
3313
+ return {
3314
+ directory,
3315
+ isValid: (await getAllFilesInsideADirectory({ directory })).length !== 0
3316
+ };
3317
+ }))).reduce((acc, cur) => {
3318
+ if (cur.isValid) return cur.directory;
3319
+ return acc;
3320
+ }, "");
3321
+ };
3322
+ //#endregion
3323
+ //#region src/deploy/staticApp/getStaticAppBucket.ts
3324
+ const STATIC_APP_BUCKET_LOGICAL_ID$1 = "StaticBucket";
3325
+ const getStaticAppBucket = async ({ stackName }) => {
3326
+ const params = {
3327
+ LogicalResourceId: STATIC_APP_BUCKET_LOGICAL_ID$1,
3328
+ StackName: stackName
3329
+ };
3330
+ try {
3331
+ const { StackResourceDetail } = await describeStackResource(params);
3332
+ return StackResourceDetail?.PhysicalResourceId;
3333
+ } catch {
3334
+ return;
3335
+ }
3336
+ };
3337
+ //#endregion
3338
+ //#region src/deploy/staticApp/invalidateCloudFront.ts
3339
+ const CLOUDFRONT_DISTRIBUTION_ID = "CloudFrontDistributionId";
3340
+ const logPrefix$5 = "static-app";
3341
+ const invalidateCloudFront = async ({ outputs }) => {
3342
+ log.info(logPrefix$5, "Invalidating CloudFront...");
3343
+ if (!outputs) {
3344
+ log.info(logPrefix$5, "Invalidation: outputs do not exist.");
3345
+ return;
3346
+ }
3347
+ const cloudFrontDistributionIDOutput = outputs.find((output) => {
3348
+ return output.OutputKey === CLOUDFRONT_DISTRIBUTION_ID;
3349
+ });
3350
+ if (cloudFrontDistributionIDOutput?.OutputValue) {
3351
+ const distributionId = cloudFrontDistributionIDOutput.OutputValue;
3352
+ const params = {
3353
+ DistributionId: distributionId,
3354
+ InvalidationBatch: {
3355
+ CallerReference: (/* @__PURE__ */ new Date()).toISOString(),
3356
+ Paths: {
3357
+ Items: ["/*"],
3358
+ Quantity: 1
3359
+ }
3360
+ }
3361
+ };
3362
+ const cloudFront = new AWS.CloudFront();
3363
+ try {
3364
+ await cloudFront.createInvalidation(params).promise();
3365
+ log.info(logPrefix$5, `CloudFront Distribution ID ${distributionId} invalidated with success.`);
3366
+ } catch (err) {
3367
+ log.error(logPrefix$5, `Error while trying to invalidate CloudFront distribution ${distributionId}.`);
3368
+ log.error(logPrefix$5, err);
3369
+ }
3370
+ } else log.info(logPrefix$5, `Cannot invalidate because distribution does not exist.`);
3371
+ };
3372
+ //#endregion
3373
+ //#region src/deploy/staticApp/staticApp.template.ts
3374
+ const PACKAGE_VERSION = getPackageVersion();
3375
+ const STATIC_APP_BUCKET_LOGICAL_ID = "StaticBucket";
3376
+ const CLOUDFRONT_DISTRIBUTION_LOGICAL_ID = "CloudFrontDistribution";
3377
+ const CLOUDFRONT_ORIGIN_ACCESS_CONTROL_LOGICAL_ID = "OriginAccessControl";
3378
+ const ROUTE_53_RECORD_SET_GROUP_LOGICAL_ID = "Route53RecordSetGroup";
3379
+ const ERROR_DOCUMENT = "404/index.html";
3380
+ /**
3381
+ * Name: Managed-CachingDisabled
3382
+ * ID: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
3383
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
3384
+ */
3385
+ const CACHE_POLICY_ID = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
3386
+ /**
3387
+ * Name: Managed-CORS-S3Origin
3388
+ * ID: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
3389
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
3390
+ */
3391
+ const ORIGIN_REQUEST_POLICY_ID = "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf";
3392
+ /**
3393
+ * Name: CORS-with-preflight-and-SecurityHeadersPolicy
3394
+ * ID: eaab4381-ed33-4a86-88ca-d9558dc6cd63
3395
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-response-headers-policies.html
3396
+ */
3397
+ const ORIGIN_RESPONSE_POLICY_ID = "eaab4381-ed33-4a86-88ca-d9558dc6cd63";
3398
+ const getBucketStaticWebsiteTemplate = ({ spa }) => {
3399
+ return {
3400
+ AWSTemplateFormatVersion: "2010-09-09",
3401
+ Resources: {
3402
+ [STATIC_APP_BUCKET_LOGICAL_ID]: {
3403
+ Type: "AWS::S3::Bucket",
3404
+ Properties: {
3405
+ CorsConfiguration: { CorsRules: [{
3406
+ AllowedHeaders: ["*"],
3407
+ AllowedMethods: ["GET"],
3408
+ AllowedOrigins: ["*"],
3409
+ Id: "OpenCors",
3410
+ MaxAge: 600
3411
+ }] },
3412
+ PublicAccessBlockConfiguration: { BlockPublicPolicy: false },
3413
+ WebsiteConfiguration: {
3414
+ IndexDocument: `index.html`,
3415
+ ErrorDocument: spa ? "index.html" : ERROR_DOCUMENT
3416
+ }
3417
+ }
3418
+ },
3419
+ [`${STATIC_APP_BUCKET_LOGICAL_ID}S3BucketPolicy`]: {
3420
+ Type: "AWS::S3::BucketPolicy",
3421
+ Properties: {
3422
+ Bucket: { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3423
+ PolicyDocument: { Statement: [{
3424
+ Action: ["s3:GetObject"],
3425
+ Effect: "Allow",
3426
+ Principal: "*",
3427
+ Resource: { "Fn::Join": ["", [
3428
+ "arn:aws:s3:::",
3429
+ { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3430
+ "/*"
3431
+ ]] }
3432
+ }] }
3433
+ }
3434
+ }
3435
+ },
3436
+ Outputs: { BucketWebsiteURL: {
3437
+ Description: "Bucket static app website URL",
3438
+ Value: { "Fn::GetAtt": [STATIC_APP_BUCKET_LOGICAL_ID, "WebsiteURL"] }
3439
+ } }
3440
+ };
3441
+ };
3442
+ const getCloudFrontTemplate = ({ acm, aliases = [], appendIndexHtml, spa, hostedZoneName }) => {
3443
+ const template = {
3444
+ AWSTemplateFormatVersion: "2010-09-09",
3445
+ Resources: {
3446
+ [STATIC_APP_BUCKET_LOGICAL_ID]: {
3447
+ Type: "AWS::S3::Bucket",
3448
+ Properties: { PublicAccessBlockConfiguration: { BlockPublicPolicy: false } }
3449
+ },
3450
+ [`${STATIC_APP_BUCKET_LOGICAL_ID}S3BucketPolicy`]: {
3451
+ Type: "AWS::S3::BucketPolicy",
3452
+ Properties: {
3453
+ Bucket: { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3454
+ PolicyDocument: { Statement: [(
3455
+ /**
3456
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html
3457
+ */
3458
+ {
3459
+ Sid: "AllowCloudFrontServicePrincipalReadOnly",
3460
+ Effect: "Allow",
3461
+ Principal: { Service: "cloudfront.amazonaws.com" },
3462
+ Action: "s3:GetObject",
3463
+ Resource: { "Fn::Join": ["", [
3464
+ "arn:aws:s3:::",
3465
+ { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3466
+ "/*"
3467
+ ]] },
3468
+ Condition: { StringEquals: { "AWS:SourceArn": { "Fn::Join": ["", [
3469
+ "arn:aws:cloudfront::",
3470
+ { Ref: "AWS::AccountId" },
3471
+ ":distribution/",
3472
+ { Ref: CLOUDFRONT_DISTRIBUTION_LOGICAL_ID }
3473
+ ]] } } }
3474
+ })] }
3475
+ }
3476
+ }
3477
+ }
3478
+ };
3479
+ const cloudFrontResources = {
3480
+ [CLOUDFRONT_DISTRIBUTION_LOGICAL_ID]: {
3481
+ Type: "AWS::CloudFront::Distribution",
3482
+ Properties: { DistributionConfig: {
3483
+ Comment: { "Fn::Sub": ["CloudFront Distribution for ${Project} project.", { Project: { Ref: "Project" } }] },
3484
+ CustomErrorResponses: [403, 404].map((errorCode) => {
3485
+ if (spa) return {
3486
+ ErrorCachingMinTTL: 3600 * 24,
3487
+ ErrorCode: errorCode,
3488
+ ResponseCode: 200,
3489
+ ResponsePagePath: "/index.html"
3490
+ };
3491
+ return {
3492
+ ErrorCachingMinTTL: 0,
3493
+ ErrorCode: errorCode,
3494
+ ResponseCode: 404,
3495
+ ResponsePagePath: "/404/index.html"
3496
+ };
3497
+ }),
3498
+ DefaultCacheBehavior: {
3499
+ AllowedMethods: [
3500
+ "GET",
3501
+ "HEAD",
3502
+ "OPTIONS"
3503
+ ],
3504
+ Compress: true,
3505
+ CachedMethods: [
3506
+ "GET",
3507
+ "HEAD",
3508
+ "OPTIONS"
3509
+ ],
3510
+ /**
3511
+ * Caching OPTIONS. Related to OriginRequestPolicyId property.
3512
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/header-caching.html#header-caching-web-cors
3513
+ */
3514
+ OriginRequestPolicyId: ORIGIN_REQUEST_POLICY_ID,
3515
+ /**
3516
+ * CachePolicyId property:
3517
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-defaultcachebehavior.html#cfn-cloudfront-distribution-defaultcachebehavior-cachepolicyid
3518
+ */
3519
+ CachePolicyId: CACHE_POLICY_ID,
3520
+ ResponseHeadersPolicyId: ORIGIN_RESPONSE_POLICY_ID,
3521
+ TargetOriginId: { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3522
+ ViewerProtocolPolicy: "redirect-to-https"
3523
+ },
3524
+ DefaultRootObject: "index.html",
3525
+ Enabled: true,
3526
+ HttpVersion: "http2",
3527
+ Origins: [{
3528
+ DomainName: { "Fn::GetAtt": [STATIC_APP_BUCKET_LOGICAL_ID, "DomainName"] },
3529
+ Id: { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
3530
+ OriginAccessControlId: { "Fn::GetAtt": [CLOUDFRONT_ORIGIN_ACCESS_CONTROL_LOGICAL_ID, "Id"] },
3531
+ /**
3532
+ * Note: As of September 2022, an empty OriginAccessIdentity must be specified in S3OriginConfig.
3533
+ */
3534
+ S3OriginConfig: { OriginAccessIdentity: "" }
3535
+ }]
3536
+ } }
3537
+ },
3538
+ [CLOUDFRONT_ORIGIN_ACCESS_CONTROL_LOGICAL_ID]: {
3539
+ Type: "AWS::CloudFront::OriginAccessControl",
3540
+ Properties: { OriginAccessControlConfig: {
3541
+ Description: { "Fn::Sub": ["Default Origin Access Control for ${Project} project.", { Project: { Ref: "Project" } }] },
3542
+ Name: { Ref: "AWS::StackName" },
3543
+ OriginAccessControlOriginType: "s3",
3544
+ SigningBehavior: "always",
3545
+ SigningProtocol: "sigv4"
3546
+ } }
3547
+ }
3548
+ };
3549
+ if (acm) {
3550
+ const acmCertificateArn = /^arn:aws:acm:[-a-z0-9]+:\d{12}:certificate\/[-a-z0-9]+$/.test(acm) ? acm : { "Fn::ImportValue": acm };
3551
+ /**
3552
+ * Add ACM to CloudFront template.
3553
+ */
3554
+ if (!cloudFrontResources.CloudFrontDistribution.Properties) cloudFrontResources.CloudFrontDistribution.Properties = {};
3555
+ cloudFrontResources.CloudFrontDistribution.Properties.DistributionConfig = {
3556
+ ...cloudFrontResources.CloudFrontDistribution.Properties.DistributionConfig,
3557
+ Aliases: aliases || { Ref: "AWS::NoValue" },
3558
+ ViewerCertificate: {
3559
+ AcmCertificateArn: acmCertificateArn,
3560
+ /**
3561
+ * AWS CloudFront recommendation.
3562
+ */
3563
+ MinimumProtocolVersion: "TLSv1.2_2021",
3564
+ SslSupportMethod: "sni-only"
3565
+ }
3566
+ };
3567
+ }
3568
+ /**
3569
+ * Add aliases to Route 53 records.
3570
+ */
3571
+ if (hostedZoneName && aliases) {
3572
+ const recordSets = aliases.map((alias) => {
3573
+ if (alias === hostedZoneName) return {
3574
+ AliasTarget: {
3575
+ DNSName: { "Fn::GetAtt": `${CLOUDFRONT_DISTRIBUTION_LOGICAL_ID}.DomainName` },
3576
+ /**
3577
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html#cfn-route53-aliastarget-hostedzoneid
3578
+ */
3579
+ HostedZoneId: "Z2FDTNDATAQYW2"
3580
+ },
3581
+ Name: alias,
3582
+ Type: "A"
3583
+ };
3584
+ return {
3585
+ Name: alias,
3586
+ ResourceRecords: [{ "Fn::GetAtt": `${CLOUDFRONT_DISTRIBUTION_LOGICAL_ID}.DomainName` }],
3587
+ TTL: 60,
3588
+ Type: "CNAME"
3589
+ };
3590
+ });
3591
+ const route53RecordSetGroupResources = { [ROUTE_53_RECORD_SET_GROUP_LOGICAL_ID]: {
3592
+ Type: "AWS::Route53::RecordSetGroup",
3593
+ DependsOn: [CLOUDFRONT_DISTRIBUTION_LOGICAL_ID],
3594
+ Properties: {
3595
+ HostedZoneName: `${hostedZoneName}${hostedZoneName.endsWith(".") ? "" : "."}`,
3596
+ RecordSets: recordSets
3597
+ }
3598
+ } };
3599
+ template.Resources = {
3600
+ ...template.Resources,
3601
+ ...route53RecordSetGroupResources
3602
+ };
3603
+ }
3604
+ template.Resources = {
3605
+ ...template.Resources,
3606
+ ...cloudFrontResources
3607
+ };
3608
+ /**
3609
+ * Add aliases output to template.
3610
+ */
3611
+ const aliasesOutput = (aliases || []).reduce((acc, alias, index) => {
3612
+ return {
3613
+ ...acc,
3614
+ [`Alias${index}URL`]: { Value: `https://${alias}` }
3615
+ };
3616
+ }, {});
3617
+ if (appendIndexHtml) {
3618
+ if (!template.Resources["CloudFrontDistribution"].Properties) template.Resources[CLOUDFRONT_DISTRIBUTION_LOGICAL_ID].Properties = {};
3619
+ template.Resources[CLOUDFRONT_DISTRIBUTION_LOGICAL_ID].Properties.DistributionConfig.DefaultCacheBehavior.FunctionAssociations = [(
3620
+ /**
3621
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-lambdafunctionassociation.html
3622
+ */
3623
+ {
3624
+ EventType: "viewer-request",
3625
+ FunctionARN: { "Fn::ImportValue": BASE_STACK_CLOUDFRONT_FUNCTION_APPEND_INDEX_HTML_ARN_EXPORTED_NAME }
3626
+ })];
3627
+ }
3628
+ /**
3629
+ * Add CloudFront Distribution ID and CloudFront URL to template.
3630
+ */
3631
+ template.Outputs = {
3632
+ ...template.Outputs,
3633
+ ...aliasesOutput,
3634
+ CloudFrontURL: { Value: { "Fn::Join": ["", ["https://", { "Fn::GetAtt": `${CLOUDFRONT_DISTRIBUTION_LOGICAL_ID}.DomainName` }]] } },
3635
+ CloudFrontDistributionId: { Value: { Ref: CLOUDFRONT_DISTRIBUTION_LOGICAL_ID } },
3636
+ CurrentVersion: { Value: PACKAGE_VERSION }
3637
+ };
3638
+ return template;
3639
+ };
3640
+ const getStaticAppTemplate = ({ acm, aliases, appendIndexHtml, cloudfront, spa, hostedZoneName, region }) => {
3641
+ if (cloudfront) return getCloudFrontTemplate({
3642
+ acm,
3643
+ aliases,
3644
+ appendIndexHtml,
3645
+ cloudfront,
3646
+ spa,
3647
+ hostedZoneName,
3648
+ region
3649
+ });
3650
+ return getBucketStaticWebsiteTemplate({ spa });
3651
+ };
3652
+ //#endregion
3653
+ //#region src/deploy/staticApp/uploadBuiltAppToS3.ts
3654
+ const uploadBuiltAppToS3 = async ({ buildFolder: directory, bucket }) => {
3655
+ /**
3656
+ * Only empty directory if the number of the files inside $directory.
3657
+ * If the number of files is zero, uploadDirectoryToS3 will thrown.
3658
+ */
3659
+ if (directory) {
3660
+ if ((await getAllFilesInsideADirectory({ directory })).length > 0) await deleteOldS3Files({
3661
+ bucket,
3662
+ retentionDays: 7
3663
+ });
3664
+ await uploadDirectoryToS3({
3665
+ bucket,
3666
+ directory
3667
+ });
3668
+ return;
3669
+ }
3670
+ const defaultDirectory = await findDefaultBuildFolder();
3671
+ if (defaultDirectory) {
3672
+ await deleteOldS3Files({
3673
+ bucket,
3674
+ retentionDays: 7
3675
+ });
3676
+ await uploadDirectoryToS3({
3677
+ bucket,
3678
+ directory: defaultDirectory
3679
+ });
3680
+ await copyRoot404To404Index({ bucket });
3681
+ return;
3682
+ }
3683
+ throw new Error(`build-folder option wasn't provided and files weren't found in ${defaultBuildFolders.join(", ")} directories.`);
3684
+ };
3685
+ //#endregion
3686
+ //#region src/deploy/staticApp/deployStaticApp.ts
3687
+ const logPrefix$4 = "static-app";
3688
+ /**
3689
+ * 1. Create the stack name that will be passed to CloudFormation.
3690
+ * 1. Create a CloudFormation template based on the type of the deployment, and
3691
+ * the options, for instance, only S3, SPA, with hosted zone...
3692
+ * 1. Create AWS resources using the templated created.
3693
+ * 1. Upload static files to the host bucket S3.
3694
+ * 1. Remove old deployment versions. Keep only the 3 most recent ones.
3695
+ */
3696
+ const deployStaticApp = async ({ acm, aliases, appendIndexHtml, buildFolder, cloudfront, spa, hostedZoneName, region, skipUpload }) => {
3697
+ try {
3698
+ const { stackName } = await handleDeployInitialization({ logPrefix: logPrefix$4 });
3699
+ const params = { StackName: stackName };
3700
+ const template = getStaticAppTemplate({
3701
+ acm,
3702
+ aliases,
3703
+ appendIndexHtml,
3704
+ cloudfront,
3705
+ spa,
3706
+ hostedZoneName,
3707
+ region
3708
+ });
3709
+ const bucket = await getStaticAppBucket({ stackName });
3710
+ /**
3711
+ * Stack already exists. Upload files first after changing the files routes
3712
+ * because of the version changing.
3713
+ */
3714
+ if (bucket) {
3715
+ if (!skipUpload) await uploadBuiltAppToS3({
3716
+ buildFolder,
3717
+ bucket,
3718
+ cloudfront
3719
+ });
3720
+ const { Outputs } = await deploy({
3721
+ params,
3722
+ template
3723
+ });
3724
+ await invalidateCloudFront({ outputs: Outputs });
3725
+ } else {
3726
+ /**
3727
+ * Stack doesn't exist. Deploy CloudFormation first, get the bucket name,
3728
+ * and upload files to S3.
3729
+ */
3730
+ await deploy({
3731
+ params,
3732
+ template
3733
+ });
3734
+ const newBucket = await getStaticAppBucket({ stackName });
3735
+ if (!newBucket) throw new Error(`Cannot find bucket at ${stackName}.`);
3736
+ await uploadBuiltAppToS3({
3737
+ buildFolder,
3738
+ bucket: newBucket,
3739
+ cloudfront
3740
+ });
3741
+ }
3742
+ } catch (error) {
3743
+ handleDeployError({
3744
+ error,
3745
+ logPrefix: logPrefix$4
3746
+ });
3747
+ }
3748
+ };
3749
+ //#endregion
3750
+ //#region src/deploy/staticApp/command.ts
3751
+ const options$4 = {
3752
+ acm: {
3753
+ describe: "The ARN of the certificate or the name of the exported variable whose value is the ARN of the certificate that will be associated to CloudFront.",
3754
+ type: "string"
3755
+ },
3756
+ aliases: {
3757
+ describe: "The aliases that will be associated with the CloudFront. See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html",
3758
+ implies: ["acm"],
3759
+ type: "array"
3760
+ },
3761
+ "append-index-html": {
3762
+ default: false,
3763
+ describe: "This option appends the `index.html` to the request URI. This is useful when deploying a Docusaurus website, for example.",
3764
+ type: "boolean"
3765
+ },
3766
+ "build-folder": {
3767
+ describe: `The folder that will be uploaded. If not provided, it'll search for the folders "${defaultBuildFolders.join(", ")}."`,
3768
+ type: "string"
3769
+ },
3770
+ cloudfront: {
3771
+ default: false,
3772
+ describe: "A CloudFront resource is created along with S3 if this option is `true`.",
3773
+ require: false,
3774
+ type: "boolean"
3775
+ },
3776
+ "hosted-zone-name": {
3777
+ required: false,
3778
+ describe: `Is the name of a Route 53 hosted zone. If this value is provided, ${NAME} creates the subdomains defined on \`--aliases\` option. E.g. if you have a hosted zone named "sub.domain.com", the value provided may be "sub.domain.com".`,
3779
+ type: "string"
3780
+ },
3781
+ /**
3782
+ * CloudFront triggers can be only in US East (N. Virginia) Region.
3783
+ * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-cloudfront-triggers
3784
+ */
3785
+ region: {
3786
+ coerce: () => {
3787
+ return CLOUDFRONT_REGION;
3788
+ },
3789
+ default: CLOUDFRONT_REGION,
3790
+ hidden: true,
3791
+ type: "string"
3792
+ },
3793
+ "skip-upload": {
3794
+ default: false,
3795
+ describe: "Skip files upload to S3. Useful when wanting update only CloudFormation.",
3796
+ type: "boolean"
3797
+ },
3798
+ spa: {
3799
+ default: false,
3800
+ describe: "This option enables CloudFront to serve a single page application (SPA).",
3801
+ require: false,
3802
+ type: "boolean"
3803
+ }
3804
+ };
3805
+ const deployStaticAppCommand = {
3806
+ command: "static-app",
3807
+ describe: "Deploy static app.",
3808
+ builder: (yargs) => {
3809
+ return yargs.options(addGroupToOptions(options$4, "Deploy Static App Options")).middleware(() => {
3810
+ AWS.config.region = CLOUDFRONT_REGION;
3811
+ });
3812
+ },
3813
+ handler: ({ destroy, ...rest }) => {
3814
+ if (destroy) destroyCloudFormation();
3815
+ else deployStaticApp(rest);
3816
+ }
3817
+ };
3818
+ //#endregion
3819
+ //#region src/deploy/vm/command.options.ts
3820
+ const options$3 = {
3821
+ "user-name": {
3822
+ demandOption: true,
3823
+ describe: "SSH user name to connect to the VM (e.g., ec2-user, ubuntu)",
3824
+ type: "string"
3825
+ },
3826
+ host: {
3827
+ demandOption: true,
3828
+ describe: "VM host IP address or DNS name",
3829
+ type: "string"
3830
+ },
3831
+ port: {
3832
+ describe: "SSH port (default: 22)",
3833
+ type: "number",
3834
+ default: 22
3835
+ },
3836
+ "key-path": {
3837
+ describe: "Path to the SSH private key file (.pem)",
3838
+ type: "string"
3839
+ },
3840
+ password: {
3841
+ describe: "SSH password (use key-path for better security)",
3842
+ type: "string"
3843
+ },
3844
+ "script-path": {
3845
+ demandOption: true,
3846
+ describe: "Path to the deployment script to execute on the VM",
3847
+ type: "string"
3848
+ },
3849
+ "fix-permissions": {
3850
+ describe: "Automatically fix SSH key permissions if too open",
3851
+ type: "boolean",
3852
+ default: false
3853
+ }
3854
+ };
3855
+ //#endregion
3856
+ //#region src/deploy/vm/VMconnection.ts
3857
+ /**
3858
+ * Generates an SSH command array for key-based authentication.
3859
+ * @param userName - SSH username for the connection.
3860
+ * @param host - Remote host address or hostname.
3861
+ * @param keyPath - Optional path to the SSH private key file.
3862
+ * @param port - SSH port (default: 22).
3863
+ * @returns Array of command parts for spawning SSH process.
3864
+ */
3865
+ const generateSSHCommand = ({ userName, host, keyPath, port }) => {
3866
+ const commandParts = ["ssh", "-T"];
3867
+ if (keyPath) commandParts.push("-i", keyPath);
3868
+ if (port && port !== 22) commandParts.push("-p", port.toString());
3869
+ commandParts.push(`${userName}@${host}`, "bash -s");
3870
+ return commandParts;
3871
+ };
3872
+ /**
3873
+ * Generate an SSH command configuration that uses password-based authentication
3874
+ * with the native `ssh` client.
3875
+ *
3876
+ * ⚠️ **IMPORTANT LIMITATION**: This implementation may not work reliably because
3877
+ * SSH's password prompt reads directly from `/dev/tty`, not from stdin. Writing
3878
+ * the password to stdin will likely fail. To support password authentication
3879
+ * properly, consider using utilities like `sshpass` or SSH libraries like
3880
+ * `node-ssh` or `ssh2` that handle interactive password prompts correctly.
3881
+ *
3882
+ * The password is **not** embedded in the command line; instead, it is returned
3883
+ * separately and is expected to be written to the SSH process stdin when the
3884
+ * client prompts for a password. This avoids exposing the password in process
3885
+ * listings but still relies on password authentication, which is generally less
3886
+ * secure than key-based authentication.
3887
+ *
3888
+ * **Recommendation**: Use key-based authentication with `generateSSHCommand` instead.
3889
+ */
3890
+ const generateSSHCommandWithPwd = ({ userName, host, password, port }) => {
3891
+ const commandParts = [
3892
+ "ssh",
3893
+ "-o",
3894
+ "PubkeyAuthentication=no",
3895
+ "-o",
3896
+ "PreferredAuthentications=password"
3897
+ ];
3898
+ if (port && port !== 22) commandParts.push("-p", port.toString());
3899
+ commandParts.push(`${userName}@${host}`, "bash -s");
3900
+ return {
3901
+ command: commandParts,
3902
+ password
3903
+ };
3904
+ };
3905
+ //#endregion
3906
+ //#region src/deploy/vm/deployVM.ts
3907
+ const logPrefix$3 = "deploy-vm";
3908
+ const deployVM = async ({ userName, host, scriptPath, keyPath, password, port, fixPermissions = false }) => {
3909
+ if (!userName || !host || !scriptPath) throw new Error("Missing required parameters: userName, host, scriptPath");
3910
+ return new Promise((resolve, reject) => {
3911
+ if (!keyPath && !password) throw new Error(`Authentication method required. Provide either --key-path for SSH key authentication or --password for password authentication.`);
3912
+ if (keyPath && !existsSync(keyPath)) throw new Error(`SSH key not found at ${keyPath}`);
3913
+ if (keyPath) try {
3914
+ const permissions = statSync(keyPath).mode & 511;
3915
+ if (!(permissions === 256 || permissions === 384)) {
3916
+ const permissionStr = permissions.toString(8);
3917
+ const fixCommand = `chmod 400 ${keyPath}`;
3918
+ if (fixPermissions) {
3919
+ log.info(logPrefix$3, `Fixing SSH key permissions: ${keyPath} (${permissionStr} → 400)`);
3920
+ chmodSync(keyPath, 256);
3921
+ log.info(logPrefix$3, `Permissions set to 400 (read-only by owner)`);
3922
+ } else {
3923
+ log.error(logPrefix$3, `SSH key permissions too open: ${permissionStr} (octal)`);
3924
+ log.error(logPrefix$3, `SSH requires permissions 400 or 600`);
3925
+ log.error(logPrefix$3, `Fix manually: ${fixCommand}`);
3926
+ log.error(logPrefix$3, `Or run with: --fix-permissions`);
3927
+ throw new Error(`Invalid SSH key permissions: ${permissionStr}. Expected 400 or 600.`);
3928
+ }
3929
+ } else log.info(logPrefix$3, `SSH key permissions OK: ${permissions.toString(8)}`);
3930
+ } catch (error) {
3931
+ if (error instanceof Error) {
3932
+ if (error.message.includes("Invalid SSH key permissions")) throw error;
3933
+ log.warn(logPrefix$3, `Warning: Could not check key permissions: ${error.message}`);
3934
+ } else log.warn(logPrefix$3, "Warning: Could not check key permissions: Unknown error");
3935
+ }
3936
+ let sshCommand;
3937
+ let sshPassword;
3938
+ if (keyPath) sshCommand = generateSSHCommand({
3939
+ userName,
3940
+ host,
3941
+ keyPath,
3942
+ port
3943
+ });
3944
+ else {
3945
+ if (!password) throw new Error("Password authentication selected but no password was provided.");
3946
+ const result = generateSSHCommandWithPwd({
3947
+ userName,
3948
+ host,
3949
+ password,
3950
+ port
3951
+ });
3952
+ sshCommand = result.command;
3953
+ sshPassword = result.password;
3954
+ }
3955
+ const sshProcess = spawn(sshCommand[0], sshCommand.slice(1), { stdio: [
3956
+ "pipe",
3957
+ "inherit",
3958
+ "inherit"
3959
+ ] });
3960
+ const validateStdin = (stdin) => {
3961
+ if (!stdin) {
3962
+ log.error(logPrefix$3, "SSH process stdin is null or undefined");
3963
+ return false;
3964
+ }
3965
+ if (stdin.destroyed) {
3966
+ log.error(logPrefix$3, "SSH process stdin has been destroyed");
3967
+ return false;
3968
+ }
3969
+ if (!stdin.writable) {
3970
+ log.error(logPrefix$3, "SSH process stdin is not writable");
3971
+ return false;
3972
+ }
3973
+ return true;
3974
+ };
3975
+ if (!validateStdin(sshProcess.stdin)) {
3976
+ reject(/* @__PURE__ */ new Error("SSH process stdin is not available or not writable"));
3977
+ return;
3978
+ }
3979
+ if (!existsSync(scriptPath)) {
3980
+ const message = `Deployment script not found at path: ${scriptPath}`;
3981
+ log.error(logPrefix$3, message);
3982
+ reject(new Error(message));
3983
+ return;
3984
+ }
3985
+ const deployScript = createReadStream(scriptPath);
3986
+ if (sshPassword) sshProcess.stdin.write(sshPassword + "\n");
3987
+ deployScript.pipe(sshProcess.stdin);
3988
+ const sigintHandler = () => {
3989
+ log.info(logPrefix$3, "Interrupting deployment...");
3990
+ sshProcess.kill("SIGINT");
3991
+ process.exit(130);
3992
+ };
3993
+ process.on("SIGINT", sigintHandler);
3994
+ const cleanup = () => {
3995
+ process.removeListener("SIGINT", sigintHandler);
3996
+ };
3997
+ sshProcess.on("close", (code) => {
3998
+ cleanup();
3999
+ if (code === 0) resolve();
4000
+ else reject(/* @__PURE__ */ new Error(`Deploy failed with code ${code}`));
4001
+ });
4002
+ sshProcess.on("error", (error) => {
4003
+ cleanup();
4004
+ reject(error);
4005
+ });
4006
+ });
4007
+ };
4008
+ //#endregion
4009
+ //#region src/deploy/vm/command.ts
4010
+ const logPrefix$2 = "deploy-vm";
4011
+ const deployVMCommand = {
4012
+ command: "vm",
4013
+ describe: "Deploy to a VM via SSH by executing a deployment script",
4014
+ builder: (yargs) => {
4015
+ return yargs.options(addGroupToOptions(options$3, "Deploy VM Options"));
4016
+ },
4017
+ handler: async ({ userName, host, port, keyPath, scriptPath, password, fixPermissions }) => {
4018
+ try {
4019
+ await deployVM({
4020
+ userName,
4021
+ host,
4022
+ scriptPath,
4023
+ keyPath,
4024
+ password,
4025
+ port,
4026
+ fixPermissions
4027
+ });
4028
+ log.info(logPrefix$2, "Deployment completed successfully!");
4029
+ } catch (error) {
4030
+ log.error(logPrefix$2, "Deployment failed: %s", error.message);
4031
+ process.exit(1);
4032
+ }
4033
+ }
4034
+ };
4035
+ //#endregion
4036
+ //#region src/deploy/command.ts
4037
+ const logPrefix$1 = "deploy";
4038
+ const checkAwsAccountId = async (awsAccountId) => {
4039
+ try {
4040
+ const currentAwsAccountId = await getAwsAccountId();
4041
+ if (String(awsAccountId) !== String(currentAwsAccountId)) throw new Error(`AWS account id does not match. Current is "${currentAwsAccountId}" but the defined in configuration files is "${awsAccountId}".`);
4042
+ } catch (error) {
4043
+ if (error.code === "CredentialsError")
4044
+ /**
4045
+ * No credentials found.
4046
+ */
4047
+ return;
4048
+ log.error(logPrefix$1, error.message);
4049
+ process.exit();
4050
+ }
4051
+ };
4052
+ const reportDeployCommand = {
4053
+ command: "report",
4054
+ describe: "Report the outputs of the deployment.",
4055
+ builder: (yargs) => {
4056
+ return yargs.options({ channel: {
4057
+ choices: ["github-pr"],
4058
+ describe: "Report deploy outputs to the specified channel. Use \"github-pr\" to post or update a PR comment with all workspace deploy outputs.",
4059
+ type: "string"
4060
+ } });
4061
+ },
4062
+ handler: async ({ stackName, channel }) => {
4063
+ try {
4064
+ if (channel === "github-pr") {
4065
+ await reportToGitHubPR();
4066
+ return;
4067
+ }
4068
+ await printStackOutputsAfterDeploy({ stackName: stackName || await getStackName() });
4069
+ } catch (error) {
4070
+ log.info(logPrefix$1, "Cannot report stack. Message: %s", error.message);
4071
+ }
4072
+ }
4073
+ };
4074
+ const options$2 = {
4075
+ "aws-account-id": {
4076
+ describe: "AWS account id associated with the deployment.",
4077
+ type: "string"
4078
+ },
4079
+ destroy: {
4080
+ default: false,
4081
+ describe: "Destroy the deployment. You cannot destroy a deploy when \"environment\" is defined.",
4082
+ type: "boolean"
4083
+ },
4084
+ "lambda-dockerfile": {
4085
+ coerce: (arg) => {
4086
+ return readDockerfile(arg);
4087
+ },
4088
+ default: "Dockerfile",
4089
+ describe: "Instructions to create the Lambda image.",
4090
+ type: "string"
4091
+ },
4092
+ "lambda-image": {
4093
+ default: false,
4094
+ describe: "A Lambda image will be created instead using S3.",
4095
+ type: "boolean"
4096
+ },
4097
+ "lambda-external": {
4098
+ default: [],
4099
+ describe: "External modules that will not be bundled in the Lambda code.",
4100
+ type: "array"
4101
+ },
4102
+ "lambda-entry-points-base-dir": {
4103
+ default: "src",
4104
+ describe: "Base directory for Lambda entry points.",
4105
+ type: "string"
4106
+ },
4107
+ "lambda-entry-points": {
4108
+ default: [],
4109
+ describe: "This is an array of files that each serve as an input to the bundling algorithm for Lambda functions.",
4110
+ type: "string"
4111
+ },
4112
+ "lambda-format": {
4113
+ choices: ["esm", "cjs"],
4114
+ default: "esm",
4115
+ describe: "Lambda code format.",
4116
+ type: "string"
4117
+ },
4118
+ "lambda-outdir": {
4119
+ default: "out",
4120
+ describe: "Output directory for built Lambda code.",
4121
+ type: "string"
4122
+ },
4123
+ "lambda-runtime": {
4124
+ choices: [
4125
+ "nodejs20.x",
4126
+ "nodejs22.x",
4127
+ "nodejs24.x"
4128
+ ],
4129
+ default: "nodejs24.x",
4130
+ describe: "Node.js runtime for Lambda functions.",
4131
+ type: "string"
4132
+ },
4133
+ /**
4134
+ * This option has the format to match [CloudFormation parameter](https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_Parameter.html).
4135
+ *
4136
+ * ```ts
4137
+ * {
4138
+ * key: string,
4139
+ * value: string,
4140
+ * usePreviousValue: boolean,
4141
+ * resolvedValue: string
4142
+ * }[]
4143
+ * ```
4144
+ *
4145
+ * For example:
4146
+ *
4147
+ * ```ts
4148
+ * [
4149
+ * {
4150
+ * key: 'key1',
4151
+ * value: 'value1',
4152
+ * },
4153
+ * {
4154
+ * key: 'key2',
4155
+ * value: 'value2',
4156
+ * }
4157
+ * ]
4158
+ * ```
4159
+ *
4160
+ * If you want to simplify the usage, you can pass a object with key and value only:
4161
+ *
4162
+ * ```ts
4163
+ * {
4164
+ * key1: 'value1',
4165
+ * key2: 'value2'
4166
+ * }
4167
+ * ```
4168
+ */
4169
+ parameters: {
4170
+ alias: "p",
4171
+ coerce: (arg) => {
4172
+ if (Array.isArray(arg)) return arg;
4173
+ if (typeof arg === "object") return Object.entries(arg).map(([key, value]) => {
4174
+ return {
4175
+ key,
4176
+ value
4177
+ };
4178
+ });
4179
+ return [];
4180
+ },
4181
+ default: [],
4182
+ describe: "A list of parameters that will be passed to CloudFormation Parameters when deploying."
4183
+ },
4184
+ "skip-deploy": {
4185
+ alias: "skip",
4186
+ default: false,
4187
+ describe: "Skip the deploy command.",
4188
+ type: "boolean"
4189
+ },
4190
+ "stack-name": {
4191
+ describe: "Set the stack name.",
4192
+ type: "string"
4193
+ },
4194
+ "template-path": {
4195
+ alias: "t",
4196
+ describe: "Path to the CloudFormation template.",
4197
+ type: "string"
4198
+ }
4199
+ };
4200
+ const examples = [
4201
+ ["carlin deploy -t src/cloudformation.template1.yml", "Change the CloudFormation template path."],
4202
+ ["carlin deploy -e Production", "Set environment."],
4203
+ ["carlin deploy --lambda-externals momentjs", "Lambda exists. Don't bundle momentjs."],
4204
+ ["carlin deploy --lambda-runtime nodejs20.x", "Use Node.js 20.x runtime for Lambda functions."],
4205
+ ["carlin deploy --destroy --stack-name StackToBeDeleted", "Destroy a specific stack."]
4206
+ ];
4207
+ const deployCommand = {
4208
+ command: "deploy [deploy]",
4209
+ describe: "Deploy cloud resources.",
4210
+ builder: (yargsBuilder) => {
4211
+ yargsBuilder.example(examples).options(addGroupToOptions(options$2, "Deploy Options")).middleware(({ stackName }) => {
4212
+ if (stackName) setPreDefinedStackName(stackName);
4213
+ }).middleware((argv) => {
4214
+ if (argv.lambdaDockerfile) Object.assign(argv, { lambdaImage: true });
4215
+ }).middleware(async ({ environments, environment, awsAccountId: defaultAwsAccountId }) => {
4216
+ const envAwsAccountId = (() => {
4217
+ return environments && environment && environments[environment] ? environments[environment].awsAccountId : void 0;
4218
+ })();
4219
+ if (envAwsAccountId) await checkAwsAccountId(envAwsAccountId);
4220
+ if (defaultAwsAccountId) await checkAwsAccountId(defaultAwsAccountId);
4221
+ }).middleware(({ skipDeploy }) => {
4222
+ if (skipDeploy) {
4223
+ log.warn(logPrefix$1, "Skip deploy flag is true, then the deploy command wasn't executed.");
4224
+ process.exit(0);
4225
+ }
4226
+ }).middleware(({ lambdaExternals, lambdaInput }) => {
4227
+ if (lambdaInput) throw new Error("Option \"lambdaInput\" was removed. Please use \"lambdaEntryPoints\" instead.");
4228
+ if (lambdaExternals) throw new Error("Option \"lambdaExternals\" was removed. Please use \"lambdaExternal\" instead.");
4229
+ });
4230
+ const commands = [
4231
+ deployLambdaLayerCommand,
4232
+ reportDeployCommand,
4233
+ deployBaseStackCommand,
4234
+ deployStaticAppCommand,
4235
+ deployCicdCommand,
4236
+ deployVMCommand
4237
+ ];
4238
+ yargsBuilder.positional("deploy", {
4239
+ choices: commands.map(({ command }) => {
4240
+ return command;
4241
+ }),
4242
+ describe: "Deploy command.",
4243
+ type: "string"
4244
+ });
4245
+ for (const command of commands) yargsBuilder.command(command);
4246
+ return yargsBuilder;
4247
+ },
4248
+ handler: ({ destroy, ...rest }) => {
4249
+ if (destroy) destroyCloudFormation();
4250
+ else deployCloudFormation(rest);
4251
+ }
4252
+ };
4253
+ //#endregion
4254
+ //#region src/generateEnv/generateEnv.ts
4255
+ const logPrefix = "generate-env";
4256
+ const readEnvFile = async ({ envFileName, envsPath }) => {
4257
+ try {
4258
+ return await fs$3.promises.readFile(path$2.resolve(process.cwd(), envsPath, envFileName), "utf8");
4259
+ } catch {
4260
+ return;
4261
+ }
4262
+ };
4263
+ const writeEnvFile = async ({ envFileName, content }) => {
4264
+ return fs$3.promises.writeFile(path$2.resolve(process.cwd(), envFileName), content);
4265
+ };
4266
+ const readDeployOutputLines = async ({ envFromDeployOutputs }) => {
4267
+ const lines = [];
4268
+ for (const { dir, variables } of envFromDeployOutputs) {
4269
+ const latestDeployPath = path$2.resolve(process.cwd(), dir, ".carlin", LATEST_DEPLOY_OUTPUTS_FILENAME);
4270
+ let latestDeploy;
4271
+ try {
4272
+ const raw = await fs$3.promises.readFile(latestDeployPath, "utf8");
4273
+ latestDeploy = JSON.parse(raw);
4274
+ } catch {
4275
+ log.warn(logPrefix, "Could not read latest-deploy.json from %s. Skipping.", latestDeployPath);
4276
+ continue;
4277
+ }
4278
+ const outputs = latestDeploy.outputs ?? {};
4279
+ for (const [envVarName, outputPath] of Object.entries(variables)) {
4280
+ const dotIndex = outputPath.indexOf(".");
4281
+ const outputKey = dotIndex === -1 ? outputPath : outputPath.slice(0, dotIndex);
4282
+ const field = dotIndex === -1 ? "OutputValue" : outputPath.slice(dotIndex + 1);
4283
+ const outputValue = outputs[outputKey]?.[field];
4284
+ if (outputValue === void 0) {
4285
+ log.warn(logPrefix, "Output path \"%s\" not found in %s. Skipping %s.", outputPath, latestDeployPath, envVarName);
4286
+ continue;
4287
+ }
4288
+ lines.push(`${envVarName}=${outputValue}`);
4289
+ }
4290
+ }
4291
+ return lines;
4292
+ };
4293
+ /**
4294
+ * Generate environment for packages using `carlin`. If [environment](/docs/CLI#environment)
4295
+ * isn't defined, `carlin` will read `.env.Staging` file if exists and write
4296
+ * `.env` file. If it's `Environment`, it'll read `.env.Environment` file instead.
4297
+ * For example, if `environment` is `Production`, `carlin` will read `.env.Production`
4298
+ */
4299
+ const generateEnv = async ({ defaultEnvironment, envFromDeployOutputs, path: envsPath }) => {
4300
+ const envFileName = `.env.${getEnvironment() || defaultEnvironment}`;
4301
+ const envFile = await readEnvFile({
4302
+ envFileName,
4303
+ envsPath
4304
+ });
4305
+ if (!envFile) {
4306
+ log.info(logPrefix, "Env file %s doesn't exist. Skip generating env file.", envFileName);
4307
+ return;
4308
+ }
4309
+ const deployOutputLines = envFromDeployOutputs && envFromDeployOutputs.length > 0 ? await readDeployOutputLines({ envFromDeployOutputs }) : [];
4310
+ const deployOutputKeys = new Set(deployOutputLines.map((line) => {
4311
+ return line.split("=")[0];
4312
+ }));
4313
+ const filteredEnvFile = envFile.split("\n").filter((line) => {
4314
+ const trimmed = line.trim();
4315
+ if (!trimmed || trimmed.startsWith("#")) return true;
4316
+ const key = trimmed.split("=")[0].trim();
4317
+ return !deployOutputKeys.has(key);
4318
+ }).join("\n");
4319
+ await writeEnvFile({
4320
+ content: deployOutputLines.length > 0 ? `${filteredEnvFile}\n${deployOutputLines.join("\n")}\n` : envFile,
4321
+ envFileName: ".env"
4322
+ });
4323
+ log.info(logPrefix, "Generate env file %s from %s successfully.", ".env", envFileName);
4324
+ };
4325
+ const options$1 = {
4326
+ "default-environment": {
4327
+ alias: "d",
4328
+ type: "string",
4329
+ describe: "Default environment.",
4330
+ default: "Staging"
4331
+ },
4332
+ path: {
4333
+ alias: "p",
4334
+ type: "string",
4335
+ describe: "Path to the directory where envs files are located.",
4336
+ default: "./"
4337
+ }
4338
+ };
4339
+ const generateEnvCommand = {
4340
+ command: [
4341
+ "generate-env",
4342
+ "ge",
4343
+ "env"
4344
+ ],
4345
+ describe: "Generate environment files.",
4346
+ builder: (yargs) => {
4347
+ return yargs.options(options$1);
4348
+ },
4349
+ handler: (args) => {
4350
+ return generateEnv(args);
4351
+ }
4352
+ };
4353
+ //#endregion
4354
+ //#region src/cli.ts
4355
+ const coerceSetEnvVar = (env) => {
4356
+ return (value) => {
4357
+ setEnvVar(env, value);
4358
+ return value;
4359
+ };
4360
+ };
4361
+ const options = {
4362
+ branch: {
4363
+ coerce: coerceSetEnvVar("BRANCH"),
4364
+ require: false,
4365
+ type: "string"
4366
+ },
4367
+ config: {
4368
+ alias: "c",
4369
+ describe: "Path to config file. You can create a config file and set all options there. Valid extensions: .js, .json, .ts, .yml, or .yaml.",
4370
+ require: false,
4371
+ type: "string"
4372
+ },
4373
+ environment: {
4374
+ alias: ["e", "env"],
4375
+ coerce: coerceSetEnvVar("ENVIRONMENT"),
4376
+ type: "string"
4377
+ },
4378
+ environments: {},
4379
+ project: {
4380
+ coerce: coerceSetEnvVar("PROJECT"),
4381
+ require: false,
4382
+ type: "string"
4383
+ },
4384
+ region: {
4385
+ alias: "r",
4386
+ default: AWS_DEFAULT_REGION,
4387
+ describe: "AWS region.",
4388
+ type: "string"
4389
+ }
4390
+ };
4391
+ /**
4392
+ * You can also provide the options creating a property name `carlin`
4393
+ * inside your `package.json`.
4394
+ */
4395
+ const getPkgConfig = () => {
4396
+ return NAME;
4397
+ };
4398
+ /**
4399
+ * You can set the options as environment variables matching the prefix
4400
+ * `CARLIN`. The examples below are equivalent:
4401
+ *
4402
+ * - `carlin deploy --stack-name MyStackName`
4403
+ * - `CARLIN_STACK_NAME=MyStackName carlin deploy`
4404
+ *
4405
+ * `ENVIRONMENT` is a special case because it is used to set the `environment`
4406
+ * option, as well `CARLIN_ENVIRONMENT`. The examples below are
4407
+ * equivalent:
4408
+ *
4409
+ * - `carlin deploy --environment Production`
4410
+ * - `CARLIN_ENVIRONMENT=Production carlin deploy`
4411
+ * - `ENVIRONMENT=Production carlin deploy`
4412
+ */
4413
+ const getEnv = () => {
4414
+ return constantCase(NAME);
4415
+ };
4416
+ const normalizeConfigOptionValue = ({ value }) => {
4417
+ if (!value || value === "undefined") return;
4418
+ return value;
4419
+ };
4420
+ const getArgValue = ({ args, names }) => {
4421
+ for (const [index, arg] of args.entries()) {
4422
+ const equalSignName = names.find((name) => {
4423
+ return arg.startsWith(`${name}=`);
4424
+ });
4425
+ if (equalSignName) return normalizeConfigOptionValue({ value: arg.slice(equalSignName.length + 1) });
4426
+ if (names.includes(arg)) return normalizeConfigOptionValue({ value: args[index + 1] });
4427
+ }
4428
+ };
4429
+ const getConfigFileOptions = ({ args = hideBin(process.argv) } = {}) => {
4430
+ return {
4431
+ branch: getArgValue({
4432
+ args,
4433
+ names: ["--branch"]
4434
+ }) || process.env.CARLIN_BRANCH,
4435
+ environment: getArgValue({
4436
+ args,
4437
+ names: [
4438
+ "--environment",
4439
+ "--env",
4440
+ "-e"
4441
+ ]
4442
+ }) || process.env.CARLIN_ENVIRONMENT || process.env.ENVIRONMENT,
4443
+ project: getArgValue({
4444
+ args,
4445
+ names: ["--project"]
4446
+ }) || process.env.CARLIN_PROJECT
4447
+ };
4448
+ };
4449
+ const getConfigFileNames = () => {
4450
+ return [
4451
+ "ts",
4452
+ "js",
4453
+ "yml",
4454
+ "yaml",
4455
+ "json"
4456
+ ].map((ext) => {
4457
+ return `${NAME}.${ext}`;
4458
+ });
4459
+ };
4460
+ const findConfigFilePaths = () => {
4461
+ const names = getConfigFileNames();
4462
+ const paths = [];
4463
+ let currentPath = process.cwd();
4464
+ let findUpPath;
4465
+ do {
4466
+ findUpPath = findUpSync(names, { cwd: currentPath });
4467
+ if (findUpPath) {
4468
+ currentPath = path.resolve(findUpPath, "../..");
4469
+ paths.push(findUpPath);
4470
+ }
4471
+ } while (findUpPath);
4472
+ return paths;
4473
+ };
4474
+ /**
4475
+ * If `--config` isn't provided, Carlin searches for config files named
4476
+ * `carlin.ts`, `carlin.js`, `carlin.yml`, `carlin.yaml`, or `carlin.json`.
4477
+ * In monorepos, files from parent directories are merged first, so the nearest
4478
+ * config file takes precedence over shared root configuration.
4479
+ */
4480
+ const readConfigFiles = () => {
4481
+ const configs = findConfigFilePaths().map((configFilePath) => {
4482
+ return readConfigFileSync({
4483
+ configFilePath,
4484
+ options: getConfigFileOptions()
4485
+ }) || {};
4486
+ });
4487
+ return deepMerge.all(configs.reverse());
4488
+ };
4489
+ /**
4490
+ * Load the appropriate .env file. If an environment is specified (e.g. `-e
4491
+ * Production`) and a `.env.Production` file exists, load only that file so
4492
+ * environment-specific values are authoritative and nothing from a generic
4493
+ * `.env` can bleed through. Fall back to `.env` when no environment-specific
4494
+ * file is found or when no environment is specified.
4495
+ */
4496
+ const loadDotEnv = () => {
4497
+ const { environment } = getConfigFileOptions();
4498
+ if (environment) {
4499
+ if (dotenv.config({ path: path.resolve(process.cwd(), `.env.${environment}`) }).error) dotenv.config();
4500
+ } else dotenv.config();
4501
+ };
4502
+ const syncEnvironmentOption = (argv) => {
4503
+ const finalEnvironment = argv.environment || process.env.ENVIRONMENT;
4504
+ if (finalEnvironment) {
4505
+ setEnvVar("ENVIRONMENT", finalEnvironment);
4506
+ const envEntries = ["environment", ...options.environment.alias].map((key) => {
4507
+ return [key, finalEnvironment];
4508
+ });
4509
+ Object.assign(argv, Object.fromEntries(envEntries));
4510
+ }
4511
+ };
4512
+ /**
4513
+ * Transformed to method because finalConfig was failing the tests because as
4514
+ * function we encapsulate the logic and it is not executed on the import.
4515
+ */
4516
+ const cli = () => {
4517
+ loadDotEnv();
4518
+ let finalConfig;
4519
+ const getConfig = () => {
4520
+ return finalConfig = readConfigFiles();
4521
+ };
4522
+ const handleEnvironments = (argv, { parsed }) => {
4523
+ const { environment, environments } = argv;
4524
+ if (environment && environments && environments[environment]) {
4525
+ for (const [key, value] of Object.entries(environments[environment])) if (!(() => {
4526
+ const kebabCaseKey = kebabCase(key);
4527
+ if (parsed?.defaulted?.[kebabCaseKey]) return false;
4528
+ if (deepEqual(argv[key], finalConfig[key])) return false;
4529
+ return true;
4530
+ })()) argv[key] = value;
4531
+ }
4532
+ };
4533
+ return yargs(hideBin(process.argv)).strictCommands().scriptName(NAME).env(getEnv()).options(addGroupToOptions(options, "Common Options")).middleware(syncEnvironmentOption).middleware(handleEnvironments).middleware(({ environment }) => {
4534
+ if (!["string", "undefined"].includes(typeof environment)) throw new Error(`environment type is invalid. The value: ${JSON.stringify(environment)}`);
4535
+ }).middleware(({ region }) => {
4536
+ AWS.config.region = region;
4537
+ setEnvVar("REGION", region);
4538
+ }).pkgConf(getPkgConfig()).config(getConfig()).config("config", (configFilePath) => {
4539
+ return readConfigFileSync({
4540
+ configFilePath,
4541
+ options: getConfigFileOptions()
4542
+ });
4543
+ }).command({
4544
+ command: "print-args",
4545
+ describe: false,
4546
+ handler: (argv) => {
4547
+ return console.log(JSON.stringify(argv, null, 2));
4548
+ }
4549
+ }).command(deployCommand).command(ecsTaskReportCommand).command(generateEnvCommand).epilogue("For more information, read our docs at https://ttoss.dev/docs/carlin/").help();
4550
+ };
4551
+ //#endregion
4552
+ //#region src/index.ts
4553
+ cli().parse();
4554
+ //#endregion
4555
+ export {};