Skip to content

Commit daf39c1

Browse files
feat: SAM nested stack
1 parent 2e6b640 commit daf39c1

File tree

22 files changed

+1008
-33
lines changed

22 files changed

+1008
-33
lines changed

.github/workflows/common-test.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,55 @@ jobs:
131131
- name: Test - observability mode
132132
run: OBSERVABLE_MODE=true npx vitest --retry 2 test/cdk-esm.test.ts
133133

134+
test-cdk-nested:
135+
runs-on: ubuntu-latest
136+
concurrency:
137+
group: test-cdk-nested
138+
steps:
139+
- uses: actions/checkout@v4
140+
- name: Use Node.js
141+
uses: actions/setup-node@v4
142+
with:
143+
node-version: ${{ env.node_version }}
144+
registry-url: 'https://registry.npmjs.org'
145+
- name: Install dependencies
146+
run: |
147+
node prepareForTest.js cdk-nested
148+
npm i
149+
- name: Download build artifact
150+
uses: actions/download-artifact@v4
151+
if: ${{ inputs.mode == 'build' }}
152+
with:
153+
name: dist
154+
path: dist
155+
- name: Install lambda-live-debugger globally
156+
if: ${{ inputs.mode == 'global' }}
157+
run: |
158+
npm i lambda-live-debugger@${{ inputs.version || 'latest' }} -g
159+
working-directory: test
160+
- name: Install lambda-live-debugger locally
161+
if: ${{ inputs.mode == 'local' }}
162+
run: |
163+
npm i lambda-live-debugger@${{ inputs.version || 'latest' }}
164+
working-directory: test
165+
- name: Configure AWS Credentials
166+
uses: aws-actions/configure-aws-credentials@v4
167+
with:
168+
aws-region: eu-west-1
169+
role-to-assume: ${{ secrets.AWS_ROLE }}
170+
role-session-name: GitHubActions
171+
- name: Destroy
172+
run: npm run destroy
173+
working-directory: test/cdk-nested
174+
continue-on-error: true
175+
- name: Deploy
176+
run: npm run deploy
177+
working-directory: test/cdk-nested
178+
- name: Test
179+
run: npx vitest --retry 2 test/cdk-nested.test.ts
180+
- name: Test - observability mode
181+
run: OBSERVABLE_MODE=true npx vitest --retry 2 test/cdk-nested.test.ts
182+
134183
test-sls-basic:
135184
runs-on: ubuntu-latest
136185
concurrency:
@@ -484,6 +533,59 @@ jobs:
484533
- name: Test - observability mode
485534
run: OBSERVABLE_MODE=true npx vitest --retry 2 test/sam-basic.test.ts
486535

536+
test-sam-nested:
537+
runs-on: ubuntu-latest
538+
concurrency:
539+
group: test-sam-nested
540+
steps:
541+
- uses: actions/checkout@v4
542+
- uses: aws-actions/setup-sam@v2
543+
with:
544+
use-installer: true
545+
token: ${{ secrets.GITHUB_TOKEN }}
546+
- name: Use Node.js
547+
uses: actions/setup-node@v4
548+
with:
549+
node-version: ${{ env.node_version }}
550+
registry-url: 'https://registry.npmjs.org'
551+
- name: Install dependencies
552+
run: |
553+
node prepareForTest.js sam-nested
554+
npm i
555+
- name: Download build artifact
556+
uses: actions/download-artifact@v4
557+
if: ${{ inputs.mode == 'build' }}
558+
with:
559+
name: dist
560+
path: dist
561+
- name: Install lambda-live-debugger globally
562+
if: ${{ inputs.mode == 'global' }}
563+
run: |
564+
npm i lambda-live-debugger@${{ inputs.version || 'latest' }} -g
565+
working-directory: test
566+
- name: Install lambda-live-debugger locally
567+
if: ${{ inputs.mode == 'local' }}
568+
run: |
569+
npm i lambda-live-debugger@${{ inputs.version || 'latest' }}
570+
working-directory: test
571+
- name: Configure AWS Credentials
572+
uses: aws-actions/configure-aws-credentials@v4
573+
with:
574+
aws-region: eu-west-1
575+
role-to-assume: ${{ secrets.AWS_ROLE }}
576+
role-session-name: GitHubActions
577+
- name: Destroy
578+
run: npm run destroy
579+
working-directory: test/sam-nested
580+
continue-on-error: true
581+
- name: Deploy
582+
run: npm run deploy
583+
working-directory: test/sam-nested
584+
- name: Test
585+
run: npx vitest --retry 2 test/sam-nested.test.ts
586+
- name: Test - observability mode
587+
run: OBSERVABLE_MODE=true npx vitest --retry 2 test/sam-nested.test.ts
588+
487589
test-sam-alt:
488590
runs-on: ubuntu-latest
489591
concurrency:

