diff --git a/aws_lambda_builders/workflows/rust_cargo/DESIGN.md b/aws_lambda_builders/workflows/rust_cargo/DESIGN.md index 4b504503d..9e7ca411a 100644 --- a/aws_lambda_builders/workflows/rust_cargo/DESIGN.md +++ b/aws_lambda_builders/workflows/rust_cargo/DESIGN.md @@ -16,10 +16,16 @@ The general algorithm for preparing a rust executable for use on AWS Lambda is a It builds a binary in the standard cargo target directory. The binary's name is always `bootstrap`, and it's always located under `target/lambda/HANDLER_NAME/bootstrap`. +For a Cargo workspace, the build targets the workspace's shared `target` directory rather than a `target` directory under each member. Because `sam build` invokes this workflow once per function, sharing a single target directory lets cargo compile common dependencies once instead of recompiling the whole dependency tree for every function. The shared directory and the member's binary name are both read from a single `cargo metadata` call. For a standalone (non-workspace) project the shared `target` directory is the project's own — unchanged from prior behavior. An explicit `CARGO_TARGET_DIR` in the environment always takes precedence. + ### Copy and Rename executable It then copies the executable to the target directory honoring the provided runtime's [expectation on executable names](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html). +Because every workspace member now builds into the same `target/lambda` directory, that directory holds all of the workspace's binaries. When no handler (`artifact_executable_name`) is given, the copy step selects the binary named for the package whose manifest lives in the function's source directory (from the same `cargo metadata` call). It falls back to the previous single-directory heuristic when the binary name cannot be resolved. + +If two or more workspace members define a `bin` target with the same name (for example each package declaring a `bootstrap` bin), those binaries compile to the same path in the shared directory and overwrite each other. `sam build` still produces correct artifacts because it copies each function's binary out immediately after building it, but this relies on build ordering, so the workflow logs a warning recommending unique bin names per function. + ## Notes Like the go builders, the workflow argument `options.artifact_executable_name` diff --git a/aws_lambda_builders/workflows/rust_cargo/actions.py b/aws_lambda_builders/workflows/rust_cargo/actions.py index 82cac9018..04caf5ef0 100644 --- a/aws_lambda_builders/workflows/rust_cargo/actions.py +++ b/aws_lambda_builders/workflows/rust_cargo/actions.py @@ -104,7 +104,9 @@ class RustCopyAndRenameAction(BaseAction): DESCRIPTION = "Copy Rust executable, renaming if needed" PURPOSE = Purpose.COPY_SOURCE - def __init__(self, source_dir, artifacts_dir, handler=None, osutils=OSUtils()): + def __init__( + self, source_dir, artifacts_dir, handler=None, binaries=None, subprocess_cargo_lambda=None, osutils=OSUtils() + ): """ Copy and rename Rust executable @@ -119,21 +121,57 @@ def __init__(self, source_dir, artifacts_dir, handler=None, osutils=OSUtils()): handler : str, optional Handler name in `package.bin_name` or `bin_name` format + binaries : dict, optional + Resolved path dependencies, used to locate the `cargo` binary when + resolving the workspace target directory + + subprocess_cargo_lambda : aws_lambda_builders.workflows.rust_cargo.cargo_lambda.SubprocessCargoLambda, optional + The Cargo Lambda process wrapper, used to resolve the same target + directory the build action compiled into + osutils : aws_lambda_builders.workflows.rust_cargo.utils.OSUtils, optional Optional, External IO utils """ self._source_dir = source_dir self._handler = handler self._artifacts_dir = artifacts_dir + self._binaries = binaries + self._subprocess_cargo_lambda = subprocess_cargo_lambda self._osutils = osutils + def _workspace_layout(self): + # Resolve the same shared target directory and binary name the build action + # used, from a single cached cargo metadata call. Returns None when the + # cargo wrapper is unavailable (e.g. in unit tests exercising the legacy path). + if self._subprocess_cargo_lambda and self._binaries and self._binaries.get("cargo"): + return self._subprocess_cargo_lambda.resolve_workspace_layout( + self._binaries["cargo"].binary_path, self._source_dir + ) + return None + def base_path(self): + # For a workspace member this is the workspace root's shared target/lambda; for + # a standalone project it is source_dir/target/lambda, matching the legacy path. + layout = self._workspace_layout() + if layout and layout.get("target_directory"): + return os.path.join(layout["target_directory"], "lambda") return os.path.join(self._source_dir, "target", "lambda") def binary_path(self): base = self.base_path() - if self._handler: - binary_path = os.path.join(base, self._handler, "bootstrap") + + # An explicit handler (artifact_executable_name) always wins. + binary_name = self._handler + # Otherwise use the bin name cargo reported for this member. This is what lets + # the copy step pick the right binary now that every member shares one + # target/lambda directory holding all of the workspace's binaries. + if not binary_name: + layout = self._workspace_layout() + if layout: + binary_name = layout.get("binary_name") + + if binary_name: + binary_path = os.path.join(base, binary_name, "bootstrap") LOG.debug("copying function binary from %s", binary_path) return binary_path diff --git a/aws_lambda_builders/workflows/rust_cargo/cargo_lambda.py b/aws_lambda_builders/workflows/rust_cargo/cargo_lambda.py index 223fcc633..6de623410 100644 --- a/aws_lambda_builders/workflows/rust_cargo/cargo_lambda.py +++ b/aws_lambda_builders/workflows/rust_cargo/cargo_lambda.py @@ -3,6 +3,7 @@ """ import io +import json import logging import os import shutil @@ -39,6 +40,7 @@ def __init__(self, which, executable_search_paths=None, osutils=OSUtils()): self._which = which self._executable_search_paths = executable_search_paths self._osutils = osutils + self._workspace_layout_cache = {} def check_cargo_lambda_installation(self): """ @@ -69,6 +71,119 @@ def check_cargo_lambda_installation(self): "https://www.cargo-lambda.info/guide/getting-started.html" ) + def resolve_workspace_layout(self, cargo_path, source_dir): + """ + Resolves the Cargo target directory and the binary produced for ``source_dir``. + + A single ``cargo metadata`` call yields both: + + - ``target_directory`` is the workspace's shared ``target`` directory. + For a Cargo workspace member this is the workspace root's ``target``, + which cargo shares across every member. Building each function into + that shared directory lets cargo reuse compiled dependencies across + the separate per-function builds that ``sam build`` runs, instead of + recompiling the whole dependency tree once per function. For a + standalone project it is the project's own ``target`` -- the location + used before this change. + + - ``binary_name`` is the name of the ``bin`` target defined by the + package whose manifest lives in ``source_dir``. Because every member + now builds into the same ``target/lambda`` directory, the copy step + can no longer assume that directory holds a single binary; this name + tells it which one belongs to the function being built. + + Results are cached per ``source_dir``. + + Parameters + ---------- + cargo_path : str + Path to the ``cargo`` binary. + + source_dir : str + Path to the folder containing the function's source code. + + Returns + ------- + dict + ``{"target_directory": str or None, "binary_name": str or None}``. + Either value is ``None`` when it cannot be resolved, in which case + callers fall back to the previous behavior. + """ + + if source_dir in self._workspace_layout_cache: + return self._workspace_layout_cache[source_dir] + + layout = {"target_directory": None, "binary_name": None} + command = [cargo_path, "metadata", "--no-deps", "--format-version", "1"] + LOG.debug("Resolving cargo workspace layout: %s", " ".join(command)) + try: + process = self._osutils.popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=source_dir) + out, err = process.communicate() + if process.returncode == 0: + metadata = json.loads(out.decode("utf-8")) + layout["target_directory"] = metadata.get("target_directory") + layout["binary_name"] = self._find_binary_name(metadata, source_dir) + self._warn_on_colliding_binaries(metadata) + else: + LOG.debug( + "Could not resolve cargo workspace layout, falling back to previous behavior: %s", + err.decode("utf-8", "replace").strip(), + ) + except (OSError, ValueError, json.JSONDecodeError) as ex: + LOG.debug("Could not run cargo metadata, falling back to previous behavior: %s", ex) + + self._workspace_layout_cache[source_dir] = layout + return layout + + @staticmethod + def _find_binary_name(metadata, source_dir): + """ + Finds the bin target name of the package whose manifest is in source_dir. + """ + # cargo metadata emits absolute, symlink-resolved manifest paths, so resolve + # both operands the same way; os.path.normpath alone would never match a + # relative source_dir against cargo's absolute path. + member_manifest = os.path.realpath(os.path.join(source_dir, "Cargo.toml")) + for package in metadata.get("packages", []): + if os.path.realpath(package.get("manifest_path", "")) != member_manifest: + continue + bin_targets = [target["name"] for target in package.get("targets", []) if "bin" in target.get("kind", [])] + if len(bin_targets) == 1: + return bin_targets[0] + # A package with zero or several bins is ambiguous; let the copy step + # fall back to its directory-listing heuristic. + LOG.debug("Package %s does not have exactly one bin target: %s", package.get("name"), bin_targets) + return None + return None + + @staticmethod + def _warn_on_colliding_binaries(metadata): + """ + Warns when workspace members share a bin target name. + + Since every member now builds into the same target/lambda directory, + two bins with the same name (e.g. several packages each defining a + `bootstrap` bin) compile to the same path and overwrite each other. + `sam build` still produces correct artifacts because it copies each + function's binary out immediately after building it, but the shared + output is fragile; unique bin names per function avoid it. + """ + packages_by_bin = {} + for package in metadata.get("packages", []): + for target in package.get("targets", []): + if "bin" in target.get("kind", []): + packages_by_bin.setdefault(target["name"], []).append(package.get("name")) + + for bin_name, owners in packages_by_bin.items(): + if len(owners) > 1: + LOG.warning( + "Multiple workspace packages define a bin named '%s' (%s). They build to the same path in the " + "shared target directory and overwrite each other; give each function a unique bin name to " + "avoid relying on build ordering.", + bin_name, + ", ".join(sorted(owners)), + ) + def run(self, command, cwd): """ Runs the build command. @@ -101,16 +216,25 @@ def run(self, command, cwd): os.environ["RUST_LOG"] = "debug" LOG.debug("RUST_LOG environment variable set to `%s`", os.environ.get("RUST_LOG")) - if not os.getenv("CARGO_TARGET_DIR"): - # This results in the "target" dir being created under the member dir of a cargo workspace - # This is for supporting sam build for a Cargo Workspace project - os.environ["CARGO_TARGET_DIR"] = "target" + cargo_env = dict(os.environ) + if not cargo_env.get("CARGO_TARGET_DIR"): + # Point every build at the workspace's shared target directory so cargo + # compiles dependencies once rather than once per function. For a standalone + # project this is the project's own target directory, matching the previous + # behavior. An explicit CARGO_TARGET_DIR in the environment is left untouched. + # The first element of command is the cargo binary path. + target_directory = self.resolve_workspace_layout(command[0], cwd)["target_directory"] + # Fall back to the relative "target" the workflow used before this change when + # metadata is unavailable, so the build still lands where the copy step (which + # falls back the same way) looks for it. + cargo_env["CARGO_TARGET_DIR"] = target_directory or "target" cargo_process = self._osutils.popen( command, stderr=subprocess.PIPE, stdout=subprocess.PIPE, cwd=cwd, + env=cargo_env, ) stdout = "" # Create a buffer and use a thread to gather the stderr stream into the buffer diff --git a/aws_lambda_builders/workflows/rust_cargo/workflow.py b/aws_lambda_builders/workflows/rust_cargo/workflow.py index 5c621c73f..b3199829f 100644 --- a/aws_lambda_builders/workflows/rust_cargo/workflow.py +++ b/aws_lambda_builders/workflows/rust_cargo/workflow.py @@ -51,7 +51,7 @@ def __init__( handler, flags, ), - RustCopyAndRenameAction(source_dir, artifacts_dir, handler), + RustCopyAndRenameAction(source_dir, artifacts_dir, handler, self.binaries, subprocess_cargo_lambda), ] def get_resolvers(self): diff --git a/tests/integration/workflows/rust_cargo/test_rust_cargo.py b/tests/integration/workflows/rust_cargo/test_rust_cargo.py index 411a373d7..455eadc78 100644 --- a/tests/integration/workflows/rust_cargo/test_rust_cargo.py +++ b/tests/integration/workflows/rust_cargo/test_rust_cargo.py @@ -139,6 +139,35 @@ def test_builds_workspace_member(self): self.assertEqual(expected_files, output_files) self.assertIn("bar", os.path.join(source_dir, "bar", "target", "lambda")) + def test_builds_workspace_members_into_shared_target_dir(self): + # Building each member of a workspace, without an explicit handler, should place + # every binary under the single workspace-root target/lambda directory so cargo + # reuses compiled dependencies across the per-function builds. Each member's own + # binary must still be copied to its artifacts dir even though the shared + # target/lambda now holds every member's binary. + source_dir = os.path.join(self.TEST_DATA_FOLDER, "workspaces") + rm_target(source_dir) + + member_artifacts = {} + for member in ("foo", "bar"): + artifacts_dir = tempfile.mkdtemp() + member_artifacts[member] = artifacts_dir + self.builder.build( + os.path.join(source_dir, member), + artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, member, "Cargo.toml"), + runtime=self.runtime, + ) + + shared_lambda_dir = os.path.join(source_dir, "target", "lambda") + self.assertEqual({"foo", "bar"}, set(os.listdir(shared_lambda_dir))) + self.assertFalse(os.path.isdir(os.path.join(source_dir, "foo", "target"))) + self.assertFalse(os.path.isdir(os.path.join(source_dir, "bar", "target"))) + for member, artifacts_dir in member_artifacts.items(): + self.assertEqual({"bootstrap"}, set(os.listdir(artifacts_dir))) + shutil.rmtree(artifacts_dir, ignore_errors=True) + def test_builds_workspaces_project_with_package_option(self): source_dir = os.path.join(self.TEST_DATA_FOLDER, "workspaces") rm_target(source_dir) diff --git a/tests/unit/workflows/rust_cargo/test_actions.py b/tests/unit/workflows/rust_cargo/test_actions.py index 6e286ae4a..c124345aa 100644 --- a/tests/unit/workflows/rust_cargo/test_actions.py +++ b/tests/unit/workflows/rust_cargo/test_actions.py @@ -1,5 +1,5 @@ from unittest import TestCase -from unittest.mock import patch +from unittest.mock import MagicMock, patch from parameterized import parameterized import io import logging @@ -9,6 +9,7 @@ from aws_lambda_builders.binary_path import BinaryPath from aws_lambda_builders.workflow import BuildMode from aws_lambda_builders.workflows.rust_cargo.actions import ( + CargoLambdaExecutionException, RustCargoLambdaBuildAction, RustCopyAndRenameAction, ) @@ -32,11 +33,19 @@ def wait(self): return self.returncode +def fake_metadata_popen(): + # Stands in for `cargo metadata`, which run() calls to resolve the shared + # target directory before invoking the build. + metadata = b'{"target_directory": "/source_dir/target", "packages": []}' + return FakePopen(out=metadata, retcode=0) + + class TestBuildAction(TestCase): @patch("aws_lambda_builders.workflows.rust_cargo.actions.OSUtils") def setUp(self, OSUtilMock): self.osutils = OSUtilMock.return_value - self.osutils.popen.side_effect = [FakePopen()] + # run() first calls `cargo metadata` to resolve the target dir, then the build + self.osutils.popen.side_effect = [fake_metadata_popen(), FakePopen()] def which(cmd, executable_search_paths): return ["/bin/cargo-lambda"] @@ -148,7 +157,7 @@ def test_execute_happy_path(self): def test_execute_cargo_build_fail(self): popen = FakePopen(retcode=1, err=b"build failed") - self.subprocess_cargo_lambda._osutils.popen.side_effect = [popen] + self.subprocess_cargo_lambda._osutils.popen.side_effect = [fake_metadata_popen(), popen] cargo = BinaryPath(None, None, None, binary_path="path/to/cargo") action = RustCargoLambdaBuildAction( @@ -167,7 +176,7 @@ def test_execute_happy_with_logger(self): ) out = action.execute() self.assertEqual(out, "out") - mock_warning.assert_called_with("RUST_LOG environment variable set to `%s`", "debug") + mock_warning.assert_any_call("RUST_LOG environment variable set to `%s`", "debug") class TestCopyAndRenameAction(TestCase): @@ -183,6 +192,68 @@ def test_nonlinux_copy_path(self): action = RustCopyAndRenameAction("source_dir", "output_dir", "foo") self.assertEqual(action.binary_path(), os.path.join("source_dir", "target", "lambda", "foo", "bootstrap")) + def test_copy_path_uses_shared_target_dir_and_resolved_binary(self): + # Workspace member with no explicit handler: the binary is found in the shared + # workspace target dir under the bin name cargo reported for this member, even + # though that directory also holds the other members' binaries. + cargo = BinaryPath(None, None, None, binary_path="path/to/cargo") + subprocess_cargo_lambda = MagicMock() + workspace_target = os.path.join(os.sep, "ws_root", "target") + subprocess_cargo_lambda.resolve_workspace_layout.return_value = { + "target_directory": workspace_target, + "binary_name": "member", + } + + action = RustCopyAndRenameAction( + os.path.join(os.sep, "ws_root", "member"), "output_dir", None, {"cargo": cargo}, subprocess_cargo_lambda + ) + + self.assertEqual(action.binary_path(), os.path.join(workspace_target, "lambda", "member", "bootstrap")) + subprocess_cargo_lambda.resolve_workspace_layout.assert_called_with( + "path/to/cargo", os.path.join(os.sep, "ws_root", "member") + ) + + def test_copy_path_explicit_handler_overrides_resolved_binary(self): + cargo = BinaryPath(None, None, None, binary_path="path/to/cargo") + subprocess_cargo_lambda = MagicMock() + workspace_target = os.path.join(os.sep, "ws_root", "target") + subprocess_cargo_lambda.resolve_workspace_layout.return_value = { + "target_directory": workspace_target, + "binary_name": "member", + } + + action = RustCopyAndRenameAction( + os.path.join(os.sep, "ws_root", "member"), "output_dir", "foo", {"cargo": cargo}, subprocess_cargo_lambda + ) + + self.assertEqual(action.binary_path(), os.path.join(workspace_target, "lambda", "foo", "bootstrap")) + + def test_copy_path_falls_back_to_source_target_when_layout_unresolved(self): + cargo = BinaryPath(None, None, None, binary_path="path/to/cargo") + subprocess_cargo_lambda = MagicMock() + subprocess_cargo_lambda.resolve_workspace_layout.return_value = { + "target_directory": None, + "binary_name": None, + } + + action = RustCopyAndRenameAction("source_dir", "output_dir", "foo", {"cargo": cargo}, subprocess_cargo_lambda) + + self.assertEqual(action.binary_path(), os.path.join("source_dir", "target", "lambda", "foo", "bootstrap")) + + @patch("aws_lambda_builders.workflows.rust_cargo.actions.os.listdir") + def test_binary_path_without_handler_uses_single_binary_dir(self, listdir_mock): + listdir_mock.return_value = ["only_bin"] + action = RustCopyAndRenameAction("source_dir", "output_dir") + self.assertEqual(action.binary_path(), os.path.join("source_dir", "target", "lambda", "only_bin", "bootstrap")) + + @patch("aws_lambda_builders.workflows.rust_cargo.actions.os.listdir") + def test_binary_path_without_handler_raises_when_ambiguous(self, listdir_mock): + listdir_mock.return_value = ["bin_a", "bin_b"] + action = RustCopyAndRenameAction("source_dir", "output_dir") + with self.assertRaises(CargoLambdaExecutionException) as raised: + action.binary_path() + self.assertIn("unable to find function binary", raised.exception.args[0]) + @patch("aws_lambda_builders.workflows.rust_cargo.actions.OSUtils") def test_execute(self, OSUtilsMock): osutils = OSUtilsMock.return_value diff --git a/tests/unit/workflows/rust_cargo/test_cargo_lambda.py b/tests/unit/workflows/rust_cargo/test_cargo_lambda.py index 7fc86ecb7..a0e5de0e2 100644 --- a/tests/unit/workflows/rust_cargo/test_cargo_lambda.py +++ b/tests/unit/workflows/rust_cargo/test_cargo_lambda.py @@ -1,9 +1,31 @@ +import io +import json +import logging +import os + from unittest import TestCase +from unittest.mock import MagicMock, patch from aws_lambda_builders.workflows.rust_cargo.actions import CargoLambdaExecutionException from aws_lambda_builders.workflows.rust_cargo.cargo_lambda import SubprocessCargoLambda +def which(cmd, executable_search_paths): + return ["/bin/cargo-lambda"] + + +def metadata_json(target_directory, packages): + return json.dumps({"target_directory": target_directory, "packages": packages}).encode("utf-8") + + +def package(name, manifest_path, bins): + return { + "name": name, + "manifest_path": manifest_path, + "targets": [{"name": bin_name, "kind": ["bin"]} for bin_name in bins], + } + + class TestSubprocessCargoLambda(TestCase): def test_raises_RustCargoLambdaBuilderError_if_which_returns_no_results(self): def which(cmd, executable_search_paths): @@ -19,3 +41,198 @@ def which(cmd, executable_search_paths): "Cargo Lambda failed: Cannot find Cargo Lambda. Cargo Lambda must be installed on the host machine to use this feature. " "Follow the gettings started guide to learn how to install it: https://www.cargo-lambda.info/guide/getting-started.html", ) + + +class TestResolveWorkspaceLayout(TestCase): + def setUp(self): + self.osutils = MagicMock() + + def _metadata_process(self, stdout=b"", stderr=b"", returncode=0): + process = MagicMock() + process.communicate.return_value = (stdout, stderr) + process.returncode = returncode + return process + + def test_resolves_shared_target_dir_and_member_binary(self): + member_manifest = os.path.join(os.sep, "ws", "root", "member", "Cargo.toml") + other_manifest = os.path.join(os.sep, "ws", "root", "other", "Cargo.toml") + stdout = metadata_json( + os.path.join(os.sep, "ws", "root", "target"), + [package("member", member_manifest, ["member"]), package("other", other_manifest, ["other"])], + ) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "ws", "root", "member")) + + self.assertEqual(layout["target_directory"], os.path.join(os.sep, "ws", "root", "target")) + self.assertEqual(layout["binary_name"], "member") + self.osutils.popen.assert_called_once() + args, kwargs = self.osutils.popen.call_args + self.assertEqual(args[0], ["/bin/cargo", "metadata", "--no-deps", "--format-version", "1"]) + self.assertEqual(kwargs["cwd"], os.path.join(os.sep, "ws", "root", "member")) + + def test_resolves_binary_when_source_dir_is_relative(self): + # cargo metadata always reports absolute manifest paths; a caller may still + # pass a relative source_dir. Both sides must be resolved to absolute paths + # or the member never matches and binary_name is silently None. + relative_source_dir = os.path.join("functions", "member") + absolute_manifest = os.path.realpath(os.path.join(relative_source_dir, "Cargo.toml")) + stdout = metadata_json(os.path.join(os.sep, "ws", "target"), [package("member", absolute_manifest, ["member"])]) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", relative_source_dir) + + self.assertEqual(layout["binary_name"], "member") + + def test_caches_resolution_per_source_dir(self): + manifest = os.path.join(os.sep, "proj", "Cargo.toml") + stdout = metadata_json(os.path.join(os.sep, "proj", "target"), [package("proj", manifest, ["proj"])]) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + first = proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "proj")) + second = proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "proj")) + + self.assertEqual(first, second) + self.osutils.popen.assert_called_once() + + def test_binary_name_none_when_member_has_multiple_bins(self): + manifest = os.path.join(os.sep, "proj", "Cargo.toml") + stdout = metadata_json(os.path.join(os.sep, "proj", "target"), [package("proj", manifest, ["a", "b"])]) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "proj")) + + self.assertEqual(layout["target_directory"], os.path.join(os.sep, "proj", "target")) + self.assertIsNone(layout["binary_name"]) + + def test_binary_name_none_when_no_member_matches(self): + other_manifest = os.path.join(os.sep, "elsewhere", "Cargo.toml") + stdout = metadata_json(os.path.join(os.sep, "ws", "target"), [package("other", other_manifest, ["other"])]) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "ws", "member")) + + self.assertIsNone(layout["binary_name"]) + + def test_falls_back_when_metadata_fails(self): + self.osutils.popen.return_value = self._metadata_process(stderr=b"not a cargo project", returncode=101) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", "/some/dir") + + self.assertEqual(layout, {"target_directory": None, "binary_name": None}) + + def test_falls_back_when_metadata_output_invalid_json(self): + self.osutils.popen.return_value = self._metadata_process(stdout=b"not json") + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", "/some/dir") + + self.assertEqual(layout, {"target_directory": None, "binary_name": None}) + + def test_falls_back_when_popen_raises(self): + self.osutils.popen.side_effect = OSError("cargo not found") + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + layout = proc.resolve_workspace_layout("/bin/cargo", "/some/dir") + + self.assertEqual(layout, {"target_directory": None, "binary_name": None}) + + def test_warns_when_workspace_members_share_a_bin_name(self): + alpha_manifest = os.path.join(os.sep, "ws", "alpha", "Cargo.toml") + beta_manifest = os.path.join(os.sep, "ws", "beta", "Cargo.toml") + stdout = metadata_json( + os.path.join(os.sep, "ws", "target"), + [package("alpha", alpha_manifest, ["bootstrap"]), package("beta", beta_manifest, ["bootstrap"])], + ) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + with self.assertLogs("aws_lambda_builders.workflows.rust_cargo.cargo_lambda", level="WARNING") as logs: + proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "ws", "alpha")) + + self.assertTrue(any("bin named 'bootstrap'" in message for message in logs.output)) + self.assertTrue(any("alpha" in message and "beta" in message for message in logs.output)) + + def test_does_not_warn_when_bin_names_are_unique(self): + alpha_manifest = os.path.join(os.sep, "ws", "alpha", "Cargo.toml") + beta_manifest = os.path.join(os.sep, "ws", "beta", "Cargo.toml") + stdout = metadata_json( + os.path.join(os.sep, "ws", "target"), + [package("alpha", alpha_manifest, ["alpha"]), package("beta", beta_manifest, ["beta"])], + ) + self.osutils.popen.return_value = self._metadata_process(stdout=stdout) + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + logger = logging.getLogger("aws_lambda_builders.workflows.rust_cargo.cargo_lambda") + with patch.object(logger, "warning") as mock_warning: + proc.resolve_workspace_layout("/bin/cargo", os.path.join(os.sep, "ws", "alpha")) + + mock_warning.assert_not_called() + + +class TestRunTargetDir(TestCase): + def setUp(self): + self.osutils = MagicMock() + + def _metadata_process(self, stdout=b"", stderr=b"", returncode=0): + process = MagicMock() + process.communicate.return_value = (stdout, stderr) + process.returncode = returncode + return process + + def _build_process(self): + process = MagicMock() + process.stdout = [b"built"] + process.stderr = io.BytesIO(b"") + process.wait.return_value = 0 + return process + + def _target_dir_passed_to_build(self): + # The build is the second popen call (the first is `cargo metadata`). + build_call = self.osutils.popen.call_args_list[-1] + return build_call.kwargs["env"]["CARGO_TARGET_DIR"] + + def test_run_uses_resolved_shared_target_dir(self): + manifest = os.path.join(os.sep, "ws", "member", "Cargo.toml") + stdout = metadata_json(os.path.join(os.sep, "ws", "target"), [package("member", manifest, ["member"])]) + self.osutils.popen.side_effect = [self._metadata_process(stdout=stdout), self._build_process()] + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CARGO_TARGET_DIR", None) + proc.run(["/bin/cargo", "lambda", "build"], os.path.join(os.sep, "ws", "member")) + + self.assertEqual(self._target_dir_passed_to_build(), os.path.join(os.sep, "ws", "target")) + + def test_run_falls_back_to_relative_target_when_metadata_unavailable(self): + # When cargo metadata fails, the build must still target the relative "target" + # dir the workflow used before this change, so the copy step (which falls back + # the same way) finds the binary. + self.osutils.popen.side_effect = [ + self._metadata_process(stderr=b"boom", returncode=101), + self._build_process(), + ] + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CARGO_TARGET_DIR", None) + proc.run(["/bin/cargo", "lambda", "build"], os.path.join(os.sep, "ws", "member")) + + self.assertEqual(self._target_dir_passed_to_build(), "target") + + def test_run_preserves_explicit_env_target_dir(self): + self.osutils.popen.side_effect = [self._build_process()] + proc = SubprocessCargoLambda(which=which, osutils=self.osutils) + + with patch.dict(os.environ, {"CARGO_TARGET_DIR": "/explicit/target"}): + proc.run(["/bin/cargo", "lambda", "build"], os.path.join(os.sep, "ws", "member")) + + # metadata is never consulted; the single popen call is the build itself + self.osutils.popen.assert_called_once() + self.assertEqual(self._target_dir_passed_to_build(), "/explicit/target")