Skip to content

Commit 3310e80

Browse files
authored
feat: add cli client support for image builds to enable buildkit (#8772)
* feat: add support for using CLI container clients (#8661) * feat: add build_client * feat: add --use-buildkit option * feat: use build client when building with images * lint and format * feat: add command to cli * fix: use absolute path for dockerfile * test: add unit test for build_client * test: add unit test for build_context * fix: additional test fixes * fix: schema update * fix: remove hardcoded unix pathseps * fix: remove provenance and sbom from docker buildx * fix: refactor code to use SDKBuildClient This change also refactors a lot of docker specific things to be "container_client" rather than "docker_client". In addition, moving from instantiating the image build client in build context to lazily doing it in the app builder. * test: add additional unit tests * fix: fix error handling in CLIBuildClient * fix: reorder error handling in CLIImageBuilder * test: add integration tests for CLI client (#8689) * test: add basic integration test * test: more comprehensive integ tests * test: add arm64 test and mark as tier1 * fix: update dockerfile error messages * test: remove arm64 test * fix: allow finch without sudo
1 parent f201f96 commit 3310e80

18 files changed

Lines changed: 887 additions & 68 deletions

File tree

samcli/commands/_utils/options.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,20 @@ def use_container_build_option(f):
862862
return use_container_build_click_option()(f)
863863

864864

865+
def use_buildkit_click_option():
866+
return click.option(
867+
"--use-buildkit/--no-use-buildkit",
868+
required=False,
869+
default=False,
870+
is_flag=True,
871+
help="Enable buildkit for container image builds. Requires Docker with buildx plugin or Finch CLI.",
872+
)
873+
874+
875+
def use_buildkit_option(f):
876+
return use_buildkit_click_option()(f)
877+
878+
865879
def mount_symlinks_click_option():
866880
return click.option(
867881
"--mount-symlinks/--no-mount-symlinks",

samcli/commands/build/build_context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def __init__(
8383
build_in_source: Optional[bool] = None,
8484
mount_with: str = MountMode.READ.value,
8585
mount_symlinks: Optional[bool] = False,
86+
use_buildkit: Optional[bool] = False,
8687
) -> None:
8788
"""
8889
Initialize the class
@@ -142,6 +143,8 @@ def __init__(
142143
Mount mode of source code directory when building inside container, READ ONLY by default
143144
mount_symlinks Optional[bool]:
144145
Indicates if symlinks should be mounted inside the container
146+
use_buildkit Optional[bool]:
147+
Enable buildkit for container image builds
145148
"""
146149

147150
self._resource_identifier = resource_identifier
@@ -184,6 +187,7 @@ def __init__(
184187
self._build_result: Optional[ApplicationBuildResult] = None
185188
self._mount_with = MountMode(mount_with)
186189
self._mount_symlinks = mount_symlinks
190+
self._use_buildkit = use_buildkit
187191

188192
def __enter__(self) -> "BuildContext":
189193
self.set_up()
@@ -278,6 +282,7 @@ def run(self) -> None:
278282
build_in_source=self._build_in_source,
279283
mount_with_write=mount_with_write,
280284
mount_symlinks=self._mount_symlinks,
285+
use_buildkit=self._use_buildkit,
281286
)
282287

283288
self._check_exclude_warning()

samcli/commands/build/command.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
skip_prepare_infra_option,
3030
template_option_without_build,
3131
terraform_project_root_path_option,
32+
use_buildkit_option,
3233
use_container_build_option,
3334
)
3435
from samcli.commands.build.click_container import ContainerOptions
@@ -82,6 +83,7 @@
8283
)
8384
@skip_prepare_infra_option
8485
@use_container_build_option
86+
@use_buildkit_option
8587
@build_in_source_option
8688
@click.option(
8789
"--container-env-var",
@@ -161,6 +163,7 @@ def cli(
161163
terraform_project_root_path: Optional[str],
162164
build_in_source: Optional[bool],
163165
mount_symlinks: Optional[bool],
166+
use_buildkit: Optional[bool],
164167
) -> None:
165168
"""
166169
`sam build` command entry point
@@ -193,6 +196,7 @@ def cli(
193196
build_in_source,
194197
mount_with,
195198
mount_symlinks,
199+
use_buildkit,
196200
) # pragma: no cover
197201

198202

@@ -220,6 +224,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
220224
build_in_source: Optional[bool],
221225
mount_with: str,
222226
mount_symlinks: Optional[bool],
227+
use_buildkit: Optional[bool],
223228
) -> None:
224229
"""
225230
Implementation of the ``cli`` method
@@ -260,6 +265,7 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements
260265
build_in_source=build_in_source,
261266
mount_with=mount_with,
262267
mount_symlinks=mount_symlinks,
268+
use_buildkit=use_buildkit,
263269
) as ctx:
264270
ctx.run()
265271

samcli/commands/build/core/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
CONTAINER_OPTION_NAMES: List[str] = [
1818
"use_container",
19+
"use_buildkit",
1920
"container_env_var",
2021
"container_env_var_file",
2122
"build_image",

samcli/lib/build/app_builder.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
)
6161
from samcli.lib.utils.stream_writer import StreamWriter
6262
from samcli.local.docker.container import ContainerContext
63-
from samcli.local.docker.exceptions import ContainerArchiveImageLoadFailedException
63+
from samcli.local.docker.container_client import ContainerClient
64+
from samcli.local.docker.exceptions import BuildkitNotAvailableException, ContainerArchiveImageLoadFailedException
65+
from samcli.local.docker.image_build_client import CLIBuildClient, ImageBuildClient, SDKBuildClient
6466
from samcli.local.docker.lambda_build_container import LambdaBuildContainer
6567
from samcli.local.docker.manager import ContainerManager, DockerImagePullFailedException
6668
from samcli.local.docker.utils import (
@@ -106,14 +108,15 @@ def __init__(
106108
parallel: bool = False,
107109
mode: Optional[str] = None,
108110
stream_writer: Optional[StreamWriter] = None,
109-
docker_client: Optional[docker.DockerClient] = None,
111+
container_client: Optional[ContainerClient] = None,
110112
container_env_var: Optional[Dict] = None,
111113
container_env_var_file: Optional[str] = None,
112114
build_images: Optional[Dict] = None,
113115
combine_dependencies: bool = True,
114116
build_in_source: Optional[bool] = None,
115117
mount_with_write: bool = False,
116118
mount_symlinks: Optional[bool] = False,
119+
use_buildkit: Optional[bool] = False,
117120
) -> None:
118121
"""
119122
Initialize the class
@@ -144,8 +147,8 @@ def __init__(
144147
Optional, name of the build mode to use ex: 'debug'
145148
stream_writer : Optional[StreamWriter]
146149
An optional stream writer to accept stderr output
147-
docker_client : Optional[docker.DockerClient]
148-
An optional Docker client object to replace the default one loaded from env
150+
container_client : Optional[ContainerClient]
151+
An optional container client object to replace the default one loaded from env
149152
container_env_var : Optional[Dict]
150153
An optional dictionary of environment variables to pass to the container
151154
container_env_var_file : Optional[str]
@@ -161,6 +164,8 @@ def __init__(
161164
Mount source code directory with write permissions when building inside container.
162165
mount_symlinks: Optional[bool]
163166
True if symlinks should be mounted in the container.
167+
use_buildkit: Optional[bool]
168+
Optional flag for building Image functions with buildkit support.
164169
"""
165170
self._resources_to_build = resources_to_build
166171
self._build_dir = build_dir
@@ -175,11 +180,12 @@ def __init__(
175180
self._mode = mode
176181
self._stream_writer = stream_writer if stream_writer else StreamWriter(stream=osutils.stderr(), auto_flush=True)
177182

178-
# Store docker_client parameter for lazy initialization
179-
# Only validate container runtime when Docker client is actually accessed
183+
# Store container_client parameter for lazy initialization
184+
# Only validate container runtime when container client is actually accessed
180185
# This prevents unnecessary validation for builds that don't require containers
181-
self._docker_client_param = docker_client
182-
self._validated_docker_client: Optional[docker.DockerClient] = None
186+
# NOTE: It seems like at this point container_client is only ever passed in the tests for mocking.
187+
self._container_client_param = container_client
188+
self._validated_container_client: Optional[ContainerClient] = None
183189

184190
self._deprecated_runtimes = DEPRECATED_RUNTIMES
185191
self._colored = Colored()
@@ -190,16 +196,18 @@ def __init__(
190196
self._build_in_source = build_in_source
191197
self._mount_with_write = mount_with_write
192198
self._mount_symlinks = mount_symlinks
199+
self._use_buildkit = use_buildkit
200+
self._image_build_client: Optional[ImageBuildClient] = None
193201

194202
@property
195-
def _docker_client(self) -> docker.DockerClient:
203+
def _container_client(self) -> ContainerClient:
196204
"""
197-
Lazy initialization of Docker client. Only validates container runtime when actually accessed.
205+
Lazy initialization of container client. Only validates container runtime when actually accessed.
198206
This prevents unnecessary container runtime validation for builds that don't require containers.
199207
"""
200-
if self._validated_docker_client is None:
201-
self._validated_docker_client = self._docker_client_param or get_validated_container_client()
202-
return self._validated_docker_client
208+
if self._validated_container_client is None:
209+
self._validated_container_client = self._container_client_param or get_validated_container_client()
210+
return self._validated_container_client
203211

204212
def build(self) -> ApplicationBuildResult:
205213
"""
@@ -453,14 +461,28 @@ def _build_lambda_image(self, function_name: str, metadata: Dict, architecture:
453461
build_args["target"] = cast(str, docker_build_target)
454462

455463
try:
456-
build_image, build_logs = self._docker_client.images.build(**build_args)
457-
LOG.debug("%s image is built for %s function", build_image, function_name)
464+
if not self._image_build_client:
465+
if self._use_buildkit:
466+
container_client = self._container_client
467+
engine_type = container_client.get_runtime_type()
468+
469+
is_available, error_msg = CLIBuildClient.is_available(engine_type)
470+
if not is_available:
471+
raise BuildkitNotAvailableException(error_msg)
472+
473+
self._image_build_client = CLIBuildClient(engine_type=engine_type)
474+
LOG.debug(f"Using CLIBuildClient with engine_type {engine_type}")
475+
else:
476+
self._image_build_client = SDKBuildClient(self._container_client)
477+
LOG.debug("Using SDKBuildClient")
478+
build_logs = self._image_build_client.build_image(**build_args) # type: ignore[arg-type]
479+
LOG.debug(f"Image built for {function_name} function")
458480
except docker.errors.BuildError as ex:
459481
LOG.error("Failed building function %s", function_name)
460482
self._stream_lambda_image_build_logs(ex.build_log, function_name, False)
461483
raise DockerBuildFailed(str(ex)) from ex
462484
except docker.errors.APIError as e:
463-
if self._docker_client.is_dockerfile_error(e):
485+
if self._container_client.is_dockerfile_error(e):
464486
raise DockerfileOutSideOfContext(e.explanation) from e
465487

466488
# Re-raise other API errors
@@ -469,9 +491,9 @@ def _build_lambda_image(self, function_name: str, metadata: Dict, architecture:
469491
# The Docker-py low level api will stream logs back but if an exception is raised by the api
470492
# this is raised when accessing the generator. So we need to wrap accessing build_logs in a try: except.
471493
try:
472-
self._stream_lambda_image_build_logs(build_logs, function_name)
494+
self._stream_lambda_image_build_logs(build_logs, function_name) # type: ignore[arg-type]
473495
except docker.errors.APIError as e:
474-
if self._docker_client.is_dockerfile_error(e):
496+
if self._container_client.is_dockerfile_error(e):
475497
raise DockerfileOutSideOfContext(e.explanation) from e
476498

477499
# Not sure what else can be raise that we should be catching but re-raising for now
@@ -501,7 +523,7 @@ def _stream_lambda_image_build_logs(
501523
def _load_lambda_image(self, image_archive_path: str) -> str:
502524
try:
503525
with open(image_archive_path, mode="rb") as image_archive:
504-
image = self._docker_client.load_image_from_archive(image_archive)
526+
image = self._container_client.load_image_from_archive(image_archive)
505527
return f"{image.id}"
506528
except (docker.errors.APIError, OSError, ContainerArchiveImageLoadFailedException) as ex:
507529
raise DockerBuildFailed(msg=str(ex)) from ex

samcli/local/docker/container_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,22 +382,23 @@ def is_dockerfile_error(self, error: Union[Exception, str]) -> bool:
382382
Check if error is a dockerfile-related error for Docker.
383383
384384
Docker-specific error patterns for dockerfile-related issues typically
385-
contain "Cannot locate specified Dockerfile" in the error message.
385+
contain "Cannot locate specified Dockerfile" or "failed to read dockerfile" in the error message.
386386
387387
Args:
388388
error: Exception or error message to check
389389
390390
Returns:
391391
bool: True if the error indicates a dockerfile-related issue
392392
"""
393+
patterns = ["Cannot locate specified Dockerfile", "failed to read dockerfile"]
393394
if isinstance(error, docker.errors.APIError):
394395
if not error.is_server_error:
395396
return False
396397
if not hasattr(error, "explanation") or error.explanation is None:
397398
return False
398-
return "Cannot locate specified Dockerfile" in str(error.explanation)
399+
return any(pattern in str(error.explanation) for pattern in patterns)
399400
elif isinstance(error, str):
400-
return "Cannot locate specified Dockerfile" in error
401+
return any(pattern in error for pattern in patterns)
401402
return False
402403

403404
def list_containers_by_image(self, image_name: str, all_containers: bool = True) -> List[Any]:

samcli/local/docker/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,9 @@ class ContainerInvalidSocketPathException(UserException):
6868
"""
6969
Failed to load Docker/Finch container image from archive file
7070
"""
71+
72+
73+
class BuildkitNotAvailableException(UserException):
74+
"""
75+
Raised when --with-buildkit is specified but buildkit is not available
76+
"""

0 commit comments

Comments
 (0)