Skip to content

Commit ade3619

Browse files
authored
Merge branch 'develop' into issue49
2 parents eb562c0 + d8a8759 commit ade3619

4 files changed

Lines changed: 238 additions & 39 deletions

File tree

__mocks__/child_process.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const child_process = jest.genMockFromModule("child_process") as any;
2+
3+
child_process.exec = jest.fn((cmd, cb) => cb({ code: 0 }));
4+
module.exports = child_process;

__mocks__/path.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@ path.relative = (folder: string, name: string): string => {
4646
throw new Error(`Unknown relative ${folder}, ${name}`);
4747
};
4848

49+
path.resolve = (folder: string): string => {
50+
return process.cwd() + "/" + folder;
51+
};
52+
4953
module.exports = path;

src/__tests__/integration.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SamPlugin from "../index";
22
import fs from "fs";
33
import path from "path";
4+
import child_process from "child_process";
45

6+
jest.mock("child_process");
57
jest.mock("fs");
68
jest.mock("path");
79

@@ -20,6 +22,44 @@ Resources:
2022
CodeUri: src/my-lambda
2123
Handler: app.handler
2224
`;
25+
const samTemplateWithLayer = `
26+
AWSTemplateFormatVersion: "2010-09-09"
27+
Transform: AWS::Serverless-2016-10-31
28+
29+
Globals:
30+
Function:
31+
Runtime: nodejs10.x
32+
33+
Resources:
34+
MyLambda:
35+
Type: AWS::Serverless::Function
36+
Properties:
37+
CodeUri: src/my-lambda
38+
Handler: app.handler
39+
LayerSharp:
40+
Type: AWS::Serverless::LayerVersion
41+
Metadata:
42+
BuildMethod: makefile
43+
Properties:
44+
LayerName: layer-sharp
45+
Description: Package sharp
46+
ContentUri: layers/sharp
47+
CompatibleRuntimes:
48+
- nodejs14.x
49+
RetentionPolicy: Retain
50+
LayerSharp2:
51+
Type: AWS::Serverless::LayerVersion
52+
Metadata:
53+
BuildMethod: makefile
54+
Properties:
55+
LayerName: layer-sharp2
56+
Description: Package sharp2
57+
ContentUri: layers/sharp2
58+
CompatibleRuntimes:
59+
- nodejs14.x
60+
RetentionPolicy: Retain
61+
62+
`;
2363

2464
test("Happy path with default constructor works", () => {
2565
const plugin = new SamPlugin();
@@ -50,6 +90,7 @@ test("Happy path with default constructor works", () => {
5090
tap: (n: string, f: (_compilation: any) => void) => {
5191
afterEmit = f;
5292
},
93+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
5394
},
5495
},
5596
});
@@ -89,6 +130,7 @@ test("Happy path with empty options in the constructor works", () => {
89130
tap: (n: string, f: (_compilation: any) => void) => {
90131
afterEmit = f;
91132
},
133+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
92134
},
93135
},
94136
});
@@ -132,6 +174,7 @@ test("Happy path with empty options in the constructor works and an existing .vs
132174
tap: (n: string, f: (_compilation: any) => void) => {
133175
afterEmit = f;
134176
},
177+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
135178
},
136179
},
137180
});
@@ -175,6 +218,7 @@ test("Happy path with VS Code debugging disabled", () => {
175218
tap: (n: string, f: (_compilation: any) => void) => {
176219
afterEmit = f;
177220
},
221+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
178222
},
179223
},
180224
});
@@ -377,6 +421,7 @@ test("Happy path with multiple projects works", () => {
377421
tap: (n: string, f: (_compilation: any) => void) => {
378422
afterEmit = f;
379423
},
424+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
380425
},
381426
},
382427
});
@@ -419,6 +464,7 @@ test("Happy path with multiple projects and different template names works", ()
419464
tap: (n: string, f: (_compilation: any) => void) => {
420465
afterEmit = f;
421466
},
467+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
422468
},
423469
},
424470
});
@@ -445,6 +491,7 @@ test("Calling apply() before entry() throws an error", () => {
445491
tap: (n: string, f: (_compilation: any) => void) => {
446492
afterEmit = f;
447493
},
494+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
448495
},
449496
},
450497
});
@@ -554,6 +601,7 @@ test("Happy path with an output file specified", () => {
554601
tap: (n: string, f: (_compilation: any) => void) => {
555602
afterEmit = f;
556603
},
604+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {},
557605
},
558606
},
559607
});
@@ -563,3 +611,51 @@ test("Happy path with an output file specified", () => {
563611
// @ts-ignore
564612
expect({ entryPoints, files: fs.__getMockWrittenFiles() }).toMatchSnapshot();
565613
});
614+
615+
test("Happy exec make template with layers", async () => {
616+
const plugin = new SamPlugin({ outFile: "index" });
617+
618+
// @ts-ignore
619+
fs.__clearMocks();
620+
// @ts-ignore
621+
fs.__setMockDirs(["."]);
622+
// @ts-ignore
623+
fs.__setMockFiles({ "./template.yaml": samTemplateWithLayer });
624+
625+
// @ts-ignore
626+
path.__clearMocks();
627+
// @ts-ignore
628+
path.__setMockBasenames({ "./template.yaml": "template.yaml" });
629+
// @ts-ignore
630+
path.__setMockDirnames({ "./template.yaml": "." });
631+
// @ts-ignore
632+
path.__setMockRelatives({ ".#.": "" });
633+
634+
const entryPoints = plugin.entry();
635+
636+
// let afterEmit: (_compilation: any) => void;
637+
let afterEmitPromise: (_compilation: any) => Promise<void>;
638+
639+
plugin.apply({
640+
hooks: {
641+
afterEmit: {
642+
tap: (n: string, f: (_compilation: any) => void) => {
643+
// afterEmit = f;
644+
},
645+
tapPromise: async (n: string, f: (_compilation: any) => Promise<void>) => {
646+
afterEmitPromise = f;
647+
},
648+
},
649+
},
650+
});
651+
652+
const execMocked = child_process.exec as unknown as jest.Mock;
653+
execMocked.mockClear();
654+
// @ts-ignore
655+
await afterEmitPromise(null);
656+
657+
expect(execMocked.mock.calls.length).toBe(2);
658+
expect(execMocked.mock.calls[0][0]).toMatch(
659+
/make -C ".\/layers\/sharp" ARTIFACTS_DIR="[^"]+\/\.aws-sam\/build\/LayerSharp" build-LayerSharp/
660+
);
661+
});

src/index.ts

Lines changed: 134 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "fs";
22
import * as path from "path";
3+
import { exec } from "child_process";
34
import { schema } from "yaml-cfn";
45
import yaml from "js-yaml";
56

@@ -32,11 +33,20 @@ interface IEntryForResult {
3233
samConfigs: SamConfig[];
3334
}
3435

36+
interface ILayerConfig {
37+
templateName: string;
38+
resourceKey: string;
39+
buildRoot: string;
40+
contentDir: string;
41+
buildMethod?: string;
42+
}
43+
3544
class AwsSamPlugin {
3645
private static defaultTemplates = ["template.yaml", "template.yml"];
3746
private launchConfig: any;
3847
private options: AwsSamPluginOptions;
3948
private samConfigs: SamConfig[];
49+
private layersConfigs: ILayerConfig[] = [];
4050

4151
constructor(options?: Partial<AwsSamPluginOptions>) {
4252
this.options = {
@@ -252,6 +262,42 @@ class AwsSamPlugin {
252262
templateName: projectTemplateName,
253263
});
254264
}
265+
266+
if (resource.Type === "AWS::Serverless::LayerVersion") {
267+
const properties = resource.Properties;
268+
if (!properties || typeof properties !== "object") {
269+
throw new Error(`${resourceKey} is missing Properties`);
270+
}
271+
272+
// Check we have a CodeUri
273+
const contentUri = properties.ContentUri ?? defaultCodeUri;
274+
if (!contentUri) {
275+
throw new Error(`${resourceKey} is missing a CodeUri`);
276+
}
277+
278+
const basePathPrefix = projectPath === "" ? "." : `./${projectPath}`;
279+
const contentDir = `${basePathPrefix}/${contentUri}`;
280+
281+
const buildMethod = resource.Metadata?.BuildMethod;
282+
if (buildMethod === "makefile") {
283+
if (
284+
!this.layersConfigs.find(
285+
(e) =>
286+
e.templateName === projectTemplateName && e.resourceKey === resourceKey && e.buildRoot === buildRoot
287+
)
288+
) {
289+
this.layersConfigs.push({
290+
templateName: projectTemplateName,
291+
resourceKey,
292+
buildRoot,
293+
contentDir,
294+
buildMethod,
295+
});
296+
}
297+
} else {
298+
throw new Error(`Unsupported layer BuildMethod '${buildMethod}'`);
299+
}
300+
}
255301
}
256302

257303
return { entryPoints, launchConfigs, samConfigs };
@@ -317,53 +363,102 @@ class AwsSamPlugin {
317363
}
318364

319365
public apply(compiler: any) {
320-
compiler.hooks.afterEmit.tap("SamPlugin", (_compilation: any) => {
321-
if (this.samConfigs && this.launchConfig) {
322-
for (const samConfig of this.samConfigs) {
323-
fs.writeFileSync(
324-
`${samConfig.buildRoot}/template.yaml`,
325-
yaml.dump(samConfig.samConfig, { indent: 2, quotingType: '"', schema })
326-
);
366+
compiler.hooks.afterEmit?.tapPromise(
367+
"SamPlugin",
368+
async (_compilation: any /* webpack.Compilation */): Promise<void> => {
369+
if (!(this.samConfigs && this.launchConfig)) {
370+
throw new Error("It looks like AwsSamPlugin.entry() was not called");
327371
}
328-
if (this.options.vscodeDebug !== false) {
329-
if (!fs.existsSync(".vscode")) {
330-
fs.mkdirSync(".vscode");
372+
373+
for (const layerConfig of this.layersConfigs) {
374+
const { templateName, resourceKey, buildRoot, contentDir, buildMethod } = layerConfig;
375+
if (buildMethod === "makefile") {
376+
console.log("Start building layer %s#%s ... ", templateName, resourceKey);
377+
const artifactsDir = `${buildRoot}/${resourceKey}`;
378+
try {
379+
fs.mkdirSync(buildRoot);
380+
} catch (err) {
381+
if (!(err?.code === "EEXIST")) throw err;
382+
}
383+
try {
384+
fs.mkdirSync(artifactsDir);
385+
} catch (err) {
386+
if (!(err?.code === "EEXIST")) throw err;
387+
}
388+
const cmdLine = [
389+
//
390+
`make`,
391+
`-C "${contentDir}"`,
392+
`ARTIFACTS_DIR="${path.resolve(artifactsDir)}"`,
393+
`build-${resourceKey}`,
394+
].join(" ");
395+
// console.info("MAKE %s cmdLine: %s", resourceKey, cmdLine);
396+
try {
397+
await new Promise((res, rej) => {
398+
exec(cmdLine, (e) => (e?.code ? rej(e) : res(e)));
399+
});
400+
} catch (err) {
401+
if (err.cmd) {
402+
console.error(err.stdout);
403+
console.error(err.stderr);
404+
}
405+
throw err;
406+
}
407+
} else {
408+
throw new Error(`Unsupported layer BuildMethod '${buildMethod}'`);
331409
}
410+
}
411+
}
412+
);
413+
compiler.hooks.afterEmit.tap("SamPlugin", (_compilation: any) => {
414+
if (!(this.samConfigs && this.launchConfig)) {
415+
throw new Error("It looks like AwsSamPlugin.entry() was not called");
416+
}
417+
const yamlUnique = this.samConfigs.reduce((a, e) => {
418+
const { buildRoot, samConfig } = e;
419+
a[buildRoot] = samConfig;
420+
return a;
421+
}, {} as Record<string, any>);
422+
for (const buildRoot in yamlUnique) {
423+
const samConfig = yamlUnique[buildRoot];
424+
fs.writeFileSync(`${buildRoot}/template.yaml`, yaml.dump(samConfig, { indent: 2, quotingType: '"', schema }));
425+
}
332426

333-
const launchPath = ".vscode/launch.json";
427+
if (this.options.vscodeDebug !== false) {
428+
if (!fs.existsSync(".vscode")) {
429+
fs.mkdirSync(".vscode");
430+
}
431+
const launchPath = ".vscode/launch.json";
334432

335-
const launchContent = JSON.stringify(this.launchConfig, null, 2)
336-
.replace(/^(.*"configurations": \[\s*)$/m, "$1\n // BEGIN AwsSamPlugin")
337-
.replace(/(\n \s*\][\r\n]+\})$/m, "\n // END AwsSamPlugin$1");
338-
const regexBlock = /\s+\/\/ BEGIN AwsSamPlugin(\r|\n|.)+\/\/ END AwsSamPlugin/m;
433+
const launchContent = JSON.stringify(this.launchConfig, null, 2)
434+
.replace(/^(.*"configurations": \[\s*)$/m, "$1\n // BEGIN AwsSamPlugin")
435+
.replace(/(\n \s*\][\r\n]+\})$/m, "\n // END AwsSamPlugin$1");
436+
const regexBlock = /\s+\/\/ BEGIN AwsSamPlugin(\r|\n|.)+\/\/ END AwsSamPlugin/m;
339437

340-
// get new "configurations" content
341-
const matches = launchContent.match(regexBlock);
342-
if (!matches) {
343-
throw new Error(launchPath + " new content does not match");
344-
}
345-
const launchConfigurations = matches[0];
346-
347-
if (fs.existsSync(launchPath)) {
348-
const launchContentOld = fs.readFileSync(launchPath).toString("utf8");
349-
if (launchContentOld.match(regexBlock)) {
350-
// partial rewrite contents
351-
const newContent = launchContentOld.replace(regexBlock, () => launchConfigurations);
352-
fs.writeFileSync(launchPath, newContent);
353-
} else {
354-
// add configurations
355-
const newContent = launchContentOld.replace(
356-
/(\n \]\n\})$/m,
357-
(p0, p1) => `,${launchConfigurations}${p1}`
358-
);
359-
fs.writeFileSync(launchPath, newContent);
360-
}
438+
// get new "configurations" content
439+
const matches = launchContent.match(regexBlock);
440+
if (!matches) {
441+
throw new Error(launchPath + " new content does not match");
442+
}
443+
const launchConfigurations = matches[0];
444+
445+
if (fs.existsSync(launchPath)) {
446+
const launchContentOld = fs.readFileSync(launchPath).toString("utf8");
447+
if (launchContentOld.match(regexBlock)) {
448+
// partial rewrite contents
449+
const newContent = launchContentOld.replace(regexBlock, () => launchConfigurations);
450+
fs.writeFileSync(launchPath, newContent);
361451
} else {
362-
fs.writeFileSync(launchPath, launchContent);
452+
// add configurations
453+
const newContent = launchContentOld.replace(
454+
/(\n \]\n\})$/m,
455+
(p0, p1) => `,${launchConfigurations}${p1}`
456+
);
457+
fs.writeFileSync(launchPath, newContent);
363458
}
459+
} else {
460+
fs.writeFileSync(launchPath, launchContent);
364461
}
365-
} else {
366-
throw new Error("It looks like AwsSamPlugin.entry() was not called");
367462
}
368463
});
369464
}

0 commit comments

Comments
 (0)