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

pantsbuild / pants / 25441711719

06 May 2026 02:31PM UTC coverage: 92.915%. Remained the same
25441711719

push

github

web-flow
use sha pin (with comment) format for generated actions (#23312)

Per the GitHub Action best practices we recently enabled at #23249, we
should pin each action to a SHA so that the reference is actually
immutable.

This will -- I hope -- knock out a large chunk of the 421 alerts we
currently get from zizmor. The next followup would then be upgrades and
harmonizing the generated and none-generated pins.

Notice: This idea was suggested by Claude while going over pinact output
and I was surprised to see that post processing the yaml wasn't too
gross.

92206 of 99237 relevant lines covered (92.91%)

4.04 hits per line

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

97.32
/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
9✔
4

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

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

80
logger = logging.getLogger(__name__)
9✔
81

82

83
class DockerImageTagValueError(InterpolationError):
9✔
84
    pass
9✔
85

86

87
class DockerRepositoryNameError(InterpolationError):
9✔
88
    pass
9✔
89

90

91
class DockerBuildTargetStageError(ValueError):
9✔
92
    pass
9✔
93

94

95
class DockerImageOptionValueError(InterpolationError):
9✔
96
    pass
9✔
97

98

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

272

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

279

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

287

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

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

297
    registries: list[DockerInfoV1Registry]
9✔
298

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

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

328
        return json.dumps(asdict(info)).encode()
4✔
329

330

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

339

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

348

349
def get_build_options(
9✔
350
    context: DockerBuildContext,
351
    field_set: DockerPackageFieldSet,
352
    global_target_stage_option: str | None,
353
    global_build_hosts_options: dict | None,
354
    global_build_no_cache_option: bool | None,
355
    use_buildx_option: bool,
356
    target: Target,
357
    output_options: FrozenDict[str, str] | None = None,
358
) -> Iterator[str]:
359
    # Build options from target fields inheriting from DockerBuildOptionFieldMixin
360
    for field_type in target.field_types:
4✔
361
        if issubclass(field_type, DockerBuildKitOptionField):
4✔
362
            if use_buildx_option is not True:
4✔
363
                if target[field_type].value != target[field_type].default:
4✔
364
                    raise DockerImageOptionValueError(
1✔
365
                        f"The {target[field_type].alias} field on the = `{target.alias}` target in `{target.address}` was set to `{target[field_type].value}`"
366
                        f" and buildx is not enabled. Buildx must be enabled via the Docker subsystem options in order to use this field."
367
                    )
368
                else:
369
                    # Case where BuildKit option has a default value - still should not be generated
370
                    continue
4✔
371

372
        if issubclass(
4✔
373
            field_type,
374
            (
375
                DockerBuildOptionFieldMixin,
376
                DockerBuildOptionFieldMultiValueDictMixin,
377
                DockerBuildOptionFieldListOfMultiValueDictMixin,
378
                DockerBuildOptionFieldValueMixin,
379
                DockerBuildOptionFieldMultiValueMixin,
380
                DockerBuildOptionFlagFieldMixin,
381
            ),
382
        ):
383
            source = InterpolationContext.TextSource(
4✔
384
                address=target.address, target_alias=target.alias, field_alias=field_type.alias
385
            )
386
            format = partial(
4✔
387
                context.interpolation_context.format,
388
                source=source,
389
                error_cls=DockerImageOptionValueError,
390
            )
391
            if field_type is DockerImageBuildImageOutputField and output_options is not None:
4✔
392
                yield (
1✔
393
                    f"{DockerImageBuildImageOutputField.docker_build_option}="
394
                    + ",".join(f"{key}={value}" for key, value in output_options.items())
395
                )
396
                continue
1✔
397
            yield from target[field_type].options(
4✔
398
                format, global_build_hosts_options=global_build_hosts_options
399
            )
400

401
    # Target stage
402
    target_stage = None
4✔
403
    if global_target_stage_option in context.stages:
4✔
404
        target_stage = global_target_stage_option
1✔
405
    elif field_set.target_stage.value:
4✔
406
        target_stage = field_set.target_stage.value
1✔
407
        if target_stage not in context.stages:
1✔
408
            raise DockerBuildTargetStageError(
1✔
409
                f"The {field_set.target_stage.alias!r} field in `{target.alias}` "
410
                f"{field_set.address} was set to {target_stage!r}"
411
                + (
412
                    f", but there is no such stage in `{context.dockerfile}`. "
413
                    f"Available stages: {', '.join(context.stages)}."
414
                    if context.stages
415
                    else f", but there are no named stages in `{context.dockerfile}`."
416
                )
417
            )
418

419
    if target_stage:
4✔
420
        yield from ("--target", target_stage)
1✔
421

422
    if global_build_no_cache_option:
4✔
423
        yield "--no-cache"
1✔
424

425

426
@dataclass(frozen=True)
9✔
427
class GetImageRefsRequest:
9✔
428
    field_set: DockerPackageFieldSet
9✔
429
    build_upstream_images: bool
9✔
430

431

432
class DockerImageRefs(Collection[ImageRefRegistry]):
9✔
433
    pass
9✔
434

435

436
@rule
9✔
437
async def get_image_refs(
9✔
438
    request: GetImageRefsRequest, options: DockerOptions, union_membership: UnionMembership
439
) -> DockerImageRefs:
440
    context, wrapped_target = await concurrently(
4✔
441
        create_docker_build_context(
442
            DockerBuildContextRequest(
443
                address=request.field_set.address,
444
                build_upstream_images=request.build_upstream_images,
445
            ),
446
            **implicitly(),
447
        ),
448
        resolve_target(
449
            WrappedTargetRequest(request.field_set.address, description_of_origin="<infallible>"),
450
            **implicitly(),
451
        ),
452
    )
453

454
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
4✔
455
    additional_image_tags = await concurrently(
4✔
456
        get_docker_image_tags(
457
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
458
        )
459
        for image_tags_request_cls in image_tags_requests
460
        if image_tags_request_cls.is_applicable(wrapped_target.target)
461
    )
462

463
    return DockerImageRefs(
4✔
464
        request.field_set.image_refs(
465
            default_repository=options.default_repository,
466
            registries=options.registries(),
467
            interpolation_context=context.interpolation_context,
468
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
469
        )
470
    )
471

472

473
@dataclass(frozen=True)
9✔
474
class DockerImageBuildProcess:
9✔
475
    process: Process
9✔
476
    context: DockerBuildContext
9✔
477
    context_root: str
9✔
478
    image_refs: DockerImageRefs
9✔
479
    tags: tuple[str, ...]
9✔
480
    captured_outputs: DockerBuildCapturedOutputs | None = None
9✔
481

482

483
@dataclass(frozen=True)
9✔
484
class DockerBuildCapturedOutputs:
9✔
485
    output_options: FrozenDict[str, str]
9✔
486
    output_files: tuple[str, ...]
9✔
487
    output_directories: tuple[str, ...]
9✔
488

489

490
def _validate_output_capture_path(
9✔
491
    path: str, field_set: DockerPackageFieldSet, field_alias: str
492
) -> str:
493
    normalized_path = os.path.normpath(path)
1✔
494
    if (
1✔
495
        os.path.isabs(normalized_path)
496
        or normalized_path == ".."
497
        or normalized_path.startswith(f"..{os.sep}")
498
    ):
499
        raise InvalidFieldException(
×
500
            softwrap(
501
                f"""
502
                The `{field_alias}` field in `docker_image` {field_set.address} contains {path!r}.
503
                Pants can only capture relative paths that stay inside the build root.
504
                """
505
            )
506
        )
507
    return normalized_path
1✔
508

509

510
def _docker_build_captured_outputs(
9✔
511
    field_set: DockerPackageFieldSet,
512
) -> DockerBuildCapturedOutputs | None:
513
    output_files = tuple(
4✔
514
        _validate_output_capture_path(path, field_set, DockerImageOutputFilesField.alias)
515
        for path in (field_set.output_files.value or ())
516
    )
517
    output_directories = tuple(
4✔
518
        _validate_output_capture_path(path, field_set, DockerImageOutputDirectoriesField.alias)
519
        for path in (field_set.output_directories.value or ())
520
    )
521
    if not (output_files or output_directories):
4✔
522
        return None
4✔
523

524
    if field_set.output.value != field_set.output.default:
1✔
525
        raise InvalidFieldException(
1✔
526
            softwrap(
527
                f"""
528
                The `docker_image` {field_set.address} sets both `{DockerImageBuildImageOutputField.alias}`
529
                and output capture fields. Use `{DockerImageOutputFilesField.alias}` and/or
530
                `{DockerImageOutputDirectoriesField.alias}` without setting
531
                `{DockerImageBuildImageOutputField.alias}`; Pants will automatically use the
532
                BuildKit local output exporter.
533
                """
534
            )
535
        )
536

537
    return DockerBuildCapturedOutputs(
1✔
538
        output_options=FrozenDict({"type": "local", "dest": "."}),
539
        output_files=output_files,
540
        output_directories=output_directories,
541
    )
542

543

544
def _docker_build_captured_output_path(field_set: DockerPackageFieldSet) -> str:
9✔
545
    if field_set.output_path.value != field_set.output_path.default:
1✔
546
        return field_set.output_path.value_or_default(file_ending=None)
1✔
547
    return field_set.address.spec_path
1✔
548

549

550
@rule
9✔
551
async def get_docker_image_build_process(
9✔
552
    field_set: DockerPackageFieldSet, options: DockerOptions, docker: DockerBinary
553
) -> DockerImageBuildProcess:
554
    context, wrapped_target, image_refs = await concurrently(
4✔
555
        create_docker_build_context(
556
            DockerBuildContextRequest(
557
                address=field_set.address,
558
                build_upstream_images=True,
559
            ),
560
            **implicitly(),
561
        ),
562
        resolve_target(
563
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
564
            **implicitly(),
565
        ),
566
        get_image_refs(
567
            GetImageRefsRequest(
568
                field_set=field_set,
569
                build_upstream_images=True,
570
            ),
571
            **implicitly(),
572
        ),
573
    )
574
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
4✔
575
    if not tags:
4✔
576
        raise InvalidFieldException(
1✔
577
            softwrap(
578
                f"""
579
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
580
                empty, unless there is a custom plugin providing additional tags using the
581
                `DockerImageTagsRequest` union type.
582
                """
583
            )
584
        )
585

586
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
587
    # image-building process correctly when an upstream image changes, even though the
588
    # process itself does not consume this data.
589
    env = {
4✔
590
        **context.build_env.environment,
591
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
592
    }
593
    context_root = field_set.get_context_root(options.default_context_root)
4✔
594
    captured_outputs = _docker_build_captured_outputs(field_set)
4✔
595
    if captured_outputs and not options.use_buildx:
4✔
596
        raise DockerImageOptionValueError(
×
597
            softwrap(
598
                f"""
599
                The `docker_image` {field_set.address} sets `{DockerImageOutputFilesField.alias}`
600
                and/or `{DockerImageOutputDirectoriesField.alias}`, which requires BuildKit.
601
                Enable BuildKit with `[docker].use_buildx = true`.
602
                """
603
            )
604
        )
605
    process = docker.build_image(
4✔
606
        build_args=context.build_args,
607
        digest=context.digest,
608
        dockerfile=context.dockerfile,
609
        context_root=context_root,
610
        env=env,
611
        tags=tags,
612
        use_buildx=options.use_buildx,
613
        extra_args=tuple(
614
            get_build_options(
615
                context=context,
616
                field_set=field_set,
617
                global_target_stage_option=options.build_target_stage,
618
                global_build_hosts_options=options.build_hosts,
619
                global_build_no_cache_option=options.build_no_cache,
620
                use_buildx_option=options.use_buildx,
621
                target=wrapped_target.target,
622
                output_options=captured_outputs.output_options if captured_outputs else None,
623
            )
624
        ),
625
        output_files=captured_outputs.output_files if captured_outputs else (),
626
        output_directories=captured_outputs.output_directories if captured_outputs else (),
627
    )
628
    return DockerImageBuildProcess(
4✔
629
        process=process,
630
        context=context,
631
        context_root=context_root,
632
        image_refs=image_refs,
633
        tags=tags,
634
        captured_outputs=captured_outputs,
635
    )
636

637

638
@rule
9✔
639
async def build_docker_image(
9✔
640
    field_set: DockerPackageFieldSet,
641
    options: DockerOptions,
642
    global_options: GlobalOptions,
643
    docker: DockerBinary,
644
    keep_sandboxes: KeepSandboxes,
645
) -> BuiltPackage:
646
    """Build a Docker image using `docker build`."""
647
    # Check if this build would push and handle according to push_on_package behavior
648
    if field_set.pushes_on_package():
4✔
649
        match options.push_on_package:
1✔
650
            case DockerPushOnPackageBehavior.IGNORE:
1✔
651
                return BuiltPackage(EMPTY_DIGEST, ())
1✔
652
            case DockerPushOnPackageBehavior.ERROR:
1✔
653
                raise DockerPushOnPackageException(field_set.address)
1✔
654
            case DockerPushOnPackageBehavior.WARN:
1✔
655
                logger.warning(
1✔
656
                    f"Docker image {field_set.address} will push to a registry during packaging"
657
                )
658

659
    build_process = await get_docker_image_build_process(field_set, **implicitly())
4✔
660
    result = await execute_process(build_process.process, **implicitly())
4✔
661

662
    if result.exit_code != 0:
4✔
663
        msg = f"Docker build failed for `docker_image` {field_set.address}."
1✔
664
        if options.suggest_renames:
1✔
665
            maybe_help_msg = format_docker_build_context_help_message(
1✔
666
                context_root=build_process.context_root,
667
                context=build_process.context,
668
                colors=global_options.colors,
669
            )
670
            if maybe_help_msg:
1✔
671
                msg += " " + maybe_help_msg
1✔
672

673
        logger.warning(msg)
1✔
674

675
        raise ProcessExecutionFailure(
1✔
676
            result.exit_code,
677
            result.stdout,
678
            result.stderr,
679
            build_process.process.description,
680
            keep_sandboxes=keep_sandboxes,
681
        )
682

683
    docker_build_output_msg = "\n".join(
4✔
684
        (
685
            f"Docker build output for {build_process.tags[0]}:",
686
            "stdout:",
687
            result.stdout.decode(),
688
            "stderr:",
689
            result.stderr.decode(),
690
        )
691
    )
692

693
    if options.build_verbose:
4✔
694
        logger.info(docker_build_output_msg)
×
695
    else:
696
        logger.debug(docker_build_output_msg)
4✔
697

698
    if build_process.captured_outputs:
4✔
699
        outputs_match_mode = field_set.outputs_match_mode.enum_value
1✔
700
        await check_outputs(
1✔
701
            output_digest=result.output_digest,
702
            output_files=build_process.captured_outputs.output_files,
703
            output_directories=build_process.captured_outputs.output_directories,
704
            outputs_match_error_behavior=outputs_match_mode.glob_match_error_behavior,
705
            outputs_match_mode=outputs_match_mode.glob_expansion_conjunction,
706
            address=field_set.address,
707
        )
708
        output_path = _docker_build_captured_output_path(field_set)
1✔
709
        digest = (
1✔
710
            await add_prefix(AddPrefix(result.output_digest, output_path))
711
            if result.output_digest != EMPTY_DIGEST and output_path and output_path != "."
712
            else result.output_digest
713
        )
714
        snapshot = await digest_to_snapshot(digest)
1✔
715
        artifact_paths = snapshot.files or snapshot.dirs
1✔
716
        return BuiltPackage(
1✔
717
            digest,
718
            tuple(BuiltPackageArtifact(relpath=path) for path in artifact_paths),
719
        )
720

721
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
4✔
722
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
4✔
723
    metadata = DockerInfoV1.serialize(build_process.image_refs, image_id=image_id)
4✔
724
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
4✔
725

726
    return BuiltPackage(
4✔
727
        digest,
728
        (BuiltDockerImage.create(image_id, build_process.tags, metadata_filename),),
729
    )
730

731

732
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
9✔
733
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
734
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
735
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
736
    #  to be an issue in practice.
737
    if docker.is_podman:
4✔
738
        for output in outputs:
1✔
739
            try:
1✔
740
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
1✔
741
            except ValueError:
×
742
                continue
×
743

744
            if success.startswith("Successfully tagged"):
1✔
745
                return image_id
1✔
746

747
    else:
748
        image_id_regexp = re.compile(
4✔
749
            "|".join(
750
                (
751
                    # BuildKit output.
752
                    r"(writing image (?P<digest>sha256:\S+))",
753
                    # BuildKit with containerd-snapshotter output.
754
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
755
                    # BuildKit with containerd-snapshotter output and no attestation.
756
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
757
                    # Docker output.
758
                    r"(Successfully built (?P<short_id>\S+))",
759
                ),
760
            )
761
        )
762
        for output in outputs:
4✔
763
            image_id_match = next(
4✔
764
                (
765
                    match
766
                    for match in (
767
                        re.search(image_id_regexp, line)
768
                        for line in reversed(output.decode().split("\n"))
769
                    )
770
                    if match
771
                ),
772
                None,
773
            )
774
            if image_id_match:
4✔
775
                image_id = (
4✔
776
                    image_id_match.group("digest")
777
                    or image_id_match.group("short_id")
778
                    or image_id_match.group("manifest_list")
779
                    or image_id_match.group("manifest")
780
                )
781
                return image_id
4✔
782

783
    return "<unknown>"
1✔
784

785

786
def format_docker_build_context_help_message(
9✔
787
    context_root: str, context: DockerBuildContext, colors: bool
788
) -> str | None:
789
    paths_outside_context_root: list[str] = []
1✔
790

791
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
1✔
792
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
793
        instruction_path, context_path = paths
1✔
794
        if not context_path:
1✔
795
            return paths
×
796
        dst = os.path.relpath(context_path, context_root)
1✔
797
        if dst.startswith("../"):
1✔
798
            paths_outside_context_root.append(context_path)
1✔
799
            return ("", "")
1✔
800
        if instruction_path == dst:
1✔
801
            return ("", "")
1✔
802
        return instruction_path, dst
1✔
803

804
    # Adjust context paths based on `context_root`.
805
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
1✔
806
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
807
    )
