• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 26080722777

19 May 2026 06:37AM UTC coverage: 52.106% (-11.5%) from 63.597%
26080722777

Pull #23250

github

web-flow
Merge 63ec06323 into 2693df832
Pull Request #23250: Feature: Add generic option to docker image

12 of 50 new or added lines in 3 files covered. (24.0%)

5382 existing lines in 201 files now uncovered.

32053 of 61515 relevant lines covered (52.11%)

1.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

37.57
/src/python/pants/backend/docker/goals/package_image.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
2✔
4

5
import json
2✔
6
import logging
2✔
7
import os
2✔
8
import re
2✔
9
from collections.abc import Iterator
2✔
10
from dataclasses import asdict, dataclass
2✔
11
from functools import partial
2✔
12
from itertools import chain
2✔
13
from typing import Literal, cast
2✔
14

15
from pants.backend.docker.package_types import (
2✔
16
    BuiltDockerImage,
17
    DockerPushOnPackageBehavior,
18
    DockerPushOnPackageException,
19
)
20
from pants.backend.docker.registries import DockerRegistries, DockerRegistryOptions
2✔
21
from pants.backend.docker.subsystems.docker_options import DockerOptions
2✔
22
from pants.backend.docker.target_types import (
2✔
23
    DockerBuildKitOptionField,
24
    DockerBuildOptionFieldListOfMultiValueDictMixin,
25
    DockerBuildOptionFieldMixin,
26
    DockerBuildOptionFieldMultiValueDictMixin,
27
    DockerBuildOptionFieldMultiValueMixin,
28
    DockerBuildOptionFieldValueMixin,
29
    DockerBuildOptionFlagFieldMixin,
30
    DockerImageBuildExtraOptionsField,
31
    DockerImageBuildImageOutputField,
32
    DockerImageContextRootField,
33
    DockerImageOutputDirectoriesField,
34
    DockerImageOutputFilesField,
35
    DockerImageOutputsMatchModeField,
36
    DockerImageRegistriesField,
37
    DockerImageRepositoryField,
38
    DockerImageSourceField,
39
    DockerImageTagsField,
40
    DockerImageTagsRequest,
41
    DockerImageTargetStageField,
42
    get_docker_image_tags,
43
)
44
from pants.backend.docker.util_rules.docker_binary import DockerBinary
2✔
45
from pants.backend.docker.util_rules.docker_build_context import (
2✔
46
    DockerBuildContext,
47
    DockerBuildContextRequest,
48
    create_docker_build_context,
49
)
50
from pants.backend.docker.utils import format_rename_suggestion
2✔
51
from pants.core.goals.package import (
2✔
52
    BuiltPackage,
53
    BuiltPackageArtifact,
54
    OutputPathField,
55
    PackageFieldSet,
56
)
57
from pants.core.util_rules.adhoc_process_support import check_outputs
2✔
58
from pants.engine.collection import Collection
2✔
59
from pants.engine.fs import (
2✔
60
    EMPTY_DIGEST,
61
    AddPrefix,
62
    CreateDigest,
63
    FileContent,
64
)
65
from pants.engine.internals.graph import resolve_target
2✔
66
from pants.engine.intrinsics import (
2✔
67
    add_prefix,
68
    create_digest,
69
    digest_to_snapshot,
70
    execute_process,
71
)
72
from pants.engine.process import Process, ProcessExecutionFailure
2✔
73
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
2✔
74
from pants.engine.target import Field, InvalidFieldException, Target, WrappedTargetRequest
2✔
75
from pants.engine.unions import UnionMembership, UnionRule
2✔
76
from pants.option.global_options import GlobalOptions, KeepSandboxes
2✔
77
from pants.util.frozendict import FrozenDict
2✔
78
from pants.util.strutil import bullet_list, softwrap
2✔
79
from pants.util.value_interpolation import InterpolationContext, InterpolationError
2✔
80

81
logger = logging.getLogger(__name__)
2✔
82

83

84
class DockerImageTagValueError(InterpolationError):
2✔
85
    pass
2✔
86

87

88
class DockerRepositoryNameError(InterpolationError):
2✔
89
    pass
2✔
90

91

92
class DockerBuildTargetStageError(ValueError):
2✔
93
    pass
2✔
94

95

96
class DockerImageOptionValueError(InterpolationError):
2✔
97
    pass
2✔
98

99

100
@dataclass(frozen=True)
2✔
101
class DockerPackageFieldSet(PackageFieldSet):
2✔
102
    required_fields = (DockerImageSourceField,)
2✔
103

104
    context_root: DockerImageContextRootField
