Skip to content

Commit 42ba2f7

Browse files
authored
feat: New parameter to resolve and mount symlinks in container (#7891)
`--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)
1 parent bb61909 commit 42ba2f7

21 files changed

Lines changed: 162 additions & 24 deletions

File tree

samcli/commands/_utils/options.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,21 @@ def use_container_build_option(f):
862862
return use_container_build_click_option()(f)
863863

864864

865+
def mount_symlinks_click_option():
866+
return click.option(
867+
"--mount-symlinks/--no-mount-symlinks",
868+
default=False,
869+
is_flag=True,
870+
help="Specify if symlinks at the top level of the code should be mounted inside the container. "
871+
"Activating this flag could allow access to locations outside of your workspace by using a symbolic link. "
872+
"By default symlinks are not mounted.",
873+
)
874+
875+
876+
def mount_symlinks_option(f):
877+
return mount_symlinks_click_option()(f)
878+
879+
865880
def terraform_plan_file_callback(ctx, param, provided_value):
866881
"""
867882
Callback for --terraform-plan-file to check if --hook-name is also specified

samcli/commands/build/build_context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def __init__(
8282
hook_name: Optional[str] = None,
8383
build_in_source: Optional[bool] = None,
8484
mount_with: str = MountMode.READ.value,
85+
mount_symlinks: Optional[bool] = False,
8586
) -> None:
8687
"""
8788
Initialize the class
@@ -139,6 +140,8 @@ def __init__(
139140
Set to True to build in the source directory.
140141
mount_with:
141142
Mount mode of source code directory when building inside container, READ ONLY by default
143+
mount_symlinks Optional[bool]:
144+
Indicates if symlinks should be mounted inside the container
142145
"""
143146

144147
self._resource_identifier = resource_identifier
@@ -180,6 +183,7 @@ def __init__(
180183
self._build_in_source = build_in_source
181184
self._build_result: Optional[ApplicationBuildResult] = None
182185
self._mount_with = MountMode(mount_with)
186+
self._mount_symlinks = mount_symlinks
183187

184188
def __enter__(self) -> "BuildContext":
185189
self.set_up()
@@ -273,6 +277,7 @@ def run(self) -> None:
273277
combine_dependencies=not self._create_auto_dependency_layer,
274278
build_in_source=self._build_in_source,
275279
mount_with_write=mount_with_write,
280+
mount_symlinks=self._mount_symlinks,
276281
)
277282

278283
self._check_exclude_warning()

samcli/commands/build/command.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
docker_common_options,
2525
hook_name_click_option,
2626
manifest_option,
27+
mount_symlinks_option,
2728
parameter_override_option,
2829
skip_prepare_infra_option,
2930
template_option_without_build,
@@ -115,6 +116,7 @@
115116
"be changed/added by the build process. By default the source code directory is read only.",
116117
cls=ContainerOptions,
117118
)
119+
@mount_symlinks_option
118120
@build_dir_option
119121
@cache_dir_option
120122
@base_dir_option
@@ -158,6 +160,7 @@ def cli(
158160
mount_with: str,
159161
terraform_project_root_path: Optional[str],
160162
build_in_source: Optional[bool],
163+
mount_symlinks: Optional[bool],
161164
) -> None:
162165
"""
163166
`sam build` command entry point
@@ -189,6 +192,7 @@ def cli(
189192
hook_name,
190193
build_in_source,
191194
mount_with,
195+
mount_symlinks,
192196
) # pragma: no cover
193197

194198

@@ -215,6 +219,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
215219
hook_name: Optional[str],
216220
build_in_source: Optional[bool],
217221
mount_with: str,
222+
mount_symlinks: Optional[bool],
218223
) -> None:
219224
"""
220225
Implementation of the ``cli`` method
@@ -254,6 +259,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
254259
hook_name=hook_name,
255260
build_in_source=build_in_source,
256261
mount_with=mount_with,
262+
mount_symlinks=mount_symlinks,
257263
) as ctx:
258264
ctx.run()
259265

samcli/commands/build/core/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"mount_with",
2323
"skip_pull_image",
2424
"docker_network",
25+
"mount_symlinks",
2526
]
2627

2728
CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS

samcli/commands/local/cli_common/invoke_context.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def __init__(
101101
container_host_interface: Optional[str] = None,
102102
add_host: Optional[dict] = None,
103103
invoke_images: Optional[str] = None,
104+
mount_symlinks: Optional[bool] = False,
104105
) -> None:
105106
"""
106107
Initialize the context
@@ -155,7 +156,10 @@ def __init__(
155156
Optional. Docker extra hosts support from --add-host parameters
156157
invoke_images dict
157158
Optional. A dictionary that defines the custom invoke image URI of each function
159+
mount_symlinks bool
160+
Optional. Indicates if symlinks should be mounted inside the container
158161
"""
162+
159163
self._template_file = template_file
160164
self._function_identifier = function_identifier
161165
self._env_vars_file = env_vars_file
@@ -197,6 +201,8 @@ def __init__(
197201

198202
self._debug_function = debug_function
199203

204+
self._mount_symlinks: Optional[bool] = mount_symlinks
205+
200206
# Note(xinhol): despite self._function_provider and self._stacks are initialized as None
201207
# they will be assigned with a non-None value in __enter__() and
202208
# it is only used in the context (after __enter__ is called)
@@ -402,10 +408,15 @@ def lambda_runtime(self) -> LambdaRuntime:
402408
layer_downloader, self._skip_pull_image, self._force_image_build, invoke_images=self._invoke_images
403409
)
404410
self._lambda_runtimes = {
405-
ContainersMode.WARM: WarmLambdaRuntime(self._container_manager, image_builder),
406-
ContainersMode.COLD: LambdaRuntime(self._container_manager, image_builder),
411+
ContainersMode.WARM: WarmLambdaRuntime(
412+
self._container_manager,
413+
image_builder,
414+
mount_symlinks=self._mount_symlinks,
415+
),
416+
ContainersMode.COLD: LambdaRuntime(
417+
self._container_manager, image_builder, mount_symlinks=self._mount_symlinks
418+
),
407419
}
408-
409420
return self._lambda_runtimes[self._containers_mode]
410421

411422
@property

samcli/commands/local/invoke/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from samcli.cli.main import aws_creds_options, pass_context, print_cmdline_args
1111
from samcli.cli.main import common_options as cli_framework_options
1212
from samcli.commands._utils.option_value_processor import process_image_options
13-
from samcli.commands._utils.options import hook_name_click_option, skip_prepare_infra_option, terraform_plan_file_option
13+
from samcli.commands._utils.options import (
14+
hook_name_click_option,
15+
mount_symlinks_option,
16+
skip_prepare_infra_option,
17+
terraform_plan_file_option,
18+
)
1419
from samcli.commands.local.cli_common.options import invoke_common_options, local_common_options
1520
from samcli.commands.local.invoke.core.command import InvokeCommand
1621
from samcli.commands.local.lib.exceptions import InvalidIntermediateImageError
@@ -60,6 +65,7 @@
6065
"is not specified, no event is assumed. Pass in the value '-' to input JSON via stdin",
6166
)
6267
@click.option("--no-event", is_flag=True, default=True, help="DEPRECATED: By default no event is assumed.", hidden=True)
68+
@mount_symlinks_option
6369
@invoke_common_options
6470
@local_common_options
6571
@cli_framework_options
@@ -99,6 +105,7 @@ def cli(
99105
hook_name,
100106
skip_prepare_infra,
101107
terraform_plan_file,
108+
mount_symlinks,
102109
):
103110
"""
104111
`sam local invoke` command entry point
@@ -129,6 +136,7 @@ def cli(
129136
add_host,
130137
invoke_image,
131138
hook_name,
139+
mount_symlinks,
132140
) # pragma: no cover
133141

134142

@@ -156,6 +164,7 @@ def do_cli( # pylint: disable=R0914
156164
add_host,
157165
invoke_image,
158166
hook_name,
167+
mount_symlinks,
159168
):
160169
"""
161170
Implementation of the ``cli`` method, just separated out for unit testing purposes
@@ -204,6 +213,7 @@ def do_cli( # pylint: disable=R0914
204213
container_host_interface=container_host_interface,
205214
add_host=add_host,
206215
invoke_images=processed_invoke_images,
216+
mount_symlinks=mount_symlinks,
207217
) as context:
208218
# Invoke the function
209219
context.local_lambda_runner.invoke(

samcli/commands/local/invoke/core/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"container_host_interface",
3636
"add_host",
3737
"invoke_image",
38+
"mount_symlinks",
3839
]
3940

4041
CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS

samcli/lib/build/app_builder.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(
111111
combine_dependencies: bool = True,
112112
build_in_source: Optional[bool] = None,
113113
mount_with_write: bool = False,
114+
mount_symlinks: Optional[bool] = False,
114115
) -> None:
115116
"""
116117
Initialize the class
@@ -156,6 +157,8 @@ def __init__(
156157
Set to True to build in the source directory.
157158
mount_with_write: bool
158159
Mount source code directory with write permissions when building inside container.
160+
mount_symlinks: Optional[bool]
161+
True if symlinks should be mounted in the container.
159162
"""
160163
self._resources_to_build = resources_to_build
161164
self._build_dir = build_dir
@@ -179,6 +182,7 @@ def __init__(
179182
self._combine_dependencies = combine_dependencies
180183
self._build_in_source = build_in_source
181184
self._mount_with_write = mount_with_write
185+
self._mount_symlinks = mount_symlinks
182186

183187
def build(self) -> ApplicationBuildResult:
184188
"""
@@ -943,7 +947,6 @@ def _build_function_on_container(
943947
log_level = LOG.getEffectiveLevel()
944948

945949
container_env_vars = container_env_vars or {}
946-
947950
container = LambdaBuildContainer(
948951
lambda_builders_protocol_version,
949952
config.language,
@@ -965,6 +968,7 @@ def _build_function_on_container(
965968
build_in_source=self._build_in_source,
966969
mount_with_write=self._mount_with_write,
967970
build_dir=self._build_dir,
971+
mount_symlinks=self._mount_symlinks,
968972
)
969973

970974
try:

samcli/local/docker/container.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def __init__(
101101
mount_with_write: bool = False,
102102
host_tmp_dir: Optional[str] = None,
103103
extra_hosts: Optional[dict] = None,
104+
mount_symlinks: Optional[bool] = False,
104105
):
105106
"""
106107
Initializes the class with given configuration. This does not automatically create or run the container.
@@ -123,6 +124,7 @@ def __init__(
123124
building on container
124125
:param string host_tmp_dir: Optional. Temporary directory on the host when mounting with write permissions.
125126
:param dict extra_hosts: Optional. Dict of hostname to IP resolutions
127+
:param bool mount_symlinks: Optional. True if symlinks should be mounted in the container
126128
"""
127129

128130
self._image = image
@@ -155,6 +157,7 @@ def __init__(
155157
self._container_host_interface = container_host_interface
156158
self._mount_with_write = mount_with_write
157159
self._host_tmp_dir = host_tmp_dir
160+
self._mount_symlinks = mount_symlinks
158161

159162
try:
160163
self.rapid_port_host = find_free_port(
@@ -183,7 +186,10 @@ def create(self, context):
183186
if self._host_dir:
184187
mount_mode = "rw,delegated" if self._mount_with_write else "ro,delegated"
185188
LOG.info("Mounting %s as %s:%s, inside runtime container", self._host_dir, self._working_dir, mount_mode)
186-
mapped_symlinks = self._create_mapped_symlink_files() if self._resolve_symlinks(context) else {}
189+
if self._resolve_symlinks_in_context(context) or self._mount_symlinks:
190+
mapped_symlinks = self._create_mapped_symlink_files()
191+
else:
192+
mapped_symlinks = {}
187193

188194
_volumes = {
189195
self._host_dir: {
@@ -270,16 +276,18 @@ def create(self, context):
270276

271277
def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]:
272278
"""
273-
Resolves any top level symlinked files and folders that are found on the
279+
Resolves top level symlinked files and folders that are found on the
274280
host directory and creates additional bind mounts to correctly map them
275281
inside of the container.
282+
By default only `node_modules` are mounted unless self.mount_symlinks is True
276283
277284
Returns
278285
-------
279286
Dict[str, Dict[str, str]]
280287
A dictonary representing the resolved file or directory and the bound path
281288
on the container
282289
"""
290+
283291
mount_mode = "ro,delegated"
284292
additional_volumes: Dict[str, Dict[str, str]] = {}
285293

@@ -291,10 +299,15 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]:
291299
for file in directory_iterator:
292300
if not file.is_symlink():
293301
continue
294-
295302
host_resolved_path = os.path.realpath(file.path)
303+
if not self._resolve_symlinks_for_file(file) and not self._mount_symlinks:
304+
LOG.info(
305+
"Not mounting symlink (%s -> %s) by default. "
306+
"Use --mount-symlinks to always mount symlinks in the container"
307+
% (file.path, host_resolved_path)
308+
)
309+
continue
296310
container_full_path = pathlib.Path(self._working_dir, file.name).as_posix()
297-
298311
additional_volumes[host_resolved_path] = {
299312
"bind": container_full_path,
300313
"mode": mount_mode,
@@ -659,12 +672,12 @@ def is_running(self):
659672
except docker.errors.NotFound:
660673
return False
661674

662-
def _resolve_symlinks(self, context) -> bool:
663-
"""_summary_
675+
def _resolve_symlinks_in_context(self, context) -> bool:
676+
"""
664677
665678
Parameters
666679
----------
667-
context : sacli.local.docker.container.ContainerContext
680+
context : samcli.local.docker.container.ContainerContext
668681
Context for the container management to run. (build, invoke)
669682
670683
Returns
@@ -673,3 +686,21 @@ def _resolve_symlinks(self, context) -> bool:
673686
True, if given these parameters it should resolve symlinks or not
674687
"""
675688
return bool(context != ContainerContext.BUILD)
689+
690+
def _resolve_symlinks_for_file(self, file: os.DirEntry) -> bool:
691+
"""
692+
693+
Parameters
694+
----------
695+
file : os.DirEntry
696+
File to check if it should be resolved
697+
698+
Returns
699+
-------
700+
bool
701+
True if the file should be resolved as a symlink to mount in the container.
702+
By default, the only symlinks resolved are `node_modules` used by build-in-source
703+
"""
704+
resolved_path = os.path.realpath(file.path) # resolved symlink
705+
resolved_name = os.path.basename(resolved_path)
706+
return bool(resolved_name == "node_modules")

samcli/local/docker/lambda_build_container.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__( # pylint: disable=too-many-locals
6060
build_in_source=None,
6161
mount_with_write: bool = False,
6262
build_dir=None,
63+
mount_symlinks=False,
6364
):
6465
abs_manifest_path = pathlib.Path(manifest_path).resolve()
6566
manifest_file_name = abs_manifest_path.name
@@ -133,6 +134,7 @@ def __init__( # pylint: disable=too-many-locals
133134
env_vars=env_vars,
134135
mount_with_write=mount_with_write,
135136
host_tmp_dir=host_tmp_dir,
137+
mount_symlinks=mount_symlinks,
136138
)
137139

138140
@property

0 commit comments

Comments
 (0)