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

pantsbuild / pants / 21803785359

08 Feb 2026 07:13PM UTC coverage: 43.3% (-37.0%) from 80.277%
21803785359

Pull #23085

github

web-flow
Merge 7c1cd926d into 40389cc58
Pull Request #23085: A helper method for indexing paths by source root

2 of 6 new or added lines in 1 file covered. (33.33%)

17114 existing lines in 539 files now uncovered.

26075 of 60219 relevant lines covered (43.3%)

0.43 hits per line

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

38.82
/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
1✔
4

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

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

61
logger = logging.getLogger(__name__)
1✔
62

63

64
class DockerImageTagValueError(InterpolationError):
1✔
65
    pass
1✔
66

67

68
class DockerRepositoryNameError(InterpolationError):
1✔
69
    pass
1✔
70

71

72
class DockerBuildTargetStageError(ValueError):
1✔
73
    pass
1✔
74

75

76
class DockerImageOptionValueError(InterpolationError):
1✔
77
    pass
1✔
78

79

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

250

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

257

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

265

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

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

275
    registries: list[DockerInfoV1Registry]
1✔
276

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

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

UNCOV
306
        return json.dumps(asdict(info)).encode()
×
307

308

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

317

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

326

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

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

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

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

UNCOV
393
    if global_build_no_cache_option:
×
UNCOV
394
        yield "--no-cache"
×
395

396

397
@rule
1✔
398
async def build_docker_image(
1✔
399
    field_set: DockerPackageFieldSet,
400
    options: DockerOptions,
401
    global_options: GlobalOptions,
402
    docker: DockerBinary,
403
    keep_sandboxes: KeepSandboxes,
404
    union_membership: UnionMembership,
405
) -> BuiltPackage:
406
    """Build a Docker image using `docker build`."""
407
    # Check if this build would push and handle according to push_on_package behavior
UNCOV
408
    if field_set.pushes_on_package():
×
UNCOV
409
        match options.push_on_package:
×
UNCOV
410
            case DockerPushOnPackageBehavior.IGNORE:
×
UNCOV
411
                return BuiltPackage(EMPTY_DIGEST, ())
×
UNCOV
412
            case DockerPushOnPackageBehavior.ERROR:
×
UNCOV
413
                raise DockerPushOnPackageException(field_set.address)
×
UNCOV
414
            case DockerPushOnPackageBehavior.WARN:
×
UNCOV
415
                logger.warning(
×
416
                    f"Docker image {field_set.address} will push to a registry during packaging"
417
                )
418

UNCOV
419
    context, wrapped_target = await concurrently(
×
420
        create_docker_build_context(
421
            DockerBuildContextRequest(
422
                address=field_set.address,
423
                build_upstream_images=True,
424
            ),
425
            **implicitly(),
426
        ),
427
        resolve_target(
428
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
429
            **implicitly(),
430
        ),
431
    )
432

UNCOV
433
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
×
UNCOV
434
    additional_image_tags = await concurrently(
×
435
        get_docker_image_tags(
436
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
437
        )
438
        for image_tags_request_cls in image_tags_requests
439
        if image_tags_request_cls.is_applicable(wrapped_target.target)
440
    )
441

UNCOV
442
    image_refs = tuple(
×
443
        field_set.image_refs(
444
            default_repository=options.default_repository,
445
            registries=options.registries(),
446
            interpolation_context=context.interpolation_context,
447
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
448
        )
449
    )
UNCOV
450
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
×
UNCOV
451
    if not tags:
×
UNCOV
452
        raise InvalidFieldException(
×
453
            softwrap(
454
                f"""
455
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
456
                empty, unless there is a custom plugin providing additional tags using the
457
                `DockerImageTagsRequest` union type.
458
                """
459
            )
460
        )
461

462
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
463
    # image-building process correctly when an upstream image changes, even though the
464
    # process itself does not consume this data.
UNCOV
465
    env = {
×
466
        **context.build_env.environment,
467
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
468
    }
UNCOV
469
    context_root = field_set.get_context_root(options.default_context_root)
×
UNCOV
470
    process = docker.build_image(
×
471
        build_args=context.build_args,
472
        digest=context.digest,
473
        dockerfile=context.dockerfile,
474
        context_root=context_root,
475
        env=env,
476
        tags=tags,
477
        use_buildx=options.use_buildx,
478
        extra_args=tuple(
479
            get_build_options(
480
                context=context,
481
                field_set=field_set,
482
                global_target_stage_option=options.build_target_stage,
483
                global_build_hosts_options=options.build_hosts,
484
                global_build_no_cache_option=options.build_no_cache,
485
                use_buildx_option=options.use_buildx,
486
                target=wrapped_target.target,
487
            )
488
        ),
489
    )
UNCOV
490
    result = await execute_process(process, **implicitly())
×
491

UNCOV
492
    if result.exit_code != 0:
×
UNCOV
493
        msg = f"Docker build failed for `docker_image` {field_set.address}."
×
UNCOV
494
        if options.suggest_renames:
×
UNCOV
495
            maybe_help_msg = format_docker_build_context_help_message(
×
496
                context_root=context_root,
497
                context=context,
498
                colors=global_options.colors,
499
            )
UNCOV
500
            if maybe_help_msg:
×
UNCOV
501
                msg += " " + maybe_help_msg
×
502

UNCOV
503
        logger.warning(msg)
×
504

