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

pantsbuild / pants / 23616668936

26 Mar 2026 08:33PM UTC coverage: 92.848% (-0.08%) from 92.923%
23616668936

Pull #23133

github

web-flow
Merge 51b4d6d01 into 9b3c1562e
Pull Request #23133: Add buildctl engine

325 of 386 new or added lines in 14 files covered. (84.2%)

30 existing lines in 4 files now uncovered.

91645 of 98704 relevant lines covered (92.85%)

4.05 hits per line

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

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

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

15
from pants.backend.docker.engine_types import DockerBuildEngine
9✔
16
from pants.backend.docker.package_types import (
9✔
17
    BuiltDockerImage,
18
    DockerPushOnPackageBehavior,
19
    DockerPushOnPackageException,
20
)
21
from pants.backend.docker.registries import DockerRegistries, DockerRegistryOptions
9✔
22
from pants.backend.docker.subsystems.docker_options import DockerOptions
9✔
23
from pants.backend.docker.target_types import (
9✔
24
    DockerImageBuildImageOutputField,
25
    DockerImageContextRootField,
26
    DockerImageRegistriesField,
27
    DockerImageRepositoryField,
28
    DockerImageSourceField,
29
    DockerImageTagsField,
30
    DockerImageTagsRequest,
31
    DockerImageTargetStageField,
32
    OptionValueFormatter,
33
    ValidateOptionsMixin,
34
    get_docker_image_tags,
35
)
36
from pants.backend.docker.util_rules.binaries import (
9✔
37
    BuildctlBinary,
38
    DockerBinary,
39
    PodmanBinary,
40
    get_buildctl,
41
    get_docker,
42
    get_podman,
43
)
44
from pants.backend.docker.util_rules.docker_build_context import (
9✔
45
    DockerBuildContext,
46
    DockerBuildContextRequest,
47
    create_docker_build_context,
48
)
49
from pants.backend.docker.utils import format_rename_suggestion
9✔
50
from pants.core.goals.package import BuiltPackage, OutputPathField, PackageFieldSet
9✔
51
from pants.core.goals.publish import PublishFieldSet
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 Process, 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,)
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
        return bool(self.output.value) and (
5✔
99
            self.output.value.get("push") == "true" or self.output.value["type"] == "registry"
100
        )
101

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

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

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

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

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

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

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

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

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

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

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

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

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

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

254

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

261

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

269

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

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

279
    registries: list[DockerInfoV1Registry]
9✔
280

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

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

310
        return json.dumps(asdict(info)).encode()
4✔
311

312

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

321

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

330

331
def get_value_formatter(
9✔
332
    context: DockerBuildContext, target: Target, field_alias: str
333
) -> OptionValueFormatter:
334
    return partial(
4✔
335
        context.interpolation_context.format,
336
        source=InterpolationContext.TextSource(
337
            address=target.address, target_alias=target.alias, field_alias=field_alias
338
        ),
339
        error_cls=DockerImageOptionValueError,
340
    )
341

342

343
def get_build_options(
9✔
344
    context: DockerBuildContext,
345
    docker_options: DockerOptions,
346
    target: Target,
347
) -> Iterator[str]:
348
    gen_options_func_name = (
4✔
349
        "buildctl_options"
350
        if docker_options.build_engine == DockerBuildEngine.BUILDCTL
351
        else "docker_build_options"
352
    )
353
    for field_type in target.field_types:
4✔
354
        if (
4✔
355
            issubclass(field_type, ValidateOptionsMixin)
356
            and target[field_type].validate_options(docker_options, context)
357
            and (gen_options_func := getattr(target[field_type], gen_options_func_name, None))
358
        ):
359
            yield from gen_options_func(
4✔
360
                docker=docker_options,
361
                value_formatter=get_value_formatter(context, target, field_type.alias),
362
            )
363

364
    # Special handling for global options
365
    if docker_options.build_target_stage in context.stages:
4✔
366
        compute_options_func = (
1✔
367
            DockerImageTargetStageField.compute_buildctl_options
368
            if docker_options.build_engine == DockerBuildEngine.BUILDCTL
369
            else DockerImageTargetStageField.compute_docker_build_options
370
        )
371
        yield from compute_options_func(
1✔
372
            docker_options.build_target_stage,
373
            docker=docker_options,
374
            value_formatter=get_value_formatter(context, target, DockerImageTargetStageField.alias),
375
        )
376

377
    # This is the same for docker and buildkit
378
    if docker_options.build_no_cache:
4✔
379
        yield "--no-cache"
1✔
380

381

382
@dataclass(frozen=True)
9✔
383
class GetImageRefsRequest:
9✔
384
    field_set: DockerPackageFieldSet
9✔
385
    build_upstream_images: bool
9✔
386

387

388
class DockerImageRefs(Collection[ImageRefRegistry]):
9✔
389
    pass
9✔
390

391