.vscode/launch.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,26 @@
230230
"type": "node",
231231
"cwd": "${workspaceRoot}/test/sam-basic"
232232
},
233+
{
234+
"name": "LLDebugger - SAM nested",
235+
"program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs",
236+
"args": ["../../src/lldebugger.ts", "--config-env=test"],
237+
"request": "launch",
238+
"skipFiles": ["<node_internals>/**"],
239+
"console": "integratedTerminal",
240+
"type": "node",
241+
"cwd": "${workspaceRoot}/test/sam-nested"
242+
},
243+
{
244+
"name": "LLDebugger - SAM nested - observability",
245+
"program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs",
246+
"args": ["../../src/lldebugger.ts", "--config-env=test", "-o"],
247+
"request": "launch",
248+
"skipFiles": ["<node_internals>/**"],
249+
"console": "integratedTerminal",
250+
"type": "node",
251+
"cwd": "${workspaceRoot}/test/sam-nested"
252+
},
233253
{
234254
"name": "LLDebugger - SAM alt",
235255
"program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs",

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
"test-osls-esbuild-esm-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/osls-esbuild-esm.test.ts",
7171
"test-sam-basic": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/sam-basic.test.ts",
7272
"test-sam-basic-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/sam-basic.test.ts",
73+
"test-sam-nested": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/sam-nested.test.ts",
74+
"test-sam-nested-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/sam-nested.test.ts",
7375
"test-sam-alt": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/sam-alt.test.ts",
7476
"test-sam-alt-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/sam-alt.test.ts",
7577
"test-terraform-basic": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/terraform-basic.test.ts",
@@ -161,6 +163,7 @@
161163
"test/osls-esbuild",
162164
"test/osls-esbuild-cjs",
163165
"test/sam-basic",
166+
"test/sam-nested",
164167
"test/sam-alt",
165168
"test/terraform-basic",
166169
"test/opentofu-basic"

src/cloudFormation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,13 @@ async function getCloudFormationResources(
139139
async function getLambdasInStack(
140140
stackName: string,
141141
awsConfiguration: AwsConfiguration,
142+
stackLogicalId?: string,
142143
): Promise<
143144
Array<{
144145
lambdaName: string;
145146
logicalId: string;
146147
stackName: string;
148+
stackLogicalId: string;
147149
}>
148150
> {
149151
const response = await getCloudFormationResources(
@@ -165,6 +167,7 @@ async function getLambdasInStack(
165167
lambdaName: resource.PhysicalResourceId!,
166168
logicalId: resource.LogicalResourceId!,
167169
stackName: stackName,
170+
stackLogicalId: stackLogicalId ?? stackName,
168171
};
169172
}) ?? [];
170173

@@ -175,6 +178,7 @@ async function getLambdasInStack(
175178
const lambdasInNestedStack = await getLambdasInStack(
176179
nestedStack.PhysicalResourceId,
177180
awsConfiguration,
181+
nestedStack.LogicalResourceId,
178182
);
179183

180184
return lambdasInNestedStack;

src/frameworks/samFramework.ts

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CloudFormation } from '../cloudFormation.js';
1010
import { AwsConfiguration } from '../types/awsConfiguration.js';
1111
import { LldConfigBase } from '../types/lldConfig.js';
1212
import { Logger } from '../logger.js';
13+
import type { Format } from 'esbuild';
1314

1415
/**
1516
* Support for AWS SAM framework
@@ -102,24 +103,10 @@ export class SamFramework implements IFramework {
102103
throw new Error(`Stack name not found in ${samConfigFile}`);
103104
}
104105

105-
const samTemplateContent = await fs.readFile(
106-
path.resolve(samTemplateFile),
107-
'utf-8',
106+
const lambdas = await this.parseLambdasFromTemplate(
107+
samTemplateFile,
108+
stackName,
108109
);
109-
const template = yaml.parse(samTemplateContent);
110-
111-
const lambdas: any[] = [];
112-
113-
// get all resources of type AWS::Serverless::Function
114-
for (const resourceName in template.Resources) {
115-
const resource = template.Resources[resourceName];
116-
if (resource.Type === 'AWS::Serverless::Function') {
117-
lambdas.push({
118-
Name: resourceName,
119-
...resource,
120-
});
121-
}
122-
}
123110

124111
const lambdasDiscovered: LambdaResource[] = [];
125112

@@ -137,37 +124,33 @@ export class SamFramework implements IFramework {
137124

138125
// get tags for each Lambda
139126
for (const func of lambdas) {
140-
const handlerFull = path.join(
141-
func.Properties.CodeUri ?? '',
142-
func.Properties.Handler,
143-
);
127+
const handlerFull = path.join(func.codeUri ?? '', func.handler);
144128
const handlerParts = handlerFull.split('.');
145129
const handler = handlerParts[1];
146130

147131
const functionName = lambdasInStack.find(
148-
(lambda) => lambda.logicalId === func.Name,
132+
(lambda) =>
133+
lambda.logicalId === func.name &&
134+
lambda.stackLogicalId === func.stackLogicalId,
149135
)?.lambdaName;
150136

151137
if (!functionName) {
152-
throw new Error(`Function name not found for function: ${func.Name}`);
138+
throw new Error(`Function name not found for function: ${func.name}`);
153139
}
154140

155141
let esBuildOptions: EsBuildOptions | undefined = undefined;
156142

157143
let codePath: string | undefined;
158-
if (func.Metadata?.BuildMethod?.toLowerCase() === 'esbuild') {
159-
if (func.Metadata?.BuildProperties?.EntryPoints?.length > 0) {
160-
codePath = path.join(
161-
func.Properties.CodeUri ?? '',
162-
func.Metadata?.BuildProperties?.EntryPoints[0],
163-
);
144+
if (func.buildMethod?.toLowerCase() === 'esbuild') {
145+
if (func.entryPoints && func.entryPoints.length > 0) {
146+
codePath = path.join(func.codeUri ?? '', func.entryPoints[0]);
164147
}
165148

166149
esBuildOptions = {
167-
external: func.Metadata?.BuildProperties?.External,
168-
minify: func.Metadata?.BuildProperties?.Minify,
169-
format: func.Metadata?.BuildProperties?.Format,
170-
target: func.Metadata?.BuildProperties?.Target,
150+
external: func.external,
151+
minify: func.minify,
152+
format: func.format,
153+
target: func.target,
171154
};
172155
}
173156

@@ -221,6 +204,97 @@ export class SamFramework implements IFramework {
221204

222205
return lambdasDiscovered;
223206
}
207+
208+
/**
209+
* Recursively parse templates to find all Lambda functions, including nested stacks
210+
* @param templatePath The path to the CloudFormation/SAM template file
211+
* @param stackName The name of the stack this template belongs to (for nested stacks)
212+
*/
213+
private async parseLambdasFromTemplate(
214+
templatePath: string,
215+
stackName: string,
216+
): Promise<ParsedLambda[]> {
217+
const resolvedTemplatePath = path.resolve(templatePath);
218+
const templateDir = path.dirname(resolvedTemplatePath);
219+
220+
let template: any;
221+
try {
222+
const templateContent = await fs.readFile(resolvedTemplatePath, 'utf-8');
223+
template = yaml.parse(templateContent);
224+
} catch (err: any) {
225+
Logger.warn(
226+
`[SAM] Could not read or parse template at ${templatePath}: ${err.message}`,
227+
);
228+
return [];
229+
}
230+
231+
if (!template.Resources) {
232+
return [];
233+
}
234+
235+
const lambdas: ParsedLambda[] = [];
236+
237+
for (const resourceName in template.Resources) {
238+
const resource = template.Resources[resourceName];
239+
240+
// Check if it's a Lambda function
241+
if (resource.Type === 'AWS::Serverless::Function') {
242+
lambdas.push({
243+
templatePath,
244+
name: resourceName,
245+
codeUri: resource.Properties?.CodeUri,
246+
handler: resource.Properties?.Handler,
247+
buildMethod: resource.Metadata?.BuildMethod,
248+
entryPoints: resource.Metadata?.BuildProperties?.EntryPoints,
249+
external: resource.Metadata?.BuildProperties?.External,
250+
minify: resource.Metadata?.BuildProperties?.Minify,
251+
format: resource.Metadata?.BuildProperties?.Format as
252+
| Format
253+
| undefined,
254+
target: resource.Metadata?.BuildProperties?.Target,
255+
stackLogicalId: stackName,
256+
});
257+
}
258+
// Check if it's a nested stack
259+
else if (
260+
resource.Type === 'AWS::Serverless::Application' ||
261+
resource.Type === 'AWS::CloudFormation::Stack'
262+
) {
263+
const nestedTemplateLocation =
264+
resource.Properties?.Location ?? resource.Properties?.TemplateURL;
265+
if (nestedTemplateLocation) {
266+
const nestedTemplatePath = path.resolve(
267+
templateDir,
268+
nestedTemplateLocation,
269+
);
270+
271+
const nestedLambdas = await this.parseLambdasFromTemplate(
272+
nestedTemplatePath,
273+
resourceName,
274+
);
275+
lambdas.push(...nestedLambdas);
276+
}
277+
}
278+
}
279+
280+
Logger.verbose(JSON.stringify(lambdas, null, 2));
281+
282+
return lambdas;
283+
}
224284
}
225285

226286
export const samFramework = new SamFramework();
287+
288+
type ParsedLambda = {
289+
templatePath: string;
290+
name: string;
291+
codeUri?: string;
292+
handler: string;
293+
buildMethod?: string;
294+
entryPoints?: string[];
295+
external?: string[];
296+
minify?: boolean;
297+
format?: Format;
298+
target?: string;
299+
stackLogicalId: string;
300+
};

0 commit comments

Comments
 (0)