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

pantsbuild / pants / 21728933780

05 Feb 2026 09:20PM UTC coverage: 71.92%. First build
21728933780

Pull #23074

github

web-flow
Merge e578429d8 into 8fa758091
Pull Request #23074: Skip Preemptive Docker

35 of 62 new or added lines in 3 files covered. (56.45%)

59057 of 82115 relevant lines covered (71.92%)

2.67 hits per line

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

42.41
/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
6✔
4

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

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

60
logger = logging.getLogger(__name__)
6✔
61

62

63
class DockerImageTagValueError(InterpolationError):
6✔
64
    pass
6✔
65

66

67
class DockerRepositoryNameError(InterpolationError):
6✔
68
    pass
6✔
69

70

71
class DockerBuildTargetStageError(ValueError):
6✔
72
    pass
6✔
73

74

75
class DockerImageOptionValueError(InterpolationError):
6✔
76
    pass
6✔
77

78

79
@dataclass(frozen=True)
6✔
80
class DockerPackageFieldSet(PackageFieldSet):
6✔
81
    required_fields = (DockerImageSourceField,)
6✔
82

83
    context_root: DockerImageContextRootField
6✔
84
    registries: DockerImageRegistriesField
6✔
85
    repository: DockerImageRepositoryField
6✔
86
    source: DockerImageSourceField
6✔
87
    tags: DockerImageTagsField
6✔
88
    target_stage: DockerImageTargetStageField
6✔
89
    output_path: OutputPathField
6✔
90
    output: DockerImageBuildImageOutputField
6✔
91

92
    def pushes_on_package(self) -> bool:
6✔
93
        """Returns True if this docker_image target would push to a registry during packaging."""
94
        value_or_default = self.output.value or self.output.default
×
95
        return value_or_default.get("push") == "true" or value_or_default["type"] == "registry"
×
96

97
    def format_tag(self, tag: str, interpolation_context: InterpolationContext) -> str:
6✔
98
        source = InterpolationContext.TextSource(
×
99
            address=self.address, target_alias="docker_image", field_alias=self.tags.alias
100
        )
101
        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
×
102

103
    def format_repository(
6✔
104
        self,
105
        default_repository: str,
106
        interpolation_context: InterpolationContext,
107
        registry: DockerRegistryOptions | None = None,
108
    ) -> str:
109
        repository_context = InterpolationContext.from_dict(
×
110
            {
111
                "name": self.address.target_name,
112
                "directory": os.path.basename(self.address.spec_path),
113
                "full_directory": self.address.spec_path,
114
                "parent_directory": os.path.basename(os.path.dirname(self.address.spec_path)),
115
                "default_repository": default_repository,
116
                "target_repository": self.repository.value or default_repository,
117
                **interpolation_context,
118
            }
119
        )
120
        if registry and registry.repository:
×
121
            repository_text = registry.repository
×
122
            source = InterpolationContext.TextSource(
×
123
                options_scope=f"[docker.registries.{registry.alias or registry.address}].repository"
124
            )
125
        elif self.repository.value:
×
126
            repository_text = self.repository.value
×
127
            source = InterpolationContext.TextSource(
×
128
                address=self.address, target_alias="docker_image", field_alias=self.repository.alias
129
            )
130
        else:
131
            repository_text = default_repository
×
132
            source = InterpolationContext.TextSource(options_scope="[docker].default_repository")
×
133
        return repository_context.format(
×
134
            repository_text, source=source, error_cls=DockerRepositoryNameError
135
        ).lower()
136

137
    def format_image_ref_tags(
6✔
138
        self,
139
        repository: str,
140
        tags: tuple[str, ...],
141
        interpolation_context: InterpolationContext,
142
        uses_local_alias,
143
    ) -> Iterator[ImageRefTag]:
144
        for tag in tags:
×
145
            formatted = self.format_tag(tag, interpolation_context)
