Skip to content
4 changes: 4 additions & 0 deletions docs/notes/2.33.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ The thread used by the [`workunit-logger`](https://www.pantsbuild.org/2.33/refer

The `docker_image` target now supports capturing file and directory artifacts from Docker builds using the `output_files` and `output_directories` fields. This supports workflows where a Dockerfile stage creates a build artifact(s) that should be consumed by other Pants targets, using the same behavior as the `shell_command` or `adhoc_tool` targets. This feature uses the BuildKit local output exporter and requires `[docker].use_buildx = true`.

The option `[docker].build_extra_options` or/and the `build_extra_options` field of `docker_image` targets can now be used to pass extra options to docker build or podman build. Make sure that the options are understood by the tool.
The build_extra_options field allows you to pass any valid build flags directly to docker or podman.
Options that are set in the docker image target such as `pull` or `build_no_cache` in pants.toml are surpressed by the extra options when they conflict.

#### Helm

#### JVM
Expand Down
85 changes: 82 additions & 3 deletions src/python/pants/backend/docker/goals/package_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
DockerBuildOptionFieldMultiValueMixin,
DockerBuildOptionFieldValueMixin,
DockerBuildOptionFlagFieldMixin,
DockerImageBuildExtraOptionsField,
DockerImageBuildImageOutputField,
DockerImageContextRootField,
DockerImageOutputDirectoriesField,
Expand Down Expand Up @@ -70,7 +71,7 @@
)
from pants.engine.process import Process, ProcessExecutionFailure
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
from pants.engine.target import InvalidFieldException, Target, WrappedTargetRequest
from pants.engine.target import Field, InvalidFieldException, Target, WrappedTargetRequest
from pants.engine.unions import UnionMembership, UnionRule
from pants.option.global_options import GlobalOptions, KeepSandboxes
from pants.util.frozendict import FrozenDict
Expand Down Expand Up @@ -346,6 +347,56 @@ class DockerInfoV1ImageTag:
name: str


def _extra_options_flag_names(extra_options: tuple[str, ...]) -> frozenset[str]:
"""Returns a set of flag names (e.g. --pull, --network, etc)"""
names: set[str] = set()
for opt in extra_options:
if opt.startswith("-"):
names.add(opt.split("=")[0])
return frozenset(names)