UNCOV
505
        raise ProcessExecutionFailure(
×
506
            result.exit_code,
507
            result.stdout,
508
            result.stderr,
509
            process.description,
510
            keep_sandboxes=keep_sandboxes,
511
        )
512

UNCOV
513
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
×
UNCOV
514
    docker_build_output_msg = "\n".join(
×
515
        (
516
            f"Docker build output for {tags[0]}:",
517
            "stdout:",
518
            result.stdout.decode(),
519
            "stderr:",
520
            result.stderr.decode(),
521
        )
522
    )
523

UNCOV
524
    if options.build_verbose:
×
525
        logger.info(docker_build_output_msg)
×
526
    else:
UNCOV
527
        logger.debug(docker_build_output_msg)
×
528

UNCOV
529
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
×
UNCOV
530
    metadata = DockerInfoV1.serialize(image_refs, image_id=image_id)
×
UNCOV
531
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
×
532

UNCOV
533
    return BuiltPackage(
×
534
        digest,
535
        (BuiltDockerImage.create(image_id, tags, metadata_filename),),
536
    )
537

538

539
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
1✔
540
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
541
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
542
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
543
    #  to be an issue in practice.
UNCOV
544
    if docker.is_podman:
×
UNCOV
545
        for output in outputs:
×
UNCOV
546
            try:
×
UNCOV
547
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
×
548
            except ValueError:
×
549
                continue
×
550

UNCOV
551
            if success.startswith("Successfully tagged"):
×
UNCOV
552
                return image_id
×
553

554
    else:
UNCOV
555
        image_id_regexp = re.compile(
×
556
            "|".join(
557
                (
558
                    # BuildKit output.
559
                    r"(writing image (?P<digest>sha256:\S+))",
560
                    # BuildKit with containerd-snapshotter output.
561
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
562
                    # BuildKit with containerd-snapshotter output and no attestation.
563
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
564
                    # Docker output.
565
                    r"(Successfully built (?P<short_id>\S+))",
566
                ),
567
            )
568
        )
UNCOV
569
        for output in outputs:
×
UNCOV
570
            image_id_match = next(
×
571
                (
572
                    match
573
                    for match in (
574
                        re.search(image_id_regexp, line)
575
                        for line in reversed(output.decode().split("\n"))
576
                    )
577
                    if match
578
                ),
579
                None,
580
            )
UNCOV
581
            if image_id_match:
×
UNCOV
582
                image_id = (
×
583
                    image_id_match.group("digest")
584
                    or image_id_match.group("short_id")
585
                    or image_id_match.group("manifest_list")
586
                    or image_id_match.group("manifest")
587
                )
UNCOV
588
                return image_id
×
589

UNCOV
590
    return "<unknown>"
×
591

592

593
def format_docker_build_context_help_message(
1✔
594
    context_root: str, context: DockerBuildContext, colors: bool
595
) -> str | None:
UNCOV
596
    paths_outside_context_root: list[str] = []
×
597

UNCOV
598
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
×
599
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
UNCOV
600
        instruction_path, context_path = paths
×
UNCOV
601
        if not context_path:
×
602
            return paths
×
UNCOV
603
        dst = os.path.relpath(context_path, context_root)
×
UNCOV
604
        if dst.startswith("../"):
×
UNCOV
605
            paths_outside_context_root.append(context_path)
×
UNCOV
606
            return ("", "")
×
UNCOV
607
        if instruction_path == dst:
×
UNCOV
608
            return ("", "")
×
UNCOV
609
        return instruction_path, dst
×
610

611
    # Adjust context paths based on `context_root`.
UNCOV
612
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
×
613
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
614
    )
615

UNCOV
616
    if not (copy_source_vs_context_source or paths_outside_context_root):
×
617
        # No issues found.
618
        return None
×
619

UNCOV
620
    msg = ""
×
UNCOV
621
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
×
UNCOV
622
    if has_unsourced_copy:
×
UNCOV
623
        msg += (
×
624
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
625
            f"been found in the Docker build context.\n\n"
626
        )
627

UNCOV
628
        renames = sorted(
×
629
            format_rename_suggestion(src, dst, colors=colors)
630
            for src, dst in copy_source_vs_context_source
631
            if src and dst
632
        )
UNCOV
633
        if renames:
×
UNCOV
634
            msg += (
×
635
                f"However there are possible matches. Please review the following list of "
636
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
637
            )
638

UNCOV
639
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
×
UNCOV
640
        if unknown:
×
641
            msg += (
×
642
                f"The following files were not found in the Docker build context:\n\n"
643
                f"{bullet_list(unknown)}\n\n"
644
            )
645

UNCOV
646
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
×
UNCOV
647
    if unreferenced:
×
UNCOV
648
        msg += (
×
649
            f"There are files in the Docker build context that were not referenced by "
650
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
651
        )
652

UNCOV
653
    if paths_outside_context_root:
×
UNCOV
654
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
×
UNCOV
655
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
×
UNCOV
656
        new_context_root = os.path.commonpath(context_paths)
×
UNCOV
657
        msg += (
×
658
            "There are unreachable files in these directories, excluded from the build context "
659
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
660
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
661
            "files in the build context, otherwise relocate the files to be part of the current "
662
            f"`context_root` {context_root!r}."
663
        )
664

UNCOV
665
    return msg
×
666

667

668
def rules():
1✔
669
    return [
1✔
670
        *collect_rules(),
671
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
672
    ]
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