Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions samcli/commands/_utils/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class ExperimentalFlag:
UvPackageManager = ExperimentalEntry(
"experimentalUvPackageManager", EXPERIMENTAL_ENV_VAR_PREFIX + "UV_PACKAGE_MANAGER"
)
NodejsMonorepo = ExperimentalEntry("experimentalNodejsMonorepo", EXPERIMENTAL_ENV_VAR_PREFIX + "NODEJS_MONOREPO")


def is_experimental_enabled(config_entry: ExperimentalEntry) -> bool:
Expand Down
12 changes: 11 additions & 1 deletion samcli/commands/_utils/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,17 @@ def build_in_source_click_option():
required=False,
is_flag=True,
help="Opts in to build project in the source folder. The following workflows support "
f"building in source: {SUPPORTED_BUILD_IN_SOURCE_WORKFLOWS}",
f"building in source: {SUPPORTED_BUILD_IN_SOURCE_WORKFLOWS}\n"
"\n"
"For `sam build` on an npm workspaces monorepo, npm installs once at the workspace root "
"instead of once per function, and each function's artifacts are narrowed to the "
"dependencies it resolves. The install runs in the workspace root npm itself reports, "
"which is inside your project but above the function's CodeUri.\n"
"\n"
"This grouping is in beta: enable it with --beta-features or "
"SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in "
"earlier releases. It is also skipped for --use-container and --cached builds, so "
"`sam sync` does not use it.",
cls=ClickMutex,
incompatible_params=["use_container", "hook_name"],
)
Expand Down
12 changes: 12 additions & 0 deletions samcli/commands/build/core/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ def format_examples(ctx: Context, formatter: CommandHelpTextFormatter):
),
]
)
with formatter.indented_section(
name="Build an npm workspaces monorepo, installing once at the workspace root (beta)",
extra_indents=1,
):
formatter.write_text_rows(
[
RowDefinition(
name=style(f"$ {ctx.command_path} --build-in-source --beta-features"),
extra_row_modifiers=[ShowcaseRowModifier()],
),
]
)
with formatter.indented_section(name="Build sam project and deploy", extra_indents=1):
formatter.write_text_rows(
[
Expand Down
91 changes: 90 additions & 1 deletion samcli/lib/build/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@
from aws_lambda_builders.builder import LambdaBuilder
from aws_lambda_builders.exceptions import LambdaBuilderError

from samcli.commands._utils.experimental import get_enabled_experimental_flags
from samcli.commands._utils.experimental import (
ExperimentalFlag,
get_enabled_experimental_flags,
is_experimental_enabled,
)
from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition
from samcli.lib.build.build_strategy import (
BuildStrategy,
CachedOrIncrementalBuildStrategyWrapper,
DefaultBuildStrategy,
ParallelBuildStrategy,
WorkspaceAwareBuildStrategy,
)
from samcli.lib.build.constants import BUILD_PROPERTIES, DEPRECATED_RUNTIMES
from samcli.lib.build.exceptions import (
Expand Down Expand Up @@ -209,6 +214,8 @@ def __init__(
self._mount_symlinks = mount_symlinks
self._use_buildkit = use_buildkit
self._image_build_client: Optional[ImageBuildClient] = None
# one SubprocessNpm per build session, so `npm prefix` answers are cached per directory
self._npm_subprocess: Optional[object] = None

@property
def _container_client(self) -> ContainerClient:
Expand Down Expand Up @@ -271,6 +278,26 @@ def build(self) -> ApplicationBuildResult:
bool(self._container_manager),
)

if (
is_experimental_enabled(ExperimentalFlag.NodejsMonorepo)
and self._build_in_source
and not self._cached
and not self._container_manager
):
# npm workspaces monorepo: install the shared dependency tree once per workspace root
# instead of once per function. Outside ParallelBuildStrategy on purpose - the install
# must complete before any grouped function build starts. --cached is excluded because
# it supplies dependencies from .aws-sam/deps and manages download_dependencies itself.
# Behind SAM_CLI_BETA_NODEJS_MONOREPO while the grouping rolls out: without it every
# function keeps its own install, which is the behaviour every release so far has had.
build_strategy = WorkspaceAwareBuildStrategy(
build_graph,
build_strategy,
self._base_dir,
self._resolve_npm_project_root,
self._prepare_workspace_shared_project,
)

return ApplicationBuildResult(build_graph, build_strategy.build())

def _get_build_graph(
Expand Down Expand Up @@ -701,6 +728,68 @@ def _build_layer(
# Not including subfolder in return so that we copy subfolder, instead of copying artifacts inside it.
return artifact_dir

def _resolve_npm_project_root(self, code_dir: str) -> Optional[str]:
"""
Ask npm (through aws-lambda-builders) which project root covers code_dir.

Returns None when the answer is unavailable for any reason - npm missing, an
aws-lambda-builders too old to expose the resolver - which disables workspace grouping for
that function and keeps today's per-function install.
"""
try:
from aws_lambda_builders.workflows.nodejs_npm.npm import SubprocessNpm
from aws_lambda_builders.workflows.nodejs_npm.utils import OSUtils as NpmOSUtils
except ImportError:
return None

if self._npm_subprocess is None:
self._npm_subprocess = SubprocessNpm(NpmOSUtils())
resolver = getattr(self._npm_subprocess, "resolve_project_root", None)
if resolver is None:
return None
try:
return cast(Optional[str], resolver(code_dir))
except OSError as ex:
LOG.debug("Could not resolve the npm project root of %s: %s", code_dir, ex)
return None

def _prepare_workspace_shared_project(self, project_root: str, build_definition: "FunctionBuildDefinition") -> bool:
"""
Do once, at project_root, the work every workspace function under it shares.

For the npm workflows that is installing the hoisted dependency tree. The build definition
supplies the workflow (npm or npm-esbuild) whose builder does it; both funnel into the same
npm project tree. Returns False on any failure so the caller leaves the grouped functions on
per-function installs.
"""
code_dir = str(pathlib.Path(self._base_dir, cast(str, build_definition.codeuri)).resolve())
specified_workflow = build_definition.metadata.get("BuildMethod") if build_definition.metadata else None
try:
config = get_workflow_config(
build_definition.runtime, code_dir, self._base_dir, specified_workflow=specified_workflow
)
builder = LambdaBuilder(
language=config.language,
dependency_manager=config.dependency_manager,
application_framework=config.application_framework,
)
prepare = getattr(builder, "prepare_shared_project", None)
if prepare is None:
LOG.debug("Installed aws-lambda-builders has no shared-project hook; skipping grouping")
return False
# The same flags the per-function build() calls get: the builders hook refuses without
# experimentalNodejsMonorepo, because the per-function artifacts link is gated on it too
# and preparing the root without it would ship artifacts carrying no dependencies.
prepare(project_root, experimental_flags=get_enabled_experimental_flags())
return True
except (UnsupportedRuntimeException, LambdaBuilderError) as ex:
LOG.warning(
"Preparing the shared project root %s failed (%s); falling back to per-function installs",
project_root,
ex,
)
return False

def _build_function( # pylint: disable=R1710
self,
function_name: str,
Expand Down
113 changes: 113 additions & 0 deletions samcli/lib/build/build_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,116 @@ def _is_incremental_build_supported(self, runtime: Optional[str]) -> bool:
return True

return False


class WorkspaceAwareBuildStrategy(BuildStrategy):
"""
Wraps another build strategy to install nodejs dependencies once per npm workspace root.

In an npm workspaces monorepo npm keeps ONE dependency tree, hoisted to the workspace root, and
every member's install reconciles that whole tree. Building N functions from one monorepo with
--build-in-source therefore reconciles the same tree N times. This strategy resolves each nodejs
function's npm project root before any function builds, installs each distinct root once through
aws-lambda-builders, and marks the grouped functions with download_dependencies=False - the same
lever IncrementalBuildStrategy uses - so their builds link the artifacts to the tree that one
install wrote.

A function is grouped only when all of these hold, so anything else keeps today's per-function
install:

- Zip package type, nodejs runtime, npm or esbuild build method
- npm resolves the function's project root OUTSIDE its own code directory, which is what
distinguishes a workspace member from a standalone package
- the shared install itself succeeded; a failed root leaves its functions untouched

This strategy must wrap OUTSIDE ParallelBuildStrategy, whose _build_functions fires every
definition into one AsyncContext: running the install first is the barrier that keeps parallel
function builds from racing the shared tree. It is wired only for non-cached builds; --cached
supplies dependencies from .aws-sam/deps and manages download_dependencies itself.
"""

def __init__(
self,
build_graph: BuildGraph,
delegate_build_strategy: BuildStrategy,
base_dir: str,
project_root_resolver: Callable[[str], Optional[str]],
prepare_shared_project_function: Callable[[str, FunctionBuildDefinition], bool],
) -> None:
"""
Parameters
----------
project_root_resolver:
Given a function's resolved code directory, returns npm's project root for it, or None
when it cannot be resolved (npm missing, too-old aws-lambda-builders, non-npm project).
prepare_shared_project_function:
Runs the shared install for (project_root, one grouped build definition); returns True
on success. Failures must be handled inside and reported with False.
"""
super().__init__(build_graph)
self._delegate_build_strategy = delegate_build_strategy
self._base_dir = base_dir
self._project_root_resolver = project_root_resolver
self._prepare_shared_project_function = prepare_shared_project_function

def build(self) -> Dict[str, str]:
with self._delegate_build_strategy:
return super().build()

def _build_layers(self, build_graph: BuildGraph) -> Dict[str, str]:
return self._delegate_build_strategy._build_layers(build_graph)

def _build_functions(self, build_graph: BuildGraph) -> Dict[str, str]:
self._prepare_shared_project_for_workspace_groups(build_graph)
return self._delegate_build_strategy._build_functions(build_graph)

def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]:
return self._delegate_build_strategy.build_single_function_definition(build_definition)

def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) -> Dict[str, str]:
return self._delegate_build_strategy.build_single_layer_definition(layer_definition)

def _prepare_shared_project_for_workspace_groups(self, build_graph: BuildGraph) -> None:
groups: Dict[str, List[FunctionBuildDefinition]] = {}
for build_definition in build_graph.get_function_build_definitions():
project_root = self._workspace_root_for(build_definition)
if project_root:
groups.setdefault(project_root, []).append(build_definition)

for project_root, build_definitions in groups.items():
LOG.info(
"Installing dependencies once for workspace root %s, shared by (%s)",
project_root,
", ".join(definition.get_resource_full_paths() for definition in build_definitions),
)
if self._prepare_shared_project_function(project_root, build_definitions[0]):
for build_definition in build_definitions:
build_definition.download_dependencies = False

def _workspace_root_for(self, build_definition: FunctionBuildDefinition) -> Optional[str]:
"""
The npm project root the function's install would write to, or None when the function is not
a workspace member covered by this strategy.
"""
if build_definition.packagetype != ZIP or not build_definition.codeuri:
return None
runtime = build_definition.runtime or ""
if not runtime.startswith("nodejs"):
return None
# only the npm workflows install through the npm project tree; BuildMethod naming the
# runtime is the template's way of spelling the default
build_method = (build_definition.metadata or {}).get("BuildMethod")
if build_method not in (None, "esbuild", runtime):
return None

code_dir = str(pathlib.Path(self._base_dir, build_definition.codeuri).resolve())
project_root = self._project_root_resolver(code_dir)
if not project_root:
return None
# npm answers with the directory itself for anything that is not a workspace member. normcase
# because npm's stdout and this path are spelled independently, and on Windows two spellings
# differing only in case - a drive letter included - name the same directory; reading those as
# different roots would group a project that is not a workspace at all. No-op off Windows.
if os.path.normcase(os.path.realpath(project_root)) == os.path.normcase(os.path.realpath(code_dir)):
return None
return project_root
Loading
Loading