Skip to content

Commit c3d64cd

Browse files
authored
fix: build emulator image and copy binary to it (#8484)
1 parent 4cb46c0 commit c3d64cd

4 files changed

Lines changed: 242 additions & 23 deletions

File tree

samcli/local/docker/durable_functions_emulator_container.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
from http import HTTPStatus
99
from pathlib import Path
10+
from tempfile import NamedTemporaryFile
1011
from typing import Optional
1112

1213
import docker
@@ -15,7 +16,8 @@
1516

1617
from samcli.lib.build.utils import _get_host_architecture
1718
from samcli.lib.clients.lambda_client import DurableFunctionsClient
18-
from samcli.local.docker.utils import get_validated_container_client, is_image_current
19+
from samcli.lib.utils.tar import create_tarball
20+
from samcli.local.docker.utils import get_tar_filter_for_windows, get_validated_container_client, is_image_current
1921

2022
LOG = logging.getLogger(__name__)
2123

@@ -27,6 +29,7 @@ class DurableFunctionsEmulatorContainer:
2729

2830
_RAPID_SOURCE_PATH = Path(__file__).parent.joinpath("..", "rapid").resolve()
2931
_EMULATOR_IMAGE = "public.ecr.aws/ubuntu/ubuntu:24.04"
32+
_EMULATOR_IMAGE_PREFIX = "samcli/durable-execution-emulator"
3033
_CONTAINER_NAME = "sam-durable-execution-emulator"
3134
_EMULATOR_DATA_DIR_NAME = ".durable-executions-local"
3235
_EMULATOR_DEFAULT_STORE_TYPE = "sqlite"
@@ -190,6 +193,62 @@ def _get_emulator_binary_name(self):
190193
arch = _get_host_architecture()
191194
return f"aws-durable-execution-emulator-{arch}"
192195

196+
def _generate_emulator_dockerfile(self, emulator_binary_name: str) -> str:
197+
"""Generate Dockerfile content for emulator image."""
198+
return (
199+
f"FROM {self._EMULATOR_IMAGE}\n"
200+
f"COPY {emulator_binary_name} /usr/local/bin/{emulator_binary_name}\n"
201+
f"RUN chmod +x /usr/local/bin/{emulator_binary_name}\n"
202+
)
203+
204+
def _get_emulator_image_tag(self, emulator_binary_name: str) -> str:
205+
"""Get the Docker image tag for the emulator."""
206+
return f"{self._EMULATOR_IMAGE_PREFIX}:{emulator_binary_name}"
207+
208+
def _build_emulator_image(self):
209+
"""Build Docker image with emulator binary."""
210+
emulator_binary_name = self._get_emulator_binary_name()
211+
binary_path = self._RAPID_SOURCE_PATH / emulator_binary_name
212+
213+
if not binary_path.exists():
214+
raise RuntimeError(f"Durable Functions Emulator binary not found at {binary_path}")
215+
216+
image_tag = self._get_emulator_image_tag(emulator_binary_name)
217+
218+
# Check if image already exists
219+
try:
220+
self._docker_client.images.get(image_tag)
221+
LOG.debug(f"Emulator image {image_tag} already exists")
222+
return image_tag
223+
except docker.errors.ImageNotFound:
224+
LOG.debug(f"Building emulator image {image_tag}")
225+
226+
# Generate Dockerfile content
227+
dockerfile_content = self._generate_emulator_dockerfile(emulator_binary_name)
228+
229+
# Write Dockerfile to temp location and build image
230+
with NamedTemporaryFile(mode="w", suffix="_Dockerfile") as dockerfile:
231+
dockerfile.write(dockerfile_content)
232+
dockerfile.flush()
233+
234+
# Prepare tar paths for build context
235+
tar_paths = {
236+
dockerfile.name: "Dockerfile",
237+
str(binary_path): emulator_binary_name,
238+
}
239+
240+
# Use shared tar filter for Windows compatibility
241+
tar_filter = get_tar_filter_for_windows()
242+
243+
# Build image using create_tarball utility
244+
with create_tarball(tar_paths, tar_filter=tar_filter, dereference=True) as tarballfile:
245+
try:
246+
self._docker_client.images.build(fileobj=tarballfile, custom_context=True, tag=image_tag, rm=True)
247+
LOG.info(f"Built emulator image {image_tag}")
248+
return image_tag
249+
except Exception as e:
250+
raise ClickException(f"Failed to build emulator image: {e}")
251+
193252
def _pull_image_if_needed(self):
194253
"""Pull the emulator image if it doesn't exist locally or is out of date."""
195254
try:
@@ -218,9 +277,6 @@ def start(self):
218277
return
219278

220279
emulator_binary_name = self._get_emulator_binary_name()
221-
binary_path = self._RAPID_SOURCE_PATH / emulator_binary_name
222-
if not binary_path.exists():
223-
raise RuntimeError(f"Durable Functions Emulator binary not found at {binary_path}")
224280

225281
"""
226282
Create persistent volume for execution data to be stored in.
@@ -231,16 +287,15 @@ def start(self):
231287
os.makedirs(emulator_data_dir, exist_ok=True)
232288

233289
volumes = {
234-
str(self._RAPID_SOURCE_PATH): {"bind": "/usr/local/bin", "mode": "ro"},
235290
emulator_data_dir: {"bind": "/tmp/.durable-executions-local", "mode": "rw"},
236291
}
237292

238-
# Pull the image if needed
239-
self._pull_image_if_needed()
293+
# Build image with emulator binary
294+
image_tag = self._build_emulator_image()
240295

241296
LOG.debug(f"Creating container with name={self._container_name}, port={self.port}")
242297
self.container = self._docker_client.containers.create(
243-
image=self._EMULATOR_IMAGE,
298+
image=image_tag,
244299
command=[f"/usr/local/bin/{emulator_binary_name}", "--host", "0.0.0.0", "--port", str(self.port)],
245300
name=self._container_name,
246301
ports={f"{self.port}/tcp": self.port},

samcli/local/docker/lambda_image.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626
from samcli.lib.utils.stream_writer import StreamWriter
2727
from samcli.lib.utils.tar import create_tarball
2828
from samcli.local.common.file_lock import FileLock, cleanup_stale_locks
29-
from samcli.local.docker.utils import get_docker_platform, get_rapid_name, get_validated_container_client
29+
from samcli.local.docker.utils import (
30+
get_docker_platform,
31+
get_rapid_name,
32+
get_tar_filter_for_windows,
33+
get_validated_container_client,
34+
)
3035

3136
LOG = logging.getLogger(__name__)
3237

@@ -409,15 +414,8 @@ def _build_image(self, base_image, docker_tag, layers, architecture, stream=None
409414
for layer in layers:
410415
tar_paths[layer.codeuri] = "/" + layer.name
411416

412-
# Set permission for all the files in the tarball to 500(Read and Execute Only)
413-
# This is need for systems without unix like permission bits(Windows) while creating a unix image
414-
# Without setting this explicitly, tar will default the permission to 666 which gives no execute permission
415-
def set_item_permission(tar_info):
416-
tar_info.mode = 0o500
417-
return tar_info
418-
419-
# Set only on Windows, unix systems will preserve the host permission into the tarball
420-
tar_filter = set_item_permission if platform.system().lower() == "windows" else None
417+
# Use shared tar filter for Windows compatibility
418+
tar_filter = get_tar_filter_for_windows()
421419

422420
with create_tarball(tar_paths, tar_filter=tar_filter, dereference=True) as tarballfile:
423421
try:

samcli/local/docker/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import pathlib
8+
import platform
89
import posixpath
910
import random
1011
import re
@@ -138,6 +139,27 @@ def get_validated_container_client():
138139
return ContainerClientFactory.create_client()
139140

140141

142+
def get_tar_filter_for_windows():
143+
"""
144+
Get tar filter function for Windows compatibility.
145+
146+
Sets permission for all files in the tarball to 500 (Read and Execute Only).
147+
This is needed for systems without unix-like permission bits (Windows) while creating a unix image.
148+
Without setting this explicitly, tar will default the permission to 666 which gives no execute permission.
149+
150+
Returns
151+
-------
152+
callable or None
153+
Filter function for Windows, None for Unix systems
154+
"""
155+
156+
def set_item_permission(tar_info):
157+
tar_info.mode = 0o500
158+
return tar_info
159+
160+
return set_item_permission if platform.system().lower() == "windows" else None
161+
162+
141163
def is_image_current(docker_client: docker.DockerClient, image_name: str) -> bool:
142164
"""
143165
Check if local image is up-to-date with remote by comparing digests.

tests/unit/local/docker/test_durable_functions_emulator_container.py

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,28 +248,46 @@ def test_binary_selection_by_architecture(self, arch, expected_binary, mock_get_
248248
),
249249
]
250250
)
251-
@patch("samcli.local.docker.durable_functions_emulator_container.is_image_current")
251+
@patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture")
252252
@patch("os.makedirs")
253253
@patch("os.getcwd")
254+
@patch("pathlib.Path.exists")
254255
def test_create_container(
255-
self, name, env_vars, expected_port, expected_store, expected_scale, mock_getcwd, mock_makedirs, mock_is_current
256+
self,
257+
name,
258+
env_vars,
259+
expected_port,
260+
expected_store,
261+
expected_scale,
262+
mock_path_exists,
263+
mock_getcwd,
264+
mock_makedirs,
265+
mock_get_host_arch,
256266
):
257267
"""Test container creation with all configuration permutations"""
258-
mock_is_current.return_value = True
268+
mock_get_host_arch.return_value = "x86_64"
259269
test_dir = "/test/dir"
260270
mock_getcwd.return_value = test_dir
271+
mock_path_exists.return_value = True
272+
273+
# Mock image already exists
274+
mock_image = Mock()
275+
self.mock_docker_client.images.get.return_value = mock_image
261276