def _filter_global_extra_options(
global_extra_options: tuple[str, ...], target_flag_names: frozenset[str]
) -> tuple[str, ...]:
"""Remove any global extra options that are included in the per-target options."""
result = []
for opt in global_extra_options:
if opt.startswith("-") and opt.split("=")[0] in target_flag_names:
logger.warning(
f"Global docker extra option `{opt}` is overridden by a per-target option and will be ignored."
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not warn in this case, it is the "happy path".

else:
result.append(opt)
return tuple(result)


def _overwriten_flag_warning(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo

target: Target, field_type: type[Field], extra_options: tuple[str, ...]
) -> None:
for opt in extra_options:
if opt.startswith(f"--{field_type.alias}"):
extra_build_options = opt
break
logger.warning(
f"The individual `--{field_type.alias}={target[field_type].value}` field on the `{target.alias}` target in `{target.address}` is overridden by "
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the--, the user hasn't written that so they'd be confused.

f"`extra_build_options` (`{extra_build_options}`). Individual build option fields are deprecated in favor of using `extra_build_options`"
Comment on lines +382 to +383
Copy link
Copy Markdown
Contributor

@tobni tobni May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the deprecation note and instead just hint at the extra_build_options field.

)


def _overwrite_flag_warning_global_option(
target: Target, option_name: str, extra_options: tuple[str, ...]
) -> None:
for opt in extra_options:
if opt.split("=")[0] == option_name.split("=")[0]:
extra_build_options = opt
break
logger.warning(
f"The individual `{option_name}` field on the `{target.alias}` target in `{target.address}` is overridden by "
f"`extra_build_options` (`{extra_build_options}`). Individual build option fields are deprecated in favor of using `extra_build_options`"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the deprecation note.

)


def get_build_options(
context: DockerBuildContext,
field_set: DockerPackageFieldSet,
Expand All @@ -354,8 +405,20 @@ def get_build_options(
global_build_no_cache_option: bool | None,
use_buildx_option: bool,
target: Target,
global_extra_options: tuple[str, ...] = (),
output_options: FrozenDict[str, str] | None = None,
) -> Iterator[str]:
target_extra = tuple(target[DockerImageBuildExtraOptionsField].value or ())
target_flag_names = _extra_options_flag_names(target_extra)

# Per-target wins: drop any global entry whose flag is already covered by the per-target list.
filtered_global = _filter_global_extra_options(global_extra_options, target_flag_names)

extra_options: tuple[str, ...] = (*filtered_global, *target_extra)

# Compute the set of flag names that are provided by global and target extra options.
overridden_flags = _extra_options_flag_names(extra_options)

# Build options from target fields inheriting from DockerBuildOptionFieldMixin
for field_type in target.field_types:
if issubclass(field_type, DockerBuildKitOptionField):
Expand All @@ -380,6 +443,13 @@ def get_build_options(
DockerBuildOptionFlagFieldMixin,
),
):
flag = getattr(
field_type, "docker_build_option", None
) # get the flag name if it exists such as --pull or --network, etc.
if flag and flag in overridden_flags:
_overwriten_flag_warning(target, field_type, extra_options)
continue

source = InterpolationContext.TextSource(
address=target.address, target_alias=target.alias, field_alias=field_type.alias
)
Expand Down Expand Up @@ -417,10 +487,18 @@ def get_build_options(
)

if target_stage:
yield from ("--target", target_stage)
if "--target" in overridden_flags:
_overwrite_flag_warning_global_option(target, f"--target={target_stage}", extra_options)
else:
extra_options = extra_options + ("--target", target_stage)

if global_build_no_cache_option:
yield "--no-cache"
if "--no-cache" in overridden_flags:
_overwrite_flag_warning_global_option(target, "--no-cache", extra_options)
else:
extra_options = extra_options + ("--no-cache",)

yield from extra_options


@dataclass(frozen=True)
Expand Down Expand Up @@ -619,6 +697,7 @@ async def get_docker_image_build_process(
global_build_no_cache_option=options.build_no_cache,
use_buildx_option=options.use_buildx,
target=wrapped_target.target,
global_extra_options=options.build_extra_options,
output_options=captured_outputs.output_options if captured_outputs else None,
)
),
Expand Down
144 changes: 144 additions & 0 deletions src/python/pants/backend/docker/goals/package_image_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ def _setup_docker_options(rule_runner: RuleRunner, options: dict | None) -> Dock
opts.setdefault("build_hosts", None)
opts.setdefault("build_verbose", False)
opts.setdefault("build_no_cache", False)
opts.setdefault("build_extra_options", [])
opts.setdefault("use_buildx", False)
opts.setdefault("env_vars", [])
opts.setdefault("suggest_renames", True)
Expand Down Expand Up @@ -3316,3 +3317,146 @@ def test_field_set_pushes_on_package(output: dict | None, expected: bool) -> Non
rule_runner.get_target(Address("", target_name="image"))
)
assert field_set.pushes_on_package() is expected


@pytest.mark.parametrize(
("global_options", "target_options", "expected"),
[
(["--compress"], None, ["--compress"]),
(["--compress"], ["--pull=always"], ["--compress", "--pull=always"]),
# The `--no-cache` option is set in a different place in the code and hence requires separate tests.
(["--no-cache"], None, ["--no-cache"]),
(None, ["--no-cache"], ["--no-cache"]),
],
)
def test_global_build_extra_options_merge(
rule_runner: RuleRunner,
global_options: list | None,
target_options: list | None,
expected: list,
) -> None:
if global_options:
rule_runner.set_options(
[],
env={
"PANTS_DOCKER_BUILD_EXTRA_OPTIONS": f"{global_options}",
},
)
rule_runner.write_files(
{
"docker/test/BUILD": dedent(
f"""\
docker_image(
name="img1",
extra_build_options={target_options},
)
"""
),
}
)

def check_build_process(result: DockerImageBuildProcess):
for expected_option in expected:
assert expected_option in result.process.argv

assert_build_process(
rule_runner,
Address("docker/test", target_name="img1"),
build_process_assertions=check_build_process,
)


def test_extra_build_options_overrides_pull_field_and_global_field(
rule_runner: RuleRunner,
caplog: pytest.LogCaptureFixture,
) -> None:
rule_runner.set_options(
[],
env={
"PANTS_DOCKER_BUILD_EXTRA_OPTIONS": '["--pull=missing"]',
},
)
rule_runner.write_files(
{
"docker/test/BUILD": dedent(
"""\
docker_image(
name="img1",
pull=True,
extra_build_options=["--pull=always"],
)
"""
),
}
)

