Skip to content

Commit 19bef71

Browse files
authored
Merge pull request #6 from bnusunny/fix-finch-symlink-mount
fix: replace symlinks with dirs for Finch container mount compatibility
2 parents b51bdf6 + 3db71fe commit 19bef71

9 files changed

Lines changed: 615 additions & 11 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105
fi
106106
107107
- name: Configure AWS credentials via OIDC
108-
uses: aws-actions/configure-aws-credentials@v5
108+
uses: aws-actions/configure-aws-credentials@v6
109109
with:
110110
role-to-assume: ${{ secrets.OIDC_ROLE_ARN }}
111111
aws-region: us-east-1
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
name: Verify Finch Symlink Mount Fix
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
id-token: write
8+
contents: read
9+
10+
env:
11+
AWS_DEFAULT_REGION: us-east-1
12+
SAM_CLI_DEV: 1
13+
SAM_CLI_TELEMETRY: 0
14+
SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60
15+
NOSE_PARAMETERIZED_NO_WARN: 1
16+
UV_PYTHON: python3.11
17+
18+
jobs:
19+
verify-symlink-fix:
20+
name: build-in-source invoke (${{ matrix.container_runtime }})
21+
runs-on: ubuntu-22.04
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
container_runtime: [docker, finch]
26+
27+
steps:
28+
- name: Checkout code
29+
uses: actions/checkout@v6
30+
31+
- name: Free up disk space
32+
run: |
33+
if [ "${{ matrix.container_runtime }}" = "finch" ]; then
34+
docker system prune -af --volumes || true
35+
else
36+
nohup docker system prune -af --volumes || true &
37+
fi
38+
39+
- name: Configure AWS credentials via OIDC
40+
uses: aws-actions/configure-aws-credentials@v6
41+
with:
42+
role-to-assume: ${{ secrets.OIDC_ROLE_ARN }}
43+
aws-region: us-east-1
44+
45+
- name: Set up Python
46+
uses: actions/setup-python@v6
47+
with:
48+
python-version: "3.11"
49+
50+
- name: Install uv
51+
uses: astral-sh/setup-uv@v7
52+
53+
- name: Setup Finch runtime
54+
if: matrix.container_runtime == 'finch'
55+
run: |
56+
echo "=== Initializing Finch runtime ==="
57+
sudo systemctl stop docker || true
58+
sudo systemctl stop docker.socket || true
59+
sudo systemctl disable docker || true
60+
sudo systemctl disable docker.socket || true
61+
62+
for i in {1..3}; do
63+
if curl -fsSL https://artifact.runfinch.com/deb/GPG_KEY.pub | sudo gpg --dearmor -o /usr/share/keyrings/runfinch-finch-archive-keyring.gpg; then
64+
break
65+
fi
66+
sleep 10
67+
done
68+
69+
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
70+
sudo apt update
71+
sudo apt install -y runfinch-finch
72+
sudo systemctl enable --now finch
73+
sudo systemctl enable --now finch-buildkit
74+
sleep 3
75+
sudo chmod 666 /var/run/finch.sock
76+
77+
for i in {1..12}; do
78+
if sudo finch info >/dev/null 2>&1; then
79+
break
80+
fi
81+
sleep 5
82+
done
83+
84+
sudo mkdir -p /run/buildkit-finch /run/buildkit-default /run/buildkit
85+
sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-finch/buildkitd.sock
86+
sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-default/buildkitd.sock
87+
sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit/buildkitd.sock
88+
sudo chmod 666 /var/lib/finch/buildkit/buildkitd.sock
89+
sudo chmod 666 /run/buildkit-*/buildkitd.sock
90+
sudo finch run --privileged --rm tonistiigi/binfmt:master --install all
91+
92+
sudo finch info
93+
sudo finch version
94+
95+
- name: Setup Docker runtime
96+
if: matrix.container_runtime == 'docker'
97+
run: |
98+
echo "=== Initializing Docker runtime ==="
99+
docker info
100+
docker version
101+
102+
- name: Initialize project
103+
run: |
104+
export CONTAINER_RUNTIME=${{ matrix.container_runtime }}
105+
make init
106+
107+
- name: Login to Public ECR
108+
run: |
109+
if [ "${{ matrix.container_runtime }}" = "finch" ]; then
110+
aws ecr-public get-login-password --region us-east-1 | sudo finch login --username AWS --password-stdin public.ecr.aws
111+
else
112+
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws
113+
fi
114+
115+
- name: Run build-in-source invoke tests
116+
run: |
117+
if [ "${{ matrix.container_runtime }}" = "finch" ]; then
118+
export CONTAINER_RUNTIME="finch"
119+
fi
120+
121+
pytest -vv --reruns 3 tests/integration/local/invoke/test_invoke_build_in_source.py \
122+
--json-report \
123+
--json-report-file=TEST_REPORT-build-in-source-${{ matrix.container_runtime }}.json
124+
125+
- name: Upload test results
126+
if: always()
127+
uses: actions/upload-artifact@v6
128+
with:
129+
name: test-results-build-in-source-${{ matrix.container_runtime }}
130+
path: TEST_REPORT-*.json

