Skip to content

Commit 34da7c8

Browse files
authored
feat(build): support mount symlink in terraform build (#8854)
* feat(build): support --mount-symlinks in terraform build Thread the --mount-symlinks flag through the Terraform build pipeline so that zip artifacts containing symlinks (e.g. absolute symlinks) can be extracted during sam build --hook-name terraform. Changes: - copy_terraform_built_artifacts.py: Add --mount-symlinks CLI flag and pass it to unzip() - makefile_generator.py: Include $(SAM_CLI_MOUNT_SYMLINKS_FLAG) placeholder in the Makefile recipe - app_builder.py: Replace the placeholder with --mount-symlinks in the Makefile before invoking the builder (per-build, race-safe) - build_integ_base.py: Add mount_symlinks parameter to get_command_list Tests: - Script-level tests for symlink zip extraction with/without flag - E2E integration test with sam build --hook-name terraform - Unit test for makefile recipe generation * black * change to pass mount-symlink down from the call chain * feedback
1 parent 0f50bea commit 34da7c8

19 files changed

Lines changed: 240 additions & 10 deletions

File tree

samcli/commands/_utils/custom_options/hook_name_option.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def _call_prepare_hook(self, iac_hook_wrapper, opts, ctx):
9090
skip_prepare_infra = _read_parameter_value("skip_prepare_infra", opts, ctx, False)
9191
plan_file = _read_parameter_value("terraform_plan_file", opts, ctx)
9292
project_root_dir = _read_parameter_value("terraform_project_root_path", opts, ctx)
93+
mount_symlinks = _read_parameter_value("mount_symlinks", opts, ctx, False)
9394

9495
metadata_file = iac_hook_wrapper.prepare(
9596
output_dir_path,
@@ -100,6 +101,7 @@ def _call_prepare_hook(self, iac_hook_wrapper, opts, ctx):
100101
skip_prepare_infra,
101102
plan_file,
102103
project_root_dir,
104+
mount_symlinks,
103105
)
104106

105107
LOG.info("Prepare hook completed and metadata file generated at: %s", metadata_file)

samcli/hook_packages/terraform/copy_terraform_built_artifacts.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def cli_exit():
218218
sys.exit(1)
219219

220220

221-
def find_and_copy_assets(directory_path, expression, data_object):
221+
def find_and_copy_assets(directory_path, expression, data_object, mount_symlinks=False):
222222
"""
223223
Takes in an expression, directory_path and a json input from the standard input,
224224
tries to find the appropriate element within the json based on the element. It then takes action to
@@ -264,7 +264,7 @@ def find_and_copy_assets(directory_path, expression, data_object):
264264

265265
try:
266266
if zipfile.is_zipfile(abs_attribute_path):
267-
unzip(abs_attribute_path, directory_path)
267+
unzip(abs_attribute_path, directory_path, mount_symlinks=mount_symlinks)
268268
else:
269269
copytree(abs_attribute_path, directory_path)
270270
except OSError as ex:
@@ -342,12 +342,19 @@ def validate_environment_variables():
342342
required=False,
343343
help="Terraform output json body. This option is not to be used with --target.",
344344
)
345+
argparser.add_argument(
346+
"--mount-symlinks",
347+
action="store_true",
348+
default=False,
349+
help="Allow symlinks pointing outside the extraction directory when unzipping artifacts.",
350+
)
345351

346352
arguments = argparser.parse_args()
347353
directory_path = os.path.abspath(arguments.directory)
348354
expression = arguments.expression
349355
target = arguments.target
350356
json_str = arguments.json
357+
mount_symlinks = arguments.mount_symlinks
351358

352359
# validate environment variables do not contain blocked arguments
353360
validate_environment_variables()
@@ -384,4 +391,4 @@ def validate_environment_variables():
384391
cli_exit()
385392

386393
LOG.info("Find and copy built assets")
387-
find_and_copy_assets(directory_path, expression, data_object)
394+
find_and_copy_assets(directory_path, expression, data_object, mount_symlinks=mount_symlinks)

samcli/hook_packages/terraform/hooks/prepare/enrich.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def enrich_resources_and_generate_makefile(
5252
terraform_application_dir: str,
5353
lambda_resources_to_code_map: Dict,
5454
project_root_dir: str,
55+
mount_symlinks: bool = False,
5556
) -> None:
5657
"""
5758
Use the sam metadata resources to enrich the mapped resources and to create a Makefile with a rule for
@@ -111,7 +112,12 @@ def enrich_resources_and_generate_makefile(
111112

112113
# get makefile rule for resource
113114
makefile_rule = generate_makefile_rule_for_lambda_resource(
114-
sam_metadata_resource, logical_id, terraform_application_dir, python_command_name, output_directory_path
115+
sam_metadata_resource,
116+
logical_id,
117+
terraform_application_dir,
118+
python_command_name,
119+
output_directory_path,
120+
mount_symlinks=mount_symlinks,
115121
)
116122
makefile_rules.append(makefile_rule)
117123

samcli/hook_packages/terraform/hooks/prepare/hook.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def prepare(params: dict) -> dict:
9494
LOG.debug("The normalized OutputDirPath value is %s", output_dir_path)
9595

9696
skip_prepare_infra = params.get("SkipPrepareInfra", False)
97+
mount_symlinks = params.get("MountSymlinks", False)
9798
metadata_file_path = os.path.join(output_dir_path, TERRAFORM_METADATA_FILE)
9899

99100
plan_file = params.get("PlanFile")
@@ -112,7 +113,9 @@ def prepare(params: dict) -> dict:
112113

113114
# convert terraform to cloudformation
114115
LOG.info("Generating metadata file")
115-
cfn_dict = translate_to_cfn(tf_json, output_dir_path, terraform_application_dir, project_root_dir)
116+
cfn_dict = translate_to_cfn(
117+
tf_json, output_dir_path, terraform_application_dir, project_root_dir, mount_symlinks=mount_symlinks
118+
)
116119

117120
if cfn_dict.get("Resources"):
118121
_update_resources_paths(cfn_dict.get("Resources"), terraform_application_dir) # type: ignore

samcli/hook_packages/terraform/hooks/prepare/makefile_generator.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def generate_makefile_rule_for_lambda_resource(
2929
terraform_application_dir: str,
3030
python_command_name: str,
3131
output_dir: str,
32+
mount_symlinks: bool = False,
3233
) -> str:
3334
"""
3435
Generates and returns a makefile rule for the lambda resource associated with the given sam metadata resource.
@@ -56,7 +57,12 @@ def generate_makefile_rule_for_lambda_resource(
5657
resource_address = sam_metadata_resource.resource.get("address", "")
5758
python_command_recipe = _format_makefile_recipe(
5859
_build_makerule_python_command(
59-
python_command_name, output_dir, resource_address, sam_metadata_resource, terraform_application_dir
60+
python_command_name,
61+
output_dir,
62+
resource_address,
63+
sam_metadata_resource,
64+
terraform_application_dir,
65+
mount_symlinks=mount_symlinks,
6066
)
6167
)
6268
return f"{target}{python_command_recipe}"
@@ -124,6 +130,7 @@ def _build_makerule_python_command(
124130
resource_address: str,
125131
sam_metadata_resource: SamMetadataResource,
126132
terraform_application_dir: str,
133+
mount_symlinks: bool = False,
127134
) -> str:
128135
"""
129136
Build the Python command recipe to be used inside of the Makefile rule
@@ -155,12 +162,15 @@ def _build_makerule_python_command(
155162
terraform_built_artifacts_script_path = convert_path_to_unix_path(
156163
str(Path(output_dir, TERRAFORM_BUILD_SCRIPT).relative_to(terraform_application_dir))
157164
)
158-
return show_command_template.format(
165+
command = show_command_template.format(
159166
python_command_name=python_command_name,
160167
terraform_built_artifacts_script_path=terraform_built_artifacts_script_path,
161168
jpath_string=jpath_string.replace('"', '\\"'),
162169
resource_address=resource_address.replace('"', '\\"'),
163170
)
171+
if mount_symlinks:
172+
command += " --mount-symlinks"
173+
return command
164174

165175

166176
def _get_makefile_build_target(logical_id: str) -> str:

samcli/hook_packages/terraform/hooks/prepare/translate.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,11 @@ def _check_unresolvable_values(root_module: dict, root_tf_module: TFModule) -> N
152152

153153

154154
def translate_to_cfn(
155-
tf_json: dict, output_directory_path: str, terraform_application_dir: str, project_root_dir: str
155+
tf_json: dict,
156+
output_directory_path: str,
157+
terraform_application_dir: str,
158+
project_root_dir: str,
159+
mount_symlinks: bool = False,
156160
) -> dict:
157161
"""
158162
Translates the json output of a terraform show into CloudFormation
@@ -339,6 +343,7 @@ def translate_to_cfn(
339343
terraform_application_dir,
340344
lambda_resources_to_code_map,
341345
project_root_dir,
346+
mount_symlinks=mount_symlinks,
342347
)
343348
else:
344349
LOG.debug("There is no sam metadata resources, no enrichment or Makefile is required")

samcli/lib/hook/hook_wrapper.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def prepare(
5454
skip_prepare_infra: bool = False,
5555
plan_file: Optional[str] = None,
5656
project_root_dir: Optional[str] = None,
57+
mount_symlinks: bool = False,
5758
) -> str:
5859
"""
5960
Run the prepare hook to generate the IaC Metadata file.
@@ -96,6 +97,8 @@ def prepare(
9697
params["PlanFile"] = plan_file
9798
if project_root_dir:
9899
params["ProjectRootDir"] = project_root_dir
100+
if mount_symlinks:
101+
params["MountSymlinks"] = mount_symlinks
99102

100103
output = self._execute("prepare", params)
101104

tests/integration/buildcmd/build_integ_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def get_command_list(
114114
save_params=False,
115115
project_root_dir=None,
116116
use_buildkit=False,
117+
mount_symlinks=False,
117118
):
118119
command_list = [self.cmd, "build"]
119120

@@ -189,6 +190,9 @@ def get_command_list(
189190
if project_root_dir is not None:
190191
command_list += ["--terraform-project-root-path", project_root_dir]
191192

193+
if mount_symlinks:
194+
command_list += ["--mount-symlinks"]
195+
192196
return command_list
193197

194198
def verify_docker_container_cleanedup(self, runtime):

tests/integration/buildcmd/test_build_terraform_applications.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,57 @@ def test_build_and_invoke_lambda_functions(self):
435435
"multiValueHeaders": None,
436436
},
437437
)
438+
439+
440+
@skipIf(
441+
(not RUN_BY_CANARY and not CI_OVERRIDE),
442+
"Skip Terraform test cases unless running in CI",
443+
)
444+
class TestBuildTerraformWithSymlinkZip(BuildTerraformApplicationIntegBase):
445+
"""Test sam build --hook-name terraform with a zip artifact containing symlinks."""
446+
447+
terraform_application = Path("terraform/zip_with_symlink")
448+
function_identifier = "aws_lambda_function.symlink_function"
449+
450+
def setUp(self):
451+
# Call grandparent setUp (BuildIntegBase) for working_dir setup, then copy terraform project.
452+
# Skip BuildTerraformApplicationIntegBase.setUp which calls build_with_prepare_hook().
453+
BuildIntegBase.setUp(self)
454+
shutil.rmtree(Path(self.working_dir))
455+
shutil.copytree(Path(self.terraform_application_path), Path(self.working_dir))
456+
457+
def _run_build(self, mount_symlinks=False):
458+
build_cmd = self.get_command_list(
459+
function_identifier=self.function_identifier,
460+
hook_name="terraform",
461+
mount_symlinks=mount_symlinks,
462+
)
463+
LOG.info("Running: %s", " ".join(build_cmd))
464+
return self.run_command(build_cmd)
465+
466+
def test_build_fails_without_mount_symlinks(self):
467+
"""Build should fail when zip contains absolute symlink and --mount-symlinks is not set."""
468+
_, stderr, returncode = self._run_build(mount_symlinks=False)
469+
stderr_str = stderr.decode("utf-8") if isinstance(stderr, bytes) else stderr
470+
self.assertNotEqual(returncode, 0, f"Expected build to fail but it succeeded. stderr: {stderr_str}")
471+
self.assertIn(
472+
"Failed to extract file from the zip file. A symlink has an absolute target which is not allowed",
473+
stderr_str,
474+
)
475+
476+
def test_build_succeeds_with_mount_symlinks(self):
477+
"""Build should succeed when zip contains absolute symlink and --mount-symlinks is set."""
478+
_, stderr, returncode = self._run_build(mount_symlinks=True)
479+
stderr_str = stderr.decode("utf-8") if isinstance(stderr, bytes) else stderr
480+
self.assertEqual(returncode, 0, f"Expected build to succeed but it failed. stderr: {stderr_str}")
481+
482+
# Find the function build directory (SAM CLI generates a hashed logical ID)
483+
build_dir = Path(self.working_dir) / ".aws-sam" / "build"
484+
self.assertTrue(build_dir.exists(), f"Build output directory not found: {build_dir}")
485+
function_dirs = [d for d in build_dir.iterdir() if d.is_dir()]
486+
self.assertEqual(len(function_dirs), 1, f"Expected 1 function dir, found: {function_dirs}")
487+
488+
function_dir = function_dirs[0]
489+
symlink_path = function_dir / "external_link"
490+
self.assertTrue(symlink_path.is_symlink(), f"Expected symlink at {symlink_path}")
491+
self.assertEqual(os.readlink(str(symlink_path)), "/tmp/some_external_target")

tests/integration/scripts/test_copy_terraform_built_artifacts.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,60 @@ def test_script_output_path_invalid_json(self):
200200
subprocess.check_call(
201201
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, cwd=self.working_dir
202202
)
203+
204+
def test_script_zip_with_symlink_fails_without_mount_symlinks(self):
205+
"""Test that unzipping a zip with an absolute symlink fails by default."""
206+
input_file = self.testdata_directory.joinpath("build-output-path-symlink-zip.json")
207+
with open(input_file, "rb") as f:
208+
json_str = f.read().decode("utf-8")
209+
command = [
210+
str(sys.executable),
211+
str(self.script_location),
212+
"--directory",
213+
str(self.directory),
214+
"--expression",
215+
self.expression,
216+
"--json",
217+
json_str,
218+
]
219+
env = os.environ.copy()
220+
with self.assertRaises(subprocess.CalledProcessError) as ctx:
221+
subprocess.check_output(
222+
command,
223+
stderr=subprocess.STDOUT,
224+
stdin=subprocess.PIPE,
225+
cwd=self.working_dir,
226+
env=env,
227+
)
228+
self.assertIn(
229+
"Failed to extract file from the zip file. A symlink has an absolute target which is not allowed",
230+
ctx.exception.output.decode("utf-8"),
231+
)
232+
233+
def test_script_zip_with_symlink_succeeds_with_mount_symlinks(self):
234+
"""Test that unzipping a zip with an absolute symlink succeeds when --mount-symlinks is passed."""
235+
input_file = self.testdata_directory.joinpath("build-output-path-symlink-zip.json")
236+
with open(input_file, "rb") as f:
237+
json_str = f.read().decode("utf-8")
238+
command = [
239+
str(sys.executable),
240+
str(self.script_location),
241+
"--directory",
242+
str(self.directory),
243+
"--expression",
244+
self.expression,
245+
"--json",
246+
json_str,
247+
"--mount-symlinks",
248+
]
249+
subprocess.check_call(
250+
command,
251+
stdout=subprocess.PIPE,
252+
stderr=subprocess.PIPE,
253+
stdin=subprocess.PIPE,
254+
cwd=self.working_dir,
255+
)
256+
# Verify the symlink was created
257+
link_path = os.path.join(self.directory, "external_link")
258+
self.assertTrue(os.path.islink(link_path))
259+
self.assertEqual(os.readlink(link_path), "/tmp/external_target")

0 commit comments

Comments
 (0)