×
146
            yield ImageRefTag(
×
147
                template=tag,
148
                formatted=formatted,
149
                full_name=":".join(s for s in [repository, formatted] if s),
150
                uses_local_alias=uses_local_alias,
151
            )
152

153
    def image_refs(
6✔
154
        self,
155
        default_repository: str,
156
        registries: DockerRegistries,
157
        interpolation_context: InterpolationContext,
158
        additional_tags: tuple[str, ...] = (),
159
    ) -> Iterator[ImageRefRegistry]:
160
        """The per-registry image refs: each returned element is a collection of the tags applied to
161
        the image in a single registry.
162

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

168
        This function returns all image refs to apply to the Docker image, grouped by
169
        registry. Within each registry, the `tags` attribute contains a metadata about each tag in
170
        the context of that registry, and the `full_name` attribute of each `ImageRefTag` provides
171
        the image ref, of the following form:
172

173
            [<registry>/]<repository-name>[:<tag>]
174

175
        Where the `<repository-name>` may contain any number of separating slashes `/`, depending on
176
        the `default_repository` from configuration or the `repository` field on the target
177
        `docker_image`.
178

179
        This method will always return at least one `ImageRefRegistry`, and there will be at least
180
        one tag.
181
        """
182
        image_tags = (self.tags.value or ()) + additional_tags
×
183
        registries_options = tuple(registries.get(*(self.registries.value or [])))
×
184
        if not registries_options:
×
185
            # The image name is also valid as image ref without registry.
186
            repository = self.format_repository(default_repository, interpolation_context)
×
187
            yield ImageRefRegistry(
×
188
                registry=None,
189
                repository=repository,
190
                tags=tuple(
191
                    self.format_image_ref_tags(
192
                        repository, image_tags, interpolation_context, uses_local_alias=False
193
                    )
194
                ),
195
            )
196
            return
×
197

198
        for registry in registries_options:
×
199
            repository = self.format_repository(default_repository, interpolation_context, registry)
×
200
            address_repository = "/".join([registry.address, repository])
×
201
            if registry.use_local_alias and registry.alias:
×
202
                alias_repository = "/".join([registry.alias, repository])
×
203
            else:
204
                alias_repository = None
×
205

206
            yield ImageRefRegistry(
×
207
                registry=registry,
208
                repository=repository,
209
                tags=(
210
                    *self.format_image_ref_tags(
211
                        address_repository,
212
                        image_tags + registry.extra_image_tags,
213
                        interpolation_context,
214
                        uses_local_alias=False,
215
                    ),
216
                    *(
217
                        self.format_image_ref_tags(
218
                            alias_repository,
219
                            image_tags + registry.extra_image_tags,
220
                            interpolation_context,
221
                            uses_local_alias=True,
222
                        )
223
                        if alias_repository
224
                        else []
225
                    ),
226
                ),
227
            )
228

229
    def get_context_root(self, default_context_root: str) -> str:
6✔
230
        """Examines `default_context_root` and `self.context_root.value` and translates that to a
231
        context root for the Docker build operation.
232

233
        That is, in the configuration/field value, the context root is relative to build root when
234
        in the form `path/..` (implies semantics as `//path/..` for target addresses) or the BUILD
235
        file when `./path/..`.
236

237
        The returned path is always relative to the build root.
238
        """
239
        if self.context_root.value is not None:
×
240
            context_root = self.context_root.value
×
241
        else:
242
            context_root = cast(
×
243
                str, self.context_root.compute_value(default_context_root, self.address)
244
            )
245
        if context_root.startswith("./"):
×
246
            context_root = os.path.join(self.address.spec_path, context_root)
×
247
        return os.path.normpath(context_root)
×
248

249

250
@dataclass(frozen=True)
6✔
251
class ImageRefRegistry:
6✔
252
    registry: DockerRegistryOptions | None
6✔
253
    repository: str
6✔
254
    tags: tuple[ImageRefTag, ...]
6✔
255

256

257
@dataclass(frozen=True)
6✔
258
class ImageRefTag:
6✔
259
    template: str