2✔
105
    registries: DockerImageRegistriesField
2✔
106
    repository: DockerImageRepositoryField
2✔
107
    source: DockerImageSourceField
2✔
108
    tags: DockerImageTagsField
2✔
109
    target_stage: DockerImageTargetStageField
2✔
110
    output_path: OutputPathField
2✔
111
    output: DockerImageBuildImageOutputField
2✔
112
    output_files: DockerImageOutputFilesField
2✔
113
    output_directories: DockerImageOutputDirectoriesField
2✔
114
    outputs_match_mode: DockerImageOutputsMatchModeField
2✔
115

116
    def pushes_on_package(self) -> bool:
2✔
117
        """Returns True if this docker_image target would push to a registry during packaging."""
118
        value_or_default = self.output.value or self.output.default
×
119
        return value_or_default.get("push") == "true" or value_or_default["type"] == "registry"
×
120

121
    def format_tag(self, tag: str, interpolation_context: InterpolationContext) -> str:
2✔
122
        source = InterpolationContext.TextSource(
×
123
            address=self.address, target_alias="docker_image", field_alias=self.tags.alias
124
        )
125
        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
×
126

127
    def format_repository(
2✔
128
        self,
129
        default_repository: str,
130
        interpolation_context: InterpolationContext,
131
        registry: DockerRegistryOptions | None = None,
132
    ) -> str:
133
        repository_context = InterpolationContext.from_dict(
×
134
            {
135
                "name": self.address.target_name,
136
                "directory": os.path.basename(self.address.spec_path),
137
                "full_directory": self.address.spec_path,
138
                "parent_directory": os.path.basename(os.path.dirname(self.address.spec_path)),
139
                "default_repository": default_repository,
140
                "target_repository": self.repository.value or default_repository,
141
                **interpolation_context,
142
            }
143
        )
144
        if registry and registry.repository:
×
145
            repository_text = registry.repository
×
146
            source = InterpolationContext.TextSource(
×
147
                options_scope=f"[docker.registries.{registry.alias or registry.address}].repository"
148
            )
149
        elif self.repository.value:
×
150
            repository_text = self.repository.value
×
151
            source = InterpolationContext.TextSource(
×
152
                address=self.address, target_alias="docker_image", field_alias=self.repository.alias
153
            )
154
        else:
155
            repository_text = default_repository
×
156
            source = InterpolationContext.TextSource(options_scope="[docker].default_repository")
×
157
        return repository_context.format(
×
158
            repository_text, source=source, error_cls=DockerRepositoryNameError
159
        ).lower()
160

161
    def format_image_ref_tags(
2✔
162
        self,
163
        repository: str,
164
        tags: tuple[str, ...],
165
        interpolation_context: InterpolationContext,
166
        uses_local_alias,
167
    ) -> Iterator[ImageRefTag]:
168
        for tag in tags:
×
169
            formatted = self.format_tag(tag, interpolation_context)
×
170
            yield ImageRefTag(
×
171
                template=tag,
172
                formatted=formatted,
173
                full_name=":".join(s for s in [repository, formatted] if s),
174
                uses_local_alias=uses_local_alias,
175
            )
176

177
    def image_refs(
2✔
178
        self,
179
        default_repository: str,
180
        registries: DockerRegistries,
181
        interpolation_context: InterpolationContext,
182
        additional_tags: tuple[str, ...] = (),
183
    ) -> Iterator[ImageRefRegistry]:
184
        """The per-registry image refs: each returned element is a collection of the tags applied to
185
        the image in a single registry.
186

187
        In the Docker world, the term `tag` is used both for what we here prefer to call the image
188
        `ref`, as well as for the image version, or tag, that is at the end of the image name
189
        separated with a colon. By introducing the image `ref` we can retain the use of `tag` for
190
        the version part of the image name.
191

192
        This function returns all image refs to apply to the Docker image, grouped by
193
        registry. Within each registry, the `tags` attribute contains a metadata about each tag in
194
        the context of that registry, and the `full_name` attribute of each `ImageRefTag` provides
195
        the image ref, of the following form:
196

197
            [<registry>/]<repository-name>[:<tag>]
198

199
        Where the `<repository-name>` may contain any number of separating slashes `/`, depending on
200
        the `default_repository` from configuration or the `repository` field on the target
201
        `docker_image`.
202

203
        This method will always return at least one `ImageRefRegistry`, and there will be at least
204
        one tag.
205
        """
206
        image_tags = (self.tags.value or ()) + additional_tags
×
207
        registries_options = tuple(registries.get(*(self.registries.value or [])))