def check_build_process(result: DockerImageBuildProcess):
argv = result.process.argv
assert "--pull=always" in argv
assert "--pull=True" not in argv
assert "--pull=missing" not in argv

assert_build_process(
rule_runner,
Address("docker/test", target_name="img1"),
build_process_assertions=check_build_process,
)

caplog.set_level(logging.WARNING)

warning_messages = [
record.message for record in caplog.records if record.levelno == logging.WARNING
]
assert (
warning_messages[0]
== "Global docker extra option `--pull=missing` is overridden by a per-target option and will be ignored."
)
assert (
warning_messages[1]
== "The individual `--pull=True` field on the `docker_image` target in `docker/test:img1` is overridden by `extra_build_options` (`--pull=always`). Individual build option fields are deprecated in favor of using `extra_build_options`"
)


def test_warning_on_deprecated_target_stage_field(
rule_runner: RuleRunner, caplog: pytest.LogCaptureFixture
) -> None:
rule_runner.set_options(
[],
env={
"PANTS_DOCKER_BUILD_TARGET_STAGE": "dev",
},
)
rule_runner.write_files(
{
"docker/test/BUILD": dedent(
"""\
docker_image(
name="img1",
extra_build_options=["--target=test"],
)
"""
),
"docker/test/Dockerfile": dedent(
"""\
FROM base AS dev
FROM dev AS prod
"""
),
}
)

assert_build_process(
rule_runner,
Address("docker/test", target_name="img1"),
version_tags=("dev latest", "prod latest"),
)

caplog.set_level(logging.WARNING)

warning_messages = [
record.message for record in caplog.records if record.levelno == logging.WARNING
]
assert (
warning_messages[0]
== "The individual `--target=dev` field on the `docker_image` target in `docker/test:img1` is overridden by `extra_build_options` (`--target=test`). Individual build option fields are deprecated in favor of using `extra_build_options`"
)
22 changes: 22 additions & 0 deletions src/python/pants/backend/docker/subsystems/docker_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ def env_vars(self) -> tuple[str, ...]:
default=False,
help="Do not use the Docker cache when building images.",
)
_build_extra_options = ShellStrListOption(
help=softwrap(
f"""
Global extra options to pass to the `docker build` / `podman build` command.

These are appended after all other computed flags. If any option specified here uses a
flag that is already covered by a dedicated field (e.g. `--pull`, `--network`,
`--platform`), the dedicated field's contribution is suppressed in favour of
the value given here, to avoid duplicate or conflicting flags.

Example:

[{options_scope}]
build_extra_options = ["--pull=always"]

"""
),
)
build_verbose = BoolOption(
default=False,
help="Whether to log the Docker output to the console. If false, only the image ID is logged.",
Expand Down Expand Up @@ -302,6 +320,10 @@ def env_vars(self) -> tuple[str, ...]:
def build_args(self) -> tuple[str, ...]:
return tuple(sorted(set(self._build_args)))

@property
def build_extra_options(self) -> tuple[str, ...]:
return tuple(self._build_extra_options)

@property
def tools(self) -> tuple[str, ...]:
return tuple(sorted(set(self._tools)))
Expand Down
25 changes: 25 additions & 0 deletions src/python/pants/backend/docker/target_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,30 @@ class DockerImageRunExtraArgsField(StringSequenceField):
)


class DockerImageBuildExtraOptionsField(StringSequenceField):
alias = "extra_build_options"
default = ()
help = help_text(
lambda: f"""
Additional options to pass directly to the `docker build` (or `podman build`) command.

These are appended after all other computed flags. If any option specified here uses a
flag that is already covered by a dedicated field (e.g. `--pull`, `--network`,
`--platform`), the dedicated field's contribution is suppressed in favour of
the value given here, to avoid duplicate or conflicting flags on the command line.

Example:

docker_image(
extra_build_options=["--pull=always", "--compress"],
)

Use `[{DockerOptions.options_scope}].build_extra_options` to set default options for all
images. Per-target option overrides a conflicting global one.
"""
)


class DockerImageTarget(Target):
alias = "docker_image"
core_fields = (
Expand Down Expand Up @@ -719,6 +743,7 @@ class DockerImageTarget(Target):
DockerImageOutputDirectoriesField,
DockerImageOutputsMatchModeField,
DockerImageRunExtraArgsField,
DockerImageBuildExtraOptionsField,
OutputPathField,
RestartableField,
)
Expand Down