262277
with patch.dict("os.environ", env_vars, clear=True):
263278
container = self._create_container()
279+
container._RAPID_SOURCE_PATH = Path(__file__).parent
264280
container._wait_for_ready = Mock()
265281
container.start()
266282

267283
# Verify container was created
268284
self.mock_docker_client.containers.create.assert_called_once()
269285
call_args = self.mock_docker_client.containers.create.call_args
270286

271-
# Verify image and working directory
272-
self.assertEqual(call_args.kwargs["image"], DurableFunctionsEmulatorContainer._EMULATOR_IMAGE)
287+
# Verify built image is used
288+
self.assertEqual(
289+
call_args.kwargs["image"], "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64"
290+
)
273291
self.assertEqual(call_args.kwargs["working_dir"], "/tmp/.durable-executions-local")
274292

275293
# Verify port configuration
@@ -306,6 +324,132 @@ def test_start_raises_error_when_binary_not_found(self):
306324
container.start()
307325
self.assertIn("Durable Functions Emulator binary not found", str(context.exception))
308326

327+
@parameterized.expand(
328+
[
329+
(
330+
"x86_64",
331+
"aws-durable-execution-emulator-x86_64",
332+
"samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64",
333+
),
334+
(
335+
"arm64",
336+
"aws-durable-execution-emulator-arm64",
337+
"samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64",
338+
),
339+
]
340+
)
341+
@patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture")
342+
@patch("samcli.local.docker.durable_functions_emulator_container.create_tarball")
343+
@patch("samcli.local.docker.durable_functions_emulator_container.get_tar_filter_for_windows")
344+
@patch("builtins.open", new_callable=mock_open)
345+
@patch("os.unlink")
346+
@patch("pathlib.Path.exists")
347+
def test_build_emulator_image_creates_new_image(
348+
self,
349+
arch,
350+
binary_name,
351+
expected_tag,
352+
mock_path_exists,
353+
mock_unlink,
354+
mock_file,
355+
mock_tar_filter,
356+
mock_create_tarball,
357+
mock_get_host_arch,
358+
):
359+
"""Test building emulator image when it doesn't exist, including dockerfile generation and image tag"""
360+
mock_get_host_arch.return_value = arch
361+
mock_tar_filter.return_value = None
362+
mock_tarball = Mock()
363+
mock_create_tarball.return_value.__enter__.return_value = mock_tarball
364+
mock_path_exists.return_value = True
365+
366+
# Mock image doesn't exist
367+
self.mock_docker_client.images.get.side_effect = docker.errors.ImageNotFound("not found")
368+
mock_build_result = Mock()
369+
self.mock_docker_client.images.build.return_value = mock_build_result
370+
371+
container = self._create_container()
372+
container._RAPID_SOURCE_PATH = Path(__file__).parent
373+
374+
result = container._build_emulator_image()
375+
376+
# Verify image tag generation
377+
self.assertEqual(result, expected_tag)
378+
tag = container._get_emulator_image_tag(binary_name)
379+
self.assertEqual(tag, expected_tag)
380+
381+
# Verify dockerfile generation
382+
dockerfile = container._generate_emulator_dockerfile(binary_name)
383+
self.assertIn(f"FROM {container._EMULATOR_IMAGE}", dockerfile)
384+
self.assertIn(f"COPY {binary_name} /usr/local/bin/{binary_name}", dockerfile)
385+
self.assertIn(f"RUN chmod +x /usr/local/bin/{binary_name}", dockerfile)
386+
387+
# Verify image was built
388+
self.mock_docker_client.images.build.assert_called_once()
389+
build_call = self.mock_docker_client.images.build.call_args
390+
self.assertEqual(build_call.kwargs["tag"], expected_tag)
391+
self.assertTrue(build_call.kwargs["rm"])
392+
self.assertTrue(build_call.kwargs["custom_context"])
393+
394+
# Verify tarball was created with correct filter
395+
mock_create_tarball.assert_called_once()
396+
397+
@parameterized.expand(
398+
[
399+
("x86_64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64"),
400+
("arm64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64"),
401+
]
402+
)
403+
@patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture")
404+
@patch("pathlib.Path.exists")
405+
def test_build_emulator_image_reuses_existing(self, arch, expected_tag, mock_path_exists, mock_get_host_arch):
406+
"""Test that existing image is reused without rebuilding"""
407+
mock_get_host_arch.return_value = arch
408+
mock_path_exists.return_value = True
409+
mock_image = Mock()
410+
self.mock_docker_client.images.get.return_value = mock_image
411+
412+
container = self._create_container()
413+
container._RAPID_SOURCE_PATH = Path(__file__).parent
414+
415+
result = container._build_emulator_image()
416+
417+
# Verify image was not built
418+
self.mock_docker_client.images.build.assert_not_called()
419+
self.assertEqual(result, expected_tag)
420+
421+
@parameterized.expand(
422+
[
423+
("x86_64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-x86_64"),
424+
("arm64", "samcli/durable-execution-emulator:aws-durable-execution-emulator-arm64"),
425+
]
426+
)
427+
@patch("samcli.local.docker.durable_functions_emulator_container._get_host_architecture")
428+
@patch("os.makedirs")
429+
@patch("os.getcwd")
430+
@patch("pathlib.Path.exists")
431+
def test_start_uses_built_image(
432+
self, arch, expected_tag, mock_path_exists, mock_getcwd, mock_makedirs, mock_get_host_arch
433+
):
434+
"""Test that start() uses the built image instead of base image"""
435+
mock_get_host_arch.return_value = arch
436+
mock_getcwd.return_value = "/test/dir"
437+
mock_path_exists.return_value = True
438+
439+
# Mock image already exists
440+
mock_image = Mock()
441+
self.mock_docker_client.images.get.return_value = mock_image
442+
443+
container = self._create_container()
444+
container._RAPID_SOURCE_PATH = Path(__file__).parent
445+
container._wait_for_ready = Mock()
446+
447+
container.start()
448+
449+
# Verify container was created with built image tag
450+
call_args = self.mock_docker_client.containers.create.call_args
451+
self.assertEqual(call_args.kwargs["image"], expected_tag)
452+
309453
@parameterized.expand(
310454
[
311455
# (name, image_exists, is_current, should_pull)

0 commit comments

Comments
 (0)