Skip to content

Commit

Permalink
feat: New parameter to resolve and mount symlinks in container (#7891)
Browse files Browse the repository at this point in the history
`--mount-symlinks` will make SAM CLI to always mount symlinks present
among the files to build or invoke (top level only). By default symlinks
are not mounted, except ones needed to use build-in-source (node_modules)
  • Loading branch information
valerena authored Feb 14, 2025
1 parent bb61909 commit 42ba2f7
Show file tree
Hide file tree
Showing 21 changed files with 162 additions and 24 deletions.
15 changes: 15 additions & 0 deletions samcli/commands/_utils/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,21 @@ def use_container_build_option(f):
return use_container_build_click_option()(f)


def mount_symlinks_click_option():
return click.option(
"--mount-symlinks/--no-mount-symlinks",
default=False,
is_flag=True,
help="Specify if symlinks at the top level of the code should be mounted inside the container. "
"Activating this flag could allow access to locations outside of your workspace by using a symbolic link. "
"By default symlinks are not mounted.",
)


def mount_symlinks_option(f):
return mount_symlinks_click_option()(f)


def terraform_plan_file_callback(ctx, param, provided_value):
"""
Callback for --terraform-plan-file to check if --hook-name is also specified
Expand Down
5 changes: 5 additions & 0 deletions samcli/commands/build/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def __init__(
hook_name: Optional[str] = None,
build_in_source: Optional[bool] = None,
mount_with: str = MountMode.READ.value,
mount_symlinks: Optional[bool] = False,
) -> None:
"""
Initialize the class
Expand Down Expand Up @@ -139,6 +140,8 @@ def __init__(
Set to True to build in the source directory.
mount_with:
Mount mode of source code directory when building inside container, READ ONLY by default
mount_symlinks Optional[bool]:
Indicates if symlinks should be mounted inside the container
"""

self._resource_identifier = resource_identifier
Expand Down Expand Up @@ -180,6 +183,7 @@ def __init__(
self._build_in_source = build_in_source
self._build_result: Optional[ApplicationBuildResult] = None
self._mount_with = MountMode(mount_with)
self._mount_symlinks = mount_symlinks

def __enter__(self) -> "BuildContext":
self.set_up()
Expand Down Expand Up @@ -273,6 +277,7 @@ def run(self) -> None:
combine_dependencies=not self._create_auto_dependency_layer,
build_in_source=self._build_in_source,
mount_with_write=mount_with_write,
mount_symlinks=self._mount_symlinks,
)

self._check_exclude_warning()
Expand Down
6 changes: 6 additions & 0 deletions samcli/commands/build/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
docker_common_options,
hook_name_click_option,
manifest_option,
mount_symlinks_option,
parameter_override_option,
skip_prepare_infra_option,
template_option_without_build,
Expand Down Expand Up @@ -115,6 +116,7 @@
"be changed/added by the build process. By default the source code directory is read only.",
cls=ContainerOptions,
)
@mount_symlinks_option
@build_dir_option
@cache_dir_option
@base_dir_option
Expand Down Expand Up @@ -158,6 +160,7 @@ def cli(
mount_with: str,
terraform_project_root_path: Optional[str],
build_in_source: Optional[bool],
mount_symlinks: Optional[bool],
) -> None:
"""
`sam build` command entry point
Expand Down Expand Up @@ -189,6 +192,7 @@ def cli(
hook_name,
build_in_source,
mount_with,
mount_symlinks,
) # pragma: no cover


Expand All @@ -215,6 +219,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
hook_name: Optional[str],
build_in_source: Optional[bool],
mount_with: str,
mount_symlinks: Optional[bool],
) -> None:
"""
Implementation of the ``cli`` method
Expand Down Expand Up @@ -254,6 +259,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
hook_name=hook_name,
build_in_source=build_in_source,
mount_with=mount_with,
mount_symlinks=mount_symlinks,
) as ctx:
ctx.run()

Expand Down
1 change: 1 addition & 0 deletions samcli/commands/build/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"mount_with",
"skip_pull_image",
"docker_network",
"mount_symlinks",
]

CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS
Expand Down
17 changes: 14 additions & 3 deletions samcli/commands/local/cli_common/invoke_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def __init__(
container_host_interface: Optional[str] = None,
add_host: Optional[dict] = None,
invoke_images: Optional[str] = None,
mount_symlinks: Optional[bool] = False,
) -> None:
"""
Initialize the context
Expand Down Expand Up @@ -155,7 +156,10 @@ def __init__(
Optional. Docker extra hosts support from --add-host parameters
invoke_images dict
Optional. A dictionary that defines the custom invoke image URI of each function
mount_symlinks bool
Optional. Indicates if symlinks should be mounted inside the container
"""

self._template_file = template_file
self._function_identifier = function_identifier
self._env_vars_file = env_vars_file
Expand Down Expand Up @@ -197,6 +201,8 @@ def __init__(

self._debug_function = debug_function

self._mount_symlinks: Optional[bool] = mount_symlinks

# Note(xinhol): despite self._function_provider and self._stacks are initialized as None
# they will be assigned with a non-None value in __enter__() and
# it is only used in the context (after __enter__ is called)
Expand Down Expand Up @@ -402,10 +408,15 @@ def lambda_runtime(self) -> LambdaRuntime:
layer_downloader, self._skip_pull_image, self._force_image_build, invoke_images=self._invoke_images
)
self._lambda_runtimes = {
ContainersMode.WARM: WarmLambdaRuntime(self._container_manager, image_builder),
ContainersMode.COLD: LambdaRuntime(self._container_manager, image_builder),
ContainersMode.WARM: WarmLambdaRuntime(
self._container_manager,
image_builder,
mount_symlinks=self._mount_symlinks,
),
ContainersMode.COLD: LambdaRuntime(
self._container_manager, image_builder, mount_symlinks=self._mount_symlinks
),
}

return self._lambda_runtimes[self._containers_mode]

@property
Expand Down
12 changes: 11 additions & 1 deletion samcli/commands/local/invoke/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from samcli.cli.main import aws_creds_options, pass_context, print_cmdline_args
from samcli.cli.main import common_options as cli_framework_options
from samcli.commands._utils.option_value_processor import process_image_options
from samcli.commands._utils.options import hook_name_click_option, skip_prepare_infra_option, terraform_plan_file_option
from samcli.commands._utils.options import (
hook_name_click_option,
mount_symlinks_option,
skip_prepare_infra_option,
terraform_plan_file_option,
)
from samcli.commands.local.cli_common.options import invoke_common_options, local_common_options
from samcli.commands.local.invoke.core.command import InvokeCommand
from samcli.commands.local.lib.exceptions import InvalidIntermediateImageError
Expand Down Expand Up @@ -60,6 +65,7 @@
"is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin",
)
@click.option("--no-event", is_flag=True, default=True, help="DEPRECATED: By default no event is assumed.", hidden=True)
@mount_symlinks_option
@invoke_common_options
@local_common_options
@cli_framework_options
Expand Down Expand Up @@ -99,6 +105,7 @@ def cli(
hook_name,
skip_prepare_infra,
terraform_plan_file,
mount_symlinks,
):
"""
`sam local invoke` command entry point
Expand Down Expand Up @@ -129,6 +136,7 @@ def cli(
add_host,
invoke_image,
hook_name,
mount_symlinks,
) # pragma: no cover


Expand Down Expand Up @@ -156,6 +164,7 @@ def do_cli( # pylint: disable=R0914
add_host,
invoke_image,
hook_name,
mount_symlinks,
):
"""
Implementation of the ``cli`` method, just separated out for unit testing purposes
Expand Down Expand Up @@ -204,6 +213,7 @@ def do_cli( # pylint: disable=R0914
container_host_interface=container_host_interface,
add_host=add_host,
invoke_images=processed_invoke_images,
mount_symlinks=mount_symlinks,
) as context:
# Invoke the function
context.local_lambda_runner.invoke(
Expand Down
1 change: 1 addition & 0 deletions samcli/commands/local/invoke/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"container_host_interface",
"add_host",
"invoke_image",
"mount_symlinks",
]

CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS
Expand Down
6 changes: 5 additions & 1 deletion samcli/lib/build/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def __init__(
combine_dependencies: bool = True,
build_in_source: Optional[bool] = None,
mount_with_write: bool = False,
mount_symlinks: Optional[bool] = False,
) -> None:
"""
Initialize the class
Expand Down Expand Up @@ -156,6 +157,8 @@ def __init__(
Set to True to build in the source directory.
mount_with_write: bool
Mount source code directory with write permissions when building inside container.
mount_symlinks: Optional[bool]
True if symlinks should be mounted in the container.
"""
self._resources_to_build = resources_to_build
self._build_dir = build_dir
Expand All @@ -179,6 +182,7 @@ def __init__(
self._combine_dependencies = combine_dependencies
self._build_in_source = build_in_source
self._mount_with_write = mount_with_write
self._mount_symlinks = mount_symlinks

def build(self) -> ApplicationBuildResult:
"""
Expand Down Expand Up @@ -943,7 +947,6 @@ def _build_function_on_container(
log_level = LOG.getEffectiveLevel()

container_env_vars = container_env_vars or {}

container = LambdaBuildContainer(
lambda_builders_protocol_version,
config.language,
Expand All @@ -965,6 +968,7 @@ def _build_function_on_container(
build_in_source=self._build_in_source,
mount_with_write=self._mount_with_write,
build_dir=self._build_dir,
mount_symlinks=self._mount_symlinks,
)

try:
Expand Down
45 changes: 38 additions & 7 deletions samcli/local/docker/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def __init__(
mount_with_write: bool = False,
host_tmp_dir: Optional[str] = None,
extra_hosts: Optional[dict] = None,
mount_symlinks: Optional[bool] = False,
):
"""
Initializes the class with given configuration. This does not automatically create or run the container.
Expand All @@ -123,6 +124,7 @@ def __init__(
building on container
:param string host_tmp_dir: Optional. Temporary directory on the host when mounting with write permissions.
:param dict extra_hosts: Optional. Dict of hostname to IP resolutions
:param bool mount_symlinks: Optional. True if symlinks should be mounted in the container
"""

self._image = image
Expand Down Expand Up @@ -155,6 +157,7 @@ def __init__(
self._container_host_interface = container_host_interface
self._mount_with_write = mount_with_write
self._host_tmp_dir = host_tmp_dir
self._mount_symlinks = mount_symlinks

try:
self.rapid_port_host = find_free_port(
Expand Down Expand Up @@ -183,7 +186,10 @@ def create(self, context):
if self._host_dir:
mount_mode = "rw,delegated" if self._mount_with_write else "ro,delegated"
LOG.info("Mounting %s as %s:%s, inside runtime container", self._host_dir, self._working_dir, mount_mode)
mapped_symlinks = self._create_mapped_symlink_files() if self._resolve_symlinks(context) else {}
if self._resolve_symlinks_in_context(context) or self._mount_symlinks:
mapped_symlinks = self._create_mapped_symlink_files()
else:
mapped_symlinks = {}

_volumes = {
self._host_dir: {
Expand Down Expand Up @@ -270,16 +276,18 @@ def create(self, context):

def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]:
"""
Resolves any top level symlinked files and folders that are found on the
Resolves top level symlinked files and folders that are found on the
host directory and creates additional bind mounts to correctly map them
inside of the container.
By default only `node_modules` are mounted unless self.mount_symlinks is True
Returns
-------
Dict[str, Dict[str, str]]
A dictonary representing the resolved file or directory and the bound path
on the container
"""

mount_mode = "ro,delegated"
additional_volumes: Dict[str, Dict[str, str]] = {}

Expand All @@ -291,10 +299,15 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]:
for file in directory_iterator:
if not file.is_symlink():
continue

host_resolved_path = os.path.realpath(file.path)
if not self._resolve_symlinks_for_file(file) and not self._mount_symlinks:
LOG.info(
"Not mounting symlink (%s -> %s) by default. "
"Use --mount-symlinks to always mount symlinks in the container"
% (file.path, host_resolved_path)
)
continue
container_full_path = pathlib.Path(self._working_dir, file.name).as_posix()

additional_volumes[host_resolved_path] = {
"bind": container_full_path,
"mode": mount_mode,
Expand Down Expand Up @@ -659,12 +672,12 @@ def is_running(self):
except docker.errors.NotFound:
return False

def _resolve_symlinks(self, context) -> bool:
"""_summary_
def _resolve_symlinks_in_context(self, context) -> bool:
"""
Parameters
----------
context : sacli.local.docker.container.ContainerContext
context : samcli.local.docker.container.ContainerContext
Context for the container management to run. (build, invoke)
Returns
Expand All @@ -673,3 +686,21 @@ def _resolve_symlinks(self, context) -> bool:
True, if given these parameters it should resolve symlinks or not
"""
return bool(context != ContainerContext.BUILD)

def _resolve_symlinks_for_file(self, file: os.DirEntry) -> bool:
"""
Parameters
----------
file : os.DirEntry
File to check if it should be resolved
Returns
-------
bool
True if the file should be resolved as a symlink to mount in the container.
By default, the only symlinks resolved are `node_modules` used by build-in-source
"""
resolved_path = os.path.realpath(file.path) # resolved symlink
resolved_name = os.path.basename(resolved_path)
return bool(resolved_name == "node_modules")
2 changes: 2 additions & 0 deletions samcli/local/docker/lambda_build_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def __init__( # pylint: disable=too-many-locals
build_in_source=None,
mount_with_write: bool = False,
build_dir=None,
mount_symlinks=False,
):
abs_manifest_path = pathlib.Path(manifest_path).resolve()
manifest_file_name = abs_manifest_path.name
Expand Down Expand Up @@ -133,6 +134,7 @@ def __init__( # pylint: disable=too-many-locals
env_vars=env_vars,
mount_with_write=mount_with_write,
host_tmp_dir=host_tmp_dir,
mount_symlinks=mount_symlinks,
)

@property
Expand Down
Loading

0 comments on commit 42ba2f7

Please sign in to comment.