terraguardian-cli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/package.json +3 -2
- package/src/common/constants.js +4 -0
- package/src/features/build/node-build.js +57 -12
- package/src/features/build/zip.js +1 -0
- package/src/features/deploy/lambda.js +66 -3
- package/src/features/scaffold/helpers/extractBranchPrefix.js +19 -0
- package/src/features/scaffold/helpers/file.js +11 -0
- package/src/features/scaffold/helpers/format.js +17 -0
- package/src/features/scaffold/helpers/scanServiceResources.js +29 -0
- package/src/features/scaffold/index.js +10 -0
- package/src/features/scaffold/scaffolder.js +49 -0
- package/src/features/scaffold/services/Lambda.js +83 -0
- package/src/features/scaffold/services/ServiceRegistry.js +50 -0
- package/src/features/scaffold/templates/lambda/iam.tf.js +55 -0
- package/src/features/scaffold/templates/lambda/main.tf.js +26 -0
- package/src/features/scaffold/templates/lambda/src.js +10 -0
- package/src/index.js +80 -54
package/README.md
CHANGED
|
@@ -46,3 +46,69 @@ npm unlink
|
|
|
46
46
|
This will remove the global symbolic link created for your package.
|
|
47
47
|
|
|
48
48
|
That's it! You've successfully installed, tested, and used your package locally using npm link.
|
|
49
|
+
|
|
50
|
+
## Build Configuration
|
|
51
|
+
|
|
52
|
+
### Default Build Settings
|
|
53
|
+
|
|
54
|
+
The CLI uses esbuild to bundle Node.js Lambda functions with the following default settings:
|
|
55
|
+
- **Target**: Node.js 22 (`node22`)
|
|
56
|
+
- **Platform**: Node
|
|
57
|
+
- **Bundle**: Enabled
|
|
58
|
+
- **Minify**: Enabled
|
|
59
|
+
|
|
60
|
+
### Custom esbuild Configuration
|
|
61
|
+
|
|
62
|
+
You can customize the build process by creating an `esbuild.config.js` file in your project root. The CLI will automatically detect and use your custom configuration.
|
|
63
|
+
|
|
64
|
+
**Example `esbuild.config.js`:**
|
|
65
|
+
|
|
66
|
+
```javascript
|
|
67
|
+
module.exports = {
|
|
68
|
+
// Bundle all dependencies (default: true)
|
|
69
|
+
bundle: true,
|
|
70
|
+
|
|
71
|
+
// Target platform (default: 'node')
|
|
72
|
+
platform: 'node',
|
|
73
|
+
|
|
74
|
+
// Target Node.js version (default: 'node22')
|
|
75
|
+
target: 'node22',
|
|
76
|
+
|
|
77
|
+
// Minify the output (default: true)
|
|
78
|
+
minify: true,
|
|
79
|
+
|
|
80
|
+
// External dependencies - these will not be bundled
|
|
81
|
+
external: [
|
|
82
|
+
'@aws-sdk/*', // Exclude all AWS SDK v3 packages
|
|
83
|
+
],
|
|
84
|
+
|
|
85
|
+
// Custom esbuild flags
|
|
86
|
+
customFlags: [
|
|
87
|
+
'--sourcemap', // Enable source maps
|
|
88
|
+
'--keep-names', // Preserve function names
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Configuration Options
|
|
94
|
+
|
|
95
|
+
- **bundle** (boolean): Bundle all dependencies into a single file
|
|
96
|
+
- **platform** (string): Target platform (`'node'`, `'browser'`, etc.)
|
|
97
|
+
- **target** (string): Node.js version target (`'node18'`, `'node20'`, `'node22'`, etc.)
|
|
98
|
+
- **minify** (boolean): Minify the output for smaller bundle size
|
|
99
|
+
- **external** (array): List of packages to exclude from bundling (useful for AWS SDK v3)
|
|
100
|
+
- **customFlags** (array): Additional esbuild CLI flags
|
|
101
|
+
|
|
102
|
+
### Environment Variables
|
|
103
|
+
|
|
104
|
+
You can also override configuration using environment variables:
|
|
105
|
+
|
|
106
|
+
- `NODE_TARGET`: Node.js version (default: `node22`)
|
|
107
|
+
- `ESBUILD_CONFIG_FILE_PATH`: Path to custom config file (default: `esbuild.config.js`)
|
|
108
|
+
- `NODE_BUILD_FILE_NAME`: Output filename (default: `index.js`)
|
|
109
|
+
- `NODE_ENTRY_POINT`: Entry point filename (default: `index.js`)
|
|
110
|
+
|
|
111
|
+
**Example:**
|
|
112
|
+
```bash
|
|
113
|
+
NODE_TARGET=node20 tg-cli --node-build
|
|
114
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terraguardian-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "CLI that helps with projects that are built with terraform and helps with deployments of lambda src code",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"archiver": "^7.0.0",
|
|
31
31
|
"chalk": "^4.0.0",
|
|
32
|
+
"glob": "^10.3.10",
|
|
32
33
|
"inquirer": "^8.2.6",
|
|
33
34
|
"yargs": "^17.7.2"
|
|
34
35
|
},
|
|
@@ -42,4 +43,4 @@
|
|
|
42
43
|
"jest": "^29.7.0",
|
|
43
44
|
"prettier": "^3.2.5"
|
|
44
45
|
}
|
|
45
|
-
}
|
|
46
|
+
}
|
package/src/common/constants.js
CHANGED
|
@@ -3,6 +3,8 @@ const WEBPACK_FILE_PATH = process.env.WEBPACK_FILE_PATH || 'webpack.config.js';
|
|
|
3
3
|
const NODE_LIBARY_TYPE = process.env.NODE_LIBARY_TYPE || 'commonjs2';
|
|
4
4
|
const DEFAULT_WEBPACK_MODE = process.env.DEFAULT_WEBPACK_MODE || 'production';
|
|
5
5
|
const NODE_BUILD_FILE_NAME = process.env.NODE_BUILD_FILE_NAME || 'index.js';
|
|
6
|
+
const NODE_TARGET = process.env.NODE_TARGET || 'node22';
|
|
7
|
+
const ESBUILD_CONFIG_FILE_PATH = process.env.ESBUILD_CONFIG_FILE_PATH || 'esbuild.config.js';
|
|
6
8
|
|
|
7
9
|
const NODE_RUNTIME_TF_VAR = 'node_runtime';
|
|
8
10
|
const GO_RUNTIME_TF_VAR = 'go_runtime';
|
|
@@ -11,6 +13,7 @@ const GO_ENTRY_POINT = process.env.GO_ENTRY_POINT || 'main.go';
|
|
|
11
13
|
|
|
12
14
|
module.exports = {
|
|
13
15
|
DEFAULT_WEBPACK_MODE,
|
|
16
|
+
ESBUILD_CONFIG_FILE_PATH,
|
|
14
17
|
GO_ENTRY_POINT,
|
|
15
18
|
GO_RUNTIME_TF_VAR,
|
|
16
19
|
LAMBDA_BUILD_DIR,
|
|
@@ -18,5 +21,6 @@ module.exports = {
|
|
|
18
21
|
NODE_ENTRY_POINT,
|
|
19
22
|
NODE_LIBARY_TYPE,
|
|
20
23
|
NODE_RUNTIME_TF_VAR,
|
|
24
|
+
NODE_TARGET,
|
|
21
25
|
WEBPACK_FILE_PATH,
|
|
22
26
|
};
|
|
@@ -1,25 +1,69 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const { executeCommand } = require('../../common/helpers');
|
|
3
4
|
const FileHandler = require('../../fs-utils/FileHandler');
|
|
4
5
|
const { findEntryPoints, getNormalizedLambdaNames } = require('./helpers');
|
|
5
6
|
|
|
6
7
|
const {
|
|
8
|
+
ESBUILD_CONFIG_FILE_PATH,
|
|
7
9
|
LAMBDA_BUILD_DIR,
|
|
8
10
|
NODE_BUILD_FILE_NAME,
|
|
9
11
|
NODE_RUNTIME_TF_VAR,
|
|
12
|
+
NODE_TARGET,
|
|
10
13
|
} = require('../../common/constants');
|
|
11
14
|
|
|
15
|
+
// Load custom esbuild configuration if it exists
|
|
16
|
+
function loadCustomEsbuildConfig() {
|
|
17
|
+
const configPath = path.resolve(process.cwd(), ESBUILD_CONFIG_FILE_PATH);
|
|
18
|
+
|
|
19
|
+
if (FileHandler.doesFileExist(configPath)) {
|
|
20
|
+
try {
|
|
21
|
+
console.log(chalk.blue(`Using custom esbuild config from ${ESBUILD_CONFIG_FILE_PATH}`));
|
|
22
|
+
return require(configPath); // eslint-disable-line import/no-dynamic-require, global-require
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.log(chalk.yellow(`Warning: Failed to load custom esbuild config: ${error.message}`));
|
|
25
|
+
console.log(chalk.yellow('Falling back to default configuration'));
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Get default esbuild configuration
|
|
33
|
+
function getDefaultEsbuildConfig() {
|
|
34
|
+
return {
|
|
35
|
+
bundle: true,
|
|
36
|
+
platform: 'node',
|
|
37
|
+
target: NODE_TARGET,
|
|
38
|
+
minify: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
12
42
|
// Create esbuild build command
|
|
13
43
|
function getEsbuildBuildCommand(lambdaName, lambdaPath, buildOutputDir) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
const customConfig = loadCustomEsbuildConfig();
|
|
45
|
+
const config = customConfig || getDefaultEsbuildConfig();
|
|
46
|
+
|
|
47
|
+
const buildCommand = ['npx esbuild', lambdaPath];
|
|
48
|
+
|
|
49
|
+
// Add configuration flags
|
|
50
|
+
if (config.bundle !== false) buildCommand.push('--bundle');
|
|
51
|
+
if (config.platform) buildCommand.push(`--platform=${config.platform}`);
|
|
52
|
+
if (config.target) buildCommand.push(`--target=${config.target}`);
|
|
53
|
+
if (config.minify) buildCommand.push('--minify');
|
|
54
|
+
|
|
55
|
+
// Add output file
|
|
56
|
+
buildCommand.push(`--outfile=${buildOutputDir}/${NODE_BUILD_FILE_NAME}`);
|
|
57
|
+
|
|
58
|
+
// Add external dependencies if specified
|
|
59
|
+
if (config.external && Array.isArray(config.external)) {
|
|
60
|
+
config.external.forEach((ext) => buildCommand.push(`--external:${ext}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add any custom flags
|
|
64
|
+
if (config.customFlags && Array.isArray(config.customFlags)) {
|
|
65
|
+
buildCommand.push(...config.customFlags);
|
|
66
|
+
}
|
|
23
67
|
|
|
24
68
|
return buildCommand.join(' ');
|
|
25
69
|
}
|
|
@@ -48,9 +92,10 @@ async function buildNodeLambdas() {
|
|
|
48
92
|
}
|
|
49
93
|
}
|
|
50
94
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
// Build all lambdas and await each one
|
|
96
|
+
for (let index = 0; index < lambdaPaths.length; index++) {
|
|
97
|
+
await buildNodeLambda(normalizedLambdaNames[index], lambdaPaths[index]);
|
|
98
|
+
}
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
module.exports = {
|
|
@@ -1,8 +1,71 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const inquirer = require('inquirer');
|
|
1
3
|
const { executeCommand } = require('../../common/helpers');
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
function getGitRepoName() {
|
|
6
|
+
const repoPath = execSync('git rev-parse --show-toplevel').toString().trim();
|
|
7
|
+
return repoPath.split('/').pop();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getGitBranch() {
|
|
11
|
+
return execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isTagBranch() {
|
|
15
|
+
const result = execSync('git describe --exact-match --tags HEAD || true').toString().trim();
|
|
16
|
+
return result.length > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeProjectName(name) {
|
|
20
|
+
return name.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function shortenProjectName(name) {
|
|
24
|
+
return name.replace(/[-_]/g, '').substring(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cleanBranch(branch) {
|
|
28
|
+
return branch.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getEnvironment(branch) {
|
|
32
|
+
if (branch === 'main' || branch === 'master') {
|
|
33
|
+
return 'test';
|
|
34
|
+
}
|
|
35
|
+
if (isTagBranch()) {
|
|
36
|
+
return 'prod';
|
|
37
|
+
}
|
|
38
|
+
return cleanBranch(branch);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Deploys the zip file using the lambda key (e.g., 'lambda_get_products')
|
|
42
|
+
async function deployLambda(lambdaKey, zipFileName) {
|
|
43
|
+
const projectName = getGitRepoName();
|
|
44
|
+
const branchName = getGitBranch();
|
|
45
|
+
const shortName = shortenProjectName(normalizeProjectName(projectName));
|
|
46
|
+
const environment = getEnvironment(branchName);
|
|
47
|
+
const formattedKey = lambdaKey.replace(/_/g, '-');
|
|
48
|
+
|
|
49
|
+
const fullLambdaName = `${shortName}-${environment}-${formattedKey}`;
|
|
50
|
+
|
|
51
|
+
console.log(`\nPreparing to deploy: ${fullLambdaName}`);
|
|
52
|
+
const { confirmDeploy } = await inquirer.prompt([
|
|
53
|
+
{
|
|
54
|
+
type: 'confirm',
|
|
55
|
+
name: 'confirmDeploy',
|
|
56
|
+
message: 'Are you sure you want to deploy this Lambda?',
|
|
57
|
+
default: false,
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
if (!confirmDeploy) {
|
|
62
|
+
console.log('Deployment cancelled.');
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deployCommand = `aws lambda update-function-code --function-name ${fullLambdaName} --zip-file fileb://dist/${zipFileName}.zip 1> /dev/null`;
|
|
67
|
+
|
|
68
|
+
console.log(`Deploying: ${fullLambdaName}...`);
|
|
6
69
|
return executeCommand(deployCommand);
|
|
7
70
|
}
|
|
8
71
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function extractBranchPrefix(tfDir) {
|
|
5
|
+
const tfFiles = fs.readdirSync(tfDir).filter((file) => file.endsWith('.tf'));
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
8
|
+
for (const file of tfFiles) {
|
|
9
|
+
const content = fs.readFileSync(path.join(tfDir, file), 'utf-8');
|
|
10
|
+
const match = content.match(/\s*branch_prefix\s*=\s*"(.*?)"/);
|
|
11
|
+
if (match && match[1]) {
|
|
12
|
+
return match[1];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
throw new Error('Could not find branch_prefix in any .tf file.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = extractBranchPrefix;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// helpers/file.js
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function writeFile(filePath, content) {
|
|
7
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
8
|
+
fs.writeFileSync(filePath, content);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { writeFile };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// helpers/format.js
|
|
2
|
+
|
|
3
|
+
function toSnakeCase(name) {
|
|
4
|
+
return name.trim().toLowerCase().replace(/[-\s]/g, '_');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function toPascalCase(name) {
|
|
8
|
+
return name
|
|
9
|
+
.replace(/[-_]+/g, ' ')
|
|
10
|
+
.replace(/\s(.)/g, (_, group1) => group1.toUpperCase())
|
|
11
|
+
.replace(/^(.)/, (_, group1) => group1.toUpperCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
toSnakeCase,
|
|
16
|
+
toPascalCase,
|
|
17
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const glob = require('glob');
|
|
4
|
+
|
|
5
|
+
async function scanServiceResources(serviceKey, serviceConfig, modulesDir = 'modules') {
|
|
6
|
+
const basePath = path.join(process.cwd(), modulesDir, serviceKey);
|
|
7
|
+
|
|
8
|
+
if (!fs.existsSync(basePath)) {
|
|
9
|
+
return []; // No such module
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const files = glob.sync(serviceConfig.scanPattern, {
|
|
13
|
+
cwd: basePath,
|
|
14
|
+
absolute: true,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const resourceNames = new Set();
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const contents = fs.readFileSync(file, 'utf8');
|
|
22
|
+
const names = serviceConfig.extractResourceNames(contents);
|
|
23
|
+
names.forEach((n) => resourceNames.add(n));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return [...resourceNames];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { scanServiceResources };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Scaffolder.js
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const inquirer = require('inquirer');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
|
|
7
|
+
const LambdaScaffolder = require('./services/Lambda');
|
|
8
|
+
const extractBranchPrefix = require('./helpers/extractBranchPrefix');
|
|
9
|
+
|
|
10
|
+
const SERVICES = {
|
|
11
|
+
lambda: LambdaScaffolder,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class Scaffolder {
|
|
15
|
+
// eslint-disable-next-line class-methods-use-this
|
|
16
|
+
async promptAndRun() {
|
|
17
|
+
const { service } = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: 'list',
|
|
20
|
+
name: 'service',
|
|
21
|
+
message: 'What service do you want to scaffold?',
|
|
22
|
+
choices: Object.keys(SERVICES),
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const { name } = await inquirer.prompt([
|
|
27
|
+
{
|
|
28
|
+
type: 'input',
|
|
29
|
+
name: 'name',
|
|
30
|
+
message: `Enter a name for the ${service} (e.g., get-user):`,
|
|
31
|
+
validate: (input) => !!input.trim() || 'Name cannot be empty.',
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
let branchPrefix = 'YOUR_PREFIX_HERE';
|
|
36
|
+
const terraformProjectPath = path.join(process.cwd(), 'modules');
|
|
37
|
+
try {
|
|
38
|
+
branchPrefix = extractBranchPrefix(terraformProjectPath);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.warn(chalk.yellow('Warning:'), e.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ServiceScaffolder = SERVICES[service];
|
|
44
|
+
const scaffolder = new ServiceScaffolder(name, branchPrefix);
|
|
45
|
+
await scaffolder.run();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = Scaffolder;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const inquirer = require('inquirer');
|
|
5
|
+
const { toSnakeCase } = require('../helpers/format');
|
|
6
|
+
const { writeFile } = require('../helpers/file');
|
|
7
|
+
const mainTfTemplate = require('../templates/lambda/main.tf');
|
|
8
|
+
const iamTfTemplate = require('../templates/lambda/iam.tf');
|
|
9
|
+
const handlerTemplate = require('../templates/lambda/src.js');
|
|
10
|
+
|
|
11
|
+
const ServiceRegistry = require('./ServiceRegistry');
|
|
12
|
+
const { scanServiceResources } = require('../helpers/scanServiceResources');
|
|
13
|
+
|
|
14
|
+
class LambdaScaffolder {
|
|
15
|
+
constructor(name, branchPrefix) {
|
|
16
|
+
this.originalName = name; // e.g., get-user
|
|
17
|
+
this.snakeName = toSnakeCase(name); // e.g., get_user
|
|
18
|
+
this.folderName = `lambda_${this.snakeName}`; // e.g., lambda_get_user
|
|
19
|
+
this.basePath = path.join(process.cwd(), 'modules/lambda', this.folderName);
|
|
20
|
+
this.branchPrefix = branchPrefix;
|
|
21
|
+
this.shortName = `l${name.split('-').join('')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line class-methods-use-this
|
|
25
|
+
async promptResourceConnections() {
|
|
26
|
+
const choices = [];
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
29
|
+
for (const [serviceKey, config] of Object.entries(ServiceRegistry)) {
|
|
30
|
+
const resources = await scanServiceResources(serviceKey, config);
|
|
31
|
+
if (resources.length > 0) {
|
|
32
|
+
choices.push(new inquirer.Separator(`--- ${config.displayName} ---`));
|
|
33
|
+
// Sort resources alphabetically within each service group
|
|
34
|
+
const sortedResources = resources.sort((a, b) => a.localeCompare(b));
|
|
35
|
+
sortedResources.forEach((r) => choices.push({
|
|
36
|
+
name: `${config.displayName}: ${r}`,
|
|
37
|
+
value: { serviceKey, resourceName: r },
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (choices.length === 0) {
|
|
43
|
+
console.log(chalk.yellow('⚠️ No connectable resources found.'));
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { selectedResources } = await inquirer.prompt([
|
|
48
|
+
{
|
|
49
|
+
type: 'checkbox',
|
|
50
|
+
name: 'selectedResources',
|
|
51
|
+
message: 'Which resources should this Lambda connect to?',
|
|
52
|
+
choices,
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
return selectedResources;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async run() {
|
|
60
|
+
if (fs.existsSync(this.basePath)) {
|
|
61
|
+
console.log(chalk.red(`❌ Folder already exists: ${this.basePath}`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(chalk.blue(`📦 Creating Lambda: ${this.folderName}`));
|
|
66
|
+
|
|
67
|
+
// ✅ Create base folders (recursive)
|
|
68
|
+
fs.mkdirSync(path.join(this.basePath, 'src'), { recursive: true });
|
|
69
|
+
|
|
70
|
+
const connectedResources = await this.promptResourceConnections();
|
|
71
|
+
|
|
72
|
+
writeFile(path.join(this.basePath, 'main.tf'), mainTfTemplate(this));
|
|
73
|
+
writeFile(
|
|
74
|
+
path.join(this.basePath, 'iam.tf'),
|
|
75
|
+
iamTfTemplate(this, connectedResources),
|
|
76
|
+
);
|
|
77
|
+
writeFile(path.join(this.basePath, 'src', 'index.js'), handlerTemplate(this));
|
|
78
|
+
|
|
79
|
+
console.log(chalk.green(`✅ Lambda scaffolded at ${this.basePath}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = LambdaScaffolder;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dynamodb: {
|
|
3
|
+
displayName: 'DynamoDB',
|
|
4
|
+
scanPattern: '**/*.tf', // Scan for any .tf file
|
|
5
|
+
extractResourceNames: (fileContents) => {
|
|
6
|
+
const regex = /resource\s+"aws_dynamodb_table"\s+"([^"]+)"/g;
|
|
7
|
+
const matches = [...fileContents.matchAll(regex)];
|
|
8
|
+
return matches.map((m) => m[1]); // e.g., ["users_table", "orders_table"]
|
|
9
|
+
},
|
|
10
|
+
getPolicy: (resourceName) => `
|
|
11
|
+
{
|
|
12
|
+
"Action": [
|
|
13
|
+
"dynamodb:GetItem",
|
|
14
|
+
"dynamodb:PutItem",
|
|
15
|
+
"dynamodb:UpdateItem",
|
|
16
|
+
"dynamodb:DeleteItem",
|
|
17
|
+
"dynamodb:Query",
|
|
18
|
+
"dynamodb:Scan"
|
|
19
|
+
],
|
|
20
|
+
"Effect": "Allow",
|
|
21
|
+
"Resource": "\${aws_dynamodb_table.${resourceName}.arn}"
|
|
22
|
+
}
|
|
23
|
+
`,
|
|
24
|
+
},
|
|
25
|
+
s3: {
|
|
26
|
+
displayName: 'S3 Bucket',
|
|
27
|
+
scanPattern: '**/*.tf',
|
|
28
|
+
extractResourceNames: (fileContents) => {
|
|
29
|
+
const regex = /resource\s+"aws_s3_bucket"\s+"([^"]+)"/g;
|
|
30
|
+
const matches = [...fileContents.matchAll(regex)];
|
|
31
|
+
return matches.map((m) => m[1]);
|
|
32
|
+
},
|
|
33
|
+
getPolicy: (resourceName) => `
|
|
34
|
+
{
|
|
35
|
+
"Action": [
|
|
36
|
+
"s3:GetObject",
|
|
37
|
+
"s3:PutObject",
|
|
38
|
+
"s3:DeleteObject",
|
|
39
|
+
"s3:ListBucket"
|
|
40
|
+
],
|
|
41
|
+
"Effect": "Allow",
|
|
42
|
+
"Resource": [
|
|
43
|
+
"\${aws_s3_bucket.${resourceName}.arn}",
|
|
44
|
+
"\${aws_s3_bucket.${resourceName}.arn}/*"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
`,
|
|
48
|
+
},
|
|
49
|
+
// Add more services (SQS, API Gateway, etc.) later
|
|
50
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const ServiceRegistry = require('../../services/ServiceRegistry');
|
|
2
|
+
|
|
3
|
+
module.exports = ({ originalName, snakeName }, connectedResources = []) => `
|
|
4
|
+
resource "aws_iam_role" "lambda_${snakeName}" {
|
|
5
|
+
name = "\${local.branch_prefix}-lambda-${originalName}-role"
|
|
6
|
+
|
|
7
|
+
assume_role_policy = jsonencode({
|
|
8
|
+
Version = "2012-10-17"
|
|
9
|
+
Statement = [
|
|
10
|
+
{
|
|
11
|
+
Action = "sts:AssumeRole"
|
|
12
|
+
Principal = {
|
|
13
|
+
Service = "lambda.amazonaws.com"
|
|
14
|
+
}
|
|
15
|
+
Effect = "Allow"
|
|
16
|
+
Sid = ""
|
|
17
|
+
},
|
|
18
|
+
]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
tags = local.tags
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
resource "aws_iam_role_policy_attachment" "lambda_${snakeName}_common_permissions" {
|
|
25
|
+
role = aws_iam_role.lambda_${snakeName}.name
|
|
26
|
+
policy_arn = aws_iam_policy.policy_lambda_common_permissions.arn
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
resource "aws_iam_role_policy_attachment" "lambda_${snakeName}_basic_execution" {
|
|
30
|
+
role = aws_iam_role.lambda_${snakeName}.name
|
|
31
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
${connectedResources.length > 0
|
|
35
|
+
? `
|
|
36
|
+
resource "aws_iam_policy" "lambda_${snakeName}_connected_resources" {
|
|
37
|
+
name = "\${local.branch_prefix}-lambda-${originalName}-resources"
|
|
38
|
+
policy = jsonencode({
|
|
39
|
+
Version = "2012-10-17",
|
|
40
|
+
Statement = [
|
|
41
|
+
${connectedResources
|
|
42
|
+
.map((r) => ServiceRegistry[r.serviceKey].getPolicy(r.resourceName))
|
|
43
|
+
.join(',\n')}
|
|
44
|
+
]
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resource "aws_iam_role_policy_attachment" "lambda_${snakeName}_connected_resources_attach" {
|
|
49
|
+
role = aws_iam_role.lambda_${snakeName}.name
|
|
50
|
+
policy_arn = aws_iam_policy.lambda_${snakeName}_connected_resources.arn
|
|
51
|
+
}
|
|
52
|
+
`.trim()
|
|
53
|
+
: ''
|
|
54
|
+
}
|
|
55
|
+
`.trim();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module.exports = ({ originalName, snakeName, shortName }) => `
|
|
2
|
+
locals {
|
|
3
|
+
${shortName} = "lambda-${originalName}"
|
|
4
|
+
${shortName}_function_name = "\${local.branch_prefix}-\${local.${shortName}}"
|
|
5
|
+
path_to_${shortName}_zip = local.zip_info.lambda_${snakeName}.path
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
resource "aws_lambda_function" "lambda_${snakeName}" {
|
|
9
|
+
function_name = local.${shortName}_function_name
|
|
10
|
+
role = aws_iam_role.lambda_${snakeName}.arn
|
|
11
|
+
handler = var.node_handler
|
|
12
|
+
runtime = var.node_runtime
|
|
13
|
+
filename = local.path_to_${shortName}_zip
|
|
14
|
+
source_code_hash = filebase64sha256(local.path_to_${shortName}_zip)
|
|
15
|
+
|
|
16
|
+
environment {
|
|
17
|
+
variables = {
|
|
18
|
+
ENVIRONMENT = var.branch == "prod" ? "prod" : "dev"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
resource "aws_cloudwatch_log_group" "lambda_${snakeName}_log_group" {
|
|
24
|
+
name = "/aws/lambda/\${local.${shortName}_function_name}"
|
|
25
|
+
retention_in_days = 1
|
|
26
|
+
}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = ({ originalName }) => `
|
|
2
|
+
exports.handler = async (event) => {
|
|
3
|
+
console.log("Received event:", JSON.stringify(event, null, 2));
|
|
4
|
+
|
|
5
|
+
return {
|
|
6
|
+
statusCode: 200,
|
|
7
|
+
body: JSON.stringify({ message: "Hello from ${originalName}!" }),
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
`.trim();
|
package/src/index.js
CHANGED
|
@@ -6,19 +6,16 @@ const path = require('path');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const inquirer = require('inquirer');
|
|
8
8
|
|
|
9
|
-
const { zipAndMoveFolders } = require('./features/build/zip');
|
|
10
|
-
const { buildNodeLambdas } = require('./features/build/node-build');
|
|
9
|
+
const { zipFolder, zipAndMoveFolders } = require('./features/build/zip');
|
|
10
|
+
const { buildNodeLambdas, buildNodeLambda } = require('./features/build/node-build');
|
|
11
11
|
const { deployLambda } = require('./features/deploy/lambda');
|
|
12
12
|
const { validateTerraformCode } = require('./features/validate-terraform/validate');
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const files = fs.readdirSync(dirPath);
|
|
17
|
-
const zipFiles = files.filter((file) => path.extname(file) === '.zip').map((file) => path.basename(file, '.zip'));
|
|
18
|
-
return zipFiles;
|
|
19
|
-
}
|
|
13
|
+
const { findEntryPoints, getNormalizedLambdaNames } = require('./features/build/helpers');
|
|
14
|
+
const { NODE_RUNTIME_TF_VAR } = require('./common/constants');
|
|
15
|
+
const { scaffold } = require('./features/scaffold');
|
|
20
16
|
|
|
21
17
|
const usage = chalk.keyword('violet')('\nUsage: tg-cli [<command> [, options]]');
|
|
18
|
+
|
|
22
19
|
const argv = yargs
|
|
23
20
|
.version('1.0.0')
|
|
24
21
|
.usage(usage)
|
|
@@ -34,61 +31,90 @@ const argv = yargs
|
|
|
34
31
|
})
|
|
35
32
|
.option('d', {
|
|
36
33
|
alias: 'deploy',
|
|
37
|
-
describe: '
|
|
34
|
+
describe: 'Deploy a Lambda',
|
|
38
35
|
type: 'boolean',
|
|
39
36
|
})
|
|
40
37
|
.option('v', {
|
|
41
38
|
alias: 'validate-tf',
|
|
42
|
-
describe: '
|
|
39
|
+
describe: 'Validate Terraform code even if nested',
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
})
|
|
42
|
+
.option('s', {
|
|
43
|
+
alias: 'scaffold',
|
|
44
|
+
describe: 'Scaffold boilerplate code for a service',
|
|
43
45
|
type: 'boolean',
|
|
44
46
|
})
|
|
45
47
|
.help(true)
|
|
46
48
|
.alias('h', 'help').argv;
|
|
47
49
|
|
|
48
50
|
async function executeUserCommand(userCommands) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
51
|
+
try {
|
|
52
|
+
if (userCommands.z || userCommands.zip) {
|
|
53
|
+
console.log(chalk.blue('🔄 Zipping folders...'));
|
|
54
|
+
const zipDetails = await zipAndMoveFolders('build', 'dist');
|
|
55
|
+
|
|
56
|
+
const resultFilePath = path.join(process.cwd(), 'zip-details.json');
|
|
57
|
+
await fs.promises.writeFile(resultFilePath, JSON.stringify(zipDetails, null, 2), 'utf-8');
|
|
58
|
+
console.log(chalk.green(`✅ Zipping complete. Details saved to ${resultFilePath}`));
|
|
59
|
+
} else if (userCommands.d || userCommands.deploy) {
|
|
60
|
+
console.log(chalk.blue('🚀 Deploying Lambda...'));
|
|
61
|
+
const lambdaPaths = findEntryPoints(NODE_RUNTIME_TF_VAR);
|
|
62
|
+
const normalizedLambdaNames = getNormalizedLambdaNames(lambdaPaths);
|
|
63
|
+
|
|
64
|
+
// Create a map of lambda names to their paths and sort alphabetically
|
|
65
|
+
const lambdaMap = normalizedLambdaNames.map((name, index) => ({
|
|
66
|
+
name,
|
|
67
|
+
path: lambdaPaths[index],
|
|
68
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
|
|
70
|
+
const sortedNames = lambdaMap.map((item) => item.name);
|
|
71
|
+
|
|
72
|
+
const { deployOptions } = await inquirer.prompt([
|
|
73
|
+
{
|
|
74
|
+
type: 'list',
|
|
75
|
+
name: 'deployOptions',
|
|
76
|
+
message: 'Which Lambda would you like to deploy?',
|
|
77
|
+
choices: sortedNames,
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const selectedLambda = lambdaMap.find((item) => item.name === deployOptions);
|
|
82
|
+
const lambdaPath = selectedLambda.path;
|
|
83
|
+
|
|
84
|
+
// Build the selected Lambda
|
|
85
|
+
const builtFile = await buildNodeLambda(deployOptions, lambdaPath);
|
|
86
|
+
|
|
87
|
+
// Ensure dist folder exists
|
|
88
|
+
const distDir = path.join(process.cwd(), 'dist');
|
|
89
|
+
if (!fs.existsSync(distDir)) {
|
|
90
|
+
fs.mkdirSync(distDir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const zipFilePath = `dist/${deployOptions}.zip`;
|
|
94
|
+
if (fs.existsSync(zipFilePath)) {
|
|
95
|
+
fs.unlinkSync(zipFilePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await zipFolder(path.dirname(builtFile), zipFilePath);
|
|
99
|
+
await deployLambda(deployOptions, deployOptions);
|
|
100
|
+
|
|
101
|
+
console.log(chalk.green(`✅ Lambda ${deployOptions} deployed successfully`));
|
|
102
|
+
} else if (userCommands.N || userCommands['node-build']) {
|
|
103
|
+
console.log(chalk.blue('🔨 Building Node Lambdas...'));
|
|
104
|
+
await buildNodeLambdas();
|
|
105
|
+
} else if (userCommands.s || userCommands.scaffold) {
|
|
106
|
+
console.log(chalk.blue('📦 Scaffolding new service...'));
|
|
107
|
+
await scaffold();
|
|
108
|
+
} else if (userCommands.v || userCommands['validate-tf']) {
|
|
109
|
+
console.log(chalk.blue('📝 Validating Terraform code...'));
|
|
110
|
+
await validateTerraformCode();
|
|
111
|
+
} else {
|
|
112
|
+
yargs.showHelp();
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(chalk.red(`❌ Error: ${err.message}`));
|
|
116
|
+
console.error(err.stack);
|
|
117
|
+
process.exit(1);
|
|
92
118
|
}
|
|
93
119
|
}
|
|
94
120
|
|