×
208
        if not registries_options:
×
209
            # The image name is also valid as image ref without registry.
210
            repository = self.format_repository(default_repository, interpolation_context)
×
211
            yield ImageRefRegistry(
×
212
                registry=None,
213
                repository=repository,
214
                tags=tuple(
215
                    self.format_image_ref_tags(
216
                        repository, image_tags, interpolation_context, uses_local_alias=False
217
                    )
218
                ),
219
            )
220
            return
×
221

222
        for registry in registries_options:
×
223
            repository = self.format_repository(default_repository, interpolation_context, registry)
×
224
            address_repository = "/".join([registry.address, repository])
×
225
            if registry.use_local_alias and registry.alias:
×
226
                alias_repository = "/".join([registry.alias, repository])
×
227
            else:
228
                alias_repository = None
×
229

230
            yield ImageRefRegistry(
×
231
                registry=registry,
232
                repository=repository,
233
                tags=(
234
                    *self.format_image_ref_tags(
235
                        address_repository,
236
                        image_tags + registry.extra_image_tags,
237
                        interpolation_context,
238
                        uses_local_alias=False,
239
                    ),
240
                    *(
241
                        self.format_image_ref_tags(
242
                            alias_repository,
243
                            image_tags + registry.extra_image_tags,
244
                            interpolation_context,
245
                            uses_local_alias=True,
246
                        )
247
                        if alias_repository
248
                        else []
249
                    ),
250
                ),
251
            )
252

253
    def get_context_root(self, default_context_root: str) -> str:
2✔
254
        """Examines `default_context_root` and `self.context_root.value` and translates that to a
255
        context root for the Docker build operation.
256

257
        That is, in the configuration/field value, the context root is relative to build root when
258
        in the form `path/..` (implies semantics as `//path/..` for target addresses) or the BUILD
259
        file when `./path/..`.
260

261
        The returned path is always relative to the build root.
262
        """
263
        if self.context_root.value is not None:
×
264
            context_root = self.context_root.value
×
265
        else:
266
            context_root = cast(
×
267
                str, self.context_root.compute_value(default_context_root, self.address)
268
            )
269
        if context_root.startswith("./"):
×
270
            context_root = os.path.join(self.address.spec_path, context_root)
×
271
        return os.path.normpath(context_root)
×
272

273

274
@dataclass(frozen=True)
2✔
275
class ImageRefRegistry:
2✔
276
    registry: DockerRegistryOptions | None
2✔
277
    repository: str
2✔
278
    tags: tuple[ImageRefTag, ...]
2✔
279

280

281
@dataclass(frozen=True)
2✔
282
class ImageRefTag:
2✔
283
    template: str
2✔
284
    formatted: str
2✔
285
    full_name: str
2✔
286
    uses_local_alias: bool
2✔
287

288

289
@dataclass(frozen=True)
2✔
290
class DockerInfoV1:
2✔
291
    """The format of the `$target_name.docker-info.json` file."""
292

293
    version: Literal[1]
2✔
294
    image_id: str
2✔
295
    # It'd be good to include the digest here (e.g. to allow 'docker run
296
    # registry/repository@digest'), but that is only known after pushing to a V2 registry
297

298
    registries: list[DockerInfoV1Registry]
2✔
299

300
    @staticmethod
2✔
301
    def serialize(image_refs: tuple[ImageRefRegistry, ...], image_id: str) -> bytes:
2✔
302
        # make sure these are in a consistent order (the exact order doesn't matter
303
        # so much), no matter how they were configured
304
        sorted_refs = sorted(image_refs, key=lambda r: r.registry.address if r.registry else "")
×
305

306
        info = DockerInfoV1(
×
307
            version=1,
308
            image_id=image_id,
309
            registries=[
310
                DockerInfoV1Registry(
311
                    alias=r.registry.alias if r.registry and r.registry.alias else None,
312
                    address=r.registry.address if r.registry else None,
313
                    repository=r.repository,
314
                    tags=[
315
                        DockerInfoV1ImageTag(
316
                            template=t.template,
317
                            tag=t.formatted,
318
                            uses_local_alias=t.uses_local_alias,
319
                            name=t.full_name,
320
                        )
321
                        # consistent order, as above
322
                        for t in sorted(r.tags, key=lambda t: t.full_name)
323
                    ],
324
                )
325
                for r in sorted_refs
326
            ],
327
        )
328

329
        return json.dumps(asdict(info)).encode()
×
330

331

332
@dataclass(frozen=True)
2✔
333
class DockerInfoV1Registry:
2✔
334
    # set if registry was specified as `@something`