6✔
260
    formatted: str
6✔
261
    full_name: str
6✔
262
    uses_local_alias: bool
6✔
263

264

265
@dataclass(frozen=True)
6✔
266
class DockerInfoV1:
6✔
267
    """The format of the `$target_name.docker-info.json` file."""
268

269
    version: Literal[1]
6✔
270
    image_id: str
6✔
271
    # It'd be good to include the digest here (e.g. to allow 'docker run
272
    # registry/repository@digest'), but that is only known after pushing to a V2 registry
273

274
    registries: list[DockerInfoV1Registry]
6✔
275

276
    @staticmethod
6✔
277
    def serialize(image_refs: tuple[ImageRefRegistry, ...], image_id: str) -> bytes:
6✔
278
        # make sure these are in a consistent order (the exact order doesn't matter
279
        # so much), no matter how they were configured
280
        sorted_refs = sorted(image_refs, key=lambda r: r.registry.address if r.registry else "")
×
281

282
        info = DockerInfoV1(
×
283
            version=1,
284
            image_id=image_id,
285
            registries=[
286
                DockerInfoV1Registry(
287
                    alias=r.registry.alias if r.registry and r.registry.alias else None,
288
                    address=r.registry.address if r.registry else None,
289
                    repository=r.repository,
290
                    tags=[
291
                        DockerInfoV1ImageTag(
292
                            template=t.template,
293
                            tag=t.formatted,
294
                            uses_local_alias=t.uses_local_alias,
295
                            name=t.full_name,
296
                        )
297
                        # consistent order, as above
298
                        for t in sorted(r.tags, key=lambda t: t.full_name)
299
                    ],
300
                )
301
                for r in sorted_refs
302
            ],
303
        )
304

305
        return json.dumps(asdict(info)).encode()
×
306

307

308
@dataclass(frozen=True)
6✔
309
class DockerInfoV1Registry:
6✔
310
    # set if registry was specified as `@something`
311
    alias: str | None
6✔
312
    address: str | None
6✔
313
    repository: str
6✔
314
    tags: list[DockerInfoV1ImageTag]
6✔
315

316

317
@dataclass(frozen=True)
6✔
318
class DockerInfoV1ImageTag:
6✔
319
    template: str
6✔
320
    tag: str
6✔
321
    uses_local_alias: bool
6✔
322
    # for convenience, include the concatenated registry/repository:tag name (using this tag)
323
    name: str
6✔
324

325

326
def get_build_options(
6✔
327
    context: DockerBuildContext,
328
    field_set: DockerPackageFieldSet,
329
    global_target_stage_option: str | None,
330
    global_build_hosts_options: dict | None,
331
    global_build_no_cache_option: bool | None,
332
    use_buildx_option: bool,
333
    target: Target,
334
) -> Iterator[str]:
335
    # Build options from target fields inheriting from DockerBuildOptionFieldMixin
336
    for field_type in target.field_types:
×
337
        if issubclass(field_type, DockerBuildKitOptionField):
×
338
            if use_buildx_option is not True:
×
339
                if target[field_type].value != target[field_type].default:
×
340
                    raise DockerImageOptionValueError(
×
341
                        f"The {target[field_type].alias} field on the = `{target.alias}` target in `{target.address}` was set to `{target[field_type].value}`"
342
                        f" and buildx is not enabled. Buildx must be enabled via the Docker subsystem options in order to use this field."
343
                    )
344
                else:
345
                    # Case where BuildKit option has a default value - still should not be generated
346
                    continue
×
347

348
        if issubclass(
×
349
            field_type,
350
            (
351
                DockerBuildOptionFieldMixin,
352
                DockerBuildOptionFieldMultiValueDictMixin,
353
                DockerBuildOptionFieldListOfMultiValueDictMixin,
354
                DockerBuildOptionFieldValueMixin,
355
                DockerBuildOptionFieldMultiValueMixin,
356
                DockerBuildOptionFlagFieldMixin,
357
            ),
358
        ):
