Skip to content

Commit 84980da

Browse files
gojkojfuss
authored andcommitted
feat: Support for building nodejs_npm functions (#44)
1 parent 3744cea commit 84980da

File tree

29 files changed

+869
-0
lines changed

29 files changed

+869
-0
lines changed

.appveyor.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ environment:
1313
build: off
1414

1515
install:
16+
# To run Nodejs workflow integ tests
17+
- ps: Install-Product node 8.10
18+
1619
- "set PATH=%PYTHON%\\Scripts;%PYTHON%\\bin;%PATH%"
1720
- "%PYTHON%\\python.exe -m pip install -r requirements/dev.txt"
1821
- "%PYTHON%\\python.exe -m pip install -e ."

.travis.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ matrix:
1111
dist: xenial
1212
sudo: true
1313
install:
14+
15+
# To run Nodejs workflow integ tests
16+
- nvm install 8.10.0
17+
- nvm use 8.10.0
18+
1419
# Install the code requirements
1520
- make init
1621
script:

aws_lambda_builders/workflows/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
"""
44

55
import aws_lambda_builders.workflows.python_pip
6+
import aws_lambda_builders.workflows.nodejs_npm
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
## NodeJS - NPM Lambda Builder
2+
3+
### Scope
4+
5+
This package is an effort to port the Claudia.JS packager to a library that can
6+
be used to handle the dependency resolution portion of packaging NodeJS code
7+
for use in AWS Lambda. The scope for this builder is to take an existing
8+
directory containing customer code, including a valid `package.json` manifest
9+
specifying third-party dependencies. The builder will use NPM to include
10+
production dependencies and exclude test resources in a way that makes them
11+
deployable to AWS Lambda.
12+
13+
### Challenges
14+
15+
NPM normally stores all dependencies in a `node_modules` subdirectory. It
16+
supports several dependency categories, such as development dependencies
17+
(usually third-party build utilities and test resources), optional dependencies
18+
(usually required for local execution but already available on the production
19+
environment, or peer-dependencies for optional third-party packages) and
20+
production dependencies (normally the minimum required for correct execution).
21+
All these dependency types are mixed in the same directory.
22+
23+
To speed up Lambda startup time and optimise usage costs, the correct thing to
24+
do in most cases is just to package up production dependencies. During development
25+
work we can expect that the local `node_modules` directory contains all the
26+
various dependency types, and NPM does not provide a way to directly identify
27+
just the ones relevant for production. To identify production dependencies,
28+
this packager needs to copy the source to a clean temporary directory and re-run
29+
dependency installation there.
30+
31+
A frequently used trick to speed up NodeJS Lambda deployment is to avoid
32+
bundling the `aws-sdk`, since it is already available on the Lambda VM.
33+
This makes deployment significantly faster for single-file lambdas, for
34+
example. Although this is not good from a consistency and compatibility
35+
perspective (as the version of the API used in production might be different
36+
from what was used during testing), people do this frequently enough that the
37+
packager should handle it in some way. A common way of marking this with ClaudiaJS
38+
is to include `aws-sdk` as an optional dependency, then deploy without optional
39+
dependencies.
40+
41+
Other runtimes do not have this flexibility, so instead of adding a specific
42+
parameter to the SAM CLI, the packager should support a flag to include or
43+
exclude optional dependencies through environment variables.
44+
45+
NPM also provides support for running user-defined scripts as part of the build
46+
process, so this packager needs to support standard NPM script execution.
47+
48+
NPM, since version 5, uses symbolic links to optimise disk space usage, so
49+
cross-project dependencies will just be linked to elsewhere on the local disk
50+
instead of included in the `node_modules` directory. This means that just copying
51+
the `node_modules` directory (even if symlinks would be resolved to actual paths)
52+
far from optimal to create a stand-alone module. Copying would lead to significantly
53+
larger packages than necessary, as sub-modules might still have test resources, and
54+
common references from multiple projects would be duplicated.
55+
56+
NPM also uses a locking mechanism (`package-lock.json`) that's in many ways more
57+
broken than functional, as it in some cases hard-codes locks to local disk
58+
paths, and gets confused by including the same package as a dependency
59+
throughout the project tree in different dependency categories
60+
(development/optional/production). Although the official tool recommends
61+
including this file in the version control, as a way to pin down dependency
62+
versions, when using on several machines with different project layout it can
63+
lead to uninstallable dependencies.
64+
65+
NPM dependencies are usually plain javascript libraries, but they may include
66+
native binaries precompiled for a particular platform, or require some system
67+
libraries to be installed. A notable example is `sharp`, a popular image
68+
manipulation library, that uses symbolic links to system libraries. Another
69+
notable example is `puppeteer`, a library to control a headless Chrome browser,
70+
that downloads a Chromium binary for the target platform during installation.
71+
72+
To fully deal with those cases, this packager may need to execute the
73+
dependency installation step on a Docker image compatible with the target
74+
Lambda environment.
75+
76+
### Implementation
77+
78+
The general algorithm for preparing a node package for use on AWS Lambda
79+
is as follows.
80+
81+
#### Step 1: Prepare a clean copy of the project source files
82+
83+
Execute `npm pack` to perform project-specific packaging using the supplied
84+
`package.json` manifest, which will automatically exclude temporary files,
85+
test resources and other source files unnecessary for running in a production
86+
environment.
87+
88+
This will produce a `tar` archive that needs to be unpacked into the artifacts
89+
directory. Note that the archive will actually contain a `package`
90+
subdirectory containing the files, so it's not enough to just directly unpack
91+
files.
92+
93+
#### Step 2: Rewrite local dependencies
94+
95+
_(out of scope for the current version)_
96+
97+
To optimise disk space and avoid including development dependencies from other
98+
locally linked packages, inspect the `package.json` manifest looking for dependencies
99+
referring to local file paths (can be identified as they start with `.` or `file:`),
100+
then for each dependency recursively execute the packaging process
101+
102+
Local dependencies may include other local dependencies themselves, this is a very
103+
common way of sharing configuration or development utilities such as linting or testing
104+
tools. This means that for each packaged local dependency this packager needs to
105+
recursively apply the packaging process. It also means that the packager needs to
106+
track local paths and avoid re-packaging directories it already visited.
107+
108+
NPM produces a `tar` archive while packaging that can be directly included as a
109+
dependency. This will make NPM unpack and install a copy correctly. Once the
110+
packager produces all `tar` archives required by local dependencies, rewrite
111+
the manifest to point to `tar` files instead of the original location.
112+
113+
If the project contains a package lock file, this will cause NPM to ignore changes
114+
to the package.json manifest. In this case, the packager will need to remove
115+
`package-lock.json` so that dependency rewrites take effect.
116+
_(out of scope for the current version)_
117+
118+
#### Step 3: Install dependencies
119+
120+
The packager should then run `npm install` to download an expand all dependencies to
121+
the local `node_modules` subdirectory. This has to be executed in the directory with
122+
a clean copy of the source files.
123+
124+
Note that NPM can be configured to use proxies or local company repositories using
125+
a local file, `.npmrc`. The packaging process from step 1 normally excludes this file, so it may
126+
need to be copied additionally before dependency installation, and then removed.
127+
_(out of scope for the current version)_
128+
129+
Some users may want to exclude optional dependencies, or even include development dependencies.
130+
To avoid incompatible flags in the `sam` CLI, the packager should allow users to specify
131+
options for the `npm install` command using an environment variable.
132+
_(out of scope for the current version)_
133+
134+
To fully support dependencies that download or compile binaries for a target platform, this step
135+
needs to be executed inside a Docker image compatible with AWS Lambda.
136+
_(out of scope for the current version)_
137+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Builds NodeJS Lambda functions using NPM dependency manager
3+
"""
4+
5+
from .workflow import NodejsNpmWorkflow
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Action to resolve NodeJS dependencies using NPM
3+
"""
4+
5+
import logging
6+
7+
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError
8+
from .npm import NpmExecutionError
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
13+
class NodejsNpmPackAction(BaseAction):
14+
15+
"""
16+
A Lambda Builder Action that packages a Node.js package using NPM to extract the source and remove test resources
17+
"""
18+
19+
NAME = 'NpmPack'
20+
DESCRIPTION = "Packaging source using NPM"
21+
PURPOSE = Purpose.COPY_SOURCE
22+
23+
def __init__(self, artifacts_dir, scratch_dir, manifest_path, osutils, subprocess_npm):
24+
"""
25+
:type artifacts_dir: str
26+
:param artifacts_dir: an existing (writable) directory where to store the output.
27+
Note that the actual result will be in the 'package' subdirectory here.
28+
29+
:type scratch_dir: str
30+
:param scratch_dir: an existing (writable) directory for temporary files
31+
32+
:type manifest_path: str
33+
:param manifest_path: path to package.json of an NPM project with the source to pack
34+
35+
:type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils
36+
:param osutils: An instance of OS Utilities for file manipulation
37+
38+
:type subprocess_npm: aws_lambda_builders.workflows.nodejs_npm.npm.SubprocessNpm
39+
:param subprocess_npm: An instance of the NPM process wrapper
40+
"""
41+
super(NodejsNpmPackAction, self).__init__()
42+
self.artifacts_dir = artifacts_dir
43+
self.manifest_path = manifest_path
44+
self.scratch_dir = scratch_dir
45+
self.osutils = osutils
46+
self.subprocess_npm = subprocess_npm
47+
48+
def execute(self):
49+
"""
50+
Runs the action.
51+
52+
:raises lambda_builders.actions.ActionFailedError: when NPM packaging fails
53+
"""
54+
try:
55+
package_path = "file:{}".format(self.osutils.abspath(self.osutils.dirname(self.manifest_path)))
56+
57+
LOG.debug("NODEJS packaging %s to %s", package_path, self.scratch_dir)
58+
59+
tarfile_name = self.subprocess_npm.run(['pack', '-q', package_path], cwd=self.scratch_dir)
60+
61+
LOG.debug("NODEJS packed to %s", tarfile_name)
62+
63+
tarfile_path = self.osutils.joinpath(self.scratch_dir, tarfile_name)
64+
65+
LOG.debug("NODEJS extracting to %s", self.artifacts_dir)
66+
67+
self.osutils.extract_tarfile(tarfile_path, self.artifacts_dir)
68+
69+
except NpmExecutionError as ex:
70+
raise ActionFailedError(str(ex))
71+
72+
73+
class NodejsNpmInstallAction(BaseAction):
74+
75+
"""
76+
A Lambda Builder Action that installs NPM project dependencies
77+
"""
78+
79+
NAME = 'NpmInstall'
80+
DESCRIPTION = "Installing dependencies from NPM"
81+
PURPOSE = Purpose.RESOLVE_DEPENDENCIES
82+
83+
def __init__(self, artifacts_dir, subprocess_npm):
84+
"""
85+
:type artifacts_dir: str
86+
:param artifacts_dir: an existing (writable) directory with project source files.
87+
Dependencies will be installed in this directory.
88+
89+
:type subprocess_npm: aws_lambda_builders.workflows.nodejs_npm.npm.SubprocessNpm
90+
:param subprocess_npm: An instance of the NPM process wrapper
91+
"""
92+
93+
super(NodejsNpmInstallAction, self).__init__()
94+
self.artifacts_dir = artifacts_dir
95+
self.subprocess_npm = subprocess_npm
96+
97+
def execute(self):
98+
"""
99+
Runs the action.
100+
101+
:raises lambda_builders.actions.ActionFailedError: when NPM execution fails
102+
"""
103+
104+
try:
105+
LOG.debug("NODEJS installing in: %s", self.artifacts_dir)
106+
107+
self.subprocess_npm.run(
108+
['install', '-q', '--no-audit', '--no-save', '--production'],
109+
cwd=self.artifacts_dir
110+
)
111+
112+
except NpmExecutionError as ex:
113+
raise ActionFailedError(str(ex))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Wrapper around calling npm through a subprocess.
3+
"""
4+
5+
import logging
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
10+
class NpmExecutionError(Exception):
11+
12+
"""
13+
Exception raised in case NPM execution fails.
14+
It will pass on the standard error output from the NPM console.
15+
"""
16+
17+
MESSAGE = "NPM Failed: {message}"
18+
19+
def __init__(self, **kwargs):
20+
Exception.__init__(self, self.MESSAGE.format(**kwargs))
21+
22+
23+
class SubprocessNpm(object):
24+
25+
"""
26+
Wrapper around the NPM command line utility, making it
27+
easy to consume execution results.
28+
"""
29+
30+
def __init__(self, osutils, npm_exe=None):
31+
"""
32+
:type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils
33+
:param osutils: An instance of OS Utilities for file manipulation
34+
35+
:type npm_exe: str
36+
:param npm_exe: Path to the NPM binary. If not set,
37+
the default executable path npm will be used
38+
"""
39+
self.osutils = osutils
40+
41+
if npm_exe is None:
42+
if osutils.is_windows():
43+
npm_exe = 'npm.cmd'
44+
else:
45+
npm_exe = 'npm'
46+
47+
self.npm_exe = npm_exe
48+
49+
def run(self, args, cwd=None):
50+
51+
"""
52+
Runs the action.
53+
54+
:type args: list
55+
:param args: Command line arguments to pass to NPM
56+
57+
:type cwd: str
58+
:param cwd: Directory where to execute the command (defaults to current dir)
59+
60+
:rtype: str
61+
:return: text of the standard output from the command
62+
63+
:raises aws_lambda_builders.workflows.nodejs_npm.npm.NpmExecutionError:
64+
when the command executes with a non-zero return code. The exception will
65+
contain the text of the standard error output from the command.
66+
67+
:raises ValueError: if arguments are not provided, or not a list
68+
"""
69+
70+
if not isinstance(args, list):
71+
raise ValueError('args must be a list')
72+
73+
if not args:
74+
raise ValueError('requires at least one arg')
75+
76+
invoke_npm = [self.npm_exe] + args
77+
78+
LOG.debug("executing NPM: %s", invoke_npm)
79+
80+
p = self.osutils.popen(invoke_npm,
81+
stdout=self.osutils.pipe,
82+
stderr=self.osutils.pipe,
83+
cwd=cwd)
84+
85+
out, err = p.communicate()
86+
87+
if p.returncode != 0:
88+
raise NpmExecutionError(message=err.decode('utf8').strip())
89+
90+
return out.decode('utf8').strip()

0 commit comments

Comments
 (0)