Skip to content

[WIP] support for building nodejs_npm functions #44

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Dec 10, 2018
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
140fd87
Merge pull request #41 from awslabs/develop
sanathkr Nov 27, 2018
03bddad
simple flow - trivial copying and executing npm
gojko Nov 30, 2018
50b55cf
clean up
gojko Nov 30, 2018
bab2c3d
use npm pack to build the source
gojko Nov 30, 2018
de32212
tests for changes in osutils
gojko Nov 30, 2018
ccf9e6d
integration tests for nodejs_npm
gojko Dec 1, 2018
3ac1a38
styling fixes
gojko Dec 3, 2018
3810cd8
remove executable autodetection, as per https://github.com/awslabs/aw…
gojko Dec 3, 2018
a08723a
remove executable autodetection, as per https://github.com/awslabs/aw…
gojko Dec 3, 2018
9ac2e18
fix copy/paste error
gojko Dec 3, 2018
cec44ae
- moved osutils into nodejs_npm (as per https://github.com/awslabs/aw…
gojko Dec 3, 2018
4a00c1e
- removing guard against manifest missing, as per https://github.com/…
gojko Dec 3, 2018
3e945b2
- using "run" as the npm method entry point (as per
gojko Dec 3, 2018
d91c537
using standard copysource action (as per
gojko Dec 3, 2018
2542cdb
- using more specific purpose, as per https://github.com/awslabs/aws-…
gojko Dec 3, 2018
d183932
- simplify target directory naming, as per https://github.com/awslabs…
gojko Dec 3, 2018
1008117
initial tests for nodejs_npm actions
gojko Dec 3, 2018
dc98d9f
unit tests for the npm subprocess
gojko Dec 4, 2018
79fc787
clean up utils
gojko Dec 4, 2018
77e6116
clean up dependency injection
gojko Dec 4, 2018
d141caf
- better exception message interpolation, as per https://github.com/a…
gojko Dec 4, 2018
7d5a1d0
documentation and cleanup
gojko Dec 4, 2018
70517bf
tar required for functional tests
gojko Dec 4, 2018
7eaad47
Install nodejs8.10 in travis & appveyor
sanathkr Dec 4, 2018
bbb92a5
experiment to see if this will fix it for windows
gojko Dec 4, 2018
9270a81
try fixing for windows
gojko Dec 4, 2018
6f23b11
better testing for windows
gojko Dec 4, 2018
53f9d34
calling parent constructor for actions, as per https://github.com/aws…
gojko Dec 5, 2018
2c5d780
Using platform to check for windows, to make it consistent with SAM CLI
gojko Dec 5, 2018
ed23979
using unittest instead of pytest (as per
gojko Dec 5, 2018
fcd47a9
use self.assert* instead of assert (as per https://github.com/awslabs…
gojko Dec 5, 2018
09d4190
added design document, as per https://github.com/awslabs/aws-lambda-b…
gojko Dec 5, 2018
607079e
added a note on package locking
gojko Dec 5, 2018
6863067
group imports according to PEP8, as per https://github.com/awslabs/aw…
gojko Dec 5, 2018
fdc2707
clarify the need for package customisation
gojko Dec 5, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aws_lambda_builders/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
"""

import aws_lambda_builders.workflows.python_pip
import aws_lambda_builders.workflows.nodejs_npm
5 changes: 5 additions & 0 deletions aws_lambda_builders/workflows/nodejs_npm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Builds NodeJS Lambda functions using NPM dependency manager
"""

from .workflow import NodejsNpmWorkflow
85 changes: 85 additions & 0 deletions aws_lambda_builders/workflows/nodejs_npm/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""
Action to resolve NodeJS dependencies using NPM
"""

import logging
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError
from .utils import OSUtils
from .npm import SubprocessNpm, NpmExecutionError

LOG = logging.getLogger(__name__)


class NodejsNpmPackAction(BaseAction):

NAME = 'NpmPack'
DESCRIPTION = "Packaging source using NPM"
PURPOSE = Purpose.COMPILE_SOURCE

def __init__(self, artifacts_dir, scratch_dir, manifest_path, runtime, osutils=None, subprocess_npm=None):
self.artifacts_dir = artifacts_dir
self.manifest_path = manifest_path
self.scratch_dir = scratch_dir
self.runtime = runtime

self.osutils = osutils
if osutils is None:
self.osutils = OSUtils()

self.subprocess_npm = subprocess_npm

if self.subprocess_npm is None:
self.subprocess_npm = SubprocessNpm(self.osutils)

def execute(self):
try:
package_path = "file:{}".format(self.osutils.abspath(self.osutils.dirname(self.manifest_path)))

LOG.debug("NODEJS packaging %s to %s", package_path, self.scratch_dir)

tarfile_name = self.subprocess_npm.run(['pack', '-q', package_path], cwd=self.scratch_dir)

LOG.debug("NODEJS packed to %s", tarfile_name)

tarfile_path = self.osutils.joinpath(self.scratch_dir, tarfile_name)

LOG.debug("NODEJS extracting to %s", self.artifacts_dir)

self.osutils.extract_tarfile(tarfile_path, self.artifacts_dir)

except NpmExecutionError as ex:
raise ActionFailedError(str(ex))


class NodejsNpmInstallAction(BaseAction):

NAME = 'NpmInstall'
DESCRIPTION = "Installing dependencies from NPM"
PURPOSE = Purpose.RESOLVE_DEPENDENCIES

def __init__(self, artifacts_dir, scratch_dir, manifest_path, runtime, osutils=None, subprocess_npm=None):
self.artifacts_dir = artifacts_dir
self.manifest_path = manifest_path
self.scratch_dir = scratch_dir
self.runtime = runtime

self.osutils = osutils
if osutils is None:
self.osutils = OSUtils()

self.subprocess_npm = subprocess_npm

if self.subprocess_npm is None:
self.subprocess_npm = SubprocessNpm(self.osutils)

def execute(self):
try:
LOG.debug("NODEJS installing in: %s from: %s", self.artifacts_dir, self.manifest_path)

self.subprocess_npm.run(
['install', '-q', '--no-audit', '--no-save', '--production'],
cwd=self.artifacts_dir
)

except NpmExecutionError as ex:
raise ActionFailedError(str(ex))
49 changes: 49 additions & 0 deletions aws_lambda_builders/workflows/nodejs_npm/npm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Wrapper around calling npm through a subprocess.
"""

import logging

from aws_lambda_builders.exceptions import LambdaBuilderError

from .utils import OSUtils

LOG = logging.getLogger(__name__)


class NpmExecutionError(LambdaBuilderError):
MESSAGE = "NPM Failed: {message}"


class SubprocessNpm(object):

def __init__(self, osutils=None, npm_exe=None):
if osutils is None:
osutils = OSUtils()
self.osutils = osutils

if npm_exe is None:
npm_exe = 'npm'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future if there are multiple paths of npm on your system, how would you imagine passing them in? or maybe thats not a big concern.

I started a PR here: #35 that would help with that. But I'm going to take a step back and maybe write a mini design doc to get started on it. I was curious to know if you had any comments on that?


self.npm_exe = npm_exe

def run(self, args, cwd=None):

if not isinstance(args, list):
raise ValueError('args must be a list')

invoke_npm = [self.npm_exe] + args

LOG.debug("executing NPM: %s", invoke_npm)

p = self.osutils.popen(invoke_npm,
stdout=self.osutils.pipe,
stderr=self.osutils.pipe,
cwd=cwd)

out, err = p.communicate()

if p.returncode != 0:
raise NpmExecutionError(message=err.decode('utf8').strip())

return out.decode('utf8').strip()
53 changes: 53 additions & 0 deletions aws_lambda_builders/workflows/nodejs_npm/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Commonly used utilities
"""

import os
import shutil
import tarfile
import subprocess


class OSUtils(object):

def environ(self):
return os.environ

def file_exists(self, filename):
return os.path.isfile(filename)

def extract_tarfile(self, tarfile_path, unpack_dir):
with tarfile.open(tarfile_path, 'r:*') as tar:
tar.extractall(unpack_dir)

def get_directory_contents(self, path):
return os.listdir(path)

def joinpath(self, *args):
return os.path.join(*args)

def copytree(self, source, destination):
if not os.path.exists(destination):
self.makedirs(destination)
names = self.get_directory_contents(source)
for name in names:
new_source = os.path.join(source, name)
new_destination = os.path.join(destination, name)
if os.path.isdir(new_source):
self.copytree(new_source, new_destination)
else:
shutil.copy2(new_source, new_destination)

def popen(self, command, stdout=None, stderr=None, env=None, cwd=None):
p = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env, cwd=cwd)
return p

@property
def pipe(self):
return subprocess.PIPE

def dirname(self, path):
return os.path.dirname(path)

def abspath(self, path):
return os.path.abspath(path)
56 changes: 56 additions & 0 deletions aws_lambda_builders/workflows/nodejs_npm/workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
NodeJS NPM Workflow
"""

import hashlib

from aws_lambda_builders.workflow import BaseWorkflow, Capability
from aws_lambda_builders.actions import CopySourceAction

from .actions import NodejsNpmPackAction, NodejsNpmInstallAction
from .utils import OSUtils


def unique_dir(manifest_path):
md5 = hashlib.md5()
md5.update(manifest_path.encode('utf8'))
return md5.hexdigest()


class NodejsNpmWorkflow(BaseWorkflow):

NAME = "NodejsNpmBuilder"

CAPABILITY = Capability(language="nodejs",
dependency_manager="npm",
application_framework=None)

EXCLUDED_FILES = (".aws-sam")

def __init__(self,
source_dir,
artifacts_dir,
scratch_dir,
manifest_path,
runtime=None,
osutils=None,
**kwargs):

super(NodejsNpmWorkflow, self).__init__(source_dir,
artifacts_dir,
scratch_dir,
manifest_path,
runtime=runtime,
**kwargs)

if osutils is None:
osutils = OSUtils()

tar_dest_dir = osutils.joinpath(scratch_dir, unique_dir(manifest_path))
tar_package_dir = osutils.joinpath(tar_dest_dir, 'package')

self.actions = [
NodejsNpmPackAction(tar_dest_dir, scratch_dir, manifest_path, runtime),
CopySourceAction(tar_package_dir, artifacts_dir, excludes=self.EXCLUDED_FILES),
NodejsNpmInstallAction(artifacts_dir, scratch_dir, manifest_path, runtime)
]
3 changes: 3 additions & 0 deletions tests/functional/testdata/cwd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import os

print(os.getcwd())
44 changes: 44 additions & 0 deletions tests/functional/workflows/nodejs_npm/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import io

import pytest

import sys

import os

from aws_lambda_builders.workflows.nodejs_npm import utils


@pytest.fixture
def osutils():
return utils.OSUtils()


class TestOSUtils(object):
def test_dirname_returns_directory_for_path(self, tmpdir, osutils):
dirname = osutils.dirname(sys.executable)

assert dirname == os.path.dirname(sys.executable)

def test_abspath_returns_absolute_path(self, tmpdir, osutils):

result = osutils.abspath('.')

assert os.path.isabs(result)

assert result == os.path.abspath('.')

def test_popen_can_accept_cwd(self, tmpdir, osutils):

testdata_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata')

p = osutils.popen([sys.executable, 'cwd.py'],
stdout=osutils.pipe,
stderr=osutils.pipe,
cwd=testdata_dir)

out, err = p.communicate()

assert p.returncode == 0

assert out.decode('utf8').strip() == os.path.abspath(testdata_dir)
79 changes: 79 additions & 0 deletions tests/integration/workflows/nodejs_npm/test_nodejs_npm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@

import os
import shutil
import tempfile
from unittest import TestCase

from aws_lambda_builders.builder import LambdaBuilder
from aws_lambda_builders.exceptions import WorkflowFailedError


class TestNodejsNpmWorkflow(TestCase):
"""
Verifies that `nodejs_npm` workflow works by building a Lambda using NPM
"""

TEST_DATA_FOLDER = os.path.join(os.path.dirname(__file__), "testdata")

def setUp(self):
self.artifacts_dir = tempfile.mkdtemp()
self.scratch_dir = tempfile.mkdtemp()

self.no_deps = os.path.join(self.TEST_DATA_FOLDER, "no-deps")

self.builder = LambdaBuilder(language="nodejs",
dependency_manager="npm",
application_framework=None)
self.runtime = "nodejs8.10"

def tearDown(self):
shutil.rmtree(self.artifacts_dir)
shutil.rmtree(self.scratch_dir)

def test_builds_project_without_dependencies(self):
source_dir = os.path.join(self.TEST_DATA_FOLDER, "no-deps")

self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
os.path.join(source_dir, "package.json"),
runtime=self.runtime)

expected_files = {"package.json", "included.js"}
output_files = set(os.listdir(self.artifacts_dir))
self.assertEquals(expected_files, output_files)

def test_builds_project_with_remote_dependencies(self):
source_dir = os.path.join(self.TEST_DATA_FOLDER, "npm-deps")

self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
os.path.join(source_dir, "package.json"),
runtime=self.runtime)

expected_files = {"package.json", "included.js", "node_modules"}
output_files = set(os.listdir(self.artifacts_dir))
self.assertEquals(expected_files, output_files)

expected_modules = {"minimal-request-promise"}
output_modules = set(os.listdir(os.path.join(self.artifacts_dir, "node_modules")))
self.assertEquals(expected_modules, output_modules)

def test_fails_if_npm_cannot_resolve_dependencies(self):

source_dir = os.path.join(self.TEST_DATA_FOLDER, "broken-deps")

with self.assertRaises(WorkflowFailedError) as ctx:
self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
os.path.join(source_dir, "package.json"),
runtime=self.runtime)

self.assertIn("No matching version found for [email protected]_EXISTENT", str(ctx.exception))

def test_fails_if_package_json_is_broken(self):

source_dir = os.path.join(self.TEST_DATA_FOLDER, "broken-package")

with self.assertRaises(WorkflowFailedError) as ctx:
self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
os.path.join(source_dir, "package.json"),
runtime=self.runtime)

self.assertIn("Unexpected end of JSON input", str(ctx.exception))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//excluded
const x = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//included
const x = 1;
Loading