requirements/reproducible-linux.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,9 @@ networkx==3.6.1 \
595595
--hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \
596596
--hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
597597
# via cfn-lint
598-
pycparser==2.22 \
599-
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
600-
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
598+
pycparser==3.0 \
599+
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
600+
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
601601
# via cffi
602602
pydantic==2.12.5 \
603603
--hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \

requirements/reproducible-mac.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,9 @@ networkx==3.6.1 \
595595
--hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \
596596
--hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
597597
# via cfn-lint
598-
pycparser==2.22 \
599-
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
600-
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
598+
pycparser==3.0 \
599+
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
600+
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
601601
# via cffi
602602
pydantic==2.12.5 \
603603
--hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \

requirements/reproducible-win.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,9 +599,9 @@ networkx==3.6.1 \
599599
--hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \
600600
--hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
601601
# via cfn-lint
602-
pycparser==2.22 \
603-
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
604-
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
602+
pycparser==3.0 \
603+
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
604+
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
605605
# via cffi
606606
pydantic==2.12.5 \
607607
--hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \

samcli/cli/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ def cli(ctx):
134134
import atexit
135135

136136
from samcli.lib.telemetry.metric import emit_all_metrics, send_installed_metric
137+
from samcli.lib.utils.subprocess_utils import isolate_library_paths_for_subprocess
138+
139+
# When running from PyInstaller bundle, isolate library paths so external processes
140+
# (npm, node, pip, etc.) use system libraries instead of bundled ones
141+
isolate_library_paths_for_subprocess()
137142

138143
# if development version of SAM CLI is used, attach module proxy
139144
# to catch missing configuration for dynamic/hidden imports

samcli/lib/utils/subprocess_utils.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,24 @@
99
from concurrent.futures.thread import ThreadPoolExecutor
1010
from subprocess import PIPE, STDOUT, Popen
1111
from time import sleep
12-
from typing import Any, AnyStr, Callable, Dict, Optional, Union
12+
from typing import Any, AnyStr, Callable, Dict, List, Optional, Union
1313

1414
from samcli.commands.exceptions import UserException
1515
from samcli.lib.utils.stream_writer import StreamWriter
1616

17+
# Environment variables that control library loading paths
18+
# These are set by PyInstaller and can cause conflicts with system binaries
19+
LIBRARY_PATH_VARS = [
20+
"LD_LIBRARY_PATH", # Linux
21+
"DYLD_LIBRARY_PATH", # macOS
22+
"DYLD_FALLBACK_LIBRARY_PATH", # macOS fallback
23+
"DYLD_FRAMEWORK_PATH", # macOS frameworks
24+
]
25+
26+
# Original library paths before cleanup (stored for debugging/restoration if needed)
27+
# Using a mutable container to avoid global statement (PLW0603)
28+
_library_path_state: Dict[str, Optional[Dict[str, str]]] = {"original_library_paths": None}
29+
1730
# These are the bytes that used as a prefix for a some string to color them in red to show errors.
1831
TERRAFORM_ERROR_PREFIX = [27, 91, 51, 49]
1932