808

809
    if not (copy_source_vs_context_source or paths_outside_context_root):
1✔
810
        # No issues found.
811
        return None
×
812

813
    msg = ""
1✔
814
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
1✔
815
    if has_unsourced_copy:
1✔
816
        msg += (
1✔
817
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
818
            f"been found in the Docker build context.\n\n"
819
        )
820

821
        renames = sorted(
1✔
822
            format_rename_suggestion(src, dst, colors=colors)
823
            for src, dst in copy_source_vs_context_source
824
            if src and dst
825
        )
826
        if renames:
1✔
827
            msg += (
1✔
828
                f"However there are possible matches. Please review the following list of "
829
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
830
            )
831

832
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
1✔
833
        if unknown:
1✔
834
            msg += (
×
835
                f"The following files were not found in the Docker build context:\n\n"
836
                f"{bullet_list(unknown)}\n\n"
837
            )
838

839
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
1✔
840
    if unreferenced:
1✔
841
        msg += (
1✔
842
            f"There are files in the Docker build context that were not referenced by "
843
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
844
        )
845

846
    if paths_outside_context_root:
1✔
847
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
1✔
848
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
1✔
849
        new_context_root = os.path.commonpath(context_paths)
1✔
850
        msg += (
1✔
851
            "There are unreachable files in these directories, excluded from the build context "
852
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
853
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
854
            "files in the build context, otherwise relocate the files to be part of the current "
855
            f"`context_root` {context_root!r}."
856
        )
857

858
    return msg
1✔
859

860

861
def rules():
9✔
862
    return [
7✔
863
        *collect_rules(),
864
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
865
    ]
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