335
    alias: str | None
2✔
336
    address: str | None
2✔
337
    repository: str
2✔
338
    tags: list[DockerInfoV1ImageTag]
2✔
339

340

341
@dataclass(frozen=True)
2✔
342
class DockerInfoV1ImageTag:
2✔
343
    template: str
2✔
344
    tag: str
2✔
345
    uses_local_alias: bool
2✔
346
    # for convenience, include the concatenated registry/repository:tag name (using this tag)
347
    name: str
2✔
348

349

350
def _extra_options_flag_names(extra_options: tuple[str, ...]) -> frozenset[str]:
2✔
351
    """Returns a set of flag names (e.g. --pull, --network, etc)"""
NEW
352
    names: set[str] = set()
×
NEW
353
    for opt in extra_options:
×
NEW
354
        if opt.startswith("-"):
×
NEW
355
            names.add(opt.split("=")[0])
×
NEW
356
    return frozenset(names)
×
357

358

359
def _filter_global_extra_options(
2✔
360
    global_extra_options: tuple[str, ...], target_flag_names: frozenset[str]
361
) -> tuple[str, ...]:
362
    """Remove any global extra options that are included in the per-target options."""
NEW
363
    result = []
×
NEW
364
    for opt in global_extra_options:
×
NEW
365
        if opt.startswith("-") and opt.split("=")[0] in target_flag_names:
×
NEW
366
            logger.info(
×
367
                f"Global docker extra option `{opt}` is overridden by a per-target option and will be ignored."
368
            )
369
        else:
NEW
370
            result.append(opt)
×
NEW
371
    return tuple(result)
×
372

373

374
def _overwrite_flag_warning(
2✔
375
    target: Target, field_type: type[Field], extra_options: tuple[str, ...]
376
) -> None:
NEW
377
    for opt in extra_options:
×
NEW
378
        if opt.startswith(f"--{field_type.alias}"):
×
NEW
379
            extra_build_options = opt
×
NEW
380
            break
×
NEW
381
    logger.warning(
×
382
        f"The individual `{field_type.alias}={target[field_type].value}` field on the `{target.alias}` target in `{target.address}` is overridden by "
383
        f"`extra_build_options` (`{extra_build_options}`). Consider using `extra_build_options` instead of specifying individual build option fields."
384
    )
385

386

387
def _overwrite_flag_warning_global_option(
2✔
388
    target: Target, option_name: str, extra_options: tuple[str, ...]
389
) -> None:
NEW
390
    for opt in extra_options:
×
NEW
391
        if opt.split("=")[0] == option_name.split("=")[0]:
×
NEW
392
            extra_build_options = opt
×
NEW
393
            break
×
NEW
394
    logger.warning(
×
395
        f"The individual `{option_name}` field on the `{target.alias}` target in `{target.address}` is overridden by "
396
        f"`extra_build_options` (`{extra_build_options}`). Consider using `extra_build_options` instead of specifying individual build option fields."
397
    )
398

399

400
def get_build_options(
2✔
401
    context: DockerBuildContext,
402
    field_set: DockerPackageFieldSet,
403
    global_target_stage_option: str | None,
404
    global_build_hosts_options: dict | None,
405
    global_build_no_cache_option: bool | None,
406
    use_buildx_option: bool,
407
    target: Target,
408
    global_extra_options: tuple[str, ...] = (),
409
    output_options: FrozenDict[str, str] | None = None,
410
) -> Iterator[str]:
NEW
411
    target_extra = tuple(target[DockerImageBuildExtraOptionsField].value or ())
×
NEW
412
    target_flag_names = _extra_options_flag_names(target_extra)
×
413

414
    # Per-target wins: drop any global entry whose flag is already covered by the per-target list.
NEW
415
    filtered_global = _filter_global_extra_options(global_extra_options, target_flag_names)
×
416

NEW
417
    extra_options: tuple[str, ...] = (*filtered_global, *target_extra)
×
418

419
    # Compute the set of flag names that are provided by global and target extra options.
NEW
420
    overridden_flags = _extra_options_flag_names(extra_options)
×
421

422
    # Build options from target fields inheriting from DockerBuildOptionFieldMixin
423
    for field_type in target.field_types:
×
424
        if issubclass(field_type, DockerBuildKitOptionField):
×
425
            if use_buildx_option is not True:
×
426
                if target[field_type].value != target[field_type].default:
×
427
                    raise DockerImageOptionValueError(
×
428
                        f"The {target[field_type].alias} field on the = `{target.alias}` target in `{target.address}` was set to `{target[field_type].value}`"
429
                        f" and buildx is not enabled. Buildx must be enabled via the Docker subsystem options in order to use this field."
430
                    )
431
                else:
432
                    # Case where BuildKit option has a default value - still should not be generated
433
                    continue
×
434

435
        if issubclass(
×
436
            field_type,
437
            (
438
                DockerBuildOptionFieldMixin,
439
                DockerBuildOptionFieldMultiValueDictMixin,
440
                DockerBuildOptionFieldListOfMultiValueDictMixin,
441
                DockerBuildOptionFieldValueMixin,
442
                DockerBuildOptionFieldMultiValueMixin,
443
                DockerBuildOptionFlagFieldMixin,
444
            ),
445
        ):
NEW
446
            flag = getattr(
×
447
                field_type, "docker_build_option", None
448
            )  # get the flag name if it exists such as --pull or --network, etc.
NEW
449
            if flag and flag in overridden_flags:
×
NEW
450
                _overwrite_flag_warning(target, field_type, extra_options)
×
NEW
451
                continue
×
452

UNCOV
453
            source = InterpolationContext.TextSource(
×
454
                address=target.address, target_alias=target.alias, field_alias=field_type.alias
455
            )
456
            format = partial(
×
457
                context.interpolation_context.format,
458
                source=source,
459
                error_cls=DockerImageOptionValueError,
460
            )
461
            if field_type is DockerImageBuildImageOutputField and output_options is not None:
×
462
                yield (
×
463
                    f"{DockerImageBuildImageOutputField.docker_build_option}="
464
                    + ",".join(f"{key}={value}" for key, value in output_options.items())
465
                )
466
                continue
×
467
            yield from target[field_type].options(
×
468
                format, global_build_hosts_options=global_build_hosts_options
469
            )
470

471
    # Target stage
472
    target_stage = None
×
473
    if global_target_stage_option in context.stages:
×
474
        target_stage = global_target_stage_option
×
475
    elif field_set.target_stage.value:
×
476
        target_stage = field_set.target_stage.value
×
477
        if target_stage not in context.stages:
×
478
            raise DockerBuildTargetStageError(
×
479
                f"The {field_set.target_stage.alias!r} field in `{target.alias}` "
480
                f"{field_set.address} was set to {target_stage!r}"
481
                + (
482
                    f", but there is no such stage in `{context.dockerfile}`. "
483
                    f"Available stages: {', '.join(context.stages)}."
484
                    if context.stages
485
                    else f", but there are no named stages in `{context.dockerfile}`."
486
                )
487
            )
488

489
    if target_stage:
×
NEW
490
        if "--target" in overridden_flags:
×
NEW
491
            _overwrite_flag_warning_global_option(target, f"--target={target_stage}", extra_options)
×
492
        else:
NEW
493
            extra_options = extra_options + ("--target", target_stage)
×
494

495
    if global_build_no_cache_option:
×
NEW
496
        if "--no-cache" in overridden_flags:
×
NEW
497
            _overwrite_flag_warning_global_option(target, "--no-cache", extra_options)
×
498
        else:
NEW
499
            extra_options = extra_options + ("--no-cache",)
×
500

NEW
501
    yield from extra_options
×
502

503

504
@dataclass(frozen=True)
2✔
505
class GetImageRefsRequest:
2✔
506
    field_set: DockerPackageFieldSet
2✔
507
    build_upstream_images: bool
2✔
508

509

510
class DockerImageRefs(Collection[ImageRefRegistry]):
2✔
511
    pass
2✔
512

513

514
@rule
2✔
515
async def get_image_refs(
2✔
516
    request: GetImageRefsRequest, options: DockerOptions, union_membership: UnionMembership
517
) -> DockerImageRefs:
518
    context, wrapped_target = await concurrently(
×
519
        create_docker_build_context(
520
            DockerBuildContextRequest(
521
                address=request.field_set.address,
522
                build_upstream_images=request.build_upstream_images,
523
            ),
524
            **implicitly(),
525
        ),
526
        resolve_target(
527
            WrappedTargetRequest(request.field_set.address, description_of_origin="<infallible>"),
528
            **implicitly(),
529
        ),
530
    )
531

532
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
×
533
    additional_image_tags = await concurrently(
×
534
        get_docker_image_tags(
535
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
536
        )
537
        for image_tags_request_cls in image_tags_requests
538
        if image_tags_request_cls.is_applicable(wrapped_target.target)
539
    )
540