392
@rule
9✔
393
async def get_image_refs(
9✔
394
    request: GetImageRefsRequest, options: DockerOptions, union_membership: UnionMembership
395
) -> DockerImageRefs:
396
    context, wrapped_target = await concurrently(
4✔
397
        create_docker_build_context(
398
            DockerBuildContextRequest(
399
                address=request.field_set.address,
400
                build_upstream_images=request.build_upstream_images,
401
            ),
402
            **implicitly(),
403
        ),
404
        resolve_target(
405
            WrappedTargetRequest(request.field_set.address, description_of_origin="<infallible>"),
406
            **implicitly(),
407
        ),
408
    )
409

410
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
4✔
411
    additional_image_tags = await concurrently(
4✔
412
        get_docker_image_tags(
413
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
414
        )
415
        for image_tags_request_cls in image_tags_requests
416
        if image_tags_request_cls.is_applicable(wrapped_target.target)
417
    )
418

419
    return DockerImageRefs(
4✔
420
        request.field_set.image_refs(
421
            default_repository=options.default_repository,
422
            registries=options.registries(),
423
            interpolation_context=context.interpolation_context,
424
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
425
        )
426
    )
427

428

429
@dataclass(frozen=True)
9✔
430
class DockerImageBuildProcess:
9✔
431
    process: Process
9✔
432
    context: DockerBuildContext
9✔
433
    context_root: str
9✔
434
    image_refs: DockerImageRefs
9✔
435
    tags: tuple[str, ...]
9✔
436

437

438
@rule
9✔
439
async def get_docker_image_build_process(
9✔
440
    field_set: DockerPackageFieldSet, options: DockerOptions
441
) -> DockerImageBuildProcess:
442
    context, wrapped_target, image_refs = await concurrently(
4✔
443
        create_docker_build_context(
444
            DockerBuildContextRequest(
445
                address=field_set.address,
446
                build_upstream_images=True,
447
            ),
448
            **implicitly(),
449
        ),
450
        resolve_target(
451
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
452
            **implicitly(),
453
        ),
454
        get_image_refs(
455
            GetImageRefsRequest(
456
                field_set=field_set,
457
                build_upstream_images=True,
458
            ),
459
            **implicitly(),
460
        ),
461
    )
462
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
4✔
463
    if not tags:
4✔
464
        raise InvalidFieldException(
1✔
465
            softwrap(
466
                f"""
467
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
468
                empty, unless there is a custom plugin providing additional tags using the
469
                `DockerImageTagsRequest` union type.
470
                """
471
            )
472
        )
473

474
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
475
    # image-building process correctly when an upstream image changes, even though the
476
    # process itself does not consume this data.
477
    env = {
4✔
478
        **context.build_env.environment,
479
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
480
    }
481
    context_root = field_set.get_context_root(options.default_context_root)
4✔
482
    binary: BuildctlBinary | PodmanBinary | DockerBinary
483
    match options.build_engine:
4✔
484
        case DockerBuildEngine.BUILDCTL:
4✔
NEW
485
            binary = await get_buildctl(**implicitly())
×
486
        case DockerBuildEngine.PODMAN:
4✔
NEW
487
            binary = await get_podman(**implicitly())
×
488
        case _:
4✔
489
            binary = await get_docker(**implicitly())
4✔
490

491
    process = binary.build_image(
4✔
492
        build_args=context.build_args,
493
        digest=context.digest,
494
        dockerfile=context.dockerfile,
495
        context_root=context_root,
496
        env=env,
497
        tags=tags,
498
        output=field_set.output.value,
499
        extra_args=tuple(
500
            get_build_options(
501
                context=context,
502
                docker_options=options,
503
                target=wrapped_target.target,
504
            )
505
        ),
506
        is_publish=isinstance(field_set, PublishFieldSet),
507
    )
508
    return DockerImageBuildProcess(
4✔
509
        process=process,
510
        context=context,
511
        context_root=context_root,
512
        image_refs=image_refs,
513
        tags=tags,
514
    )
515

516

517
@rule
9✔
518
async def build_docker_image(
9✔
519
    field_set: DockerPackageFieldSet,
520
    options: DockerOptions,
521
    global_options: GlobalOptions,
522
    keep_sandboxes: KeepSandboxes,
523
) -> BuiltPackage:
524
    """Build a Docker image using `docker build`."""
525
    # Check if this build would push and handle according to push_on_package behavior
526
    if field_set.pushes_on_package():
4✔
527
        match options.push_on_package:
1✔
528
            case DockerPushOnPackageBehavior.IGNORE:
1✔
529
                return BuiltPackage(EMPTY_DIGEST, ())
1✔
530
            case DockerPushOnPackageBehavior.ERROR:
1✔
531
                raise DockerPushOnPackageException(field_set.address)
1✔
UNCOV
532
            case DockerPushOnPackageBehavior.WARN:
×
UNCOV
533
                logger.warning(
×
534
                    f"Docker image {field_set.address} will push to a registry during packaging"
535
                )
536

537
    build_process = await get_docker_image_build_process(field_set, **implicitly())
4✔
538
    result = await execute_process(build_process.process, **implicitly())
4✔
539

540
    if result.exit_code != 0:
4✔
541
        msg = f"{options.build_engine.value.capitalize()} build failed for `docker_image` {field_set.address}."
1✔
542
        if options.suggest_renames:
1✔
543
            maybe_help_msg = format_docker_build_context_help_message(
1✔
544
                context_root=build_process.context_root,
545
                context=build_process.context,
546
                colors=global_options.colors,
547
            )
548
            if maybe_help_msg:
1✔
549
                msg += " " + maybe_help_msg
1✔
550

551
        logger.warning(msg)
1✔
552

553
        raise ProcessExecutionFailure(
1✔
554
            result.exit_code,
555
            result.stdout,
556
            result.stderr,
557
            build_process.process.description,
558
            keep_sandboxes=keep_sandboxes,
559
        )
560

561
    parse_image_id = (
4✔
562
        parse_image_id_from_podman_build_output
563
        if options.build_engine == DockerBuildEngine.PODMAN
564
        else parse_image_id_from_buildkit_output
565
    )
566
    image_id = parse_image_id(result.stdout, result.stderr) or "<unknown>"
4✔
567
    docker_build_output_msg = "\n".join(
4✔
568
        (
569
            f"{options.build_engine.value.capitalize()} build output for {build_process.tags[0]}:",
570
            "stdout:",
571
            result.stdout.decode(),
572
            "stderr:",
573
            result.stderr.decode(),
574
        )
575
    )
576

577
    if options.build_verbose:
4✔
578
        logger.info(docker_build_output_msg)
×
579
    else:
580
        logger.debug(docker_build_output_msg)
4✔
581

582
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
4✔
583
    metadata = DockerInfoV1.serialize(build_process.image_refs, image_id=image_id)
4✔
584
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
4✔
585

586
    return BuiltPackage(
4✔
587
        digest,
588
        (BuiltDockerImage.create(image_id, build_process.tags, metadata_filename),),
589
    )
590

591

592
def parse_image_id_from_buildkit_output(*outputs: bytes) -> str | None:
9✔
593
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
594
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
595
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
596
    #  to be an issue in practice.
597
    image_id_regexp = re.compile(
4✔
598
        "|".join(
599
            (
600
                # BuildKit output.
601
                r"(writing image (?P<digest>sha256:\S+))",
602
                # Buildkit with --push=true output.
603
                r"(pushing manifest for (?P<pushed_manifest>\S+))",
604
                # BuildKit with containerd-snapshotter output.
605
                r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
606
                # BuildKit with containerd-snapshotter output and no attestation.
607
                r"(exporting manifest (?P<manifest>sha256:\S+))",
608
                # Docker output.
609
                r"(Successfully built (?P<short_id>\S+))",
610
            ),
611
        )
612
    )
613
    for output in outputs:
4✔
614
        image_id_match = next(
4✔
615
            (
616
                match
617
                for match in (
618
                    re.search(image_id_regexp, line)
619
                    for line in reversed(output.decode().split("\n"))
620
                )
621
                if match
622
            ),
623
            None,
624
        )
625
        if image_id_match:
4✔
626
            image_id = (
4✔
627
                image_id_match.group("digest")
628
                or image_id_match.group("pushed_manifest")
629
                or image_id_match.group("short_id")
630
                or image_id_match.group("manifest_list")
631
                or image_id_match.group("manifest")
632
            )
633
            return image_id
4✔
634

635
    return None
1✔
636

637

638
def parse_image_id_from_podman_build_output(*outputs: bytes) -> str | None:
9✔
639
    for output in outputs:
1✔
640
        try:
1✔
641
            _, image_id, success, *__ = reversed(output.decode().split("\n"))
1✔
NEW
642
        except ValueError:
×
NEW
643
            continue
×
644

645
        if success.startswith("Successfully tagged"):
1✔
646
            return image_id
1✔
NEW
647
    return None
×
648

649

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

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

668
    # Adjust context paths based on `context_root`.
669
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
1✔
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):
1✔
674
        # No issues found.
675
        return None
×
676

677
    msg = ""
1✔
678
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
1✔
679
    if has_unsourced_copy:
1✔
680
        msg += (
1✔
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(
1✔
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:
1✔
691
            msg += (
1✔
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)
1✔
697
        if unknown:
1✔
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)
1✔
704
    if unreferenced:
1✔
705
        msg += (
1✔
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:
1✔
711
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
1✔
712
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
1✔
713
        new_context_root = os.path.commonpath(context_paths)
1✔
714
        msg += (
1✔
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
1✔
723

724

725
def rules():
9✔
726
    return [
7✔
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