Skip to content

Commit f9cfb0d

Browse files
volkangureljfuss
authored andcommitted
feat(go): Golang Mod Builder (#65)
1 parent f53dc86 commit f9cfb0d

File tree

23 files changed

+597
-1
lines changed

23 files changed

+597
-1
lines changed

aws_lambda_builders/workflows/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
import aws_lambda_builders.workflows.nodejs_npm
77
import aws_lambda_builders.workflows.ruby_bundler
88
import aws_lambda_builders.workflows.go_dep
9+
import aws_lambda_builders.workflows.go_modules
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## Go - Go Modules Lambda Builder
2+
3+
### Scope
4+
5+
This package leverages standard Go tooling available as of Go1.11 to build Go
6+
applications to be deployed in an AWS Lambda environment. The scope of this
7+
builder is to take an existing directory containing customer code, and a
8+
top-level `go.mod` file specifying third party dependencies. The builder will
9+
run `go build` on the project and put the resulting binary in the given
10+
artifacts folder.
11+
12+
### Interface
13+
14+
The top level interface is presented by the `GoModulesBuilder` class. There
15+
will be one public method `build`, which takes the provided arguments and
16+
builds a static binary using standard go tools.
17+
18+
```python
19+
def build(self, source_dir_path, artifacts_dir_path, executable_name):
20+
"""Builds a go project onto an output path.
21+
22+
:type source_dir_path: str
23+
:param source_dir_path: Directory with the source files.
24+
25+
:type output_path: str
26+
:param output_path: Filename to write the executable output to.
27+
```
28+
29+
### Implementation
30+
31+
The general algorithm for preparing a Go package for use on AWS Lambda
32+
is very simple. It's as follows:
33+
34+
Pass in GOOS=linux and GOARCH=amd64 to the `go build` command to target the
35+
OS and architecture used on AWS Lambda. Let go tooling handle the
36+
cross-compilation, regardless of the build environment. Move the resulting
37+
static binary to the artifacts folder to be shipped as a single-file zip
38+
archive.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Builds Go Lambda functions using standard Go tooling
3+
"""
4+
5+
from .workflow import GoModulesWorkflow
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Action to build a Go project using standard Go tooling
3+
"""
4+
5+
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError
6+
from .builder import BuilderError
7+
8+
9+
class GoModulesBuildAction(BaseAction):
10+
11+
NAME = "Build"
12+
DESCRIPTION = "Building Go package with Go Modules"
13+
PURPOSE = Purpose.COMPILE_SOURCE
14+
15+
def __init__(self, source_dir, output_path, builder):
16+
self.source_dir = source_dir
17+
self.output_path = output_path
18+
self.builder = builder
19+
20+
def execute(self):
21+
try:
22+
self.builder.build(
23+
self.source_dir,
24+
self.output_path,
25+
)
26+
except BuilderError as ex:
27+
raise ActionFailedError(str(ex))
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Build a Go project using standard Go tooling
3+
"""
4+
import logging
5+
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
10+
class BuilderError(Exception):
11+
MESSAGE = "Builder Failed: {message}"
12+
13+
def __init__(self, **kwargs):
14+
Exception.__init__(self, self.MESSAGE.format(**kwargs))
15+
16+
17+
class GoModulesBuilder(object):
18+
19+
LANGUAGE = "go"
20+
21+
def __init__(self, osutils, binaries):
22+
"""Initialize a GoModulesBuilder.
23+
24+
:type osutils: :class:`lambda_builders.utils.OSUtils`
25+
:param osutils: A class used for all interactions with the
26+
outside OS.
27+
28+
:type binaries: dict
29+
:param binaries: A dict of language binaries
30+
"""
31+
self.osutils = osutils
32+
self.binaries = binaries
33+
34+
def build(self, source_dir_path, output_path):
35+
"""Builds a go project onto an output path.
36+
37+
:type source_dir_path: str
38+
:param source_dir_path: Directory with the source files.
39+
40+
:type output_path: str
41+
:param output_path: Filename to write the executable output to.
42+
"""
43+
env = {}
44+
env.update(self.osutils.environ)
45+
env.update({"GOOS": "linux", "GOARCH": "amd64"})
46+
runtime_path = self.binaries[self.LANGUAGE].binary_path
47+
cmd = [runtime_path, "build", "-o", output_path, source_dir_path]
48+
49+
p = self.osutils.popen(
50+
cmd,
51+
cwd=source_dir_path,
52+
env=env,
53+
stdout=self.osutils.pipe,
54+
stderr=self.osutils.pipe,
55+
)
56+
out, err = p.communicate()
57+
58+
if p.returncode != 0:
59+
raise BuilderError(message=err.decode("utf8").strip())
60+
61+
return out.decode("utf8").strip()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Commonly used utilities
3+
"""
4+
5+
import os
6+
import subprocess
7+
8+
9+
class OSUtils(object):
10+
"""
11+
Wrapper around file system functions, to make it easy to
12+
unit test actions in memory
13+
"""
14+
@property
15+
def environ(self):
16+
return os.environ.copy()
17+
18+
def joinpath(self, *args):
19+
return os.path.join(*args)
20+
21+
def popen(self, command, stdout=None, stderr=None, env=None, cwd=None):
22+
p = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env, cwd=cwd)
23+
return p
24+
25+
@property
26+
def pipe(self):
27+
return subprocess.PIPE
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Go Runtime Validation
3+
"""
4+
5+
import logging
6+
import os
7+
import subprocess
8+
9+
from aws_lambda_builders.exceptions import MisMatchRuntimeError
10+
11+
LOG = logging.getLogger(__name__)
12+
13+
14+
class GoRuntimeValidator(object):
15+
16+
LANGUAGE = "go"
17+
SUPPORTED_RUNTIMES = {
18+
"go1.x"
19+
}
20+
21+
def __init__(self, runtime):
22+
self.runtime = runtime
23+
self._valid_runtime_path = None
24+
25+
def has_runtime(self):
26+
"""
27+
Checks if the runtime is supported.
28+
:param string runtime: Runtime to check
29+
:return bool: True, if the runtime is supported.
30+
"""
31+
return self.runtime in self.SUPPORTED_RUNTIMES
32+
33+
def validate(self, runtime_path):
34+
"""
35+
Checks if the language supplied matches the required lambda runtime
36+
:param string runtime_path: runtime to check eg: /usr/bin/go
37+
:raises MisMatchRuntimeError: Version mismatch of the language vs the required runtime
38+
"""
39+
if not self.has_runtime():
40+
LOG.warning("'%s' runtime is not "
41+
"a supported runtime", self.runtime)
42+
return None
43+
44+
expected_major_version = int(self.runtime.replace(self.LANGUAGE, "").split('.')[0])
45+
min_expected_minor_version = 11 if expected_major_version == 1 else 0
46+
47+
p = subprocess.Popen([runtime_path, "version"],
48+
cwd=os.getcwd(),
49+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
50+
out, _ = p.communicate()
51+
52+
if p.returncode == 0:
53+
out_parts = out.decode().split()
54+
if len(out_parts) >= 3:
55+
version_parts = [int(x) for x in out_parts[2].replace(self.LANGUAGE, "").split('.')]
56+
if len(version_parts) == 3:
57+
if version_parts[0] == expected_major_version and version_parts[1] >= min_expected_minor_version:
58+
self._valid_runtime_path = runtime_path
59+
return self._valid_runtime_path
60+
61+
# otherwise, raise mismatch exception
62+
raise MisMatchRuntimeError(language=self.LANGUAGE,
63+
required_runtime=self.runtime,
64+
runtime_path=runtime_path)
65+
66+
@property
67+
def validated_runtime_path(self):
68+
return self._valid_runtime_path
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Go Modules Workflow
3+
"""
4+
from aws_lambda_builders.workflow import BaseWorkflow, Capability
5+
6+
from .actions import GoModulesBuildAction
7+
from .builder import GoModulesBuilder
8+
from .validator import GoRuntimeValidator
9+
from .utils import OSUtils
10+
11+
12+
class GoModulesWorkflow(BaseWorkflow):
13+
14+
NAME = "GoModulesBuilder"
15+
16+
CAPABILITY = Capability(language="go",
17+
dependency_manager="modules",
18+
application_framework=None)
19+
20+
def __init__(self,
21+
source_dir,
22+
artifacts_dir,
23+
scratch_dir,
24+
manifest_path,
25+
runtime=None,
26+
osutils=None,
27+
**kwargs):
28+
29+
super(GoModulesWorkflow, self).__init__(
30+
source_dir,
31+
artifacts_dir,
32+
scratch_dir,
33+
manifest_path,
34+
runtime=runtime,
35+
**kwargs)
36+
37+
if osutils is None:
38+
osutils = OSUtils()
39+
40+
options = kwargs.get("options") or {}
41+
handler = options.get("handler", None)
42+
43+
output_path = osutils.joinpath(artifacts_dir, handler)
44+
45+
builder = GoModulesBuilder(osutils, binaries=self.binaries)
46+
self.actions = [
47+
GoModulesBuildAction(source_dir, output_path, builder),
48+
]
49+
50+
def get_validators(self):
51+
return [GoRuntimeValidator(runtime=self.runtime)]