541
    return DockerImageRefs(
×
542
        request.field_set.image_refs(
543
            default_repository=options.default_repository,
544
            registries=options.registries(),
545
            interpolation_context=context.interpolation_context,
546
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
547
        )
548
    )
549

550

551
@dataclass(frozen=True)
2✔
552
class DockerImageBuildProcess:
2✔
553
    process: Process
2✔
554
    context: DockerBuildContext
2✔
555
    context_root: str
2✔
556
    image_refs: DockerImageRefs
2✔
557
    tags: tuple[str, ...]
2✔
558
    captured_outputs: DockerBuildCapturedOutputs | None = None
2✔
559

560

561
@dataclass(frozen=True)
2✔
562
class DockerBuildCapturedOutputs:
2✔
563
    output_options: FrozenDict[str, str]
2✔
564
    output_files: tuple[str, ...]
2✔
565
    output_directories: tuple[str, ...]
2✔
566

567

568
def _validate_output_capture_path(
2✔
569
    path: str, field_set: DockerPackageFieldSet, field_alias: str
570
) -> str:
571
    normalized_path = os.path.normpath(path)
×
572
    if (
×
573
        os.path.isabs(normalized_path)
574
        or normalized_path == ".."
575
        or normalized_path.startswith(f"..{os.sep}")
576
    ):
577
        raise InvalidFieldException(
×
578
            softwrap(
579
                f"""
580
                The `{field_alias}` field in `docker_image` {field_set.address} contains {path!r}.
581
                Pants can only capture relative paths that stay inside the build root.
582
                """
583
            )
584
        )
585
    return normalized_path
×
586

587

588
def _docker_build_captured_outputs(
2✔
589
    field_set: DockerPackageFieldSet,
590
) -> DockerBuildCapturedOutputs | None:
591
    output_files = tuple(
×
592
        _validate_output_capture_path(path, field_set, DockerImageOutputFilesField.alias)
593
        for path in (field_set.output_files.value or ())
594
    )
595
    output_directories = tuple(
×
596
        _validate_output_capture_path(path, field_set, DockerImageOutputDirectoriesField.alias)
597
        for path in (field_set.output_directories.value or ())
598
    )
599
    if not (output_files or output_directories):
×
600
        return None
×
601

602
    if field_set.output.value != field_set.output.default:
×
603
        raise InvalidFieldException(
×
604
            softwrap(
605
                f"""
606
                The `docker_image` {field_set.address} sets both `{DockerImageBuildImageOutputField.alias}`
607
                and output capture fields. Use `{DockerImageOutputFilesField.alias}` and/or
608
                `{DockerImageOutputDirectoriesField.alias}` without setting
609
                `{DockerImageBuildImageOutputField.alias}`; Pants will automatically use the
610
                BuildKit local output exporter.
611
                """
612
            )
613
        )
614

615
    return DockerBuildCapturedOutputs(
×
616
        output_options=FrozenDict({"type": "local", "dest": "."}),
617
        output_files=output_files,
618
        output_directories=output_directories,
619
    )
620

621

622
def _docker_build_captured_output_path(field_set: DockerPackageFieldSet) -> str:
2✔
623
    if field_set.output_path.value != field_set.output_path.default:
×
624
        return field_set.output_path.value_or_default(file_ending=None)
×
625
    return field_set.address.spec_path
×
626

627

628
@rule
2✔
629
async def get_docker_image_build_process(
2✔
630
    field_set: DockerPackageFieldSet, options: DockerOptions, docker: DockerBinary
631
) -> DockerImageBuildProcess:
632
    context, wrapped_target, image_refs = await concurrently(
×
633
        create_docker_build_context(
634
            DockerBuildContextRequest(
635
                address=field_set.address,
636
                build_upstream_images=True,
637
            ),
638
            **implicitly(),
639
        ),
640
        resolve_target(
641
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
642
            **implicitly(),
643
        ),
644
        get_image_refs(
645
            GetImageRefsRequest(
646
                field_set=field_set,
647
                build_upstream_images=True,
648
            ),
649
            **implicitly(),
650
        ),
651
    )
652
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
×
653
    if not tags:
×
654
        raise InvalidFieldException(
×
655
            softwrap(
656
                f"""
657
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
658
                empty, unless there is a custom plugin providing additional tags using the
659
                `DockerImageTagsRequest` union type.
660
                """
661
            )
662
        )
663

664
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
665
    # image-building process correctly when an upstream image changes, even though the
666
    # process itself does not consume this data.
667
    env = {
×
668
        **context.build_env.environment,
669
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
670
    }
671
    context_root = field_set.get_context_root(options.default_context_root)
