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/bin/carlin.js +1 -1
- package/dist/defineConfig.d.mts +32 -0
- package/dist/defineConfig.mjs +103 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +4555 -0
- package/package.json +8 -9
- package/dist/chunk-3GQAWCBQ.js +0 -10
- package/dist/defineConfig.d.ts +0 -28
- package/dist/defineConfig.js +0 -136
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -5498
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 {};
|