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 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 }} diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml new file mode 100644 index 00000000000..e549dee2973 --- /dev/null +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -0,0 +1,130 @@ +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 + UV_PYTHON: python3.11 + +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: 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 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 \ 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/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. 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") 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)