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

pantsbuild / pants / 21374897774

26 Jan 2026 09:37PM UTC coverage: 80.008% (-0.3%) from 80.269%
21374897774

Pull #23037

github

web-flow
Merge 4023b9eee into 09b8ecaa1
Pull Request #23037: Enable publish without package 2

105 of 178 new or added lines in 11 files covered. (58.99%)

238 existing lines in 14 files now uncovered.

78628 of 98275 relevant lines covered (80.01%)

3.35 hits per line

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

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

64
logger = logging.getLogger(__name__)
9✔
65

66

67
class DockerImageTagValueError(InterpolationError):
9✔
68
    pass
9✔
69

70

71
class DockerRepositoryNameError(InterpolationError):
9✔
72
    pass
9✔
73

74

75
class DockerBuildTargetStageError(ValueError):
9✔
76
    pass
9✔
77

78

79
class DockerImageOptionValueError(InterpolationError):
9✔
80
    pass
9✔
81

82

83
@dataclass(frozen=True)
9✔
84
class DockerPackageFieldSet(PackageFieldSet):
9✔
85
    required_fields = (DockerImageSourceField, DockerImageBuildImageOutputField)
9✔
86

87
    context_root: DockerImageContextRootField
9✔
88
    registries: DockerImageRegistriesField
9✔
89
    repository: DockerImageRepositoryField
9✔
90
    source: DockerImageSourceField
9✔
91
    tags: DockerImageTagsField
9✔
92
    target_stage: DockerImageTargetStageField
9✔
93
    output_path: OutputPathField
9✔
94
    output: DockerImageBuildImageOutputField
9✔
95

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

101
    def format_tag(self, tag: str, interpolation_context: InterpolationContext) -> str:
9✔
102
        source = InterpolationContext.TextSource(
1✔
103
            address=self.address, target_alias="docker_image", field_alias=self.tags.alias
104
        )
105
        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
1✔
106

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

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

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

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

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

177
            [<registry>/]<repository-name>[:<tag>]
178

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

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

202
        for registry in registries_options:
1✔
203
            repository = self.format_repository(default_repository, interpolation_context, registry)
1✔
204
            address_repository = "/".join([registry.address, repository])
1✔
205
            if registry.use_local_alias and registry.alias:
1✔
206
                alias_repository = "/".join([registry.alias, repository])
1✔
207
            else:
208
                alias_repository = None
1✔
209

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

233
    def get_context_root(self, default_context_root: str) -> str:
9✔
234
        """Examines `default_context_root` and `self.context_root.value` and translates that to a
235
        context root for the Docker build operation.
236

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

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

253

254
@dataclass(frozen=True)
9✔
255
class ImageRefRegistry:
9✔
256
    registry: DockerRegistryOptions | None
9✔
257
    repository: str
9✔
258
    tags: tuple[ImageRefTag, ...]
9✔
259

260

261
@dataclass(frozen=True)
9✔
262
class ImageRefTag:
9✔
263
    template: str
9✔
264
    formatted: str
9✔
265
    full_name: str
9✔
266
    uses_local_alias: bool
9✔
267

268

269
@dataclass(frozen=True)
9✔
270
class DockerInfoV1:
9✔
271
    """The format of the `$target_name.docker-info.json` file."""
272

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

278
    registries: list[DockerInfoV1Registry]
9✔
279

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

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

309
        return json.dumps(asdict(info)).encode()
1✔
310

311

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

320

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

329

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

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

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

UNCOV
393
    if target_stage:
×
UNCOV
394
        yield from ("--target", target_stage)
×
395

UNCOV
396
    if global_build_no_cache_option:
×
UNCOV
397
        yield "--no-cache"
×
398

399

400
@dataclass(frozen=True)
9✔
401
class GetImageTagsRequest:
9✔
402
    field_set: DockerPackageFieldSet
9✔
403
    build_upstream_images: bool
9✔
404

405

406
class DockerImageRefs(Collection[ImageRefRegistry]):
9✔
407
    pass
9✔
408

409

410
@rule
9✔
411
async def get_image_tags(
9✔
412
    request: GetImageTagsRequest, options: DockerOptions, union_membership: UnionMembership
413
) -> DockerImageRefs:
UNCOV
414
    context, wrapped_target = await concurrently(
×
415
        create_docker_build_context(
416
            DockerBuildContextRequest(
417
                address=request.field_set.address,
418
                build_upstream_images=request.build_upstream_images,
419
            ),
420
            **implicitly(),
421
        ),
422
        resolve_target(
423
            WrappedTargetRequest(request.field_set.address, description_of_origin="<infallible>"),
424
            **implicitly(),
425
        ),
426
    )
427

UNCOV
428
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
×
UNCOV
429
    additional_image_tags = await concurrently(
×
430
        get_docker_image_tags(
431
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
432
        )
433
        for image_tags_request_cls in image_tags_requests
434
        if image_tags_request_cls.is_applicable(wrapped_target.target)
435
    )
436

NEW
437
    return DockerImageRefs(
×
438
        request.field_set.image_refs(
439
            default_repository=options.default_repository,
440
            registries=options.registries(),
441
            interpolation_context=context.interpolation_context,
442
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
443
        )
444
    )
445

446

447
@rule
9✔
448
async def build_docker_image(
9✔
449
    field_set: DockerPackageFieldSet,
450
    options: DockerOptions,
451
    global_options: GlobalOptions,
452
    docker: DockerBinary,
453
    keep_sandboxes: KeepSandboxes,
454
) -> BuiltPackage:
455
    """Build a Docker image using `docker build`."""
456
    # Check if this build would push and handle according to push_on_package behavior
NEW
457
    if field_set.pushes_on_package():
×
NEW
458
        match options.push_on_package:
×
NEW
459
            case DockerPushOnPackageBehavior.IGNORE:
×
NEW
460
                return BuiltPackage(EMPTY_DIGEST, ())
×
NEW
461
            case DockerPushOnPackageBehavior.ERROR:
×
NEW
462
                raise DockerPushOnPackageException(field_set.address)
×
NEW
463
            case DockerPushOnPackageBehavior.WARN:
×
NEW
464
                logger.warning(
×
465
                    f"Docker image {field_set.address} will push to a registry during packaging"
466
                )
467

NEW
468
    context, wrapped_target, image_refs = await concurrently(
×
469
        create_docker_build_context(
470
            DockerBuildContextRequest(
471
                address=field_set.address,
472
                build_upstream_images=True,
473
            ),
474
            **implicitly(),
475
        ),
476
        resolve_target(
477
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
478
            **implicitly(),
479
        ),
480
        get_image_tags(
481
            GetImageTagsRequest(
482
                field_set=field_set,
483
                build_upstream_images=True,
484
            ),
485
            **implicitly(),
486
        ),
487
    )
UNCOV
488
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
×
UNCOV
489
    if not tags:
×
UNCOV
490
        raise InvalidFieldException(
×
491
            softwrap(
492
                f"""
493
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
494
                empty, unless there is a custom plugin providing additional tags using the
495
                `DockerImageTagsRequest` union type.