359
            source = InterpolationContext.TextSource(
×
360
                address=target.address, target_alias=target.alias, field_alias=field_type.alias
361
            )
362
            format = partial(
×
363
                context.interpolation_context.format,
364
                source=source,
365
                error_cls=DockerImageOptionValueError,
366
            )
367
            yield from target[field_type].options(
×
368
                format, global_build_hosts_options=global_build_hosts_options
369
            )
370

371
    # Target stage
372
    target_stage = None
×
373
    if global_target_stage_option in context.stages:
×
374
        target_stage = global_target_stage_option
×
375
    elif field_set.target_stage.value:
×
376
        target_stage = field_set.target_stage.value
×
377
        if target_stage not in context.stages:
×
378
            raise DockerBuildTargetStageError(
×
379
                f"The {field_set.target_stage.alias!r} field in `{target.alias}` "
380
                f"{field_set.address} was set to {target_stage!r}"
381
                + (
382
                    f", but there is no such stage in `{context.dockerfile}`. "
383
                    f"Available stages: {', '.join(context.stages)}."
384
                    if context.stages
385
                    else f", but there are no named stages in `{context.dockerfile}`."
386
                )
387
            )
388

389
    if target_stage:
×
390
        yield from ("--target", target_stage)
×
391

392
    if global_build_no_cache_option:
×
393
        yield "--no-cache"
×
394

395

396
@dataclass(frozen=True)
6✔
397
class GetImageRefsRequest:
6✔
398
    field_set: DockerPackageFieldSet
6✔
399
    build_upstream_images: bool
6✔
400

401

402
class DockerImageRefs(Collection[ImageRefRegistry]):
6✔
403
    pass
6✔
404

405

406
@rule
6✔
407
async def get_image_refs(
6✔
408
    request: GetImageRefsRequest, options: DockerOptions, union_membership: UnionMembership
409
) -> DockerImageRefs:
410
    context, wrapped_target = await concurrently(
×
411
        create_docker_build_context(
412
            DockerBuildContextRequest(
413
                address=request.field_set.address,
414
                build_upstream_images=request.build_upstream_images,
415
            ),
416
            **implicitly(),
417
        ),
418
        resolve_target(
419
            WrappedTargetRequest(request.field_set.address, description_of_origin="<infallible>"),
420
            **implicitly(),
421
        ),
422
    )
423

424
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
×
425
    additional_image_tags = await concurrently(
×
426
        get_docker_image_tags(
427
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
428
        )
429
        for image_tags_request_cls in image_tags_requests
430
        if image_tags_request_cls.is_applicable(wrapped_target.target)
431
    )
432

NEW
433
    return DockerImageRefs(
×
434
        request.field_set.image_refs(
435
            default_repository=options.default_repository,
436
            registries=options.registries(),
437
            interpolation_context=context.interpolation_context,
438
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
439
        )
440
    )
441

442

443
@dataclass(frozen=True)
6✔
444
class DockerImageBuildProcess:
6✔
445
    process: Process
6✔
446
    context: DockerBuildContext
6✔
447
    context_root: str
6✔
448
    image_refs: DockerImageRefs
6✔
449
    tags: tuple[str, ...]
6✔
450

451

452
@rule
6✔
453
async def get_docker_image_build_process(
6✔
454
    field_set: DockerPackageFieldSet, options: DockerOptions, docker: DockerBinary
455
) -> DockerImageBuildProcess:
NEW
456
    context, wrapped_target, image_refs = await concurrently(
×
457
        create_docker_build_context(
458
            DockerBuildContextRequest(
459
                address=field_set.address,
460
                build_upstream_images=True,
461
            ),
462
            **implicitly(),
463
        ),
464
        resolve_target(
465
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
466
            **implicitly(),
467
        ),
468
        get_image_refs(
469
            GetImageRefsRequest(
470
                field_set=field_set,
471
                build_upstream_images=True,
472
            ),
473
            **implicitly(),
474
        ),
475
    )
