diff --git a/aws_lambda_builders/__init__.py b/aws_lambda_builders/__init__.py index bb83800ad..22867c8ce 100644 --- a/aws_lambda_builders/__init__.py +++ b/aws_lambda_builders/__init__.py @@ -1,5 +1,5 @@ """ AWS Lambda Builder Library """ -__version__ = "1.11.0" +__version__ = "1.12.0" RPC_PROTOCOL_VERSION = "0.3" diff --git a/aws_lambda_builders/__main__.py b/aws_lambda_builders/__main__.py index bb7340c4c..2a0c3d887 100644 --- a/aws_lambda_builders/__main__.py +++ b/aws_lambda_builders/__main__.py @@ -129,6 +129,8 @@ def main(): # pylint: disable=too-many-statements dependencies_dir=params.get("dependencies_dir", None), combine_dependencies=params.get("combine_dependencies", True), architecture=params.get("architecture", X86_64), + is_building_layer=params.get("is_building_layer", False), + experimental_flags=params.get("experimental_flags", []), ) # Return a success response diff --git a/aws_lambda_builders/actions.py b/aws_lambda_builders/actions.py index d9025a537..489e0b722 100644 --- a/aws_lambda_builders/actions.py +++ b/aws_lambda_builders/actions.py @@ -136,7 +136,7 @@ def execute(self): if os.path.isdir(dependencies_source): copytree(dependencies_source, new_destination) else: - os.makedirs(os.path.dirname(dependencies_source), exist_ok=True) + os.makedirs(os.path.dirname(new_destination), exist_ok=True) shutil.copy2(dependencies_source, new_destination) @@ -162,6 +162,10 @@ def execute(self): dependencies_source = os.path.join(self.artifact_dir, name) new_destination = os.path.join(self.dest_dir, name) + # shutil.move can't create subfolders if this is the first file in that folder + if os.path.isfile(dependencies_source): + os.makedirs(os.path.dirname(new_destination), exist_ok=True) + shutil.move(dependencies_source, new_destination) diff --git a/aws_lambda_builders/workflows/nodejs_npm_esbuild/actions.py b/aws_lambda_builders/workflows/nodejs_npm_esbuild/actions.py index ebcaf5227..8e14ebfc7 100644 --- a/aws_lambda_builders/workflows/nodejs_npm_esbuild/actions.py +++ b/aws_lambda_builders/workflows/nodejs_npm_esbuild/actions.py @@ -2,6 +2,8 @@ Actions specific to the esbuild bundler """ import logging +from tempfile import NamedTemporaryFile + from pathlib import Path from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError @@ -23,7 +25,16 @@ class EsbuildBundleAction(BaseAction): ENTRY_POINTS = "entry_points" - def __init__(self, scratch_dir, artifacts_dir, bundler_config, osutils, subprocess_esbuild): + def __init__( + self, + scratch_dir, + artifacts_dir, + bundler_config, + osutils, + subprocess_esbuild, + subprocess_nodejs=None, + skip_deps=False, + ): """ :type scratch_dir: str :param scratch_dir: an existing (writable) directory for temporary files @@ -35,8 +46,14 @@ def __init__(self, scratch_dir, artifacts_dir, bundler_config, osutils, subproce :type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils :param osutils: An instance of OS Utilities for file manipulation - :type subprocess_esbuild: aws_lambda_builders.workflows.nodejs_npm.npm.SubprocessEsbuild + :type subprocess_esbuild: aws_lambda_builders.workflows.nodejs_npm_esbuild.esbuild.SubprocessEsbuild :param subprocess_esbuild: An instance of the Esbuild process wrapper + + :type subprocess_nodejs: aws_lambda_builders.workflows.nodejs_npm_esbuild.node.SubprocessNodejs + :param subprocess_nodejs: An instance of the nodejs process wrapper + + :type skip_deps: bool + :param skip_deps: if dependencies should be omitted from bundling """ super(EsbuildBundleAction, self).__init__() self.scratch_dir = scratch_dir @@ -44,6 +61,8 @@ def __init__(self, scratch_dir, artifacts_dir, bundler_config, osutils, subproce self.bundler_config = bundler_config self.osutils = osutils self.subprocess_esbuild = subprocess_esbuild + self.skip_deps = skip_deps + self.subprocess_nodejs = subprocess_nodejs def execute(self): """ @@ -81,11 +100,73 @@ def execute(self): args.append("--sourcemap") args.append("--target={}".format(target)) args.append("--outdir={}".format(self.artifacts_dir)) + + if self.skip_deps: + LOG.info("Running custom esbuild using Node.js") + script = EsbuildBundleAction._get_node_esbuild_template( + explicit_entry_points, target, self.artifacts_dir, minify, sourcemap + ) + self._run_external_esbuild_in_nodejs(script) + return + try: self.subprocess_esbuild.run(args, cwd=self.scratch_dir) except EsbuildExecutionError as ex: raise ActionFailedError(str(ex)) + def _run_external_esbuild_in_nodejs(self, script): + """ + Run esbuild in a separate process through Node.js + Workaround for https://github.com/evanw/esbuild/issues/1958 + + :type script: str + :param script: Node.js script to execute + + :raises lambda_builders.actions.ActionFailedError: when esbuild packaging fails + """ + with NamedTemporaryFile(dir=self.scratch_dir, mode="w") as tmp: + tmp.write(script) + tmp.flush() + try: + self.subprocess_nodejs.run([tmp.name], cwd=self.scratch_dir) + except EsbuildExecutionError as ex: + raise ActionFailedError(str(ex)) + + @staticmethod + def _get_node_esbuild_template(entry_points, target, out_dir, minify, sourcemap): + """ + Get the esbuild nodejs plugin template + + :type entry_points: List[str] + :param entry_points: list of entry points + + :type target: str + :param target: target version + + :type out_dir: str + :param out_dir: output directory to bundle into + + :type minify: bool + :param minify: if bundled code should be minified + + :type sourcemap: bool + :param sourcemap: if esbuild should produce a sourcemap + + :rtype: str + :return: formatted template + """ + curr_dir = Path(__file__).resolve().parent + with open(str(Path(curr_dir, "esbuild-plugin.js.template")), "r") as f: + input_str = f.read() + result = input_str.format( + target=target, + minify="true" if minify else "false", + sourcemap="true" if sourcemap else "false", + out_dir=repr(out_dir), + entry_points=entry_points, + ) + return result + def _get_explicit_file_type(self, entry_point, entry_path): """ Get an entry point with an explicit .ts or .js suffix. @@ -112,3 +193,67 @@ def _get_explicit_file_type(self, entry_point, entry_path): return entry_point + ext raise ActionFailedError("entry point {} does not exist".format(entry_path)) + + +class EsbuildCheckVersionAction(BaseAction): + """ + A Lambda Builder Action that verifies that esbuild is a version supported by sam accelerate + """ + + NAME = "EsbuildCheckVersion" + DESCRIPTION = "Checking esbuild version" + PURPOSE = Purpose.COMPILE_SOURCE + + MIN_VERSION = "0.14.13" + + def __init__(self, scratch_dir, subprocess_esbuild): + """ + :type scratch_dir: str + :param scratch_dir: temporary directory where esbuild is executed + + :type subprocess_esbuild: aws_lambda_builders.workflows.nodejs_npm_esbuild.esbuild.SubprocessEsbuild + :param subprocess_esbuild: An instance of the Esbuild process wrapper + """ + super().__init__() + self.scratch_dir = scratch_dir + self.subprocess_esbuild = subprocess_esbuild + + def execute(self): + """ + Runs the action. + + :raises lambda_builders.actions.ActionFailedError: when esbuild version checking fails + """ + args = ["--version"] + + try: + version = self.subprocess_esbuild.run(args, cwd=self.scratch_dir) + except EsbuildExecutionError as ex: + raise ActionFailedError(str(ex)) + + LOG.debug("Found esbuild with version: %s", version) + + try: + check_version = EsbuildCheckVersionAction._get_version_tuple(self.MIN_VERSION) + esbuild_version = EsbuildCheckVersionAction._get_version_tuple(version) + + if esbuild_version < check_version: + raise ActionFailedError( + f"Unsupported esbuild version. To use a dependency layer, the esbuild version must be at " + f"least {self.MIN_VERSION}. Version found: {version}" + ) + except (TypeError, ValueError) as ex: + raise ActionFailedError(f"Unable to parse esbuild version: {str(ex)}") + + @staticmethod + def _get_version_tuple(version_string): + """ + Get an integer tuple representation of the version for comparison + + :type version_string: str + :param version_string: string containing the esbuild version + + :rtype: tuple + :return: version tuple used for comparison + """ + return tuple(map(int, version_string.split("."))) diff --git a/aws_lambda_builders/workflows/nodejs_npm_esbuild/esbuild-plugin.js.template b/aws_lambda_builders/workflows/nodejs_npm_esbuild/esbuild-plugin.js.template new file mode 100644 index 000000000..f3d2beb99 --- /dev/null +++ b/aws_lambda_builders/workflows/nodejs_npm_esbuild/esbuild-plugin.js.template @@ -0,0 +1,19 @@ +let skipBundleNodeModules = {{ + name: 'make-all-packages-external', + setup(build) {{ + let filter = /^[^.\/]|^\.[^.\/]|^\.\.[^\/]/ // Must not start with "/" or "./" or "../" + build.onResolve({{ filter }}, args => ({{ path: args.path, external: true }})) + }}, +}} + +require('esbuild').build({{ + entryPoints: {entry_points}, + bundle: true, + platform: 'node', + format: 'cjs', + target: '{target}', + sourcemap: {sourcemap}, + outdir: {out_dir}, + minify: {minify}, + plugins: [skipBundleNodeModules], +}}).catch(() => process.exit(1)) diff --git a/aws_lambda_builders/workflows/nodejs_npm_esbuild/node.py b/aws_lambda_builders/workflows/nodejs_npm_esbuild/node.py new file mode 100644 index 000000000..6a50ea495 --- /dev/null +++ b/aws_lambda_builders/workflows/nodejs_npm_esbuild/node.py @@ -0,0 +1,104 @@ +""" +Wrapper around calling nodejs through a subprocess. +""" + +import logging + +from aws_lambda_builders.exceptions import LambdaBuilderError + +LOG = logging.getLogger(__name__) + + +class NodejsExecutionError(LambdaBuilderError): + + """ + Exception raised in case nodejs execution fails. + It will pass on the standard error output from the Node.js console. + """ + + MESSAGE = "Nodejs Failed: {message}" + + +class SubprocessNodejs(object): + + """ + Wrapper around the nodejs command line utility, making it + easy to consume execution results. + """ + + def __init__(self, osutils, executable_search_paths, which): + """ + :type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils + :param osutils: An instance of OS Utilities for file manipulation + + :type executable_search_paths: list + :param executable_search_paths: List of paths to the node package binary utilities. This will + be used to find embedded Nodejs at runtime if present in the package + + :type which: aws_lambda_builders.utils.which + :param which: Function to get paths which conform to the given mode on the PATH + with the prepended additional search paths + """ + self.osutils = osutils + self.executable_search_paths = executable_search_paths + self.which = which + + def nodejs_binary(self): + """ + Finds the Nodejs binary at runtime. + + The utility may be present as a package dependency of the Lambda project, + or in the global path. If there is one in the Lambda project, it should + be preferred over a global utility. The check has to be executed + at runtime, since nodejs dependencies will be installed by the workflow + using one of the previous actions. + """ + + LOG.debug("checking for nodejs in: %s", self.executable_search_paths) + binaries = self.which("node", executable_search_paths=self.executable_search_paths) + LOG.debug("potential nodejs binaries: %s", binaries) + + if binaries: + return binaries[0] + else: + raise NodejsExecutionError(message="cannot find nodejs") + + def run(self, args, cwd=None): + + """ + Runs the action. + + :type args: list + :param args: Command line arguments to pass to Nodejs + + :type cwd: str + :param cwd: Directory where to execute the command (defaults to current dir) + + :rtype: str + :return: text of the standard output from the command + + :raises aws_lambda_builders.workflows.nodejs_npm.npm.NodejsExecutionError: + when the command executes with a non-zero return code. The exception will + contain the text of the standard error output from the command. + + :raises ValueError: if arguments are not provided, or not a list + """ + + if not isinstance(args, list): + raise ValueError("args must be a list") + + if not args: + raise ValueError("requires at least one arg") + + invoke_nodejs = [self.nodejs_binary()] + args + + LOG.debug("executing Nodejs: %s", invoke_nodejs) + + p = self.osutils.popen(invoke_nodejs, stdout=self.osutils.pipe, stderr=self.osutils.pipe, cwd=cwd) + + out, err = p.communicate() + + if p.returncode != 0: + raise NodejsExecutionError(message=err.decode("utf8").strip()) + + return out.decode("utf8").strip() diff --git a/aws_lambda_builders/workflows/nodejs_npm_esbuild/workflow.py b/aws_lambda_builders/workflows/nodejs_npm_esbuild/workflow.py index 280b8c14e..bb59c1545 100644 --- a/aws_lambda_builders/workflows/nodejs_npm_esbuild/workflow.py +++ b/aws_lambda_builders/workflows/nodejs_npm_esbuild/workflow.py @@ -4,15 +4,22 @@ import logging import json +from typing import List from aws_lambda_builders.workflow import BaseWorkflow, Capability from aws_lambda_builders.actions import ( CopySourceAction, + CleanUpAction, + CopyDependenciesAction, + MoveDependenciesAction, + BaseAction, ) from aws_lambda_builders.utils import which from .actions import ( EsbuildBundleAction, + EsbuildCheckVersionAction, ) +from .node import SubprocessNodejs from .utils import is_experimental_esbuild_scope from .esbuild import SubprocessEsbuild, EsbuildExecutionError from ..nodejs_npm.actions import NodejsNpmCIAction, NodejsNpmInstallAction @@ -66,7 +73,7 @@ def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtim def actions_with_bundler( self, source_dir, scratch_dir, artifacts_dir, bundler_config, osutils, subprocess_npm, subprocess_esbuild - ): + ) -> List[BaseAction]: """ Generate a list of Nodejs build actions with a bundler @@ -97,15 +104,89 @@ def actions_with_bundler( lockfile_path = osutils.joinpath(source_dir, "package-lock.json") shrinkwrap_path = osutils.joinpath(source_dir, "npm-shrinkwrap.json") - copy_action = CopySourceAction(source_dir, scratch_dir, excludes=self.EXCLUDED_FILES) + actions: List[BaseAction] = [ + CopySourceAction(source_dir, scratch_dir, excludes=self.EXCLUDED_FILES + tuple(["node_modules"])) + ] + + subprocess_node = SubprocessNodejs(osutils, self.executable_search_paths, which=which) + + # Bundle dependencies separately in a dependency layer. We need to check the esbuild + # version here to ensure that it supports skipping dependency bundling + esbuild_no_deps = [ + EsbuildCheckVersionAction(scratch_dir, subprocess_esbuild), + EsbuildBundleAction( + scratch_dir, + artifacts_dir, + bundler_config, + osutils, + subprocess_esbuild, + subprocess_node, + skip_deps=True, + ), + ] + esbuild_with_deps = EsbuildBundleAction(scratch_dir, artifacts_dir, bundler_config, osutils, subprocess_esbuild) if osutils.file_exists(lockfile_path) or osutils.file_exists(shrinkwrap_path): install_action = NodejsNpmCIAction(scratch_dir, subprocess_npm=subprocess_npm) else: install_action = NodejsNpmInstallAction(scratch_dir, subprocess_npm=subprocess_npm, is_production=False) - esbuild_action = EsbuildBundleAction(scratch_dir, artifacts_dir, bundler_config, osutils, subprocess_esbuild) - return [copy_action, install_action, esbuild_action] + if self.download_dependencies and not self.dependencies_dir: + return actions + [install_action, esbuild_with_deps] + + return self._accelerate_workflow_actions( + source_dir, scratch_dir, actions, install_action, esbuild_with_deps, esbuild_no_deps + ) + + def _accelerate_workflow_actions( + self, source_dir, scratch_dir, actions, install_action, esbuild_with_deps, esbuild_no_deps + ): + """ + Generate a list of Nodejs build actions for incremental build and auto dependency layer + + :type source_dir: str + :param source_dir: an existing (readable) directory containing source files + + :type scratch_dir: str + :param scratch_dir: an existing (writable) directory for temporary files + + :type actions: List[BaseAction] + :param actions: List of existing actions + + :type install_action: BaseAction + :param install_action: Installation action for npm + + :type esbuild_with_deps: BaseAction + :param esbuild_with_deps: Standard esbuild action bundling source with deps + + :type esbuild_no_deps: List[BaseAction] + :param esbuild_no_deps: esbuild action not including dependencies in the bundled artifacts + + :rtype: list + :return: List of build actions to execute + """ + if self.download_dependencies: + actions += [install_action, CleanUpAction(self.dependencies_dir)] + if self.combine_dependencies: + # Auto dependency layer disabled, first build + actions += [esbuild_with_deps, CopyDependenciesAction(source_dir, scratch_dir, self.dependencies_dir)] + else: + # Auto dependency layer enabled, first build + actions += esbuild_no_deps + [MoveDependenciesAction(source_dir, scratch_dir, self.dependencies_dir)] + else: + if self.dependencies_dir: + actions.append(CopySourceAction(self.dependencies_dir, scratch_dir)) + if self.combine_dependencies: + # Auto dependency layer disabled, subsequent builds + actions += [esbuild_with_deps] + else: + # Auto dependency layer enabled, subsequent builds + actions += esbuild_no_deps + else: + # Invalid workflow, can't have no dependency dir and no installation + raise EsbuildExecutionError(message="Lambda Builders encountered and invalid workflow") + + return actions def get_build_properties(self): """ diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py index dc7328977..3d3a64e5d 100644 --- a/tests/functional/test_cli.py +++ b/tests/functional/test_cli.py @@ -81,6 +81,8 @@ def test_run_hello_workflow_with_backcompat(self, flavor, protocol_version): "dependencies_dir": "/ignored-dep", "combine_dependencies": False, "architecture": "x86_64", + "is_building_layer": False, + "experimental_flags": ["experimental"], }, } @@ -145,6 +147,8 @@ def test_run_hello_workflow_incompatible(self, flavor): "download_dependencies": False, "dependencies_dir": "/ignored-dep", "combine_dependencies": False, + "is_building_layer": False, + "experimental_flags": ["experimental"], }, } ) diff --git a/tests/integration/test_actions.py b/tests/integration/test_actions.py new file mode 100644 index 000000000..4e4f93a31 --- /dev/null +++ b/tests/integration/test_actions.py @@ -0,0 +1,59 @@ +import os +from pathlib import Path +import tempfile +from unittest import TestCase +from parameterized import parameterized + + +from aws_lambda_builders.actions import CopyDependenciesAction, MoveDependenciesAction +from aws_lambda_builders.utils import copytree + + +class TestCopyDependenciesAction(TestCase): + @parameterized.expand( + [ + ("single_file",), + ("multiple_files",), + ("empty_subfolders",), + ] + ) + def test_copy_dependencies_action(self, source_folder): + curr_dir = Path(__file__).resolve().parent + test_folder = os.path.join(curr_dir, "testdata", source_folder) + with tempfile.TemporaryDirectory() as tmpdir: + empty_source = os.path.join(tmpdir, "empty_source") + target = os.path.join(tmpdir, "target") + + os.mkdir(empty_source) + + copy_dependencies_action = CopyDependenciesAction(empty_source, test_folder, target) + copy_dependencies_action.execute() + + self.assertEqual(os.listdir(test_folder), os.listdir(target)) + + +class TestMoveDependenciesAction(TestCase): + @parameterized.expand( + [ + ("single_file",), + ("multiple_files",), + ("empty_subfolders",), + ] + ) + def test_move_dependencies_action(self, source_folder): + curr_dir = Path(__file__).resolve().parent + test_folder = os.path.join(curr_dir, "testdata", source_folder) + with tempfile.TemporaryDirectory() as tmpdir: + test_source = os.path.join(tmpdir, "test_source") + empty_source = os.path.join(tmpdir, "empty_source") + target = os.path.join(tmpdir, "target") + + os.mkdir(test_source) + os.mkdir(empty_source) + + copytree(test_folder, test_source) + + move_dependencies_action = MoveDependenciesAction(empty_source, test_source, target) + move_dependencies_action.execute() + + self.assertEqual(os.listdir(test_folder), os.listdir(target)) diff --git a/tests/integration/testdata/empty_subfolders/sub_folder/sub_folder/test_file.txt b/tests/integration/testdata/empty_subfolders/sub_folder/sub_folder/test_file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/testdata/multiple_files/sub_folder/test_file3.txt b/tests/integration/testdata/multiple_files/sub_folder/test_file3.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/testdata/multiple_files/test_file.txt b/tests/integration/testdata/multiple_files/test_file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/testdata/multiple_files/test_file2.txt b/tests/integration/testdata/multiple_files/test_file2.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/testdata/single_file/test_file.txt b/tests/integration/testdata/single_file/test_file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/workflows/nodejs_npm_esbuild/test_nodejs_npm_with_esbuild.py b/tests/integration/workflows/nodejs_npm_esbuild/test_nodejs_npm_with_esbuild.py index f81f742a6..90c8c364d 100644 --- a/tests/integration/workflows/nodejs_npm_esbuild/test_nodejs_npm_with_esbuild.py +++ b/tests/integration/workflows/nodejs_npm_esbuild/test_nodejs_npm_with_esbuild.py @@ -20,6 +20,7 @@ class TestNodejsNpmWorkflowWithEsbuild(TestCase): def setUp(self): self.artifacts_dir = tempfile.mkdtemp() self.scratch_dir = tempfile.mkdtemp() + self.dependencies_dir = tempfile.mkdtemp() self.no_deps = os.path.join(self.TEST_DATA_FOLDER, "no-deps-esbuild") @@ -185,3 +186,104 @@ def test_bundles_project_without_dependencies(self): expected_files = {"included.js.map", "included.js"} output_files = set(os.listdir(self.artifacts_dir)) self.assertEqual(expected_files, output_files) + + def test_builds_project_with_remote_dependencies_without_download_dependencies_with_dependencies_dir(self): + source_dir = os.path.join(self.TEST_DATA_FOLDER, "with-deps-no-node_modules") + options = {"entry_points": ["included.js"]} + + osutils = OSUtils() + npm = SubprocessNpm(osutils) + esbuild_dir = os.path.join(self.TEST_DATA_FOLDER, "esbuild-binary") + npm.run(["ci"], cwd=esbuild_dir) + binpath = npm.run(["bin"], cwd=esbuild_dir) + + self.builder.build( + source_dir, + self.artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, "package.json"), + options=options, + runtime=self.runtime, + dependencies_dir=self.dependencies_dir, + download_dependencies=False, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + executable_search_paths=[binpath], + ) + + expected_files = {"included.js.map", "included.js"} + output_files = set(os.listdir(self.artifacts_dir)) + self.assertEqual(expected_files, output_files) + + def test_builds_project_with_remote_dependencies_with_download_dependencies_and_dependencies_dir(self): + source_dir = os.path.join(self.TEST_DATA_FOLDER, "with-deps-no-node_modules") + options = {"entry_points": ["included.js"]} + + self.builder.build( + source_dir, + self.artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, "package.json"), + runtime=self.runtime, + options=options, + dependencies_dir=self.dependencies_dir, + download_dependencies=True, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + expected_files = {"included.js.map", "included.js"} + output_files = set(os.listdir(self.artifacts_dir)) + self.assertEqual(expected_files, output_files) + + expected_modules = "minimal-request-promise" + output_modules = set(os.listdir(os.path.join(self.dependencies_dir, "node_modules"))) + self.assertIn(expected_modules, output_modules) + + expected_dependencies_files = {"node_modules"} + output_dependencies_files = set(os.listdir(os.path.join(self.dependencies_dir))) + self.assertNotIn(expected_dependencies_files, output_dependencies_files) + + def test_builds_project_with_remote_dependencies_without_download_dependencies_without_dependencies_dir(self): + source_dir = os.path.join(self.TEST_DATA_FOLDER, "with-deps-no-node_modules") + + with self.assertRaises(EsbuildExecutionError) as context: + self.builder.build( + source_dir, + self.artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, "package.json"), + runtime=self.runtime, + dependencies_dir=None, + download_dependencies=False, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(str(context.exception), "Esbuild Failed: Lambda Builders encountered and invalid workflow") + + def test_builds_project_without_combine_dependencies(self): + source_dir = os.path.join(self.TEST_DATA_FOLDER, "with-deps-no-node_modules") + options = {"entry_points": ["included.js"]} + + self.builder.build( + source_dir, + self.artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, "package.json"), + runtime=self.runtime, + options=options, + dependencies_dir=self.dependencies_dir, + download_dependencies=True, + combine_dependencies=False, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + expected_files = {"included.js.map", "included.js"} + output_files = set(os.listdir(self.artifacts_dir)) + self.assertEqual(expected_files, output_files) + + expected_modules = "minimal-request-promise" + output_modules = set(os.listdir(os.path.join(self.dependencies_dir, "node_modules"))) + self.assertIn(expected_modules, output_modules) + + expected_dependencies_files = {"node_modules"} + output_dependencies_files = set(os.listdir(os.path.join(self.dependencies_dir))) + self.assertNotIn(expected_dependencies_files, output_dependencies_files) diff --git a/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/excluded.js b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/excluded.js new file mode 100644 index 000000000..8bf8be437 --- /dev/null +++ b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/excluded.js @@ -0,0 +1,2 @@ +//excluded +const x = 1; diff --git a/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/included.js b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/included.js new file mode 100644 index 000000000..39c9fd4ef --- /dev/null +++ b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/included.js @@ -0,0 +1,2 @@ +//included +const x = 5 diff --git a/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/package.json b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/package.json new file mode 100644 index 000000000..aec66dd1a --- /dev/null +++ b/tests/integration/workflows/nodejs_npm_esbuild/testdata/with-deps-no-node_modules/package.json @@ -0,0 +1,14 @@ +{ + "name": "with-deps-esbuild", + "version": "1.0.0", + "description": "", + "keywords": [], + "author": "", + "license": "APACHE2.0", + "dependencies": { + "minimal-request-promise": "^1.5.0" + }, + "devDependencies": { + "esbuild": "0.14.13" + } +} diff --git a/tests/unit/workflows/nodejs_npm_esbuild/test_actions.py b/tests/unit/workflows/nodejs_npm_esbuild/test_actions.py index a67edaf95..9150249ca 100644 --- a/tests/unit/workflows/nodejs_npm_esbuild/test_actions.py +++ b/tests/unit/workflows/nodejs_npm_esbuild/test_actions.py @@ -1,17 +1,23 @@ +import tempfile +from pathlib import Path from unittest import TestCase +from unittest.mock import Mock + from mock import patch from parameterized import parameterized from aws_lambda_builders.actions import ActionFailedError -from aws_lambda_builders.workflows.nodejs_npm_esbuild.actions import EsbuildBundleAction +from aws_lambda_builders.workflows.nodejs_npm_esbuild.actions import EsbuildBundleAction, EsbuildCheckVersionAction class TestEsbuildBundleAction(TestCase): @patch("aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils") @patch("aws_lambda_builders.workflows.nodejs_npm_esbuild.esbuild.SubprocessEsbuild") - def setUp(self, OSUtilMock, SubprocessEsbuildMock): + @patch("aws_lambda_builders.workflows.nodejs_npm_esbuild.node.SubprocessNodejs") + def setUp(self, OSUtilMock, SubprocessEsbuildMock, SubprocessNodejsMock): self.osutils = OSUtilMock.return_value self.subprocess_esbuild = SubprocessEsbuildMock.return_value + self.subprocess_nodejs = SubprocessNodejsMock.return_value self.osutils.joinpath.side_effect = lambda a, b: "{}/{}".format(a, b) self.osutils.file_exists.side_effect = [True, True] @@ -173,6 +179,43 @@ def test_includes_multiple_entry_points_if_requested(self): cwd="source", ) + def test_runs_node_subprocess_if_deps_skipped(self): + action = EsbuildBundleAction( + tempfile.mkdtemp(), + "artifacts", + {"entry_points": ["app.ts"]}, + self.osutils, + self.subprocess_esbuild, + self.subprocess_nodejs, + True, + ) + action.execute() + self.subprocess_nodejs.run.assert_called() + + def test_reads_nodejs_bundle_template_file(self): + template = EsbuildBundleAction._get_node_esbuild_template(["app.ts"], "es2020", "outdir", False, True) + expected_template = """let skipBundleNodeModules = { + name: 'make-all-packages-external', + setup(build) { + let filter = /^[^.\/]|^\.[^.\/]|^\.\.[^\/]/ // Must not start with "/" or "./" or "../" + build.onResolve({ filter }, args => ({ path: args.path, external: true })) + }, +} + +require('esbuild').build({ + entryPoints: ['app.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'es2020', + sourcemap: true, + outdir: 'outdir', + minify: false, + plugins: [skipBundleNodeModules], +}).catch(() => process.exit(1)) +""" + self.assertEqual(template, expected_template) + class TestImplicitFileTypeResolution(TestCase): @patch("aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils") @@ -211,3 +254,39 @@ def test_throws_exception_entry_point_not_found(self, file_exists, entry_point): with self.assertRaises(ActionFailedError) as context: self.action._get_explicit_file_type(entry_point, "invalid") self.assertEqual(str(context.exception), "entry point invalid does not exist") + + +class TestEsbuildVersionCheckerAction(TestCase): + @parameterized.expand(["0.14.0", "0.0.0", "0.14.12"]) + def test_outdated_esbuild_versions(self, version): + subprocess_esbuild = Mock() + subprocess_esbuild.run.return_value = version + action = EsbuildCheckVersionAction("scratch", subprocess_esbuild) + with self.assertRaises(ActionFailedError) as content: + action.execute() + self.assertEqual( + str(content.exception), + f"Unsupported esbuild version. To use a dependency layer, the esbuild version " + f"must be at least 0.14.13. Version found: {version}", + ) + + @parameterized.expand(["a.0.0", "a.b.c"]) + def test_invalid_esbuild_versions(self, version): + subprocess_esbuild = Mock() + subprocess_esbuild.run.return_value = version + action = EsbuildCheckVersionAction("scratch", subprocess_esbuild) + with self.assertRaises(ActionFailedError) as content: + action.execute() + self.assertEqual( + str(content.exception), "Unable to parse esbuild version: invalid literal for int() with base 10: 'a'" + ) + + @parameterized.expand(["0.14.13", "1.0.0", "10.0.10"]) + def test_valid_esbuild_versions(self, version): + subprocess_esbuild = Mock() + subprocess_esbuild.run.return_value = version + action = EsbuildCheckVersionAction("scratch", subprocess_esbuild) + try: + action.execute() + except ActionFailedError: + self.fail("Encountered an unexpected exception.") diff --git a/tests/unit/workflows/nodejs_npm_esbuild/test_node.py b/tests/unit/workflows/nodejs_npm_esbuild/test_node.py new file mode 100644 index 000000000..d9a7c89c2 --- /dev/null +++ b/tests/unit/workflows/nodejs_npm_esbuild/test_node.py @@ -0,0 +1,77 @@ +from unittest import TestCase +from mock import patch + +from aws_lambda_builders.workflows.nodejs_npm_esbuild.node import SubprocessNodejs, NodejsExecutionError + + +class FakePopen: + def __init__(self, out=b"out", err=b"err", retcode=0): + self.out = out + self.err = err + self.returncode = retcode + + def communicate(self): + return self.out, self.err + + +class TestSubprocessNodejs(TestCase): + @patch("aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils") + def setUp(self, OSUtilMock): + self.osutils = OSUtilMock.return_value + self.osutils.pipe = "PIPE" + self.popen = FakePopen() + self.osutils.popen.side_effect = [self.popen] + + which = lambda cmd, executable_search_paths: ["{}/{}".format(executable_search_paths[0], cmd)] + + self.under_test = SubprocessNodejs(self.osutils, ["/a/b", "/c/d"], which) + + def test_run_executes_binary_found_in_exec_paths(self): + + self.under_test.run(["arg-a", "arg-b"]) + + self.osutils.popen.assert_called_with(["/a/b/node", "arg-a", "arg-b"], cwd=None, stderr="PIPE", stdout="PIPE") + + def test_uses_cwd_if_supplied(self): + self.under_test.run(["arg-a", "arg-b"], cwd="/a/cwd") + + self.osutils.popen.assert_called_with( + ["/a/b/node", "arg-a", "arg-b"], cwd="/a/cwd", stderr="PIPE", stdout="PIPE" + ) + + def test_returns_popen_out_decoded_if_retcode_is_0(self): + self.popen.out = b"some encoded text\n\n" + + result = self.under_test.run(["pack"]) + + self.assertEqual(result, "some encoded text") + + def test_raises_NodejsExecutionError_with_err_text_if_retcode_is_not_0(self): + self.popen.returncode = 1 + self.popen.err = b"some error text\n\n" + + with self.assertRaises(NodejsExecutionError) as raised: + self.under_test.run(["pack"]) + + self.assertEqual(raised.exception.args[0], "Nodejs Failed: some error text") + + def test_raises_NodejsExecutionError_if_which_returns_no_results(self): + + which = lambda cmd, executable_search_paths: [] + self.under_test = SubprocessNodejs(self.osutils, ["/a/b", "/c/d"], which) + with self.assertRaises(NodejsExecutionError) as raised: + self.under_test.run(["pack"]) + + self.assertEqual(raised.exception.args[0], "Nodejs Failed: cannot find nodejs") + + def test_raises_ValueError_if_args_not_a_list(self): + with self.assertRaises(ValueError) as raised: + self.under_test.run(("pack")) + + self.assertEqual(raised.exception.args[0], "args must be a list") + + def test_raises_ValueError_if_args_empty(self): + with self.assertRaises(ValueError) as raised: + self.under_test.run([]) + + self.assertEqual(raised.exception.args[0], "requires at least one arg") diff --git a/tests/unit/workflows/nodejs_npm_esbuild/test_workflow.py b/tests/unit/workflows/nodejs_npm_esbuild/test_workflow.py index 11bb8afab..ccaaa4822 100644 --- a/tests/unit/workflows/nodejs_npm_esbuild/test_workflow.py +++ b/tests/unit/workflows/nodejs_npm_esbuild/test_workflow.py @@ -1,12 +1,11 @@ from unittest import TestCase from mock import patch, call -from aws_lambda_builders.actions import CopySourceAction -from aws_lambda_builders.exceptions import WorkflowFailedError +from aws_lambda_builders.actions import CopySourceAction, CleanUpAction, CopyDependenciesAction, MoveDependenciesAction from aws_lambda_builders.architecture import ARM64 from aws_lambda_builders.workflows.nodejs_npm.actions import NodejsNpmInstallAction, NodejsNpmCIAction from aws_lambda_builders.workflows.nodejs_npm_esbuild import NodejsNpmEsbuildWorkflow -from aws_lambda_builders.workflows.nodejs_npm_esbuild.actions import EsbuildBundleAction +from aws_lambda_builders.workflows.nodejs_npm_esbuild.actions import EsbuildBundleAction, EsbuildCheckVersionAction from aws_lambda_builders.workflows.nodejs_npm_esbuild.esbuild import SubprocessEsbuild from aws_lambda_builders.workflows.nodejs_npm_esbuild.utils import EXPERIMENTAL_FLAG_ESBUILD @@ -167,3 +166,106 @@ def test_must_validate_architecture(self): self.assertEqual(workflow.architecture, "x86_64") self.assertEqual(workflow_with_arm.architecture, "arm64") + + def test_workflow_sets_up_esbuild_actions_with_download_dependencies_without_dependencies_dir(self): + self.osutils.file_exists.return_value = True + + workflow = NodejsNpmEsbuildWorkflow( + "source", + "artifacts", + "scratch_dir", + "manifest", + osutils=self.osutils, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(len(workflow.actions), 3) + self.assertIsInstance(workflow.actions[0], CopySourceAction) + self.assertIsInstance(workflow.actions[1], NodejsNpmCIAction) + self.assertIsInstance(workflow.actions[2], EsbuildBundleAction) + + def test_workflow_sets_up_esbuild_actions_without_download_dependencies_with_dependencies_dir_combine_deps(self): + self.osutils.file_exists.return_value = True + + workflow = NodejsNpmEsbuildWorkflow( + "source", + "artifacts", + "scratch_dir", + "manifest", + dependencies_dir="dep", + download_dependencies=False, + combine_dependencies=True, + osutils=self.osutils, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(len(workflow.actions), 3) + self.assertIsInstance(workflow.actions[0], CopySourceAction) + self.assertIsInstance(workflow.actions[1], CopySourceAction) + self.assertIsInstance(workflow.actions[2], EsbuildBundleAction) + + def test_workflow_sets_up_esbuild_actions_without_download_dependencies_with_dependencies_dir_no_combine_deps(self): + self.osutils.file_exists.return_value = True + + workflow = NodejsNpmEsbuildWorkflow( + "source", + "artifacts", + "scratch_dir", + "manifest", + dependencies_dir="dep", + download_dependencies=False, + combine_dependencies=False, + osutils=self.osutils, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(len(workflow.actions), 4) + self.assertIsInstance(workflow.actions[0], CopySourceAction) + self.assertIsInstance(workflow.actions[1], CopySourceAction) + self.assertIsInstance(workflow.actions[2], EsbuildCheckVersionAction) + self.assertIsInstance(workflow.actions[3], EsbuildBundleAction) + + def test_workflow_sets_up_esbuild_actions_with_download_dependencies_and_dependencies_dir(self): + + self.osutils.file_exists.return_value = True + + workflow = NodejsNpmEsbuildWorkflow( + "source", + "artifacts", + "scratch_dir", + "manifest", + dependencies_dir="dep", + download_dependencies=True, + osutils=self.osutils, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(len(workflow.actions), 5) + + self.assertIsInstance(workflow.actions[0], CopySourceAction) + self.assertIsInstance(workflow.actions[1], NodejsNpmCIAction) + self.assertIsInstance(workflow.actions[2], CleanUpAction) + self.assertIsInstance(workflow.actions[3], EsbuildBundleAction) + self.assertIsInstance(workflow.actions[4], CopyDependenciesAction) + + def test_workflow_sets_up_esbuild_actions_with_download_dependencies_and_dependencies_dir_no_combine_deps(self): + workflow = NodejsNpmEsbuildWorkflow( + "source", + "artifacts", + "scratch_dir", + "manifest", + dependencies_dir="dep", + download_dependencies=True, + combine_dependencies=False, + osutils=self.osutils, + experimental_flags=[EXPERIMENTAL_FLAG_ESBUILD], + ) + + self.assertEqual(len(workflow.actions), 6) + + self.assertIsInstance(workflow.actions[0], CopySourceAction) + self.assertIsInstance(workflow.actions[1], NodejsNpmCIAction) + self.assertIsInstance(workflow.actions[2], CleanUpAction) + self.assertIsInstance(workflow.actions[3], EsbuildCheckVersionAction) + self.assertIsInstance(workflow.actions[4], EsbuildBundleAction) + self.assertIsInstance(workflow.actions[5], MoveDependenciesAction)