aws_lambda_builders/workflows/python_pip/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ def _validate_python_cmd(self, runtime_path):
7070

7171
@property
7272
def validated_runtime_path(self):
73-
return self._valid_runtime_path if self._valid_runtime_path is not None else None
73+
return self._valid_runtime_path
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import os
2+
import sys
3+
4+
from unittest import TestCase
5+
6+
from aws_lambda_builders.workflows.go_modules import utils
7+
8+
9+
class TestOSUtils(TestCase):
10+
11+
def setUp(self):
12+
self.osutils = utils.OSUtils()
13+
14+
def test_environ_returns_environment(self):
15+
result = self.osutils.environ
16+
self.assertEqual(result, os.environ)
17+
18+
def test_joinpath_joins_path_components(self):
19+
result = self.osutils.joinpath('a', 'b', 'c')
20+
self.assertEqual(result, os.path.join('a', 'b', 'c'))
21+
22+
def test_popen_runs_a_process_and_returns_outcome(self):
23+
cwd_py = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata', 'cwd.py')
24+
p = self.osutils.popen([sys.executable, cwd_py],
25+
stdout=self.osutils.pipe,
26+
stderr=self.osutils.pipe)
27+
out, err = p.communicate()
28+
self.assertEqual(p.returncode, 0)
29+
self.assertEqual(out.decode('utf8').strip(), os.getcwd())
30+
31+
def test_popen_can_accept_cwd(self):
32+
testdata_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata')
33+
p = self.osutils.popen([sys.executable, 'cwd.py'],
34+
stdout=self.osutils.pipe,
35+
stderr=self.osutils.pipe,
36+
cwd=testdata_dir)
37+
out, err = p.communicate()
38+
self.assertEqual(p.returncode, 0)
39+
self.assertEqual(out.decode('utf8').strip(), os.path.abspath(testdata_dir))
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import os
2+
import shutil
3+
import tempfile
4+
5+
from unittest import TestCase
6+
7+
from aws_lambda_builders.builder import LambdaBuilder
8+
from aws_lambda_builders.exceptions import WorkflowFailedError
9+
10+
11+
class TestGoWorkflow(TestCase):
12+
"""
13+
Verifies that `go` workflow works by building a Lambda using Go Modules
14+
"""
15+
16+
TEST_DATA_FOLDER = os.path.join(os.path.dirname(__file__), "testdata")
17+
18+
def setUp(self):
19+
self.artifacts_dir = tempfile.mkdtemp()
20+
self.scratch_dir = tempfile.mkdtemp()
21+
self.builder = LambdaBuilder(language="go",
22+
dependency_manager="modules",
23+
application_framework=None)
24+
self.runtime = "go1.x"
25+
26+
def tearDown(self):
27+
shutil.rmtree(self.artifacts_dir)
28+
shutil.rmtree(self.scratch_dir)
29+
30+
def test_builds_project_without_dependencies(self):
31+
source_dir = os.path.join(self.TEST_DATA_FOLDER, "no-deps")
32+
self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
33+
os.path.join(source_dir, "go.mod"),
34+
runtime=self.runtime,
35+
options={"handler": "no-deps-main"})
36+
expected_files = {"no-deps-main"}
37+
output_files = set(os.listdir(self.artifacts_dir))
38+
print(output_files)
39+
self.assertEquals(expected_files, output_files)
40+
41+
def test_builds_project_with_dependencies(self):
42+
source_dir = os.path.join(self.TEST_DATA_FOLDER, "with-deps")
43+
self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
44+
os.path.join(source_dir, "go.mod"),
45+
runtime=self.runtime,
46+
options={"handler": "with-deps-main"})
47+
expected_files = {"with-deps-main"}
48+
output_files = set(os.listdir(self.artifacts_dir))
49+
self.assertEquals(expected_files, output_files)
50+
51+
def test_fails_if_modules_cannot_resolve_dependencies(self):
52+
source_dir = os.path.join(self.TEST_DATA_FOLDER, "broken-deps")
53+
with self.assertRaises(WorkflowFailedError) as ctx:
54+
self.builder.build(source_dir, self.artifacts_dir, self.scratch_dir,
55+
os.path.join(source_dir, "go.mod"),
56+
runtime=self.runtime,
57+
options={"handler": "failed"})
58+
self.assertIn("GoModulesBuilder:Build - Builder Failed: ",
59+
str(ctx.exception))

0 commit comments

Comments
 (0)