476
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
×
477
    if not tags:
×
478
        raise InvalidFieldException(
×
479
            softwrap(
480
                f"""
481
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
482
                empty, unless there is a custom plugin providing additional tags using the
483
                `DockerImageTagsRequest` union type.
484
                """
485
            )
486
        )
487

488
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
489
    # image-building process correctly when an upstream image changes, even though the
490
    # process itself does not consume this data.
491
    env = {
×
492
        **context.build_env.environment,
493
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
494
    }
495
    context_root = field_set.get_context_root(options.default_context_root)
×
496
    process = docker.build_image(
×
497
        build_args=context.build_args,
498
        digest=context.digest,
499
        dockerfile=context.dockerfile,
500
        context_root=context_root,
501
        env=env,
502
        tags=tags,
503
        use_buildx=options.use_buildx,
504
        extra_args=tuple(
505
            get_build_options(
506
                context=context,
507
                field_set=field_set,
508
                global_target_stage_option=options.build_target_stage,
509
                global_build_hosts_options=options.build_hosts,
510
                global_build_no_cache_option=options.build_no_cache,
511
                use_buildx_option=options.use_buildx,
512
                target=wrapped_target.target,
513
            )
514
        ),
515
    )
NEW
516
    return DockerImageBuildProcess(
×
517
        process=process,
518
        context=context,
519
        context_root=context_root,
520
        image_refs=image_refs,
521
        tags=tags,
522
    )
523

524

525
@rule
6✔
526
async def build_docker_image(
6✔
527
    field_set: DockerPackageFieldSet,
528
    options: DockerOptions,
529
    global_options: GlobalOptions,
530
    docker: DockerBinary,
531
    keep_sandboxes: KeepSandboxes,
532
) -> BuiltPackage:
533
    """Build a Docker image using `docker build`."""
534
    # Check if this build would push and handle according to push_on_package behavior
NEW
535
    if field_set.pushes_on_package():
×
NEW
536
        match options.push_on_package:
×
NEW
537
            case DockerPushOnPackageBehavior.IGNORE:
×
NEW
538
                return BuiltPackage(EMPTY_DIGEST, ())
×
NEW
539
            case DockerPushOnPackageBehavior.ERROR:
×
NEW
540
                raise DockerPushOnPackageException(field_set.address)
×
NEW
541
            case DockerPushOnPackageBehavior.WARN:
×
NEW
542
                logger.warning(
×
543
                    f"Docker image {field_set.address} will push to a registry during packaging"
544
                )
545

NEW
546
    build_process = await get_docker_image_build_process(field_set, **implicitly())
×
NEW
547
    result = await execute_process(build_process.process, **implicitly())
×
548

549
    if result.exit_code != 0:
×
550
        msg = f"Docker build failed for `docker_image` {field_set.address}."
×
551
        if options.suggest_renames:
×
552
            maybe_help_msg = format_docker_build_context_help_message(
×
553
                context_root=build_process.context_root,
554
                context=build_process.context,
555
                colors=global_options.colors,
556
            )
557
            if maybe_help_msg:
×
558
                msg += " " + maybe_help_msg
×
559

560
        logger.warning(msg)
×
561

562
        raise ProcessExecutionFailure(
×
563
            result.exit_code,
564
            result.stdout,
565
            result.stderr,
566
            build_process.process.description,
567
            keep_sandboxes=keep_sandboxes,
568
        )
569

570
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
×
571
    docker_build_output_msg = "\n".join(
×
572
        (
573
            f"Docker build output for {build_process.tags[0]}:",
574
            "stdout:",
575
            result.stdout.decode(),
576
            "stderr:",
577
            result.stderr.decode(),
578
        )
579
    )
580

581
    if options.build_verbose:
×
582
        logger.info(docker_build_output_msg)
×
583
    else:
584
        logger.debug(docker_build_output_msg)
×
585

