Skip to content

Commit 2d9af16

Browse files
awood45sriram-mv
authored andcommitted
feat(build): Ruby2.5 (#49)
- Bundler as dependency manager.
1 parent 20a80f5 commit 2d9af16

File tree

23 files changed

+583
-1
lines changed

23 files changed

+583
-1
lines changed

.appveyor.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ install:
1919
- "set PATH=%PYTHON%\\Scripts;%PYTHON%\\bin;%PATH%"
2020
- "%PYTHON%\\python.exe -m pip install -r requirements/dev.txt"
2121
- "%PYTHON%\\python.exe -m pip install -e ."
22+
- "set PATH=C:\\Ruby25-x64\\bin;%PATH%"
23+
- "gem install bundler --no-ri --no-rdoc"
24+
- "bundler --version"
2225

2326
test_script:
2427
- "%PYTHON%\\python.exe -m pytest --cov aws_lambda_builders --cov-report term-missing tests/unit tests/functional"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ typings/
162162
# Output of 'npm pack'
163163
*.tgz
164164

165+
# Except test file
166+
!tests/functional/workflows/ruby_bundler/test_data/test.tgz
167+
165168
# Yarn Integrity file
166169
.yarn-integrity
167170

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ notes=FIXME,XXX
221221
[SIMILARITIES]
222222

223223
# Minimum lines number of a similarity.
224-
min-similarity-lines=6
224+
min-similarity-lines=10
225225

226226
# Ignore comments when computing similarities.
227227
ignore-comments=yes

aws_lambda_builders/workflows/__init__.py

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

55
import aws_lambda_builders.workflows.python_pip
66
import aws_lambda_builders.workflows.nodejs_npm
7+
import aws_lambda_builders.workflows.ruby_bundler
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Ruby - Lambda Builder
2+
3+
## Scope
4+
5+
For the basic case, building the dependencies for a Ruby Lambda project is very easy:
6+
7+
```shell
8+
# ensure you are using Ruby 2.5, for example with rbenv or rvm
9+
bundle install # if no Gemfile.lock is present
10+
bundle install --deployment
11+
zip -r source.zip * # technically handled by `sam package`
12+
```
13+
14+
The basic scope of a `sam build` script for Ruby would be as a shortcut for this, while performing some housekeeping steps:
15+
16+
- Skipping the initial `bundle install` if a Gemfile.lock file is present.
17+
- Ensuring that `ruby --version` matches `/^ ruby 2\.5\./`
18+
- Raising a soft error if there is already a `.bundle` and `vendor/bundle` folder structure, and giving an option to clobber this if desired.
19+
- I don't want this to be a default behavior, in case users are using the `vendor` or `.bundle` folder structures for other things and clobbering it could have destructive and unintended side effects.
20+
21+
Having a unified command also gives us the ability to solve once the most common issues and alternative use cases in a way that follows best practices:
22+
23+
1. Including dependencies that have native extensions, and building them in the proper environment.
24+
- An open question is how to help users represent binary dependencies, but that's not a Ruby concern per se so it should be solved the same way across all builds.
25+
2. Building and deploying the user dependencies as a layer rather than as part of the code package.
26+
- These also have slightly different folder pathing:
27+
- Bundled dependencies are looked for in `/var/task/vendor/bundle/ruby/2.5.0` which is the default result of a `bundle install --deployment` followed by an upload.
28+
- Layer dependencies are looked for in `/opt/ruby/gems/2.5.0`, so for a layer option would have to use a `--path` build or transform the folder structure slightly.
29+
3. Down the road, perhaps providing a way to bundle code as a layer, such as for shared libraries that are not gems. These need to go in the `/opt/ruby/lib` folder structure.
30+
31+
## Challenges
32+
33+
- Ensuring that builds happen in Ruby 2.5.x only.
34+
- Ensuring that builds that include native extensions happen in the proper build environment.
35+
36+
## Interface/Implementation
37+
38+
Off hand, I envision the following commands as a starting point:
39+
- `sam build`: Shorthand for the 2-liner build at the top of the document.
40+
- `sam build --use-container`: Provides a build container for native extensions.
41+
42+
I also envision Ruby tie-ins for layer commands following the same pattern. I don't yet have a mental model for how we should do shared library code as a layer, that may be an option that goes into `sam init` perhaps? Like `sam init --library-layer`? Layer implementations will be solved at a later date.
43+
44+
Some other open issues include more complex Gemfiles, where a user might want to specify certain bundle groups to explicitly include or exclude. We could also build out ways to switch back and forth between deployment and no-deployment modes.
45+
46+
### sam build
47+
48+
First, validates that `ruby --version` matches a `ruby 2.5.x` pattern, and exits if not. When in doubt, container builds will not have this issue.
49+
50+
```shell
51+
# exit with error if vendor/bundle and/or .bundle directory exists and is non-empty
52+
bundle install # if no Gemfile.lock is present
53+
bundle install --deployment
54+
```
55+
56+
This build could also include an optional cleanout of existing `vendor/bundle` and `.bundle` directories, via the `--clobber-bundle` command or similar. That would behave as follows:
57+
58+
```shell
59+
rm -rf vendor/bundle*
60+
rm -rf .bundle*
61+
bundle install # if no Gemfile.lock is present
62+
bundle install --deployment
63+
```
64+
65+
### sam build --use-container
66+
67+
This command would use some sort of container, such as `lambci/lambda:build-ruby2.5`.
68+
69+
```shell
70+
# exit with error if vendor/bundle and/or .bundle directory exists and is non-empty
71+
bundle install # if no Gemfile.lock is present
72+
docker run -v `pwd`:`pwd` -w `pwd` -i -t $CONTAINER_ID bundle install --deployment
73+
```
74+
75+
This approach does not need to validate the version of Ruby being used, as the container would use Ruby 2.5.
76+
77+
This build could also include an optional cleanout of existing `vendor/bundle` and `.bundle` directories, via the `--clobber-bundle` command or similar. That would behave as follows:
78+
79+
```shell
80+
rm -rf vendor/bundle*
81+
rm -rf .bundle*
82+
bundle install # if no Gemfile.lock is present
83+
docker run -v `pwd`:`pwd` -w `pwd` -i -t $CONTAINER_ID bundle install --deployment
84+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Builds Ruby Lambda functions using Bundler
3+
"""
4+
5+
from .workflow import RubyBundlerWorkflow
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Actions for Ruby dependency resolution with Bundler
3+
"""
4+
5+
import logging
6+
7+
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError
8+
from .bundler import BundlerExecutionError
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
class RubyBundlerInstallAction(BaseAction):
13+
14+
"""
15+
A Lambda Builder Action which runs bundle install in order to build a full Gemfile.lock
16+
"""
17+
18+
NAME = 'RubyBundle'
19+
DESCRIPTION = "Resolving dependencies using Bundler"
20+
PURPOSE = Purpose.RESOLVE_DEPENDENCIES
21+
22+
def __init__(self, source_dir, subprocess_bundler):
23+
super(RubyBundlerInstallAction, self).__init__()
24+
self.source_dir = source_dir
25+
self.subprocess_bundler = subprocess_bundler
26+
27+
def execute(self):
28+
try:
29+
LOG.debug("Running bundle install in %s", self.source_dir)
30+
self.subprocess_bundler.run(
31+
['install', '--without', 'development', 'test'],
32+
cwd=self.source_dir
33+
)
34+
except BundlerExecutionError as ex:
35+
raise ActionFailedError(str(ex))
36+
37+
class RubyBundlerVendorAction(BaseAction):
38+
"""
39+
A Lambda Builder Action which vendors dependencies to the vendor/bundle directory.
40+
"""
41+
42+
NAME = 'RubyBundleDeployment'
43+
DESCRIPTION = "Package dependencies for deployment."
44+
PURPOSE = Purpose.RESOLVE_DEPENDENCIES
45+
46+
def __init__(self, source_dir, subprocess_bundler):
47+
super(RubyBundlerVendorAction, self).__init__()
48+
self.source_dir = source_dir
49+
self.subprocess_bundler = subprocess_bundler
50+
51+
def execute(self):
52+
try:
53+
LOG.debug("Running bundle install --deployment in %s", self.source_dir)
54+
self.subprocess_bundler.run(
55+
['install', '--deployment', '--without', 'development', 'test'],
56+
cwd=self.source_dir
57+
)
58+
except BundlerExecutionError as ex:
59+
raise ActionFailedError(str(ex))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Wrapper around calls to bundler through a subprocess.
3+
"""
4+
5+
import logging
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
class BundlerExecutionError(Exception):
10+
"""
11+
Exception raised when Bundler fails.
12+
Will encapsulate error output from the command.
13+
"""
14+
15+
MESSAGE = "Bundler Failed: {message}"
16+
17+
def __init__(self, **kwargs):
18+
Exception.__init__(self, self.MESSAGE.format(**kwargs))
19+
20+
class SubprocessBundler(object):
21+
"""
22+
Wrapper around the Bundler command line utility, encapsulating
23+
execution results.
24+
"""
25+
26+
def __init__(self, osutils, bundler_exe=None):
27+
self.osutils = osutils
28+
if bundler_exe is None:
29+
if osutils.is_windows():
30+
bundler_exe = 'bundler.bat'
31+
else:
32+
bundler_exe = 'bundle'
33+
34+
self.bundler_exe = bundler_exe
35+
36+
def run(self, args, cwd=None):
37+
if not isinstance(args, list):
38+
raise ValueError('args must be a list')
39+
40+
if not args:
41+
raise ValueError('requires at least one arg')
42+
43+
invoke_bundler = [self.bundler_exe] + args
44+
45+
LOG.debug("executing Bundler: %s", invoke_bundler)
46+
47+
p = self.osutils.popen(invoke_bundler,
48+
stdout=self.osutils.pipe,
49+
stderr=self.osutils.pipe,
50+
cwd=cwd)
51+
52+
out, err = p.communicate()
53+
54+
if p.returncode != 0:
55+
raise BundlerExecutionError(message=err.decode('utf8').strip())
56+
57+
return out.decode('utf8').strip()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Commonly used utilities
3+
"""
4+
5+
import os
6+
import platform
7+
import tarfile
8+
import subprocess
9+
10+
11+
class OSUtils(object):
12+
13+
"""
14+
Wrapper around file system functions, to make it easy to
15+
unit test actions in memory
16+
"""
17+
18+
def extract_tarfile(self, tarfile_path, unpack_dir):
19+
with tarfile.open(tarfile_path, 'r:*') as tar:
20+
tar.extractall(unpack_dir)
21+
22+
def popen(self, command, stdout=None, stderr=None, env=None, cwd=None):
23+
p = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env, cwd=cwd)
24+
return p
25+
26+
def joinpath(self, *args):
27+
return os.path.join(*args)
28+
29+
@property
30+
def pipe(self):
31+
return subprocess.PIPE
32+
33+
def dirname(self, path):
34+
return os.path.dirname(path)
35+
36+
def abspath(self, path):
37+
return os.path.abspath(path)
38+
39+
def is_windows(self):
40+
return platform.system().lower() == 'windows'
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Ruby Bundler Workflow
3+
"""
4+
5+
from aws_lambda_builders.workflow import BaseWorkflow, Capability
6+
from aws_lambda_builders.actions import CopySourceAction
7+
from .actions import RubyBundlerInstallAction, RubyBundlerVendorAction
8+
from .utils import OSUtils
9+
from .bundler import SubprocessBundler
10+
11+
12+
class RubyBundlerWorkflow(BaseWorkflow):
13+
14+
"""
15+
A Lambda builder workflow that knows how to build
16+
Ruby projects using Bundler.
17+
"""
18+
NAME = "RubyBundlerBuilder"
19+
20+
CAPABILITY = Capability(language="ruby",
21+
dependency_manager="bundler",
22+
application_framework=None)
23+
24+
EXCLUDED_FILES = (".aws-sam")
25+
26+
def __init__(self,
27+
source_dir,
28+
artifacts_dir,
29+
scratch_dir,
30+
manifest_path,
31+
runtime=None,
32+
osutils=None,
33+
**kwargs):
34+
35+
super(RubyBundlerWorkflow, self).__init__(source_dir,
36+
artifacts_dir,
37+
scratch_dir,
38+
manifest_path,
39+
runtime=runtime,
40+
**kwargs)
41+
42+
if osutils is None:
43+
osutils = OSUtils()
44+
45+
subprocess_bundler = SubprocessBundler(osutils)
46+
bundle_install = RubyBundlerInstallAction(artifacts_dir,
47+
subprocess_bundler=subprocess_bundler)
48+
49+
bundle_deployment = RubyBundlerVendorAction(artifacts_dir,
50+
subprocess_bundler=subprocess_bundler)
51+
self.actions = [
52+
CopySourceAction(source_dir, artifacts_dir, excludes=self.EXCLUDED_FILES),
53+
bundle_install,
54+
bundle_deployment,
55+
]
Binary file not shown.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import shutil
3+
import sys
4+
import tempfile
5+
6+
from unittest import TestCase
7+
8+
from aws_lambda_builders.workflows.ruby_bundler import utils
9+
10+
11+
class TestOSUtils(TestCase):
12+
13+
def setUp(self):
14+
self.osutils = utils.OSUtils()
15+
16+
def test_extract_tarfile_unpacks_a_tar(self):
17+
test_tar = os.path.join(os.path.dirname(__file__), "test_data", "test.tgz")
18+
test_dir = tempfile.mkdtemp()
19+
self.osutils.extract_tarfile(test_tar, test_dir)
20+
output_files = set(os.listdir(test_dir))
21+
shutil.rmtree(test_dir)
22+
self.assertEqual({"test_utils.py"}, output_files)
23+
24+
def test_dirname_returns_directory_for_path(self):
25+
dirname = self.osutils.dirname(sys.executable)
26+
self.assertEqual(dirname, os.path.dirname(sys.executable))
27+
28+
def test_abspath_returns_absolute_path(self):
29+
result = self.osutils.abspath('.')
30+
self.assertTrue(os.path.isabs(result))
31+
self.assertEqual(result, os.path.abspath('.'))
32+
33+
def test_joinpath_joins_path_components(self):
34+
result = self.osutils.joinpath('a', 'b', 'c')
35+
self.assertEqual(result, os.path.join('a', 'b', 'c'))
36+
37+
def test_popen_runs_a_process_and_returns_outcome(self):
38+
cwd_py = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata', 'cwd.py')
39+
p = self.osutils.popen([sys.executable, cwd_py],
40+
stdout=self.osutils.pipe,
41+
stderr=self.osutils.pipe)
42+
out, err = p.communicate()
43+
self.assertEqual(p.returncode, 0)
44+
self.assertEqual(out.decode('utf8').strip(), os.getcwd())
45+
46+
def test_popen_can_accept_cwd(self):
47+
testdata_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata')
48+
p = self.osutils.popen([sys.executable, 'cwd.py'],
49+
stdout=self.osutils.pipe,
50+
stderr=self.osutils.pipe,
51+
cwd=testdata_dir)
52+
out, err = p.communicate()
53+
self.assertEqual(p.returncode, 0)
54+
self.assertEqual(out.decode('utf8').strip(), os.path.abspath(testdata_dir))

0 commit comments

Comments
 (0)