496
                """
497
            )
498
        )
499

500
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
501
    # image-building process correctly when an upstream image changes, even though the
502
    # process itself does not consume this data.
UNCOV
503
    env = {
×
504
        **context.build_env.environment,
505
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
506
    }
UNCOV
507
    context_root = field_set.get_context_root(options.default_context_root)
×
UNCOV
508
    process = docker.build_image(
×
509
        build_args=context.build_args,
510
        digest=context.digest,
511
        dockerfile=context.dockerfile,
512
        context_root=context_root,
513
        env=env,
514
        tags=tags,
515
        use_buildx=options.use_buildx,
516
        extra_args=tuple(
517
            get_build_options(
518
                context=context,
519
                field_set=field_set,
520
                global_target_stage_option=options.build_target_stage,
521
                global_build_hosts_options=options.build_hosts,
522
                global_build_no_cache_option=options.build_no_cache,
523
                use_buildx_option=options.use_buildx,
524
                target=wrapped_target.target,
525
            )
526
        ),
527
    )
UNCOV
528
    result = await execute_process(process, **implicitly())
×
529

UNCOV
530
    if result.exit_code != 0:
×
UNCOV
531
        msg = f"Docker build failed for `docker_image` {field_set.address}."
×
UNCOV
532
        if options.suggest_renames:
×
UNCOV
533
            maybe_help_msg = format_docker_build_context_help_message(
×
534
                context_root=context_root,
535
                context=context,
536
                colors=global_options.colors,
537
            )
UNCOV
538
            if maybe_help_msg:
×
UNCOV
539
                msg += " " + maybe_help_msg
×
540

UNCOV
541
        logger.warning(msg)
×
542

UNCOV
543
        raise ProcessExecutionFailure(
×
544
            result.exit_code,
545
            result.stdout,
546
            result.stderr,
547
            process.description,
548
            keep_sandboxes=keep_sandboxes,
549
        )
550

UNCOV
551
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
×
UNCOV
552
    docker_build_output_msg = "\n".join(
×
553
        (
554
            f"Docker build output for {tags[0]}:",
555
            "stdout:",
556
            result.stdout.decode(),
557
            "stderr:",
558
            result.stderr.decode(),
559
        )
560
    )
561

UNCOV
562
    if options.build_verbose:
×
563
        logger.info(docker_build_output_msg)
×
564
    else:
UNCOV
565
        logger.debug(docker_build_output_msg)
×
566

UNCOV
567
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
×
UNCOV
568
    metadata = DockerInfoV1.serialize(image_refs, image_id=image_id)
×
UNCOV
569
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
×
570

UNCOV
571
    return BuiltPackage(
×
572
        digest,
573
        (BuiltDockerImage.create(image_id, tags, metadata_filename),),
574
    )
575

576

577
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
9✔
578
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
579
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
580
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
581
    #  to be an issue in practice.
582
    if docker.is_podman:
1✔
583
        for output in outputs:
1✔
584
            try:
1✔
585
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
1✔
586
            except ValueError:
×
587
                continue
×
588

589
            if success.startswith("Successfully tagged"):
1✔
590
                return image_id
1✔
591

592
    else:
593
        image_id_regexp = re.compile(
1✔
594
            "|".join(
595
                (
596
                    # BuildKit output.
597
                    r"(writing image (?P<digest>sha256:\S+))",
598
                    # BuildKit with containerd-snapshotter output.
599
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
600
                    # BuildKit with containerd-snapshotter output and no attestation.
601
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
602
                    # Docker output.
603
                    r"(Successfully built (?P<short_id>\S+))",
604
                ),
605
            )
606
        )
607
        for output in outputs:
1✔
608
            image_id_match = next(
1✔
609
                (
610
                    match
611
                    for match in (
612
                        re.search(image_id_regexp, line)
613
                        for line in reversed(output.decode().split("\n"))
614
                    )
615
                    if match
616
                ),
617
                None,
618
            )
619
            if image_id_match:
1✔
620
                image_id = (
1✔
621
                    image_id_match.group("digest")
622
                    or image_id_match.group("short_id")
623
                    or image_id_match.group("manifest_list")
624
                    or image_id_match.group("manifest")
625
                )
626
                return image_id
1✔
627

628
    return "<unknown>"
1✔
629

630

631
def format_docker_build_context_help_message(
9✔
632
    context_root: str, context: DockerBuildContext, colors: bool
633
) -> str | None:
UNCOV
634
    paths_outside_context_root: list[str] = []
×
635

UNCOV
636
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
×
637
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
UNCOV
638
        instruction_path, context_path = paths
×
UNCOV
639
        if not context_path:
×
640
            return paths
×
UNCOV
641
        dst = os.path.relpath(context_path, context_root)
×
UNCOV
642
        if dst.startswith("../"):
×
UNCOV
643
            paths_outside_context_root.append(context_path)
×
UNCOV
644
            return ("", "")
×
UNCOV
645
        if instruction_path == dst:
×
UNCOV
646
            return ("", "")
×
UNCOV
647
        return instruction_path, dst
×
648

649
    # Adjust context paths based on `context_root`.
UNCOV
650
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
×
651
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
652
    )
653

UNCOV
654
    if not (copy_source_vs_context_source or paths_outside_context_root):
×
655
        # No issues found.
656
        return None
×
657

UNCOV
658
    msg = ""
×
UNCOV
659
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
×
UNCOV
660
    if has_unsourced_copy:
×
UNCOV
661
        msg += (
×
662
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
663
            f"been found in the Docker build context.\n\n"
664
        )
665

UNCOV
666
        renames = sorted(
×
667
            format_rename_suggestion(src, dst, colors=colors)
668
            for src, dst in copy_source_vs_context_source
669
            if src and dst
670
        )
UNCOV
671
        if renames:
×
UNCOV
672
            msg += (
×
673
                f"However there are possible matches. Please review the following list of "
674
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
675
            )
676

UNCOV
677
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
×
UNCOV
678
        if unknown:
×
679
            msg += (
×
680
                f"The following files were not found in the Docker build context:\n\n"
681
                f"{bullet_list(unknown)}\n\n"
682
            )
683

UNCOV
684
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
×
UNCOV
685
    if unreferenced:
×
UNCOV
686
        msg += (
×
687
            f"There are files in the Docker build context that were not referenced by "
688
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
689
        )
690

UNCOV
691
    if paths_outside_context_root:
×
UNCOV
692
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
×
UNCOV
693
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
×
UNCOV
694
        new_context_root = os.path.commonpath(context_paths)
×
UNCOV
695
        msg += (
×
696
            "There are unreachable files in these directories, excluded from the build context "
697
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
698
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
699
            "files in the build context, otherwise relocate the files to be part of the current "
700
            f"`context_root` {context_root!r}."
701
        )
702

UNCOV
703
    return msg
×
704

705

706
def rules():
9✔
707
    return [
7✔
708
        *collect_rules(),
709
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
710
    ]
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