×
672
    captured_outputs = _docker_build_captured_outputs(field_set)
×
673
    if captured_outputs and not options.use_buildx:
×
674
        raise DockerImageOptionValueError(
×
675
            softwrap(
676
                f"""
677
                The `docker_image` {field_set.address} sets `{DockerImageOutputFilesField.alias}`
678
                and/or `{DockerImageOutputDirectoriesField.alias}`, which requires BuildKit.
679
                Enable BuildKit with `[docker].use_buildx = true`.
680
                """
681
            )
682
        )
683
    process = docker.build_image(
×
684
        build_args=context.build_args,
685
        digest=context.digest,
686
        dockerfile=context.dockerfile,
687
        context_root=context_root,
688
        env=env,
689
        tags=tags,
690
        use_buildx=options.use_buildx,
691
        extra_args=tuple(
692
            get_build_options(
693
                context=context,
694
                field_set=field_set,
695
                global_target_stage_option=options.build_target_stage,
696
                global_build_hosts_options=options.build_hosts,
697
                global_build_no_cache_option=options.build_no_cache,
698
                use_buildx_option=options.use_buildx,
699
                target=wrapped_target.target,
700
                global_extra_options=options.build_extra_options,
701
                output_options=captured_outputs.output_options if captured_outputs else None,
702
            )
703
        ),
704
        output_files=captured_outputs.output_files if captured_outputs else (),
705
        output_directories=captured_outputs.output_directories if captured_outputs else (),
706
    )
707
    return DockerImageBuildProcess(
×
708
        process=process,
709
        context=context,
710
        context_root=context_root,
711
        image_refs=image_refs,
712
        tags=tags,
713
        captured_outputs=captured_outputs,
714
    )
715

716

717
@rule
2✔
718
async def build_docker_image(
2✔
719
    field_set: DockerPackageFieldSet,
720
    options: DockerOptions,
721
    global_options: GlobalOptions,
722
    docker: DockerBinary,
723
    keep_sandboxes: KeepSandboxes,
724
) -> BuiltPackage:
725
    """Build a Docker image using `docker build`."""
726
    # Check if this build would push and handle according to push_on_package behavior
727
    if field_set.pushes_on_package():
×
728
        match options.push_on_package:
×
729
            case DockerPushOnPackageBehavior.IGNORE:
×
730
                return BuiltPackage(EMPTY_DIGEST, ())
×
731
            case DockerPushOnPackageBehavior.ERROR:
×
732
                raise DockerPushOnPackageException(field_set.address)
×
733
            case DockerPushOnPackageBehavior.WARN:
×
734
                logger.warning(
×
735
                    f"Docker image {field_set.address} will push to a registry during packaging"
736
                )
737

738
    build_process = await get_docker_image_build_process(field_set, **implicitly())
×
739
    result = await execute_process(build_process.process, **implicitly())
×
740

741
    if result.exit_code != 0:
×
742
        msg = f"Docker build failed for `docker_image` {field_set.address}."
×
743
        if options.suggest_renames:
×
744
            maybe_help_msg = format_docker_build_context_help_message(
×
745
                context_root=build_process.context_root,
746
                context=build_process.context,
747
                colors=global_options.colors,
748
            )
749
            if maybe_help_msg:
×
750
                msg += " " + maybe_help_msg
×
751

752
        logger.warning(msg)
×
753

754
        raise ProcessExecutionFailure(
×
755
            result.exit_code,
756
            result.stdout,
757
            result.stderr,
758
            build_process.process.description,
759
            keep_sandboxes=keep_sandboxes,
760
        )
761

762
    docker_build_output_msg = "\n".join(
×
763
        (
764
            f"Docker build output for {build_process.tags[0]}:",
765
            "stdout:",
766
            result.stdout.decode(),
767
            "stderr:",
768
            result.stderr.decode(),
769
        )
770
    )
771

772
    if options.build_verbose:
×
773
        logger.info(docker_build_output_msg)
×
774
    else:
775
        logger.debug(docker_build_output_msg)
×
776

777
    if build_process.captured_outputs:
×
778
        outputs_match_mode = field_set.outputs_match_mode.enum_value
×
779
        await check_outputs(
×
780
            output_digest=result.output_digest,
781
            output_files=build_process.captured_outputs.output_files,
782
            output_directories=build_process.captured_outputs.output_directories,
783
            outputs_match_error_behavior=outputs_match_mode.glob_match_error_behavior,
784
            outputs_match_mode=outputs_match_mode.glob_expansion_conjunction,
785
            address=field_set.address,
786
        )
