Skip to content

Commit 65a3ee3

Browse files
committed
add extra_build_options
1 parent cae762f commit 65a3ee3

4 files changed

Lines changed: 273 additions & 2 deletions

File tree

src/python/pants/backend/docker/goals/package_image.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DockerBuildOptionFieldMultiValueMixin,
2828
DockerBuildOptionFieldValueMixin,
2929
DockerBuildOptionFlagFieldMixin,
30+
DockerImageBuildExtraOptionsField,
3031
DockerImageBuildImageOutputField,
3132
DockerImageContextRootField,
3233
DockerImageRegistriesField,
@@ -323,6 +324,20 @@ class DockerInfoV1ImageTag:
323324
name: str
324325

325326

327+
def _extra_options_flag_names(extra_options: tuple[str, ...]) -> frozenset[str]:
328+
"""Return the set of flag names (e.g. ``--pull``) present in *extra_options*.
329+
330+
Handles both ``--flag=value`` and ``--flag value`` styles so that any structured field whose
331+
``docker_build_option`` class variable matches one of these names will be suppressed in favour
332+
of the caller-supplied value, avoiding duplicate or conflicting flags on the command line.
333+
"""
334+
names: set[str] = set()
335+
for opt in extra_options:
336+
if opt.startswith("-"):
337+
names.add(opt.split("=")[0])
338+
return frozenset(names)
339+
340+
326341
def get_build_options(
327342
context: DockerBuildContext,
328343
field_set: DockerPackageFieldSet,
@@ -331,7 +346,23 @@ def get_build_options(
331346
global_build_no_cache_option: bool | None,
332347
use_buildx_option: bool,
333348
target: Target,
349+
global_extra_options: tuple[str, ...] = (),
334350
) -> Iterator[str]:
351+
target_extra = tuple(target[DockerImageBuildExtraOptionsField].value or ())
352+
target_flag_names = _extra_options_flag_names(target_extra)
353+
354+
# Per-target wins: drop any global entry whose flag is already covered by the per-target list.
355+
filtered_global = tuple(
356+
opt
357+
for opt in global_extra_options
358+
if opt.startswith("-") and opt.split("=")[0] not in target_flag_names
359+
)
360+
361+
extra_options: tuple[str, ...] = (*filtered_global, *target_extra)
362+
363+
# Compute the set of flag names that are provided by global and target extra options.
364+
overridden_flags = _extra_options_flag_names(extra_options)
365+
335366
# Build options from target fields inheriting from DockerBuildOptionFieldMixin
336367
for field_type in target.field_types:
337368
if issubclass(field_type, DockerBuildKitOptionField):
@@ -356,6 +387,11 @@ def get_build_options(
356387
DockerBuildOptionFlagFieldMixin,
357388
),
358389
):
390+
391+
flag = getattr(field_type, "docker_build_option", None) # get the flag name if it exists such as --pull or --network, etc.
392+
if flag and flag in overridden_flags:
393+
continue # skip this field since its flag is already covered by extra_options
394+
359395
source = InterpolationContext.TextSource(
360396
address=target.address, target_alias=target.alias, field_alias=field_type.alias
361397
)
@@ -387,10 +423,13 @@ def get_build_options(
387423
)
388424

389425
if target_stage:
390-
yield from ("--target", target_stage)
426+
extra_options.extend(("--target", target_stage))
391427

392428
if global_build_no_cache_option:
393-
yield "--no-cache"
429+
extra_options.extend("--no-cache")
430+
431+
# Append extra options last so that they take precedence over the structured fields above.
432+
yield from extra_options
394433

395434

396435
@dataclass(frozen=True)
@@ -510,6 +549,7 @@ async def get_docker_image_build_process(
510549
global_build_no_cache_option=options.build_no_cache,
511550
use_buildx_option=options.use_buildx,
512551
target=wrapped_target.target,
552+
global_extra_options=options.build_extra_options,
513553
)
514554
),
515555
)

src/python/pants/backend/docker/goals/package_image_test.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def _setup_docker_options(rule_runner: RuleRunner, options: dict | None) -> Dock
151151
opts.setdefault("build_hosts", None)
152152
opts.setdefault("build_verbose", False)
153153
opts.setdefault("build_no_cache", False)
154+
opts.setdefault("build_extra_options", [])
154155
opts.setdefault("use_buildx", False)
155156
opts.setdefault("env_vars", [])
156157
opts.setdefault("suggest_renames", True)
@@ -2972,3 +2973,186 @@ def test_field_set_pushes_on_package(output: dict | None, expected: bool) -> Non
29722973
rule_runner.get_target(Address("", target_name="image"))
29732974
)
29742975
assert field_set.pushes_on_package() is expected
2976+
2977+
def test_global_build_extra_options(rule_runner: RuleRunner) -> None:
2978+
"""Global build_extra_options from [docker] should be passed to the docker build command."""
2979+
rule_runner.set_options(
2980+
[],
2981+
env={
2982+
"PANTS_DOCKER_BUILD_EXTRA_OPTIONS": '["--compress"]',
2983+
},
2984+
)
2985+
rule_runner.write_files(
2986+
{
2987+
"docker/test/BUILD": dedent(
2988+
"""\
2989+
docker_image(
2990+
name="img1",
2991+
)
2992+
"""
2993+
),
2994+
}
2995+
)
2996+
2997+
def check_build_process(result: DockerImageBuildProcess):
2998+
assert "--compress" in result.process.argv
2999+
3000+
assert_build_process(
3001+
rule_runner,
3002+
Address("docker/test", target_name="img1"),
3003+
build_process_assertions=check_build_process,
3004+
)
3005+
3006+
3007+
def test_extra_build_options_global_and_target_merged(rule_runner: RuleRunner) -> None:
3008+
"""Global options come first; per-target options come last."""
3009+
rule_runner.set_options(
3010+
[],
3011+
env={
3012+
"PANTS_DOCKER_BUILD_EXTRA_OPTIONS": '["--compress"]',
3013+
},
3014+
)
3015+
rule_runner.write_files(
3016+
{
3017+
"docker/test/BUILD": dedent(
3018+
"""\
3019+
docker_image(
3020+
name="img1",
3021+
extra_build_options=["--pull=always"],
3022+
)
3023+
"""
3024+
),
3025+
}
3026+
)
3027+
3028+
def check_build_process(result: DockerImageBuildProcess):
3029+
argv = result.process.argv
3030+
assert "--compress" in argv
3031+
assert "--pull=always" in argv
3032+
3033+
assert_build_process(
3034+
rule_runner,
3035+
Address("docker/test", target_name="img1"),
3036+
build_process_assertions=check_build_process,
3037+
)
3038+
3039+
3040+
def test_extra_build_options_overrides_pull_field(rule_runner: RuleRunner) -> None:
3041+
"""When extra_build_options contains --pull, the docker_image target pull argument is suppressed."""
3042+
rule_runner.write_files(
3043+
{
3044+
"docker/test/BUILD": dedent(
3045+
"""\
3046+
docker_image(
3047+
name="img1",
3048+
pull=True,
3049+
extra_build_options=["--pull=always"],
3050+
)
3051+
"""
3052+
),
3053+
}
3054+
)
3055+
3056+
def check_build_process(result: DockerImageBuildProcess):
3057+
argv = result.process.argv
3058+
# Only the extra_build_options value should appear; the structured field's
3059+
# --pull=True must NOT be present to avoid duplicate flags.
3060+
assert "--pull=always" in argv
3061+
assert "--pull=True" not in argv
3062+
3063+
assert_build_process(
3064+
rule_runner,
3065+
Address("docker/test", target_name="img1"),
3066+
build_process_assertions=check_build_process,
3067+
)
3068+
3069+
def test_global_extra_options_overridden_by_target_extra_options(
3070+
rule_runner: RuleRunner,
3071+
) -> None:
3072+
"""When both global and target extra options specify the same flag, the target wins
3073+
(it comes last) and the global does not appear."""
3074+
rule_runner.set_options(
3075+
[],
3076+
env={
3077+
"PANTS_DOCKER_BUILD_EXTRA_OPTIONS": '["--pull=missing"]',
3078+
},
3079+
)
3080+
rule_runner.write_files(
3081+
{
3082+
"docker/test/BUILD": dedent(
3083+
"""\
3084+
docker_image(
3085+
name="img1",
3086+
extra_build_options=["--pull=always"],
3087+
)
3088+
"""
3089+
),
3090+
}
3091+
)
3092+
3093+
def check_build_process(result: DockerImageBuildProcess):
3094+
argv = result.process.argv
3095+
# Both global and target specify --pull; the per-target value wins and the global
3096+
# entry is dropped at the flag-level dedup stage.
3097+
assert "--pull=always" in argv
3098+
assert "--pull=missing" not in argv # global dropped because target specifies --pull
3099+
3100+
assert_build_process(
3101+
rule_runner,
3102+
Address("docker/test", target_name="img1"),
3103+
build_process_assertions=check_build_process,
3104+
)
3105+
3106+
3107+
def test_global_build_no_cache_options_exits(
3108+
rule_runner: RuleRunner,
3109+
) -> None:
3110+
rule_runner.write_files(
3111+
{
3112+
"docker/test/BUILD": dedent(
3113+
"""\
3114+
docker_image(
3115+
name="img1",
3116+
no_cache=True,
3117+
)
3118+
"""
3119+
),
3120+
}
3121+
)
3122+
3123+
def check_build_process(result: DockerImageBuildProcess):
3124+
argv = result.process.argv
3125+
assert "--no-cache" in argv
3126+
3127+
assert_build_process(
3128+
rule_runner,
3129+
Address("docker/test", target_name="img1"),
3130+
build_process_assertions=check_build_process,
3131+
)
3132+
3133+
def test_global_build_no_cache_options_overridden_by_target_extra_options(
3134+
rule_runner: RuleRunner,
3135+
) -> None:
3136+
rule_runner.write_files(
3137+
{
3138+
"docker/test/BUILD": dedent(
3139+
"""\
3140+
docker_image(
3141+
name="img1",
3142+
no_cache=False,
3143+
extra_build_options=["--no_cache"],
3144+
)
3145+
"""
3146+
),
3147+
}
3148+
)
3149+
3150+
def check_build_process(result: DockerImageBuildProcess):
3151+
argv = result.process.argv
3152+
assert "--no-cache" in argv
3153+
3154+
assert_build_process(
3155+
rule_runner,
3156+
Address("docker/test", target_name="img1"),
3157+
build_process_assertions=check_build_process,
3158+
)

src/python/pants/backend/docker/subsystems/docker_options.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,24 @@ def env_vars(self) -> tuple[str, ...]:
207207
default=False,
208208
help="Do not use the Docker cache when building images.",
209209
)
210+
_build_extra_options = ShellStrListOption(
211+
help=softwrap(
212+
f"""
213+
Global extra options to pass to the `docker build` / `podman build` command.
214+
215+
These are appended after all other computed flags. If any option specified here uses a
216+
flag that is already covered by a dedicated field (e.g. `--pull`, `--network`,
217+
`--platform`), the dedicated field's contribution is suppressed in favour of
218+
the value given here, to avoid duplicate or conflicting flags.
219+
220+
Example:
221+
222+
[{options_scope}]
223+
build_extra_options = ["--pull=always"]
224+
225+
"""
226+
),
227+
)
210228
build_verbose = BoolOption(
211229
default=False,
212230
help="Whether to log the Docker output to the console. If false, only the image ID is logged.",
@@ -302,6 +320,10 @@ def env_vars(self) -> tuple[str, ...]:
302320
def build_args(self) -> tuple[str, ...]:
303321
return tuple(sorted(set(self._build_args)))
304322

323+
@property
324+
def build_extra_options(self) -> tuple[str, ...]:
325+
return tuple(self._build_extra_options)
326+
305327
@property
306328
def tools(self) -> tuple[str, ...]:
307329
return tuple(sorted(set(self._tools)))

src/python/pants/backend/docker/target_types.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,30 @@ class DockerImageRunExtraArgsField(StringSequenceField):
596596
)
597597

598598

599+
class DockerImageBuildExtraOptionsField(StringSequenceField):
600+
alias = "extra_build_options"
601+
default = ()
602+
help = help_text(
603+
lambda: f"""
604+
Additional options to pass directly to the `docker build` (or `podman build`) command.
605+
606+
These are appended after all other computed flags. If any option specified here uses a
607+
flag that is already covered by a dedicated field (e.g. `--pull`, `--network`,
608+
`--platform`), the dedicated field's contribution is suppressed in favour of
609+
the value given here, to avoid duplicate or conflicting flags on the command line.
610+
611+
Example:
612+
613+
docker_image(
614+
extra_build_options=["--pull=always", "--compress"],
615+
)
616+
617+
Use `[{DockerOptions.options_scope}].build_extra_options` to set default options for all
618+
images. Per-target option overrides a conflicting global one.
619+
"""
620+
)
621+
622+
599623
class DockerImageTarget(Target):
600624
alias = "docker_image"
601625
core_fields = (
@@ -622,6 +646,7 @@ class DockerImageTarget(Target):
622646
DockerImageBuildImageCacheFromField,
623647
DockerImageBuildImageOutputField,
624648
DockerImageRunExtraArgsField,
649+
DockerImageBuildExtraOptionsField,
625650
OutputPathField,
626651
RestartableField,
627652
)

0 commit comments

Comments
 (0)