Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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."
)
Comment thread
tim-werner marked this conversation as resolved.
else:
result.append(opt)
return tuple(result)


def _overwriten_flag_warning(
Comment thread
tim-werner marked this conversation as resolved.
Outdated
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 "
Comment thread
tim-werner marked this conversation as resolved.
Outdated
f"`extra_build_options` (`{extra_build_options}`). Individual build option fields are deprecated in favor of using `extra_build_options`"
Comment thread
tim-werner marked this conversation as resolved.
Outdated
)


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`"
Comment thread
tim-werner marked this conversation as resolved.
Outdated
)


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