787
        output_path = _docker_build_captured_output_path(field_set)
×
788
        digest = (
×
789
            await add_prefix(AddPrefix(result.output_digest, output_path))
790
            if result.output_digest != EMPTY_DIGEST and output_path and output_path != "."
791
            else result.output_digest
792
        )
793
        snapshot = await digest_to_snapshot(digest)
×
794
        artifact_paths = snapshot.files or snapshot.dirs
×
795
        return BuiltPackage(
×
796
            digest,
797
            tuple(BuiltPackageArtifact(relpath=path) for path in artifact_paths),
798
        )
799

800
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
×
801
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
×
802
    metadata = DockerInfoV1.serialize(build_process.image_refs, image_id=image_id)
×
803
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
×
804

805
    return BuiltPackage(
×
806
        digest,
807
        (BuiltDockerImage.create(image_id, build_process.tags, metadata_filename),),
808
    )
809

810

811
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
2✔
812
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
813
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
814
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
815
    #  to be an issue in practice.
816
    if docker.is_podman:
×
817
        for output in outputs:
×
818
            try:
×
819
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
×
820
            except ValueError:
×
821
                continue
×
822

823
            if success.startswith("Successfully tagged"):
×
824
                return image_id
×
825

826
    else:
827
        image_id_regexp = re.compile(
×
828
            "|".join(
829
                (
830
                    # BuildKit output.
831
                    r"(writing image (?P<digest>sha256:\S+))",
832
                    # BuildKit with containerd-snapshotter output.
833
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
834
                    # BuildKit with containerd-snapshotter output and no attestation.
835
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
836
                    # Docker output.
837
                    r"(Successfully built (?P<short_id>\S+))",
838
                ),
839
            )
840
        )
841
        for output in outputs:
×
842
            image_id_match = next(
×
843
                (
844
                    match
845
                    for match in (
846
                        re.search(image_id_regexp, line)
847
                        for line in reversed(output.decode().split("\n"))
848
                    )
849
                    if match
850
                ),
851
                None,
852
            )
853
            if image_id_match:
×
854
                image_id = (
×
855
                    image_id_match.group("digest")
856
                    or image_id_match.group("short_id")
857
                    or image_id_match.group("manifest_list")
858
                    or image_id_match.group("manifest")
859
                )
860
                return image_id
×
861

862
    return "<unknown>"
×
863

864

865
def format_docker_build_context_help_message(
2✔
866
    context_root: str, context: DockerBuildContext, colors: bool
867
) -> str | None:
868
    paths_outside_context_root: list[str] = []
×
869

870
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
×
871
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
872
        instruction_path, context_path = paths
×
873
        if not context_path:
×
874
            return paths
×
875
        dst = os.path.relpath(context_path, context_root)
×
876
        if dst.startswith("../"):
×
877
            paths_outside_context_root.append(context_path)
×
878
            return ("", "")
×
879
        if instruction_path == dst:
×
880
            return ("", "")
×
881
        return instruction_path, dst
×
882

883
    # Adjust context paths based on `context_root`.
884
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
×
885
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
886
    )
887

888
    if not (copy_source_vs_context_source or paths_outside_context_root):
×
889
        # No issues found.
890
        return None
×
891

892
    msg = ""
×
893
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
×
894
    if has_unsourced_copy:
×
895
        msg += (
×
896
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
897
            f"been found in the Docker build context.\n\n"
898
        )
899

900
        renames = sorted(
×
901
            format_rename_suggestion(src, dst, colors=colors)
902
            for src, dst in copy_source_vs_context_source
903
            if src and dst
904
        )
905
        if renames:
×
906
            msg += (
×
907
                f"However there are possible matches. Please review the following list of "
908
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
909
            )
910

911
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
×
912
        if unknown:
×
913
            msg += (
×
914
                f"The following files were not found in the Docker build context:\n\n"
915
                f"{bullet_list(unknown)}\n\n"
916
            )
917

918
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
×
919
    if unreferenced:
×
920
        msg += (
×
921
            f"There are files in the Docker build context that were not referenced by "
922
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
923
        )
924

925
    if paths_outside_context_root:
×
926
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
×
927
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
×
928
        new_context_root = os.path.commonpath(context_paths)
×
929
        msg += (
×
930
            "There are unreachable files in these directories, excluded from the build context "
931
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
932
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
933
            "files in the build context, otherwise relocate the files to be part of the current "
934
            f"`context_root` {context_root!r}."
935
        )
936

937
    return msg
×
938

939

940
def rules():
2✔
941
    return [
2✔
942
        *collect_rules(),
943
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
944
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc