Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/notify-slack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
130 changes: 130 additions & 0 deletions .github/workflows/verify-finch-symlink-fix.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions requirements/reproducible-linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
6 changes: 3 additions & 3 deletions requirements/reproducible-mac.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
6 changes: 3 additions & 3 deletions requirements/reproducible-win.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
5 changes: 5 additions & 0 deletions samcli/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
175 changes: 174 additions & 1 deletion samcli/lib/utils/subprocess_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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 {}
Loading