586
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
×
NEW
587
    metadata = DockerInfoV1.serialize(build_process.image_refs, image_id=image_id)
×
588
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
×
589

590
    return BuiltPackage(
×
591
        digest,
592
        (BuiltDockerImage.create(image_id, build_process.tags, metadata_filename),),
593
    )
594

595

596
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
6✔
597
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
598
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
599
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
600
    #  to be an issue in practice.
601
    if docker.is_podman:
×
602
        for output in outputs:
×
603
            try:
×
604
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
×
605
            except ValueError:
×
606
                continue
×
607

608
            if success.startswith("Successfully tagged"):
×
609
                return image_id
×
610

611
    else:
612
        image_id_regexp = re.compile(
×
613
            "|".join(
614
                (
615
                    # BuildKit output.
616
                    r"(writing image (?P<digest>sha256:\S+))",
617
                    # BuildKit with containerd-snapshotter output.
618
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
619
                    # BuildKit with containerd-snapshotter output and no attestation.
620
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
621
                    # Docker output.
622
                    r"(Successfully built (?P<short_id>\S+))",
623
                ),
624
            )
625
        )
626
        for output in outputs:
×
627
            image_id_match = next(
×
628
                (
629
                    match
630
                    for match in (
631
                        re.search(image_id_regexp, line)
632
                        for line in reversed(output.decode().split("\n"))
633
                    )
634
                    if match
635
                ),
636
                None,
637
            )
638
            if image_id_match:
×
639
                image_id = (
×
640
                    image_id_match.group("digest")
641
                    or image_id_match.group("short_id")
642
                    or image_id_match.group("manifest_list")
643
                    or image_id_match.group("manifest")
644
                )
645
                return image_id
×
646

647
    return "<unknown>"
×
648

649

650
def format_docker_build_context_help_message(
6✔
651
    context_root: str, context: DockerBuildContext, colors: bool
652
) -> str | None:
653
    paths_outside_context_root: list[str] = []
×
654

655
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
×
656
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
657
        instruction_path, context_path = paths
×
658
        if not context_path:
×
659
            return paths
×
660
        dst = os.path.relpath(context_path, context_root)
×
661
        if dst.startswith("../"):
×
662
            paths_outside_context_root.append(context_path)
×
663
            return ("", "")
×
664
        if instruction_path == dst:
×
665
            return ("", "")
×
666
        return instruction_path, dst
×
667

668
    # Adjust context paths based on `context_root`.
669
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
×
670
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
671
    )
672

673
    if not (copy_source_vs_context_source or paths_outside_context_root):
×
674
        # No issues found.
675
        return None
×
676

677
    msg = ""
×
678
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
×
679
    if has_unsourced_copy:
×
680
        msg += (
×
681
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
682
            f"been found in the Docker build context.\n\n"
683
        )
684

685
        renames = sorted(
×
686
            format_rename_suggestion(src, dst, colors=colors)
687
            for src, dst in copy_source_vs_context_source
688
            if src and dst
689
        )
690
        if renames:
×
691
            msg += (
×
692
                f"However there are possible matches. Please review the following list of "
693
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
694
            )
695

696
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
×
697
        if unknown:
×
698
            msg += (
×
699
                f"The following files were not found in the Docker build context:\n\n"
700
                f"{bullet_list(unknown)}\n\n"
701
            )
702

703
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
×
704
    if unreferenced:
×
705
        msg += (
×
706
            f"There are files in the Docker build context that were not referenced by "
707
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
708
        )
709

710
    if paths_outside_context_root:
×
711
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
×
712
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
×
713
        new_context_root = os.path.commonpath(context_paths)
×
714
        msg += (
×
715
            "There are unreachable files in these directories, excluded from the build context "
716
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
717
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
718
            "files in the build context, otherwise relocate the files to be part of the current "
719
            f"`context_root` {context_root!r}."
720
        )
721

722
    return msg
×
723

724

725
def rules():
6✔
726
    return [
5✔
727
        *collect_rules(),
728
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
729
    ]
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