Skip to content

Commit da5ec4b

Browse files
committed
feat: Makefile based builder for provided runtimes
Why is this change necessary? * provided runtimes are runtimes that are custom built with own bootstraps etc, and its hard to determine which language it actually is. A generic makefile based builder gives the control back to the user on how the project needs to be built. How does it address the issue? * Provide a way to build for provided runtimes, which does not exist today.
1 parent 10594cf commit da5ec4b

File tree

8 files changed

+360
-0
lines changed

8 files changed

+360
-0
lines changed

aws_lambda_builders/workflows/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
import aws_lambda_builders.workflows.java_gradle
1111
import aws_lambda_builders.workflows.java_maven
1212
import aws_lambda_builders.workflows.dotnet_clipackage
13+
import aws_lambda_builders.workflows.provided_make
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Provided Make Builder
2+
3+
### Scope
4+
5+
Provided runtimes need a flexible builder approach as the binaries required for a building for each language is very different. The introduced builder should have scope
6+
to be able build irrespective of the provided language, this is only possible if the logic of the build is not entirely contained within the builder itself, but that the builder
7+
executes commands that are user provided. The builder should showcase a contract to the user of how those commmands need to be provided.
8+
9+
10+
### Mechanism
11+
12+
The Mechanism proposed here is that of `Makefile` Builder that has a particular make target available based on the logical id of the function to be built.
13+
The `Makefile` is the manifest and is present in the same path as the original source directory.
14+
15+
16+
### Implementation
17+
18+
When the makefile is called through a lambda builder workflow, the appropriate target is triggered and artifacts should be copied to the exposed environmental variable `$ARTIFACTS_DIR`. This environment variable is seeded by the lambda builder with a value set to it. The targets are defined as
19+
20+
```
21+
{Function_Logical_Id}-build
22+
```
23+
24+
Injected Environment Variables by Makefile Lambda Builder Workflow per Function to be built.
25+
26+
``
27+
ARTIFACTS_DIR=/Users/noname/sam-app/.aws-sam/build/HelloWorldFunction
28+
``
29+
30+
Sample Makefile:
31+
32+
````
33+
build-HelloWorldFunction:
34+
touch $(ARTIFACTS_DIR)/somefile
35+
36+
build-HelloWorldFunction2:
37+
touch $(ARTIFACTS_DIR)/somefile2
38+
````
39+
40+
The workflow expects that the name of the function logical id is passed into the workflow using the `options` dictionary. This helps to trigger the correct build target.
41+
42+
#### Step 1: Copy source to a scratch directory
43+
44+
This involves the given source to a temporary scratch directory, so that mutations can be given on the files in the scratch directory, but leave the original source directory untouched.
45+
46+
#### Step 2: Core Workflow Action - Invoke Makefile Build Target
47+
48+
Artifacts directory is created if it doesnt exist and passed into the makefile target process.
49+
50+
```python
51+
self.subprocess_make.run([f"build-{self.build_logical_id}"], env={"ARTIFACTS_DIR": self.artifacts_dir}, cwd=self.scratch_dir)
52+
```
53+
54+
It is then the responsibility of the make target to make sure the artifacts are built in the correct manner and dropped into the artifacts directory.
55+
56+
### Challenges
57+
58+
* Is `make` truly platform independent?
59+
* Make comes with linux subsystem on windows and can also be installed with `choco`.
60+
61+
* Does this become a way to introduce plugins, makefile can have any commands in it?
62+
* We only care about certain build targets. so essentially this is a pluggable builder, but nothing beyond that at this point in time.
63+
64+
* Which environment variables are usable in this makefile?
65+
* There are a series of whitelisted environment variables that need to be defined and not be overriden within the Makefile to work. Currently that is just `$ARTIFACTS_DIR`
66+
67+
* Can this be used even for runtimes that have builders associated with it? eg: python3.8?
68+
* Possibly, some changes would be needed be made to way the corresponding builder is picked up in sam cli. If we changed it such that there is a makefile we pick a makefile builder and if not fall back to the specified language builder.
69+
70+
71+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Builds provided runtime lambda functions using a Makefile based builder.
3+
"""
4+
5+
from .workflow import ProvidedMakeWorkflow
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Action to build a specific Makefile target
3+
"""
4+
5+
import logging
6+
7+
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError
8+
from .make import MakeExecutionError
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
13+
class ProvidedMakeAction(BaseAction):
14+
15+
"""
16+
A Lambda Builder Action that builds and packages a provided runtime project using Make.
17+
"""
18+
19+
NAME = "MakeBuild"
20+
DESCRIPTION = "Running build target on Makefile"
21+
PURPOSE = Purpose.COMPILE_SOURCE
22+
23+
def __init__(self, artifacts_dir, scratch_dir, manifest_path, osutils, subprocess_make, build_logical_id):
24+
"""
25+
:type artifacts_dir: str
26+
:param artifacts_dir: directory where artifacts needs to be stored.
27+
28+
:type scratch_dir: str
29+
:param scratch_dir: an existing (writable) directory for temporary files
30+
31+
:type manifest_path: str
32+
:param manifest_path: path to Makefile of an Make project with the source in same folder.
33+
34+
:type osutils: aws_lambda_builders.workflows.provided_make.utils.OSUtils
35+
:param osutils: An instance of OS Utilities for file manipulation
36+
37+
:type subprocess_make aws_lambda_builders.workflows.provided_make.make.SubprocessMake
38+
:param subprocess_make: An instance of the Make process wrapper
39+
"""
40+
super(ProvidedMakeAction, self).__init__()
41+
self.artifacts_dir = artifacts_dir
42+
self.manifest_path = manifest_path
43+
self.scratch_dir = scratch_dir
44+
self.osutils = osutils
45+
self.subprocess_make = subprocess_make
46+
self.build_logical_id = build_logical_id
47+
48+
def execute(self):
49+
"""
50+
Runs the action.
51+
52+
:raises lambda_builders.actions.ActionFailedError: when Make Build fails.
53+
"""
54+
55+
# Create the Artifacts Directory if it doesnt exist.
56+
if not self.osutils.exists(self.artifacts_dir):
57+
self.osutils.makedirs(self.artifacts_dir)
58+
59+
try:
60+
self.subprocess_make.run([f"build-{self.build_logical_id}"], env={"ARTIFACTS_DIR": self.artifacts_dir}, cwd=self.scratch_dir)
61+
except MakeExecutionError as ex:
62+
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 make through a subprocess.
3+
"""
4+
5+
import logging
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
10+
class MakeExecutionError(Exception):
11+
12+
"""
13+
Exception raised in case Make execution fails.
14+
It will pass on the standard error output from the Make console.
15+
"""
16+
17+
MESSAGE = "Make Failed: {message}"
18+
19+
def __init__(self, **kwargs):
20+
Exception.__init__(self, self.MESSAGE.format(**kwargs))
21+
22+
23+
class SubProcessMake(object):
24+
25+
"""
26+
Wrapper around the Make command line utility, making it
27+
easy to consume execution results.
28+
"""
29+
30+
def __init__(self, osutils, make_exe=None):
31+
"""
32+
:type osutils: aws_lambda_builders.workflows.provided_make.utils.OSUtils
33+
:param osutils: An instance of OS Utilities for file manipulation
34+
35+
:type make_exe: str
36+
:param make_exe: Path to the Make binary. If not set,
37+
the default executable path make will be used
38+
"""
39+
self.osutils = osutils
40+
41+
if make_exe is None:
42+
if osutils.is_windows():
43+
make_exe = "make.cmd"
44+
else:
45+
make_exe = "make"
46+
47+
self.make_exe = make_exe
48+
49+
def run(self, args, env, cwd=None):
50+
51+
"""
52+
Runs the action.
53+
54+
:type args: list
55+
:param args: Command line arguments to pass to Make
56+
57+
:type args: dict
58+
:param env : Environment variables dictionary to be passed into subprocess
59+
60+
:type cwd: str
61+
:param cwd: Directory where to execute the command (defaults to current dir)
62+
63+
:rtype: str
64+
:return: text of the standard output from the command
65+
66+
:raises aws_lambda_builders.workflows.provided_make.make.MakeExecutionError:
67+
when the command executes with a non-zero return code. The exception will
68+
contain the text of the standard error output from the command.
69+
70+
:raises ValueError: if arguments are not provided, or not a list
71+
"""
72+
73+
if not isinstance(args, list):
74+
raise ValueError("args must be a list")
75+
76+
if not args:
77+
raise ValueError("requires at least one arg")
78+
79+
invoke_make = [self.make_exe] + args
80+
81+
LOG.debug("executing Make: %s", invoke_make)
82+
83+
p = self.osutils.popen(invoke_make, stdout=self.osutils.pipe, stderr=self.osutils.pipe, cwd=cwd, env=env)
84+
85+
out, err = p.communicate()
86+
87+
if p.returncode != 0:
88+
raise MakeExecutionError(message=err.decode("utf8").strip())
89+
90+
return out.decode("utf8").strip()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Make executable resolution
3+
"""
4+
5+
from .utils import OSUtils
6+
7+
8+
class MakeResolver(object):
9+
def __init__(self, executable_search_paths=None, os_utils=None):
10+
self.binary = "make"
11+
self.executables = [self.binary]
12+
self.executable_search_paths = executable_search_paths
13+
self.os_utils = os_utils if os_utils else OSUtils()
14+
15+
@property
16+
def exec_paths(self):
17+
paths = self.os_utils.which("make", executable_search_paths=self.executable_search_paths)
18+
19+
if not paths:
20+
raise ValueError("No Make executable found!")
21+
22+
return paths
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Commonly used utilities
3+
"""
4+
5+
import os
6+
import platform
7+
import tarfile
8+
import subprocess
9+
import shutil
10+
11+
from aws_lambda_builders.utils import which
12+
13+
14+
class OSUtils(object):
15+
16+
"""
17+
Wrapper around file system functions, to make it easy to
18+
unit test actions in memory
19+
"""
20+
21+
def copy_file(self, file_path, destination_path):
22+
return shutil.copy2(file_path, destination_path)
23+
24+
def extract_tarfile(self, tarfile_path, unpack_dir):
25+
with tarfile.open(tarfile_path, "r:*") as tar:
26+
tar.extractall(unpack_dir)
27+
28+
def file_exists(self, filename):
29+
return os.path.isfile(filename)
30+
31+
def joinpath(self, *args):
32+
return os.path.join(*args)
33+
34+
def exists(self, path):
35+
return os.path.exists(path)
36+
37+
def makedirs(self, path):
38+
return os.makedirs(path)
39+
40+
def popen(self, command, stdout=None, stderr=None, env=None, cwd=None):
41+
p = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env, cwd=cwd)
42+
return p
43+
44+
@property
45+
def pipe(self):
46+
return subprocess.PIPE
47+
48+
def dirname(self, path):
49+
return os.path.dirname(path)
50+
51+
def remove_file(self, filename):
52+
return os.remove(filename)
53+
54+
def abspath(self, path):
55+
return os.path.abspath(path)
56+
57+
def is_windows(self):
58+
return platform.system().lower() == "windows"
59+
60+
def which(self, executable, executable_search_paths=None):
61+
return which(executable, executable_search_paths=executable_search_paths)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
ProvidedMakeWorkflow
3+
"""
4+
from aws_lambda_builders.workflow import BaseWorkflow, Capability
5+
from aws_lambda_builders.actions import CopySourceAction
6+
from .actions import ProvidedMakeAction
7+
from .make_resolver import MakeResolver
8+
from .utils import OSUtils
9+
from .make import SubProcessMake
10+
11+
12+
class ProvidedMakeWorkflow(BaseWorkflow):
13+
14+
"""
15+
A Lambda builder workflow for provided runtimes based on make.
16+
"""
17+
18+
NAME = "ProvidedMakeBuilder"
19+
20+
CAPABILITY = Capability(language="provided", dependency_manager=None, application_framework=None)
21+
22+
EXCLUDED_FILES = (".aws-sam", ".git")
23+
24+
def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtime=None, osutils=None, **kwargs):
25+
26+
super(ProvidedMakeWorkflow, self).__init__(
27+
source_dir, artifacts_dir, scratch_dir, manifest_path, runtime=runtime, **kwargs
28+
)
29+
30+
self.os_utils = OSUtils()
31+
32+
options = kwargs.get("options") or {}
33+
build_logical_id = options.get("build_logical_id", None)
34+
35+
subprocess_make = SubProcessMake(make_exe=self.binaries['make'].binary_path, osutils=self.os_utils)
36+
37+
make_action = ProvidedMakeAction(
38+
artifacts_dir,
39+
scratch_dir, manifest_path, osutils=osutils, subprocess_make=subprocess_make, build_logical_id=build_logical_id
40+
)
41+
42+
self.actions = [
43+
CopySourceAction(source_dir, scratch_dir, excludes=self.EXCLUDED_FILES),
44+
make_action
45+
]
46+
47+
def get_resolvers(self):
48+
return [MakeResolver(executable_search_paths=self.executable_search_paths)]

0 commit comments

Comments
 (0)