-
Notifications
You must be signed in to change notification settings - Fork 148
Add support for .NET Core build #80
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
Changes from all commits
1c0d215
1250ff0
c7608ad
61b6ce9
d2c04bd
614818c
a27c7dc
4425ceb
76753bf
12ff002
22f3ca9
ac6ab13
63d9c96
2784e64
14230e1
b3615c7
7efc34c
8d92e90
4fac2d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
# .NET Core - Lambda Builder | ||
|
||
### Scope | ||
|
||
To build .NET Core Lambda functions this builder will use the AWS .NET Core Global Tool [Amazon.Lambda.Tools](https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools). | ||
This tool has several commands for building and publishing .NET Core Lambda functions. For this integration | ||
the `dotnet lambda package` command will be used to create a zip file that can be deployed to Lambda. | ||
|
||
The builder will install the Amazon.Lambda.Tools Global Tool or update to the latest version before executing | ||
the package command. | ||
|
||
This builder assumes the [.NET Core command-line interface (CLI)](https://docs.microsoft.com/en-us/dotnet/core/tools/?tabs=netcore2x) | ||
is already installed and added to the path environment variable. This is a reasonable requirement as the | ||
.NET Core CLI is a required tool for .NET Core developers to build any .NET Core project. | ||
|
||
The .NET Core CLI handles the validation that the correct version of .NET Core is installed and errors out when there is | ||
not a correct version. | ||
|
||
### Challenges | ||
|
||
#### Output | ||
|
||
The output of `dotnet lambda package` command is a zip archive that consumers can then deploy to Lambda. For SAM build | ||
the expected output is a directory of all of the output files. To make the package command compatible with the SAM build | ||
this builder will direct the package command to output the zip file in the artifacts folder. Once the package command is complete | ||
it expands the zip file and then deletes the zip file. | ||
|
||
#### Parameters | ||
|
||
The package command takes in serveral parameters. Here is the help for the package command. | ||
```bash | ||
> dotnet lambda package --help | ||
Amazon Lambda Tools for .NET Core applications (3.1.2) | ||
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet | ||
|
||
package: | ||
Command to package a Lambda project into a zip file ready for deployment | ||
|
||
dotnet lambda package [arguments] [options] | ||
Arguments: | ||
<ZIP-FILE> The name of the zip file to package the project into | ||
Options: | ||
-c | --configuration Configuration to build with, for example Release or Debug. | ||
-f | --framework Target framework to compile, for example netcoreapp2.1. | ||
--msbuild-parameters Additional msbuild parameters passed to the 'dotnet publish' command. Add quotes around the value if the value contains spaces. | ||
-pl | --project-location The location of the project, if not set the current directory will be assumed. | ||
-cfg | --config-file Configuration file storing default values for command line arguments. | ||
-pcfg | --persist-config-file If true the arguments used for a successful deployment are persisted to a config file. | ||
-o | --output-package The output zip file name | ||
-dvc | --disable-version-check Disable the .NET Core version check. Only for advanced usage. | ||
``` | ||
|
||
Currently **--framework** is the only required parameter which tells the underlying build process what version of .NET Core to build for. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How is this infomation passed in? is it decided by the builder based on the runtime? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For most users --framework will be already set in the aws-lambda-tools-defaults.json file so they won't have to do anything. If it is not set then the user would need to specify it on the sam build command line which would get passed into the builder. I tried to follow the examples I saw in the other builders for grabbing options that were passed on the commandline. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this is currently not supported in SAM CLI, although the correct plumbing exists thru How big of a requirement is this? Do you know the fraction of users that use parameters vs .json file to pass this value? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not big requirement since the framework will most likely be set through the *.json file. It is a good thing to have for the future. As long as the code is doing it in the way SAM envisions this being handled in the future is all that I require now. We should make sure SAM init version of a .NET Core project includes the json file like all of the rest of our .NET tooling does. I also might update the Amazon.Lambda.Tools to auto detect the framework based on the project file. If/when I do this work SAM build would pull that change in automatically due to the nature of this PR always updating the version of Amazon.Lambda.Tools. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, in the future when we add add This would also yield a conversation on which is higher priority, the flag or the information in the json config file. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The existing Amazon.Lambda.Tools .NET Core Global tool that this wraps around already has the rule that if a value is specified in the command line it overrides any setting in the json file. I would expect that rule to apply from sam build as well. |
||
|
||
Parameters can be passed into the package command either by a config file called **aws-lambda-tools-defaults.json** or on | ||
the command line. All .NET Core project templates provided by AWS contain the **aws-lambda-tools-defaults.json** file which has | ||
configuration and framework set. | ||
|
||
If a parameter is set on the command line it will override any values set in the **aws-lambda-tools-defaults.json**. | ||
An alternative config file can be specified with the **--config-file** parameter. | ||
|
||
This builder will forward any options that were provided to it starting with a '-' into the Lambda package command. Forwarding | ||
all parameters to the Lambda package command keeps the builder future compatible with changes to the package command. The package | ||
command does not error out for unknown parameters. | ||
|
||
### Implementation | ||
|
||
The implementation is broken up into 2 steps. The first action is to make sure the Amazon.Lambda.Tools Global Tool | ||
is installed. The second action is to execute the `dotnet lambda package` command. | ||
|
||
#### Step 1: Install Amazon.Lambda.Tools | ||
|
||
The tool is installed by executing the command `dotnet tool install -g Amazon.Lambda.Tools` This will install the | ||
tool from [NuGet](https://www.nuget.org/packages/Amazon.Lambda.Tools/) the .NET package management system. | ||
|
||
To keep the tool updated the command `dotnet tool update -g Amazon.Lambda.Tools` will be executed if the install | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
command fail because the tool was already installed. | ||
|
||
It is a requirement for Amazon.Lambda.Tools to maintain backwards compatiblity for the package command. This is an | ||
existing requirement for compatiblity with PowerShell Lambda support and the AWS Tools for Visual Studio Team Services. | ||
|
||
#### Step 2: Build the Lambda Deployment bundle | ||
|
||
To create the Lambda deployment bundle the `dotnet lambda package` command is execute in the project directory. This will | ||
create zip file in the artifacts directory. The builder will then expand the zip file into the zip artifacts folder and | ||
delete the zip file. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
""" | ||
Builds .NET Core Lambda functions using Amazon.Lambda.Tools Global Tool https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools | ||
""" | ||
|
||
from .workflow import DotnetCliPackageWorkflow |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
""" | ||
Actions for Ruby dependency resolution with Bundler | ||
""" | ||
|
||
import os | ||
import logging | ||
|
||
from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError | ||
from .utils import OSUtils | ||
from .dotnetcli import DotnetCLIExecutionError | ||
|
||
LOG = logging.getLogger(__name__) | ||
|
||
class GlobalToolInstallAction(BaseAction): | ||
|
||
""" | ||
A Lambda Builder Action which installs the Amazon.Lambda.Tools .NET Core Global Tool | ||
""" | ||
|
||
NAME = 'GlobalToolInstall' | ||
DESCRIPTION = "Install or update the Amazon.Lambda.Tools .NET Core Global Tool." | ||
PURPOSE = Purpose.COMPILE_SOURCE | ||
|
||
def __init__(self, subprocess_dotnet): | ||
super(GlobalToolInstallAction, self).__init__() | ||
self.subprocess_dotnet = subprocess_dotnet | ||
|
||
def execute(self): | ||
try: | ||
LOG.debug("Installing Amazon.Lambda.Tools Global Tool") | ||
self.subprocess_dotnet.run( | ||
['tool', 'install', '-g', 'Amazon.Lambda.Tools'], | ||
) | ||
except DotnetCLIExecutionError as ex: | ||
LOG.debug("Error installing probably due to already installed. Attempt to update to latest version.") | ||
try: | ||
self.subprocess_dotnet.run( | ||
['tool', 'update', '-g', 'Amazon.Lambda.Tools'], | ||
) | ||
except DotnetCLIExecutionError as ex: | ||
raise ActionFailedError("Error configuring the Amazon.Lambda.Tools .NET Core Global Tool: " + str(ex)) | ||
|
||
class RunPackageAction(BaseAction): | ||
""" | ||
A Lambda Builder Action which builds the .NET Core project using the Amazon.Lambda.Tools .NET Core Global Tool | ||
""" | ||
|
||
NAME = 'RunPackageAction' | ||
DESCRIPTION = "Execute the `dotnet lambda package` command." | ||
PURPOSE = Purpose.COMPILE_SOURCE | ||
|
||
def __init__(self, source_dir, subprocess_dotnet, artifacts_dir, options, os_utils=None): | ||
super(RunPackageAction, self).__init__() | ||
self.source_dir = source_dir | ||
self.subprocess_dotnet = subprocess_dotnet | ||
self.artifacts_dir = artifacts_dir | ||
self.options = options | ||
self.os_utils = os_utils if os_utils else OSUtils() | ||
|
||
def execute(self): | ||
try: | ||
LOG.debug("Running `dotnet lambda package` in %s", self.source_dir) | ||
|
||
zipfilename = os.path.basename(os.path.normpath(self.source_dir)) + ".zip" | ||
zipfullpath = os.path.join(self.artifacts_dir, zipfilename) | ||
|
||
arguments = ['lambda', 'package', '--output-package', zipfullpath] | ||
|
||
if self.options is not None: | ||
for key in self.options: | ||
if str.startswith(key, "-"): | ||
arguments.append(key) | ||
arguments.append(self.options[key]) | ||
|
||
self.subprocess_dotnet.run( | ||
arguments, | ||
cwd=self.source_dir | ||
) | ||
|
||
# The dotnet lambda package command outputs a zip file for the package. To make this compatible | ||
# with the workflow, unzip the zip file into the artifacts directory and then delete the zip archive. | ||
self.os_utils.expand_zip(zipfullpath, self.artifacts_dir) | ||
|
||
except DotnetCLIExecutionError as ex: | ||
raise ActionFailedError(str(ex)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
""" | ||
Wrapper around calls to dotent CLI through a subprocess. | ||
""" | ||
|
||
import sys | ||
import logging | ||
|
||
from .utils import OSUtils | ||
|
||
LOG = logging.getLogger(__name__) | ||
|
||
class DotnetCLIExecutionError(Exception): | ||
""" | ||
Exception raised when dotnet CLI fails. | ||
Will encapsulate error output from the command. | ||
""" | ||
|
||
MESSAGE = "Dotnet CLI Failed: {message}" | ||
|
||
def __init__(self, **kwargs): | ||
Exception.__init__(self, self.MESSAGE.format(**kwargs)) | ||
|
||
class SubprocessDotnetCLI(object): | ||
""" | ||
Wrapper around the Dotnet CLI, encapsulating | ||
execution results. | ||
""" | ||
|
||
def __init__(self, dotnet_exe=None, os_utils=None): | ||
self.os_utils = os_utils if os_utils else OSUtils() | ||
if dotnet_exe is None: | ||
if self.os_utils.is_windows(): | ||
dotnet_exe = 'dotnet.exe' | ||
else: | ||
dotnet_exe = 'dotnet' | ||
|
||
self.dotnet_exe = dotnet_exe | ||
|
||
def run(self, args, cwd=None): | ||
if not isinstance(args, list): | ||
raise ValueError('args must be a list') | ||
|
||
if not args: | ||
raise ValueError('requires at least one arg') | ||
|
||
invoke_dotnet = [self.dotnet_exe] + args | ||
|
||
LOG.debug("executing dotnet: %s", invoke_dotnet) | ||
|
||
p = self.os_utils.popen(invoke_dotnet, | ||
stdout=self.os_utils.pipe, | ||
stderr=self.os_utils.pipe, | ||
cwd=cwd) | ||
|
||
out, err = p.communicate() | ||
|
||
# The package command contains lots of useful information on how the package was created and | ||
# information when the package command was not successful. For that reason the output is | ||
# always written to the output to help developers diagnose issues. | ||
LOG.info(out.decode('utf8').strip()) | ||
|
||
if p.returncode != 0: | ||
raise DotnetCLIExecutionError(message=err.decode('utf8').strip()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
""" | ||
Dotnet executable resolution | ||
""" | ||
|
||
from .utils import OSUtils | ||
|
||
class DotnetCliResolver(object): | ||
|
||
def __init__(self, executable_search_paths=None, os_utils=None): | ||
self.binary = 'dotnet' | ||
self.executable_search_paths = executable_search_paths | ||
self.os_utils = os_utils if os_utils else OSUtils() | ||
|
||
@property | ||
def exec_paths(self): | ||
|
||
# look for the windows executable | ||
paths = self.os_utils.which('dotnet.exe', executable_search_paths=self.executable_search_paths) | ||
if not paths: | ||
# fallback to the non windows name without the .exe suffix | ||
paths = self.os_utils.which('dotnet', executable_search_paths=self.executable_search_paths) | ||
|
||
if not paths: | ||
raise ValueError("No dotnet cli executable found!") | ||
|
||
return paths |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
""" | ||
Commonly used utilities | ||
""" | ||
|
||
import os | ||
import platform | ||
import shutil | ||
import subprocess | ||
import zipfile | ||
from aws_lambda_builders.utils import which | ||
|
||
|
||
class OSUtils(object): | ||
""" | ||
Convenience wrapper around common system functions | ||
""" | ||
|
||
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 | ||
|
||
def is_windows(self): | ||
return platform.system().lower() == 'windows' | ||
|
||
def which(self, executable, executable_search_paths=None): | ||
return which(executable, executable_search_paths=executable_search_paths) | ||
|
||
def expand_zip(self, zipfullpath,destination_dir): | ||
ziparchive = zipfile.ZipFile(zipfullpath, 'r') | ||
ziparchive.extractall(destination_dir) | ||
ziparchive.close() | ||
os.remove(zipfullpath) | ||
|
||
@property | ||
def pipe(self): | ||
return subprocess.PIPE |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
""" | ||
.NET Core CLI Package Workflow | ||
""" | ||
from aws_lambda_builders.workflow import BaseWorkflow, Capability | ||
|
||
from .actions import GlobalToolInstallAction, RunPackageAction | ||
from .dotnetcli import SubprocessDotnetCLI | ||
from .dotnetcli_resolver import DotnetCliResolver | ||
from .utils import OSUtils | ||
|
||
|
||
class DotnetCliPackageWorkflow(BaseWorkflow): | ||
|
||
""" | ||
A Lambda builder workflow that knows to build and package .NET Core Lambda functions | ||
""" | ||
NAME = "DotnetCliPackageBuilder" | ||
|
||
CAPABILITY = Capability(language="dotnet", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to add resolver and validator for the executables used by the workflow? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you have an example of what you mean? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a resolver to make sure the dotnet CLI is installed. A validator is not needed because the dotnet CLI has its own built in versioning system to match the available installed .NET SDK with the selected project. A validator would just duplicate that work and possibly get in the way. |
||
dependency_manager="cli-package", | ||
application_framework=None) | ||
|
||
def __init__(self, | ||
source_dir, | ||
artifacts_dir, | ||
scratch_dir, | ||
manifest_path, | ||
runtime=None, | ||
**kwargs): | ||
|
||
super(DotnetCliPackageWorkflow, self).__init__( | ||
source_dir, | ||
artifacts_dir, | ||
scratch_dir, | ||
manifest_path, | ||
runtime=runtime, | ||
**kwargs) | ||
|
||
options = kwargs["options"] if "options" in kwargs else {} | ||
subprocess_dotnetcli = SubprocessDotnetCLI(os_utils=OSUtils()) | ||
dotnetcli_install = GlobalToolInstallAction(subprocess_dotnet=subprocess_dotnetcli) | ||
|
||
dotnetcli_deployment = RunPackageAction(source_dir, subprocess_dotnet=subprocess_dotnetcli, artifacts_dir=artifacts_dir, options=options) | ||
self.actions = [ | ||
dotnetcli_install, | ||
dotnetcli_deployment, | ||
] | ||
|
||
def get_resolvers(self): | ||
return [DotnetCliResolver(executable_search_paths=self.executable_search_paths)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is a requirement mainly stemming from the ability to customize the zip file later during package. See this discussion - #69 (comment)