@@ -148,3 +161,163 @@ def _check_and_process_bytes(check_value: AnyStr, preserve_whitespace=False) ->
148161
return decoded_value
149162
return decoded_value.strip()
150163
return check_value
164+
165+
166+
def is_pyinstaller_bundle() -> bool:
167+
"""
168+
Check if SAM CLI is running from a PyInstaller bundle.
169+
170+
PyInstaller sets the '_MEIPASS' attribute on the sys module to point
171+
to the temporary directory where bundled files are extracted.
172+
173+
Returns
174+
-------
175+
bool
176+
True if running from a PyInstaller bundle, False otherwise.
177+
"""
178+
return hasattr(sys, "_MEIPASS")
179+
180+
181+
def get_pyinstaller_lib_path() -> Optional[str]:
182+
"""
183+
Get the PyInstaller internal library path if running from a bundle.
184+
185+
Returns
186+
-------
187+
Optional[str]
188+
The path to PyInstaller's internal libraries, or None if not in a bundle.
189+
"""
190+
if is_pyinstaller_bundle():
191+
meipass = getattr(sys, "_MEIPASS", None)
192+
if meipass:
193+
return os.path.join(meipass, "_internal")
194+
return None
195+
196+
197+
def _save_original_library_paths() -> None:
198+
"""Save original library path values before modification."""
199+
if _library_path_state["original_library_paths"] is None:
200+
original_paths: Dict[str, str] = {}
201+
for var in LIBRARY_PATH_VARS:
202+
if var in os.environ:
203+
original_paths[var] = os.environ[var]
204+
_library_path_state["original_library_paths"] = original_paths
205+
206+
207+
def _filter_pyinstaller_paths(path_value: str) -> str:
208+
"""
209+
Remove PyInstaller-related paths from a PATH-style environment variable.
210+
211+
Parameters
212+
----------
213+
path_value : str
214+
The original value of the path variable (colon or semicolon separated).
215+
216+
Returns
217+
-------
218+
str
219+
The filtered path value with PyInstaller paths removed.
220+
"""
221+
meipass = getattr(sys, "_MEIPASS", None)
222+
if not meipass:
223+
return path_value
224+
225+
separator = os.pathsep
226+
paths = path_value.split(separator)
227+
228+
# Filter out paths that are inside the PyInstaller bundle
229+
# Using specific patterns to avoid filtering legitimate system paths
230+
filtered_paths = [
231+
p for p in paths if not (p.startswith(meipass) or p.endswith("/_internal") or "dist/_internal" in p)
232+
]
233+
234+
return separator.join(filtered_paths)
235+
236+
237+
def isolate_library_paths_for_subprocess() -> None:
238+
"""
239+
Remove or filter PyInstaller-bundled library paths from the environment.
240+
241+
This function should be called early in SAM CLI initialization when running
242+
from a PyInstaller bundle. It ensures that external processes (npm, node, pip, etc.)
243+
use system libraries instead of the bundled ones.
244+
245+
This is safe to call because:
246+
1. Python and its C extensions are already loaded
247+
2. The bundled libraries have served their purpose for the main process
248+
3. External processes need system libraries for compatibility
249+
250+
Note: This modifies os.environ directly, affecting all subprocess calls
251+
that inherit the environment.
252+
"""
253+
if not is_pyinstaller_bundle():
254+
LOG.debug("Not running from PyInstaller bundle, skipping library path isolation")
255+
return
256+
257+
_save_original_library_paths()
258+
259+
pyinstaller_path = get_pyinstaller_lib_path()
260+
LOG.debug("Running from PyInstaller bundle at: %s", getattr(sys, "_MEIPASS", "unknown"))
261+
LOG.debug("PyInstaller internal lib path: %s", pyinstaller_path)
262+
263+
for var in LIBRARY_PATH_VARS:
264+
if var in os.environ:
265+
original_value = os.environ[var]
266+
filtered_value = _filter_pyinstaller_paths(original_value)
267+
268+
if filtered_value:
269+
os.environ[var] = filtered_value
270+
LOG.debug("Filtered %s: '%s' -> '%s'", var, original_value, filtered_value)
271+
else:
272+
del os.environ[var]
273+
LOG.debug("Removed %s (was: '%s')", var, original_value)
274+
275+
276+
def get_clean_env_for_subprocess(additional_vars_to_remove: Optional[List[str]] = None) -> Dict[str, str]:
277+
"""
278+
Get a copy of the current environment with library paths cleaned for subprocess use.
279+
280+
This is useful when you need to pass an explicit environment to subprocess calls
281+
rather than relying on inheritance from os.environ.
282+
283+
Parameters
284+
----------
285+
additional_vars_to_remove : Optional[List[str]]
286+
Additional environment variables to remove from the returned environment.
287+
288+
Returns
289+
-------
290+
Dict[str, str]
291+
A copy of os.environ with library paths filtered/removed.
292+
"""
293+
env = os.environ.copy()
294+
295+
if is_pyinstaller_bundle():
296+
for var in LIBRARY_PATH_VARS:
297+
if var in env:
298+
filtered = _filter_pyinstaller_paths(env[var])
299+
if filtered:
300+
env[var] = filtered
301+
else:
302+
del env[var]
303+
304+
if additional_vars_to_remove:
305+
for var in additional_vars_to_remove:
306+
env.pop(var, None)
307+
308+
return env
309+
310+
311+
def get_original_library_paths() -> Dict[str, str]:
312+
"""
313+
Get the original library path values before isolation was applied.
314+
315+
This can be useful for debugging or if restoration is ever needed.
316+
317+
Returns
318+
-------
319+
Dict[str, str]
320+
Dictionary of original library path environment variables.
321+
"""
322+
original = _library_path_state["original_library_paths"]
323+
return dict(original) if original else {}

0 commit comments

Comments
 (0)