diff --git a/aws_lambda_builders/workflows/__init__.py b/aws_lambda_builders/workflows/__init__.py index c531a1bc2..d6740014e 100644 --- a/aws_lambda_builders/workflows/__init__.py +++ b/aws_lambda_builders/workflows/__init__.py @@ -5,3 +5,4 @@ import aws_lambda_builders.workflows.python_pip import aws_lambda_builders.workflows.nodejs_npm import aws_lambda_builders.workflows.ruby_bundler +import aws_lambda_builders.workflows.rust_cargo diff --git a/aws_lambda_builders/workflows/rust_cargo/__init__.py b/aws_lambda_builders/workflows/rust_cargo/__init__.py new file mode 100644 index 000000000..ea66c4f5f --- /dev/null +++ b/aws_lambda_builders/workflows/rust_cargo/__init__.py @@ -0,0 +1,5 @@ +""" +Builds Rust Lambda functions using Cargo +""" + +from .workflow import RustCargoWorkflow diff --git a/aws_lambda_builders/workflows/rust_cargo/actions.py b/aws_lambda_builders/workflows/rust_cargo/actions.py new file mode 100644 index 000000000..68cfd00bb --- /dev/null +++ b/aws_lambda_builders/workflows/rust_cargo/actions.py @@ -0,0 +1,103 @@ +""" +Actions for the Rust Cargo workflow +""" +import subprocess +import logging +import sys +import os +import shutil +import platform + +from aws_lambda_builders.actions import BaseAction, Purpose, ActionFailedError +from .cargo import CargoParser, CargoFileNotFoundError, CargoParsingError, CargoValidationError, PathNotFoundError + +LOG = logging.getLogger(__name__) +TARGET_PLATFORM = "x86_64-unknown-linux-musl" +RUNTIME_METADATA_FILE = "runtime_release" + + +class CargoValidator(BaseAction): + """ + Validates that Cargo.toml is configured correctly to build a Lambda application + """ + NAME = 'CargoValidator' + PURPOSE = Purpose.RESOLVE_DEPENDENCIES + + def __init__(self, source_dir, manifest_path, runtime): + self.source_dir = source_dir + self.manifest_path = manifest_path + self.runtime = runtime + self.cargo_parser = CargoParser(manifest_path) + + def execute(self): + try: + self.cargo_parser.validate(print_warnings=True) + except (CargoFileNotFoundError, CargoParsingError, CargoValidationError) as ex: + raise ActionFailedError(str(ex)) + + +class RustCargoBuildAction(BaseAction): + """ + Uses Cargo to build a project + """ + NAME = 'RustCargoBuildAction' + PURPOSE = Purpose.COMPILE_SOURCE + + def __init__(self, source_dir, manifest_path, runtime): + self.source_dir = source_dir + self.manifest_path = manifest_path + self.runtime = runtime + self.cargo_parser = CargoParser(manifest_path) + + def execute(self): + try: + LOG.info("Starting cargo release build for %s", self.source_dir) + cmd = "cargo build --release" + # if we are running on linux we assume that it's the Amazon Linux Docker container. + # Otherwise we set the target pltform to musl linux. + if platform.system() != "Linux": + cmd += " --target " + TARGET_PLATFORM + subprocess.run( + cmd, + stderr=sys.stderr, + stdout=sys.stderr, + shell=True, + cwd=self.source_dir, + check=True, + ) + LOG.info("Built executable: %s", self.cargo_parser.get_executable_name()) + #LOG.info("Done: %s", build_output) + except subprocess.CalledProcessError as ex: + LOG.info("Error while executing build: %i\n%s", ex.returncode, ex.output) + raise ActionFailedError(str(ex)) + + +class CopyAndRenameExecutableAction(BaseAction): + NAME = 'CopyAndRenameExecutableAction' + PURPOSE = Purpose.COPY_SOURCE + + def __init__(self, source_dir, atrifact_path, manifest_path, runtime): + self.source_dir = source_dir + self.manifest_path = manifest_path + self.artifact_path = atrifact_path + self.runtime = runtime + self.cargo_parser = CargoParser(manifest_path) + + def execute(self): + try: + target = TARGET_PLATFORM if platform.system() != "Linux" else "" + bin_path = self.cargo_parser.get_executable_path(target) + LOG.info("Copying executable from %s to %s", bin_path, self.artifact_path) + shutil.copyfile(bin_path, os.path.join(self.artifact_path, "bootstrap"), follow_symlinks=True) + + target_dir = self.cargo_parser.get_target_path(target) + metadata_file = os.path.join(target_dir, RUNTIME_METADATA_FILE) + LOG.info("Looking for metdata file: %s", metadata_file) + if os.path.isfile(metadata_file): + LOG.info("Found runtime metdata file, copying to %s", self.artifact_path) + shutil.copyfile(metadata_file, os.path.join(self.artifact_path, + RUNTIME_METADATA_FILE), follow_symlinks=True) + except PathNotFoundError as ex: + raise ActionFailedError(str(ex)) + except (OSError, shutil.SpecialFileError) as ex: + raise ActionFailedError(str(ex)) diff --git a/aws_lambda_builders/workflows/rust_cargo/cargo.py b/aws_lambda_builders/workflows/rust_cargo/cargo.py new file mode 100644 index 000000000..90508d659 --- /dev/null +++ b/aws_lambda_builders/workflows/rust_cargo/cargo.py @@ -0,0 +1,170 @@ +""" +Cargo validator utility +""" +import os +import logging + +import toml + +LOG = logging.getLogger(__name__) + + +class PackagerError(Exception): + pass + + +class CargoFileNotFoundError(PackagerError): + def __init__(self, cargo_path): + super(CargoFileNotFoundError, self).__init__( + 'Cargo file not found: %s' % cargo_path) + + +class CargoParsingError(PackagerError): + def __init__(self, ex): + super(CargoParsingError, self).__init__( + 'Could not parse cargo file: %s' % ex) + + +class CargoValidationError(PackagerError): + def __init__(self, msg): + super(CargoValidationError, self).__init__( + 'Invalid cargo file: %s' % msg) + + +class PathNotFoundError(PackagerError): + def __init__(self, bin_path): + super(PathNotFoundError, self).__init__( + 'Path not found: %s' % bin_path) + + +class CargoParser(object): + def __init__(self, manifest): + """ + Given the path to a Cargo.toml file parses its contents. Use + the validate() method sanity-check the cargo manifest for + a Lambda function build. + + :raises CargoParsingError: If the Cargo.toml file could not be + found or parsed. + """ + self.manifest = manifest + self._parse(manifest) + + def _parse(self, manifest): + if not os.path.isfile(manifest): + raise CargoFileNotFoundError(manifest) + + try: + with open(manifest, 'r') as cargo: + package_properties = toml.load(cargo) + LOG.debug("Cargo file: %s", package_properties) + self.cargo = package_properties + except (TypeError, toml.TomlDecodeError) as ex: + raise CargoParsingError(ex) + except ValueError as ex: + raise CargoParsingError(ex) + + def get_executable_name(self): + """ + Returns the name of the executable file generated by the + cargo build process + """ + self.validate(print_warnings=False) + + bin_name = self.cargo["package"]["name"] + if "bin" in self.cargo: + bin_props = self.cargo["bin"] + for prop in bin_props: + if "name" in prop: + bin_name = prop["name"] + + return bin_name + + def get_target_path(self, target_platform): + """ + Returns the full path to the target directory where the binary is stored + + :type target_platform: str + :param target_platform: + The --target parameter that was passed to the cargo build process. + + :raise ExecutableNotFound: If the executable file does not exist. + """ + self.validate(print_warnings=False) + proj_path = os.path.dirname(os.path.abspath(self.manifest)) + target_dir = os.path.join(proj_path, "target", target_platform, "release") + + if not os.path.isdir(target_dir): + raise PathNotFoundError(target_dir) + + return target_dir + + def get_executable_path(self, target_platform): + """ + Returns the full path to the compiled executable. + + :type target_platform: str + :param target_platform: + The --target parameter that was passed to the cargo build process. + + :raise ExecutableNotFound: If the executable file does not exist. + """ + self.validate(print_warnings=False) + + bin_name = self.get_executable_name() + proj_path = os.path.dirname(os.path.abspath(self.manifest)) + bin_full_path = os.path.join(proj_path, "target", target_platform, "release", bin_name) + + if not os.path.isfile(bin_full_path): + raise PathNotFoundError(bin_full_path) + + return bin_full_path + + def validate(self, print_warnings=True): + """ + Validates a Cargo.toml file and optionally prints out warnings + and suggestions for the Cargo structure. + + :type print_warnings: bool + :param print_warnings: + Whether the method should print warnings in the LOG object. + + :raise CargoValidationError: If the cargo file was not parsed correctly + or it does not represent a valid package. + """ + if not self.cargo: + raise CargoValidationError("Cargo file not parsed") + + if "package" not in self.cargo: + raise CargoValidationError("Manifest does not contain package table") + + package = self.cargo["package"] + if "name" not in package: + raise CargoValidationError("Missing name property for package") + + if "bin" not in self.cargo: + if print_warnings: + LOG.warning(("Missing [[bin]] section from Cargo.toml. " + "The builder will rename the executable from %s " + "to bootstrap. Consider including a [[bin]] " + "section in the Cargo.toml file with a " + "name = \"bootstrap\" property."), package["name"]) + else: + bin_props = self.cargo["bin"] + name_found = False + for prop in bin_props: + if "name" in prop: + name_found = True + break + + if not name_found and print_warnings: + LOG.warning(("Missing name property from [[bin]] section " + "in Cargo.toml file. Consider including a name " + "property and setting its value to bootstrap. This " + "is the executable name AWS Lambda expects.")) + + if "dependencies" in self.cargo: + deps = self.cargo["dependencies"] + if "lambda_runtime" not in deps and print_warnings: + LOG.warning("""lambda_runtime is not included as a dependency in + Your Cargo.toml file.""") diff --git a/aws_lambda_builders/workflows/rust_cargo/workflow.py b/aws_lambda_builders/workflows/rust_cargo/workflow.py new file mode 100644 index 000000000..fa8a7d3ba --- /dev/null +++ b/aws_lambda_builders/workflows/rust_cargo/workflow.py @@ -0,0 +1,37 @@ +""" +Rust Cargo Workflow +""" +import os + +from aws_lambda_builders.workflow import BaseWorkflow, Capability +from aws_lambda_builders.actions import CopySourceAction + +from .actions import RustCargoBuildAction, CopyAndRenameExecutableAction, CargoValidator + + +class RustCargoWorkflow(BaseWorkflow): + + NAME = "RustCargoWorkflow" + CAPABILITY = Capability(language="rust", + dependency_manager="cargo", + application_framework=None) + + def __init__(self, + source_dir, + artifacts_dir, + scratch_dir, + manifest_path, + runtime=None, **kwargs): + + super(RustCargoWorkflow, self).__init__(source_dir, + artifacts_dir, + scratch_dir, + manifest_path, + runtime=runtime, + **kwargs) + + self.actions = [ + CargoValidator(source_dir, manifest_path, runtime), + RustCargoBuildAction(source_dir, manifest_path, runtime), + CopyAndRenameExecutableAction(source_dir, self.artifacts_dir, manifest_path, runtime), + ] diff --git a/requirements/base.txt b/requirements/base.txt index 0150babf3..be5737f17 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1 +1,2 @@ six~=1.11 +toml~=0.10.0 \ No newline at end of file