From 4c7e10147c4353fdf11ee02eb5c23917868ffba7 Mon Sep 17 00:00:00 2001 From: seshubaws <116689586+seshubaws@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:19:41 -0800 Subject: [PATCH 1/7] Update notify-slack.yml to add sleep (#8646) --- .github/workflows/notify-slack.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/notify-slack.yml b/.github/workflows/notify-slack.yml index c87ec70eefe..d42971a4f57 100644 --- a/.github/workflows/notify-slack.yml +++ b/.github/workflows/notify-slack.yml @@ -12,8 +12,11 @@ jobs: permissions: contents: read steps: + - name: Wait for label to be applied + run: sleep 10s + shell: bash - name: Send External PR Notification - if: github.event_name == 'pull_request' && github.event.label.name == 'pr/external' + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'pr/external') uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2 env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} From 463163d941220250359a8cdf8146fe018688d5e4 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 12 Feb 2026 17:58:26 -0800 Subject: [PATCH 2/7] fix: use tag prefix matching to clean up samcli/lambda-* images (#8647) Docker's images.list(name='samcli/lambda') does exact repository matching and won't match repositories like 'samcli/lambda-python'. This caused stale images to persist across parameterized test classes, leading to flaky test_download_two_layers failures where Layer2 should overwrite Layer1 but the image was never rebuilt. Fix by: 1. Adding _cleanup_samcli_images() that lists all images and filters by 'samcli/lambda-' tag prefix 2. Using this method in both tearDown and tearDownClass 3. Fixing the same pattern in TestLayerVersionThatDoNotCreateCache --- .../local/invoke/test_integrations_cli.py | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/tests/integration/local/invoke/test_integrations_cli.py b/tests/integration/local/invoke/test_integrations_cli.py index 2971ab00773..e2b51aa9eb1 100644 --- a/tests/integration/local/invoke/test_integrations_cli.py +++ b/tests/integration/local/invoke/test_integrations_cli.py @@ -803,13 +803,27 @@ class TestLayerVersionBase(InvokeIntegBase): def setUp(self): self.layer_cache = Path().home().joinpath("integ_layer_cache") + @staticmethod + def _cleanup_samcli_images(docker_client): + """Remove all samcli/lambda-* images. + + Docker's images.list(name="samcli/lambda") does exact repository matching + and won't match repositories like "samcli/lambda-python". We list all images + and filter by tag prefix instead. + """ + try: + all_images = docker_client.images.list() + for image in all_images: + for tag in image.tags: + if tag.startswith("samcli/lambda-"): + docker_client.remove_image_safely(image.id, force=True) + break + except Exception: + pass + def tearDown(self): docker_client = get_validated_container_client() - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method for runtime-aware image cleanup - docker_client.remove_image_safely(image.id, force=True) - + self._cleanup_samcli_images(docker_client) shutil.rmtree(str(self.layer_cache), ignore_errors=True) @classmethod @@ -823,10 +837,7 @@ def tearDownClass(cls): cls.layer_utils.delete_layers() # Added to handle the case where ^C failed the test due to invalid cleanup of layers docker_client = get_validated_container_client() - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method for runtime-aware image cleanup - docker_client.remove_image_safely(image.id, force=True) + cls._cleanup_samcli_images(docker_client) integ_layer_cache_dir = Path().home().joinpath("integ_layer_cache") if integ_layer_cache_dir.exists(): shutil.rmtree(str(integ_layer_cache_dir)) @@ -1069,12 +1080,18 @@ def setUp(self): def tearDown(self): docker_client = get_validated_container_client() - # Use strategy pattern method for runtime-aware image cleanup - # This handles both Docker and Finch cleanup strategies automatically - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method that handles runtime-specific cleanup logic - docker_client.remove_image_safely(image.id, force=True) + # Use the same prefix-based cleanup as TestLayerVersionBase. + # Docker's images.list(name="samcli/lambda") does exact repository matching + # and won't match "samcli/lambda-python" etc. + try: + all_images = docker_client.images.list() + for image in all_images: + for tag in image.tags: + if tag.startswith("samcli/lambda-"): + docker_client.remove_image_safely(image.id, force=True) + break + except Exception: + pass def test_layer_does_not_exist(self): self.layer_utils.upsert_layer(LayerUtils.generate_layer_name(), "LayerOneArn", "layer1.zip") From 59021a81297b10037e0bfb9bf0657870ac5db521 Mon Sep 17 00:00:00 2001 From: Daniel Abib Date: Fri, 13 Feb 2026 01:42:19 -0300 Subject: [PATCH 3/7] fix: isolate PyInstaller library paths for subprocess calls (#8628) * fix: isolate PyInstaller library paths for subprocess calls When SAM CLI runs from the native installer (PyInstaller-based), it sets LD_LIBRARY_PATH/DYLD_LIBRARY_PATH to include bundled libraries. This causes conflicts when spawning external processes like npm, node, or pip that need system libraries (e.g., OpenSSL version mismatch on Fedora 43). This change: - Detects PyInstaller bundle via sys._MEIPASS - Filters bundled library paths from environment variables early in CLI init - Ensures external processes use system libraries instead of bundled ones Fixes #8542 * fix: use more specific path filtering to avoid filtering legitimate system paths Addressed review feedback from @bnusunny: - Changed from '_internal not in p' to 'p.endswith(/_internal)' - This prevents accidentally filtering legitimate system paths that contain _internal in the middle - Added test to verify paths like /usr/lib/some_internal_lib are preserved --------- Co-authored-by: Harold Sun --- samcli/cli/main.py | 5 + samcli/lib/utils/subprocess_utils.py | 175 +++++++++++++- tests/unit/lib/utils/test_subprocess_env.py | 255 ++++++++++++++++++++ 3 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 tests/unit/lib/utils/test_subprocess_env.py diff --git a/samcli/cli/main.py b/samcli/cli/main.py index 7ad038dab06..072d836658b 100644 --- a/samcli/cli/main.py +++ b/samcli/cli/main.py @@ -134,6 +134,11 @@ def cli(ctx): import atexit from samcli.lib.telemetry.metric import emit_all_metrics, send_installed_metric + from samcli.lib.utils.subprocess_utils import isolate_library_paths_for_subprocess + + # When running from PyInstaller bundle, isolate library paths so external processes + # (npm, node, pip, etc.) use system libraries instead of bundled ones + isolate_library_paths_for_subprocess() # if development version of SAM CLI is used, attach module proxy # to catch missing configuration for dynamic/hidden imports diff --git a/samcli/lib/utils/subprocess_utils.py b/samcli/lib/utils/subprocess_utils.py index 284ea04d813..256c948f01f 100644 --- a/samcli/lib/utils/subprocess_utils.py +++ b/samcli/lib/utils/subprocess_utils.py @@ -9,11 +9,24 @@ from concurrent.futures.thread import ThreadPoolExecutor from subprocess import PIPE, STDOUT, Popen from time import sleep -from typing import Any, AnyStr, Callable, Dict, Optional, Union +from typing import Any, AnyStr, Callable, Dict, List, Optional, Union from samcli.commands.exceptions import UserException from samcli.lib.utils.stream_writer import StreamWriter +# Environment variables that control library loading paths +# These are set by PyInstaller and can cause conflicts with system binaries +LIBRARY_PATH_VARS = [ + "LD_LIBRARY_PATH", # Linux + "DYLD_LIBRARY_PATH", # macOS + "DYLD_FALLBACK_LIBRARY_PATH", # macOS fallback + "DYLD_FRAMEWORK_PATH", # macOS frameworks +] + +# Original library paths before cleanup (stored for debugging/restoration if needed) +# Using a mutable container to avoid global statement (PLW0603) +_library_path_state: Dict[str, Optional[Dict[str, str]]] = {"original_library_paths": None} + # These are the bytes that used as a prefix for a some string to color them in red to show errors. TERRAFORM_ERROR_PREFIX = [27, 91, 51, 49] @@ -148,3 +161,163 @@ def _check_and_process_bytes(check_value: AnyStr, preserve_whitespace=False) -> return decoded_value return decoded_value.strip() return check_value + + +def is_pyinstaller_bundle() -> bool: + """ + Check if SAM CLI is running from a PyInstaller bundle. + + PyInstaller sets the '_MEIPASS' attribute on the sys module to point + to the temporary directory where bundled files are extracted. + + Returns + ------- + bool + True if running from a PyInstaller bundle, False otherwise. + """ + return hasattr(sys, "_MEIPASS") + + +def get_pyinstaller_lib_path() -> Optional[str]: + """ + Get the PyInstaller internal library path if running from a bundle. + + Returns + ------- + Optional[str] + The path to PyInstaller's internal libraries, or None if not in a bundle. + """ + if is_pyinstaller_bundle(): + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return os.path.join(meipass, "_internal") + return None + + +def _save_original_library_paths() -> None: + """Save original library path values before modification.""" + if _library_path_state["original_library_paths"] is None: + original_paths: Dict[str, str] = {} + for var in LIBRARY_PATH_VARS: + if var in os.environ: + original_paths[var] = os.environ[var] + _library_path_state["original_library_paths"] = original_paths + + +def _filter_pyinstaller_paths(path_value: str) -> str: + """ + Remove PyInstaller-related paths from a PATH-style environment variable. + + Parameters + ---------- + path_value : str + The original value of the path variable (colon or semicolon separated). + + Returns + ------- + str + The filtered path value with PyInstaller paths removed. + """ + meipass = getattr(sys, "_MEIPASS", None) + if not meipass: + return path_value + + separator = os.pathsep + paths = path_value.split(separator) + + # Filter out paths that are inside the PyInstaller bundle + # Using specific patterns to avoid filtering legitimate system paths + filtered_paths = [ + p for p in paths if not (p.startswith(meipass) or p.endswith("/_internal") or "dist/_internal" in p) + ] + + return separator.join(filtered_paths) + + +def isolate_library_paths_for_subprocess() -> None: + """ + Remove or filter PyInstaller-bundled library paths from the environment. + + This function should be called early in SAM CLI initialization when running + from a PyInstaller bundle. It ensures that external processes (npm, node, pip, etc.) + use system libraries instead of the bundled ones. + + This is safe to call because: + 1. Python and its C extensions are already loaded + 2. The bundled libraries have served their purpose for the main process + 3. External processes need system libraries for compatibility + + Note: This modifies os.environ directly, affecting all subprocess calls + that inherit the environment. + """ + if not is_pyinstaller_bundle(): + LOG.debug("Not running from PyInstaller bundle, skipping library path isolation") + return + + _save_original_library_paths() + + pyinstaller_path = get_pyinstaller_lib_path() + LOG.debug("Running from PyInstaller bundle at: %s", getattr(sys, "_MEIPASS", "unknown")) + LOG.debug("PyInstaller internal lib path: %s", pyinstaller_path) + + for var in LIBRARY_PATH_VARS: + if var in os.environ: + original_value = os.environ[var] + filtered_value = _filter_pyinstaller_paths(original_value) + + if filtered_value: + os.environ[var] = filtered_value + LOG.debug("Filtered %s: '%s' -> '%s'", var, original_value, filtered_value) + else: + del os.environ[var] + LOG.debug("Removed %s (was: '%s')", var, original_value) + + +def get_clean_env_for_subprocess(additional_vars_to_remove: Optional[List[str]] = None) -> Dict[str, str]: + """ + Get a copy of the current environment with library paths cleaned for subprocess use. + + This is useful when you need to pass an explicit environment to subprocess calls + rather than relying on inheritance from os.environ. + + Parameters + ---------- + additional_vars_to_remove : Optional[List[str]] + Additional environment variables to remove from the returned environment. + + Returns + ------- + Dict[str, str] + A copy of os.environ with library paths filtered/removed. + """ + env = os.environ.copy() + + if is_pyinstaller_bundle(): + for var in LIBRARY_PATH_VARS: + if var in env: + filtered = _filter_pyinstaller_paths(env[var]) + if filtered: + env[var] = filtered + else: + del env[var] + + if additional_vars_to_remove: + for var in additional_vars_to_remove: + env.pop(var, None) + + return env + + +def get_original_library_paths() -> Dict[str, str]: + """ + Get the original library path values before isolation was applied. + + This can be useful for debugging or if restoration is ever needed. + + Returns + ------- + Dict[str, str] + Dictionary of original library path environment variables. + """ + original = _library_path_state["original_library_paths"] + return dict(original) if original else {} diff --git a/tests/unit/lib/utils/test_subprocess_env.py b/tests/unit/lib/utils/test_subprocess_env.py new file mode 100644 index 00000000000..19300266816 --- /dev/null +++ b/tests/unit/lib/utils/test_subprocess_env.py @@ -0,0 +1,255 @@ +""" +Tests for PyInstaller library path isolation in samcli.lib.utils.subprocess_utils module. +""" + +import os +from unittest import TestCase +from unittest.mock import patch + +from samcli.lib.utils.subprocess_utils import ( + LIBRARY_PATH_VARS, + _filter_pyinstaller_paths, + _library_path_state, + get_clean_env_for_subprocess, + get_original_library_paths, + get_pyinstaller_lib_path, + is_pyinstaller_bundle, + isolate_library_paths_for_subprocess, +) + + +class TestIsPyinstallerBundle(TestCase): + """Tests for is_pyinstaller_bundle function.""" + + def test_returns_false_when_not_bundled(self): + """Should return False when _MEIPASS is not set.""" + with patch("samcli.lib.utils.subprocess_utils.sys") as mock_sys: + del mock_sys._MEIPASS + mock_sys.configure_mock(**{"_MEIPASS": None}) + # hasattr will return False if we use spec + mock_sys_no_meipass = type("MockSys", (), {})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys_no_meipass): + result = is_pyinstaller_bundle() + self.assertFalse(result) + + def test_returns_true_when_bundled(self): + """Should return True when _MEIPASS is set.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/pyinstaller_bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + result = is_pyinstaller_bundle() + self.assertTrue(result) + + +class TestGetPyinstallerLibPath(TestCase): + """Tests for get_pyinstaller_lib_path function.""" + + def test_returns_none_when_not_bundled(self): + """Should return None when not running from PyInstaller bundle.""" + mock_sys = type("MockSys", (), {})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + result = get_pyinstaller_lib_path() + self.assertIsNone(result) + + def test_returns_internal_path_when_bundled(self): + """Should return _internal path when running from bundle.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + result = get_pyinstaller_lib_path() + self.assertEqual(result, os.path.join("/tmp/bundle", "_internal")) + + +class TestFilterPyinstallerPaths(TestCase): + """Tests for _filter_pyinstaller_paths function.""" + + def test_returns_unchanged_when_not_bundled(self): + """Should return path unchanged when not in PyInstaller bundle.""" + mock_sys = type("MockSys", (), {})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = "/usr/lib:/usr/local/lib" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, path_value) + + def test_filters_meipass_paths(self): + """Should filter out paths starting with _MEIPASS.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = f"/tmp/bundle/lib{os.pathsep}/usr/lib{os.pathsep}/tmp/bundle/_internal" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, "/usr/lib") + + def test_filters_internal_paths_ending_with_internal(self): + """Should filter out paths ending with /_internal.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = f"/some/path/_internal{os.pathsep}/usr/lib" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, "/usr/lib") + + def test_preserves_internal_in_middle_of_path(self): + """Should preserve paths that have _internal in the middle (not ending with it).""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = f"/usr/lib/some_internal_lib{os.pathsep}/usr/lib" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, f"/usr/lib/some_internal_lib{os.pathsep}/usr/lib") + + def test_filters_dist_internal_paths(self): + """Should filter out paths containing dist/_internal.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = f"/usr/local/aws-sam-cli/dist/_internal{os.pathsep}/usr/lib" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, "/usr/lib") + + def test_returns_empty_when_all_filtered(self): + """Should return empty string when all paths are filtered.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + path_value = f"/tmp/bundle/lib{os.pathsep}/tmp/bundle/_internal" + result = _filter_pyinstaller_paths(path_value) + self.assertEqual(result, "") + + +class TestIsolateLibraryPathsForSubprocess(TestCase): + """Tests for isolate_library_paths_for_subprocess function.""" + + def setUp(self): + """Reset state before each test.""" + _library_path_state["original_library_paths"] = None + + def test_does_nothing_when_not_bundled(self): + """Should not modify environment when not running from bundle.""" + mock_sys = type("MockSys", (), {})() + original_env = os.environ.copy() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + isolate_library_paths_for_subprocess() + # Environment should be unchanged + for var in LIBRARY_PATH_VARS: + self.assertEqual(os.environ.get(var), original_env.get(var)) + + def test_filters_library_paths_when_bundled(self): + """Should filter library paths when running from bundle.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + test_var = "LD_LIBRARY_PATH" + original_value = f"/tmp/bundle/_internal{os.pathsep}/usr/lib" + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + with patch.dict(os.environ, {test_var: original_value}, clear=False): + isolate_library_paths_for_subprocess() + self.assertEqual(os.environ.get(test_var), "/usr/lib") + + def test_removes_var_when_all_paths_filtered(self): + """Should remove env var when all paths are PyInstaller paths.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + test_var = "LD_LIBRARY_PATH" + original_value = "/tmp/bundle/_internal" + + _library_path_state["original_library_paths"] = None + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + env_backup = os.environ.get(test_var) + try: + os.environ[test_var] = original_value + isolate_library_paths_for_subprocess() + self.assertNotIn(test_var, os.environ) + finally: + if env_backup: + os.environ[test_var] = env_backup + elif test_var in os.environ: + del os.environ[test_var] + + def test_saves_original_paths(self): + """Should save original paths before modification.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + test_var = "LD_LIBRARY_PATH" + original_value = f"/tmp/bundle/_internal{os.pathsep}/usr/lib" + + _library_path_state["original_library_paths"] = None + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + env_backup = os.environ.get(test_var) + try: + os.environ[test_var] = original_value + isolate_library_paths_for_subprocess() + saved_paths = get_original_library_paths() + self.assertEqual(saved_paths.get(test_var), original_value) + finally: + if env_backup: + os.environ[test_var] = env_backup + elif test_var in os.environ: + del os.environ[test_var] + + +class TestGetCleanEnvForSubprocess(TestCase): + """Tests for get_clean_env_for_subprocess function.""" + + def test_returns_copy_of_environ(self): + """Should return a copy of os.environ.""" + mock_sys = type("MockSys", (), {})() + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + env = get_clean_env_for_subprocess() + self.assertIsInstance(env, dict) + # Should be a copy, not the same object + self.assertIsNot(env, os.environ) + + def test_filters_library_paths_when_bundled(self): + """Should filter library paths when running from bundle.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + test_var = "LD_LIBRARY_PATH" + original_value = f"/tmp/bundle/_internal{os.pathsep}/usr/lib" + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + with patch.dict(os.environ, {test_var: original_value}, clear=False): + env = get_clean_env_for_subprocess() + self.assertEqual(env.get(test_var), "/usr/lib") + + def test_removes_additional_vars(self): + """Should remove additional specified variables.""" + mock_sys = type("MockSys", (), {})() + test_var = "MY_CUSTOM_VAR" + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + with patch.dict(os.environ, {test_var: "some_value"}, clear=False): + env = get_clean_env_for_subprocess(additional_vars_to_remove=[test_var]) + self.assertNotIn(test_var, env) + + def test_handles_missing_additional_vars(self): + """Should not fail when additional vars to remove don't exist.""" + mock_sys = type("MockSys", (), {})() + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + # Should not raise + env = get_clean_env_for_subprocess(additional_vars_to_remove=["NONEXISTENT_VAR"]) + self.assertIsInstance(env, dict) + + def test_removes_var_when_all_paths_filtered_in_env_copy(self): + """Should remove env var from copy when all paths are PyInstaller paths.""" + mock_sys = type("MockSys", (), {"_MEIPASS": "/tmp/bundle"})() + test_var = "LD_LIBRARY_PATH" + original_value = "/tmp/bundle/_internal" + + with patch("samcli.lib.utils.subprocess_utils.sys", mock_sys): + with patch.dict(os.environ, {test_var: original_value}, clear=False): + env = get_clean_env_for_subprocess() + self.assertNotIn(test_var, env) + # Original env should still have the value + self.assertIn(test_var, os.environ) + + +class TestGetOriginalLibraryPaths(TestCase): + """Tests for get_original_library_paths function.""" + + def test_returns_empty_dict_when_no_original(self): + """Should return empty dict when no original paths saved.""" + _library_path_state["original_library_paths"] = None + result = get_original_library_paths() + self.assertEqual(result, {}) + + def test_returns_copy_of_original_paths(self): + """Should return a copy of original paths.""" + original = {"LD_LIBRARY_PATH": "/some/path"} + _library_path_state["original_library_paths"] = original + result = get_original_library_paths() + self.assertEqual(result, original) + # Should be a copy + self.assertIsNot(result, original) From b0b8e0fece32a5a0b00aa79d096ec904988e85f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:49:17 -0800 Subject: [PATCH 4/7] chore(deps): bump pycparser from 2.22 to 3.0 in /requirements (#8619) Bumps [pycparser](https://github.com/eliben/pycparser) from 2.22 to 3.0. - [Release notes](https://github.com/eliben/pycparser/releases) - [Commits](https://github.com/eliben/pycparser/compare/release_v2.22...release_v3.00) --- updated-dependencies: - dependency-name: pycparser dependency-version: '3.0' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/reproducible-linux.txt | 6 +++--- requirements/reproducible-mac.txt | 6 +++--- requirements/reproducible-win.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/reproducible-linux.txt b/requirements/reproducible-linux.txt index ea8910dd705..3b45de7386e 100644 --- a/requirements/reproducible-linux.txt +++ b/requirements/reproducible-linux.txt @@ -595,9 +595,9 @@ networkx==3.6.1 \ --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 # via cfn-lint -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi pydantic==2.12.5 \ --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ diff --git a/requirements/reproducible-mac.txt b/requirements/reproducible-mac.txt index 7254a7cfd3e..75ae21ea165 100644 --- a/requirements/reproducible-mac.txt +++ b/requirements/reproducible-mac.txt @@ -595,9 +595,9 @@ networkx==3.6.1 \ --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 # via cfn-lint -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi pydantic==2.12.5 \ --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ diff --git a/requirements/reproducible-win.txt b/requirements/reproducible-win.txt index 71ae76f53a9..669e4978c6c 100644 --- a/requirements/reproducible-win.txt +++ b/requirements/reproducible-win.txt @@ -599,9 +599,9 @@ networkx==3.6.1 \ --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 # via cfn-lint -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi pydantic==2.12.5 \ --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ From b77f53b5e262387ecfb87b1ce41e8f4b4238c295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:20:18 -0800 Subject: [PATCH 5/7] chore(deps): bump aws-actions/configure-aws-credentials from 5 to 6 (#8629) Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from 5 to 6. - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) --- updated-dependencies: - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 80205f93243..65fa7a137a3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -105,7 +105,7 @@ jobs: fi - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} aws-region: us-east-1 From 42be3c77e6676fcf64bc9faedb7fa54370580da3 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 01:35:05 +0000 Subject: [PATCH 6/7] fix: replace symlinks with dirs for Finch container mount compatibility When build_in_source is used with Node.js, the build directory contains a node_modules symlink. During local invoke, SAM CLI resolves this symlink and creates an additional bind mount. Docker tolerates creating a mountpoint over a symlink, but Finch (containerd/runc) fails with 'not a directory'. This fix temporarily replaces symlinks with empty directories before container creation, then restores them afterward. This ensures: - Finch/runc gets a valid directory mountpoint - Docker continues to work as before - Repeated invocations work because symlinks are restored - Host filesystem is left unchanged even if container creation fails --- .../workflows/verify-finch-symlink-fix.yml | 188 ++++++++++++++++++ samcli/local/docker/container.py | 41 ++++ 2 files changed, 229 insertions(+) create mode 100644 .github/workflows/verify-finch-symlink-fix.yml diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml new file mode 100644 index 00000000000..f4c93665975 --- /dev/null +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -0,0 +1,188 @@ +name: Verify Finch Symlink Mount Fix + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_DEFAULT_REGION: us-east-1 + SAM_CLI_DEV: 1 + SAM_CLI_TELEMETRY: 0 + SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true + UV_PYTHON: python3.11 + CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} + ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} + +jobs: + verify-symlink-fix: + name: build-in-source invoke (${{ matrix.container_runtime }}) + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + container_runtime: [docker, finch] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Free up disk space + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + docker system prune -af --volumes || true + else + nohup docker system prune -af --volumes || true & + fi + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} + aws-region: us-east-1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Setup Finch runtime + if: matrix.container_runtime == 'finch' + run: | + echo "=== Initializing Finch runtime ===" + sudo systemctl stop docker || true + sudo systemctl stop docker.socket || true + sudo systemctl disable docker || true + sudo systemctl disable docker.socket || true + + for i in {1..3}; do + if curl -fsSL https://artifact.runfinch.com/deb/GPG_KEY.pub | sudo gpg --dearmor -o /usr/share/keyrings/runfinch-finch-archive-keyring.gpg; then + break + fi + sleep 10 + done + + echo 'deb [signed-by=/usr/share/keyrings/runfinch-finch-archive-keyring.gpg arch=amd64] https://artifact.runfinch.com/deb noble main' | sudo tee /etc/apt/sources.list.d/runfinch-finch.list + sudo apt update + sudo apt install -y runfinch-finch + sudo systemctl enable --now finch + sudo systemctl enable --now finch-buildkit + sleep 3 + sudo chmod 666 /var/run/finch.sock + + for i in {1..12}; do + if sudo finch info >/dev/null 2>&1; then + break + fi + sleep 5 + done + + sudo mkdir -p /run/buildkit-finch /run/buildkit-default /run/buildkit + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-finch/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-default/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit/buildkitd.sock + sudo chmod 666 /var/lib/finch/buildkit/buildkitd.sock + sudo chmod 666 /run/buildkit-*/buildkitd.sock + sudo finch run --privileged --rm tonistiigi/binfmt:master --install all + + sudo finch info + sudo finch version + + - name: Setup Docker runtime + if: matrix.container_runtime == 'docker' + run: | + echo "=== Initializing Docker runtime ===" + docker info + docker version + + - name: Initialize project + run: | + export CONTAINER_RUNTIME=${{ matrix.container_runtime }} + make init + + - name: Get testing resources and credentials + run: | + test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) + + if [ $? -ne 0 ]; then + echo "First attempt with skip_role_deletion failed, trying without parameter..." + test_env_var=$(python3.11 tests/get_testing_resources.py) + + if [ $? -ne 0 ]; then + echo "get_testing_resources failed. Failed to acquire credentials or test resources." + exit 1 + fi + fi + + echo "::add-mask::$AWS_ACCESS_KEY_ID" + echo "::add-mask::$AWS_SECRET_ACCESS_KEY" + echo "::add-mask::$AWS_SESSION_TOKEN" + echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV + + TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") + TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") + TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") + TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") + + echo "::add-mask::$TEST_ACCESS_KEY_ID" + echo "::add-mask::$TEST_SECRET_ACCESS_KEY" + echo "::add-mask::$TEST_SESSION_TOKEN" + echo "::add-mask::$TEST_TASK_TOKEN" + + echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV + echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV + + echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV + echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV + + - name: Login to Public ECR + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + aws ecr-public get-login-password --region us-east-1 | sudo finch login --username AWS --password-stdin public.ecr.aws + else + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + fi + + - name: Run build-in-source invoke tests + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + export CONTAINER_RUNTIME="finch" + fi + + pytest -vv --reruns 3 tests/integration/local/invoke/test_invoke_build_in_source.py \ + --json-report \ + --json-report-file=TEST_REPORT-build-in-source-${{ matrix.container_runtime }}.json + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results-build-in-source-${{ matrix.container_runtime }} + path: TEST_REPORT-*.json + + - name: Reset test account + if: always() + run: | + export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID + export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY + export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN + + aws lambda invoke \ + --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ + --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ + ./lambda-output.txt \ + --region us-west-2 \ + --cli-binary-format raw-in-base64-out + + cat ./lambda-output.txt diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index 6ef37b6b1e4..d73b7dda7b5 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -160,6 +160,7 @@ def __init__( self._host_tmp_dir = host_tmp_dir self._mount_symlinks = mount_symlinks self.debug_options = debug_options + self._replaced_symlinks: list = [] # Track symlinks replaced with dirs for restore after container creation # Container-level concurrency management self._concurrency_semaphore: Optional[threading.Semaphore] = None # Controls concurrent Lambda executions self._max_concurrency: int = 1 # Default to 1 for normal functions @@ -286,6 +287,11 @@ def create(self, context): raise DockerContainerCreationFailedException( f"Container creation failed: {ex.explanation}, check template for potential issue" ) + finally: + # Restore any symlinks that were temporarily replaced with directories + # for container mount compatibility. This ensures the host filesystem + # remains unchanged for subsequent invocations. + self._restore_mapped_symlinks() self.id = real_container.id # Output container ID for test parsing @@ -347,6 +353,25 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]: "mode": mount_mode, } + # Replace the symlink with an empty directory so that container runtimes + # (e.g. Finch/containerd) can create a valid mountpoint. Without this, + # runc fails with "not a directory" when it encounters a symlink at the + # mount target path inside the container's rootfs. + # We record the original symlink target so it can be restored after + # container creation (see _restore_mapped_symlinks). + try: + symlink_path = file.path + symlink_target = os.readlink(symlink_path) + os.remove(symlink_path) + os.makedirs(symlink_path, exist_ok=True) + self._replaced_symlinks.append((symlink_path, symlink_target)) + LOG.debug( + "Replaced symlink at %s with empty directory for container mount compatibility", + symlink_path, + ) + except OSError: + LOG.debug("Failed to replace symlink at %s with directory", file.path, exc_info=True) + LOG.info( "Mounting resolved symlink (%s -> %s) as %s:%s, inside runtime container" % (file.path, host_resolved_path, container_full_path, mount_mode) @@ -354,6 +379,22 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]: return additional_volumes + def _restore_mapped_symlinks(self): + """ + Restores symlinks that were temporarily replaced with empty directories + by _create_mapped_symlink_files. This is called after container creation + so the host filesystem is left in its original state for subsequent invocations. + """ + for symlink_path, symlink_target in self._replaced_symlinks: + try: + if os.path.isdir(symlink_path) and not os.path.islink(symlink_path): + os.rmdir(symlink_path) + os.symlink(symlink_target, symlink_path) + LOG.debug("Restored symlink at %s -> %s", symlink_path, symlink_target) + except OSError: + LOG.debug("Failed to restore symlink at %s", symlink_path, exc_info=True) + self._replaced_symlinks.clear() + def stop(self, timeout=3): """ Stop a container, with a given number of seconds between sending SIGTERM and SIGKILL. From 3db71fe8b687caed660b715f572b86dfdbe23937 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 01:37:16 +0000 Subject: [PATCH 7/7] simplify verification workflow: use OIDC credentials directly --- .../workflows/verify-finch-symlink-fix.yml | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml index f4c93665975..e549dee2973 100644 --- a/.github/workflows/verify-finch-symlink-fix.yml +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -13,10 +13,7 @@ env: SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 NOSE_PARAMETERIZED_NO_WARN: 1 - BY_CANARY: true UV_PYTHON: python3.11 - CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} - ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} jobs: verify-symlink-fix: @@ -107,45 +104,6 @@ jobs: export CONTAINER_RUNTIME=${{ matrix.container_runtime }} make init - - name: Get testing resources and credentials - run: | - test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) - - if [ $? -ne 0 ]; then - echo "First attempt with skip_role_deletion failed, trying without parameter..." - test_env_var=$(python3.11 tests/get_testing_resources.py) - - if [ $? -ne 0 ]; then - echo "get_testing_resources failed. Failed to acquire credentials or test resources." - exit 1 - fi - fi - - echo "::add-mask::$AWS_ACCESS_KEY_ID" - echo "::add-mask::$AWS_SECRET_ACCESS_KEY" - echo "::add-mask::$AWS_SESSION_TOKEN" - echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV - - TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") - TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") - TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") - TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") - - echo "::add-mask::$TEST_ACCESS_KEY_ID" - echo "::add-mask::$TEST_SECRET_ACCESS_KEY" - echo "::add-mask::$TEST_SESSION_TOKEN" - echo "::add-mask::$TEST_TASK_TOKEN" - - echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV - echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV - - echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV - echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV - - name: Login to Public ECR run: | if [ "${{ matrix.container_runtime }}" = "finch" ]; then @@ -170,19 +128,3 @@ jobs: with: name: test-results-build-in-source-${{ matrix.container_runtime }} path: TEST_REPORT-*.json - - - name: Reset test account - if: always() - run: | - export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY - export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN - - aws lambda invoke \ - --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ - --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ - ./lambda-output.txt \ - --region us-west-2 \ - --cli-binary-format raw-in-base64-out - - cat ./lambda-output.txt