Skip to content

Commit 8415ccd

Browse files
dagnirsanathkr
authored andcommitted
Gradle builder for Java (#69)
1 parent 681d65b commit 8415ccd

File tree

49 files changed

+1669
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1669
-0
lines changed

.appveyor.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ image: Visual Studio 2017
44
environment:
55
GOPATH: c:\gopath
66
GOVERSION: 1.11
7+
GRADLE_OPTS: -Dorg.gradle.daemon=false
78

89
matrix:
910

@@ -36,6 +37,9 @@ install:
3637
- "go version"
3738
- "go env"
3839

40+
# setup Gradle
41+
- "choco install gradle"
42+
3943
test_script:
4044
- "%PYTHON%\\python.exe -m pytest --cov aws_lambda_builders --cov-report term-missing tests/unit tests/functional"
4145
- "%PYTHON%\\python.exe -m pytest tests/integration"

aws_lambda_builders/workflows/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
import aws_lambda_builders.workflows.ruby_bundler
88
import aws_lambda_builders.workflows.go_dep
99
import aws_lambda_builders.workflows.go_modules
10+
import aws_lambda_builders.workflows.java_gradle
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Java - Gradle Lambda Builder
2+
3+
## Scope
4+
5+
This package enables the creation of a Lambda deployment package for Java
6+
projects managed using the Gradle build tool.
7+
8+
For Java projects, the most popular way to create a distribution package for
9+
Java based Lambdas is to create an "uber" or "fat" JAR. This is a single JAR
10+
file that contains both the customers' classes and resources, as well as all the
11+
classes and resources extracted from their dependency JAR's. However, this can
12+
cause files that have the same path in two different JAR's to collide within the
13+
uber JAR.
14+
15+
Another solution is to create a distribution ZIP containing the customer's
16+
classes and resources and include their dependency JARs under a `lib` directory.
17+
This keeps the customers' classes and resources separate from their
18+
dependencies' to avoid any file collisions. However, this incurs some overhead
19+
as the ZIP must be unpacked before the code can run.
20+
21+
To avoid the problem of colliding files, we will choose the second option and
22+
create distribution ZIP.
23+
24+
## Challenges
25+
26+
Java bytecode can only run on the same or newer version of the JVM for which
27+
it was compiled for. For example Java 8 bytecode can run a JVM that is at
28+
least version 8, but bytecode targetting Java 9 cannot run on a Java 8 VM.
29+
This is further complicated by the fact that a newer JDK can generate code to
30+
be run on an older VM if configured using the `targetCompatibility` and
31+
`sourceCompatibility` properties of the Java plugin. Therefore, it is not
32+
sufficient to check the version of the local JDK, nor is it possible to check
33+
the value set for `targetCompatibility` or `sourceCompatibility` since it can
34+
be local to the compile/build task. At best, we can check if the local
35+
version of the JDK is newer than Java 8 and emit a warning that the built
36+
artifact may not run in Lambda.
37+
38+
Gradle projects are configured using `build.gradle` build scripts. These are
39+
executable files authored in either Groovy or since 5.0, Kotlin, and using the
40+
Gradle DSL. This presents a similar problem to `setup.py` in the Python world in
41+
that arbitrary logic can be executed during build time that could affect both
42+
how the customer's artifact is built, and which dependencies are chosen.
43+
44+
An interesting challenge is dealing with single build and multi build projects.
45+
Consider the following different projects structures:
46+
47+
**Project A**
48+
```
49+
ProjectA
50+
├── build.gradle
51+
├── gradlew
52+
├── src
53+
└── template.yaml
54+
```
55+
56+
**Project B**
57+
```
58+
ProjectB
59+
├── common
60+
│   └── build.gradle
61+
├── lambda1
62+
│   └── build.gradle
63+
├── lambda2
64+
│   └── build.gradle
65+
├── build.gradle
66+
├── gradlew
67+
├── settings.gradle
68+
└── template.yaml
69+
```
70+
71+
Here `ProjectA` is a a single lambda function, and `ProjectB` is a multi-build
72+
project where sub directories `lambda1` and `lambda2` are each a lambda
73+
function. In addition, suppose that `ProjectB/lambda1` has a dependency on its
74+
sibling project `ProjectB/common`.
75+
76+
Building Project A is relatively simple since we just need to issue `gradlew
77+
build` and place the built ZIP within the artifact directory.
78+
79+
Building `ProjectB/lambda1` is very similar from the point of view of the
80+
workflow since it still issues the same command (`gradlew build`), but it
81+
requires that Gradle is able to find its way back up to the parent `ProjectB` so
82+
that it can also build `ProjectB/common` which can be a challenge when mounting
83+
within a container.
84+
85+
## Implementation
86+
87+
### Build Workflow
88+
89+
We leverage Gradle to do all the heavy lifting for executing the
90+
`build.gradle` script which will resolve and download the dependencies and
91+
build the project. To create the distribution ZIP, we use the help of a
92+
Gradle init script to insert a post-build action to do this.
93+
94+
#### Step 1: Copy custom init file to temporary location
95+
96+
There is no standard task in Gradle to create a distribution ZIP (or uber JAR).
97+
We add this functionality through the use of a Gradle init script. The script
98+
will be responsible for adding a post-build action that creates the distribution
99+
ZIP.
100+
101+
It will do something similar to:
102+
103+
```sh
104+
cp /path/to/lambda-build-init.gradle /$SCRATCH_DIR/
105+
```
106+
107+
where the contents of `lambda-build-init.gradle` contains the code for defining
108+
the post-build action:
109+
110+
```gradle
111+
gradle.project.afterProject { p ->
112+
// Set the give project's buildDir to one under SCRATCH_DIR
113+
}
114+
115+
// Include the project classes and resources in the root, and the dependencies
116+
// under lib
117+
gradle.taskGraph.afterTask { t ->
118+
if (t.name != 'build') {
119+
return
120+
}
121+
122+
// Step 1: Find the directory under scratch_dir where the artifact for
123+
// t.project is located
124+
// Step 2: Open ZIP file in $buildDir/distributions/lambda_build
125+
// Step 3: Copy project class files and resources to ZIP root
126+
// Step 3: Copy libs in configurations.runtimeClasspath into 'lib'
127+
// subdirectory in ZIP
128+
}
129+
```
130+
131+
#### Step 2: Resolve Gradle executable to use
132+
133+
[The recommended
134+
way](https://docs.gradle.org/current/userguide/gradle_wrapper.html) way to
135+
author and distribute a Gradle project is to include a `gradlew` or Gradle
136+
Wrapper file within the root of the project. This essentially locks in the
137+
version of Gradle for the project and uses an executable that is independent of
138+
any local installations. This helps ensure that builds are always consistent
139+
over different environments.
140+
141+
The `gradlew` script, if it is included, will be located at the root of the
142+
project. We will rely on the invoker of the workflow to supply the path to the
143+
`gradlew` script.
144+
145+
We give precedence to this `gradlew` file, and if isn't found, we use the
146+
`gradle` executable found on the `PATH` using the [path resolver][path resolver].
147+
148+
#### Step 3: Check Java version and emit warning
149+
150+
Check whether the local JDK version is <= Java 8, and if it is not, emit a
151+
warning that the built artifact may not run in Lambda unless a) the project is
152+
properly configured (i.e. using `targetCompatibility`) or b) the project is
153+
built within a Lambda-compatibile environment like `lambci`.
154+
155+
We use the Gradle executable from Step 2 for this to ensure that we check the
156+
actual JVM version Gradle is using in case it has been configured to use a
157+
different one than can be found on the PATH.
158+
159+
#### Step 4: Build and package
160+
161+
```sh
162+
$GRADLE_EXECUTABLE --project-cache-dir $SCRATCH_DIR/gradle-cache \
163+
-Dsoftware.amazon.aws.lambdabuilders.scratch-dir=$SCRATCH_DIR \
164+
--init-script $SCRATCH_DIR/lambda-build-init.gradle build
165+
```
166+
167+
Since by default, Gradle stores its build-related metadata in a `.gradle`
168+
directory under the source directory, we specify an alternative directory under
169+
`scratch_dir` to avoid writing anything under `source_dir`. This is simply a
170+
`gradle-cache` directory under `scratch_dir`.
171+
172+
Next, we also pass the location of the `scratch_dir` as a Java system
173+
property so that it's availabe to our init script. This allows it to correctly
174+
map the build directory for each sub-project within `scratch_dir`. Again, this
175+
ensures that we are not writing anything under the source directory.
176+
177+
One important detail here is that the init script may create *multiple*
178+
subdirectories under `scratch_dir`, one for each project involved in building
179+
the lambda located at `source_dir`. Going back to the `ProjectB` example, if
180+
we're building `lambda1`, this also has the effect of building `common` because
181+
it's a declared dependency in its `build.gradle`. So, within `scratch_dir` will
182+
be a sub directory for each project that gets built as a result of building
183+
`source_dir`; in this case there will be one for each of `lambda1` and `common`.
184+
The init file uses some way of mapping the source root of each project involved
185+
to a unique directory under `scratch_dir`, like a hashing function.
186+
187+
#### Step 5: Copy to artifact directory
188+
189+
The workflow implementation is aware of the mapping scheme used to map a
190+
`source_dir` to the correct directory under `scratch_dir` (described in step 4),
191+
so it knows where to find the built Lambda artifact when copying it to
192+
`artifacts_dir`. They will be located in
193+
`$SCRATCH_DIR/<mapping for source_dir>/build/distributions/lambda-build`.
194+
195+
[path resolver]: https://github.com/awslabs/aws-lambda-builders/pull/55
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Builds Java Lambda functions using the Gradle build tool
3+
"""
4+
5+
from .workflow import JavaGradleWorkflow
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Actions for the Java Gradle Workflow
3+
"""
4+
5+
import os
6+
from aws_lambda_builders.actions import ActionFailedError, BaseAction, Purpose
7+
from .gradle import GradleExecutionError
8+
9+
10+
class JavaGradleBuildAction(BaseAction):
11+
NAME = "GradleBuild"
12+
DESCRIPTION = "Building the project using Gradle"
13+
PURPOSE = Purpose.COMPILE_SOURCE
14+
15+
INIT_SCRIPT = 'lambda-build-init.gradle'
16+
SCRATCH_DIR_PROPERTY = 'software.amazon.aws.lambdabuilders.scratch-dir'
17+
GRADLE_CACHE_DIR_NAME = 'gradle-cache'
18+
19+
def __init__(self,
20+
source_dir,
21+
build_file,
22+
subprocess_gradle,
23+
scratch_dir,
24+
os_utils):
25+
self.source_dir = source_dir
26+
self.build_file = build_file
27+
self.scratch_dir = scratch_dir
28+
self.subprocess_gradle = subprocess_gradle
29+
self.os_utils = os_utils
30+
self.cache_dir = os.path.join(self.scratch_dir, self.GRADLE_CACHE_DIR_NAME)
31+
32+
def execute(self):
33+
init_script_file = self._copy_init_script()
34+
self._build_project(init_script_file)
35+
36+
@property
37+
def gradle_cache_dir(self):
38+
return self.cache_dir
39+
40+
def _copy_init_script(self):
41+
try:
42+
src = os.path.join(os.path.dirname(__file__), 'resources', self.INIT_SCRIPT)
43+
dst = os.path.join(self.scratch_dir, self.INIT_SCRIPT)
44+
return self.os_utils.copy(src, dst)
45+
except Exception as ex:
46+
raise ActionFailedError(str(ex))
47+
48+
def _build_project(self, init_script_file):
49+
try:
50+
if not self.os_utils.exists(self.scratch_dir):
51+
self.os_utils.makedirs(self.scratch_dir)
52+
self.subprocess_gradle.build(self.source_dir, self.build_file, self.gradle_cache_dir,
53+
init_script_file,
54+
{self.SCRATCH_DIR_PROPERTY: os.path.abspath(self.scratch_dir)})
55+
except GradleExecutionError as ex:
56+
raise ActionFailedError(str(ex))
57+
58+
59+
class JavaGradleCopyArtifactsAction(BaseAction):
60+
NAME = "CopyArtifacts"
61+
DESCRIPTION = "Copying the built artifacts"
62+
PURPOSE = Purpose.COPY_SOURCE
63+
64+
def __init__(self,
65+
source_dir,
66+
artifacts_dir,
67+
build_dir,
68+
os_utils):
69+
self.source_dir = source_dir
70+
self.artifacts_dir = artifacts_dir
71+
self.build_dir = build_dir
72+
self.os_utils = os_utils
73+
74+
def execute(self):
75+
self._copy_artifacts()
76+
77+
def _copy_artifacts(self):
78+
lambda_build_output = os.path.join(self.build_dir, 'build', 'distributions', 'lambda-build')
79+
try:
80+
if not self.os_utils.exists(self.artifacts_dir):
81+
self.os_utils.makedirs(self.artifacts_dir)
82+
self.os_utils.copytree(lambda_build_output, self.artifacts_dir)
83+
except Exception as ex:
84+
raise ActionFailedError(str(ex))
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Wrapper around calls to Gradle through a subprocess.
3+
"""
4+
5+
import logging
6+
import subprocess
7+
8+
LOG = logging.getLogger(__name__)
9+
10+
11+
class GradleExecutionError(Exception):
12+
MESSAGE = "Gradle Failed: {message}"
13+
14+
def __init__(self, **kwargs):
15+
Exception.__init__(self, self.MESSAGE.format(**kwargs))
16+
17+
18+
class BuildFileNotFoundError(GradleExecutionError):
19+
def __init__(self, build_file_path):
20+
super(BuildFileNotFoundError, self).__init__(
21+
message='Gradle build file not found: %s' % build_file_path)
22+
23+
24+
class SubprocessGradle(object):
25+
26+
def __init__(self, gradle_binary, os_utils=None):
27+
if gradle_binary is None:
28+
raise ValueError("Must provide Gradle BinaryPath")
29+
self.gradle_binary = gradle_binary
30+
if os_utils is None:
31+
raise ValueError("Must provide OSUtils")
32+
self.os_utils = os_utils
33+
34+
def build(self, source_dir, build_file, cache_dir=None, init_script_path=None, properties=None):
35+
if not self.os_utils.exists(build_file):
36+
raise BuildFileNotFoundError(build_file)
37+
38+
args = ['build', '--build-file', build_file]
39+
if cache_dir is not None:
40+
args.extend(['--project-cache-dir', cache_dir])
41+
if properties is not None:
42+
args.extend(['-D%s=%s' % (n, v) for n, v in properties.items()])
43+
if init_script_path is not None:
44+
args.extend(['--init-script', init_script_path])
45+
ret_code, _, stderr = self._run(args, source_dir)
46+
if ret_code != 0:
47+
raise GradleExecutionError(message=stderr.decode('utf8').strip())
48+
49+
def _run(self, args, cwd=None):
50+
p = self.os_utils.popen([self.gradle_binary.binary_path] + args, cwd=cwd, stdout=subprocess.PIPE,
51+
stderr=subprocess.PIPE)
52+
stdout, stderr = p.communicate()
53+
return p.returncode, stdout, stderr
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Gradle executable resolution
3+
"""
4+
5+
from .utils import OSUtils
6+
7+
8+
class GradleResolver(object):
9+
10+
def __init__(self, executable_search_paths=None, os_utils=None):
11+
self.binary = 'gradle'
12+
self.executables = [self.binary]
13+
self.executable_search_paths = executable_search_paths
14+
self.os_utils = os_utils if os_utils else OSUtils()
15+
16+
@property
17+
def exec_paths(self):
18+
# Prefer gradlew/gradlew.bat
19+
paths = self.os_utils.which(self.wrapper_name, executable_search_paths=self.executable_search_paths)
20+
if not paths:
21+
# fallback to the gradle binary
22+
paths = self.os_utils.which('gradle', executable_search_paths=self.executable_search_paths)
23+
24+
if not paths:
25+
raise ValueError("No Gradle executable found!")
26+
27+
return paths
28+
29+
@property
30+
def wrapper_name(self):
31+
return 'gradlew.bat' if self.os_utils.is_windows() else 'gradlew'

0 commit comments

Comments
 (0)