Add command_as_args option for docker_swarm_service CLI-compatible mapping (#1307)

* fix: map docker_swarm_service command/args to ContainerSpec.Args

docker service create IMAGE [COMMAND] [ARG...] places post-image tokens
into ContainerSpec.Args so the image ENTRYPOINT is preserved. The module
previously wrote command to ContainerSpec.Command, which replaced the
entrypoint and broke flag-style commands such as -config.file=...

Fixes #1044
Fixes #212

* Add command_as_args option for CLI-compatible command mapping

Keep the historical default (command -> ContainerSpec.Command,
args -> ContainerSpec.Args) to avoid a breaking change. When
command_as_args=true, map command+args to ContainerSpec.Args to
match docker service create and preserve the image ENTRYPOINT.

Update docs, examples, unit tests for both modes, and the changelog
fragment. Addresses review feedback on #1307 / #1044.

* Update changelogs/fragments/1044-swarm-service-command-args.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/docker_swarm_service.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update changelogs/fragments/1044-swarm-service-command-args.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

---------

Co-authored-by: VedantMadane <vedant@users.noreply.github.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Vedant Madane
2026-08-18 01:02:57 +05:30
committed by GitHub
parent 6ae46db845
commit 2950337e48
3 changed files with 168 additions and 9 deletions
@@ -0,0 +1,10 @@
minor_changes:
- docker_swarm_service - add ``command_as_args`` option. When set to ``true``,
``command`` and ``args`` are concatenated and mapped to ``ContainerSpec.Args``
(matching ``docker service create IMAGE [COMMAND] [ARG...]``), preserving the
image ``ENTRYPOINT``. The default remains ``false`` (historical mapping of
``command`` to ``ContainerSpec.Command`` and ``args`` to ``ContainerSpec.Args``) for
backward compatibility
(https://github.com/ansible-collections/community.docker/issues/1044,
https://github.com/ansible-collections/community.docker/issues/212,
https://github.com/ansible-collections/community.docker/pull/1307).
+82 -9
View File
@@ -36,6 +36,10 @@ options:
description:
- List arguments to be passed to the container.
- Corresponds to the C(ARG) parameter of C(docker service create).
- When O(command_as_args=true), these values are appended after O(command)
and both are sent as ContainerSpec.Args (matching the Docker CLI).
- When O(command_as_args=false), O(args) is sent as C(ContainerSpec.Args)
while O(command) is sent as ContainerSpec.Command.
type: list
elements: str
command:
@@ -43,7 +47,28 @@ options:
- Command to execute when the container starts.
- A command may be either a string or a list or a list of strings.
- Corresponds to the C(COMMAND) parameter of C(docker service create).
- When O(command_as_args=false), this is sent as C(ContainerSpec.Command),
which replaces the image C(ENTRYPOINT). This is the historical module behavior.
- When O(command_as_args=true), this is combined with O(args) and sent as
C(ContainerSpec.Args) (same as the Docker CLI
C(docker service create IMAGE [COMMAND] [ARG...])), so the image C(ENTRYPOINT)
is preserved. Use this for flag-style commands (for example V(--config.file=...))
that must be passed as arguments to the image entrypoint
(see U(https://github.com/ansible-collections/community.docker/issues/1044)).
type: raw
command_as_args:
description:
- Controls how O(command) and O(args) are mapped to the service C(ContainerSpec).
- If V(false) (default), O(command) is written to C(ContainerSpec.Command) and
O(args) to C(ContainerSpec.Args). This matches the historical module behavior
(ContainerSpec.Command replaces the image C(ENTRYPOINT)).
- If V(true), O(command) and O(args) are concatenated and written only to
C(ContainerSpec.Args), matching C(docker service create IMAGE [COMMAND] [ARG...])
so the image C(ENTRYPOINT) is preserved.
- The current default will eventually be depreacted and change to V(true).
type: bool
default: false
version_added: 5.3.0
configs:
description:
- List of dictionaries describing the service configs.
@@ -696,7 +721,7 @@ rebuilt:
EXAMPLES = r"""
---
- name: Set command and arguments
- name: Set command and arguments (historical mapping)
community.docker.docker_swarm_service:
name: myservice
image: alpine
@@ -704,6 +729,14 @@ EXAMPLES = r"""
args:
- "3600"
- name: Pass flags to the image ENTRYPOINT (CLI-compatible mapping)
community.docker.docker_swarm_service:
name: loki
image: grafana/loki:main
command_as_args: true
command:
- '-config.file=/etc/loki/loki-config.yaml'
- name: Set a bind mount
community.docker.docker_swarm_service:
name: myservice
@@ -1054,6 +1087,22 @@ def has_dict_changed(
return False
def _combine_command_args(
command: list[str] | None, args: list[str] | None
) -> list[str] | None:
"""
Combine ansible command + args into a single argv list.
Matches docker CLI ``service create IMAGE [COMMAND] [ARG...]``, which places
all post-image tokens into ContainerSpec.Args (preserving ENTRYPOINT).
"""
if command is None:
return args
if args is None:
return command
return command + args
def has_list_changed(
new_list: list[t.Any] | None,
old_list: list[t.Any] | None,
@@ -1161,6 +1210,7 @@ class DockerService(DockerBaseClass):
self.image: str | None = ""
self.command: t.Any = None
self.args: list[str] | None = None
self.command_as_args: bool = False
self.endpoint_mode: t.Literal["vip", "dnsrr"] | None = None
self.dns: list[str] | None = None
self.healthcheck: dict[str, t.Any] | None = None
@@ -1473,6 +1523,7 @@ class DockerService(DockerBaseClass):
s = DockerService(client.docker_api_version, client.docker_py_version)
s.image = image_digest
s.args = ap["args"]
s.command_as_args = ap["command_as_args"]
s.endpoint_mode = ap["endpoint_mode"]
s.dns = ap["dns"]
s.dns_search = ap["dns_search"]
@@ -1675,10 +1726,21 @@ class DockerService(DockerBaseClass):
needs_rebuild = not self.can_update_networks
if self.replicas != os.replicas:
differences.add("replicas", parameter=self.replicas, active=os.replicas)
if has_list_changed(self.command, os.command, sort_lists=False):
differences.add("command", parameter=self.command, active=os.command)
if has_list_changed(self.args, os.args, sort_lists=False):
differences.add("args", parameter=self.args, active=os.args)
if self.command_as_args:
# command + args are stored as ContainerSpec.Args (CLI-compatible).
# Older module versions wrote command to ContainerSpec.Command,
# so compare the combined command line from either field.
desired_cmdline = _combine_command_args(self.command, self.args)
active_cmdline = _combine_command_args(os.command, os.args)
if has_list_changed(desired_cmdline, active_cmdline, sort_lists=False):
differences.add(
"command", parameter=desired_cmdline, active=active_cmdline
)
else:
if has_list_changed(self.command, os.command, sort_lists=False):
differences.add("command", parameter=self.command, active=os.command)
if has_list_changed(self.args, os.args, sort_lists=False):
differences.add("args", parameter=self.args, active=os.args)
if has_list_changed(self.constraints, os.constraints):
differences.add(
"constraints", parameter=self.constraints, active=os.constraints
@@ -1999,10 +2061,20 @@ class DockerService(DockerBaseClass):
dns_config = types.DNSConfig(**dns_config_args) if dns_config_args else None
container_spec_args: dict[str, t.Any] = {}
if self.command is not None:
container_spec_args["command"] = self.command
if self.args is not None:
container_spec_args["args"] = self.args
if self.command_as_args:
# Match `docker service create IMAGE [COMMAND] [ARG...]`: the CLI places
# all post-image tokens into ContainerSpec.Args so ENTRYPOINT is kept.
# Writing Command instead replaces ENTRYPOINT and breaks flag-style
# commands such as `-config.file=...` (see #1044, #212).
combined_args = _combine_command_args(self.command, self.args)
if combined_args is not None:
container_spec_args["args"] = combined_args
else:
# Historical mapping: command -> ContainerSpec.Command, args -> Args.
if self.command is not None:
container_spec_args["command"] = self.command
if self.args is not None:
container_spec_args["args"] = self.args
if self.env is not None:
container_spec_args["env"] = self.env
if self.user is not None:
@@ -2748,6 +2820,7 @@ def main() -> None:
"networks": {"type": "list", "elements": "raw"},
"command": {"type": "raw"},
"args": {"type": "list", "elements": "str"},
"command_as_args": {"type": "bool", "default": False},
"env": {"type": "raw"},
"env_files": {"type": "list", "elements": "path"},
"force_update": {"type": "bool", "default": False},
@@ -376,3 +376,79 @@ def test_get_docker_networks() -> None:
docker_swarm_service.get_docker_networks(
[{"name": "test", "nonexisting_option": "foo"}], {"test": "1"}
)
def test_combine_command_args() -> None:
"""command + args combine into a single argv list for CLI-compatible mode."""
combine = docker_swarm_service._combine_command_args
assert combine(None, None) is None
assert combine(["sleep"], ["3600"]) == ["sleep", "3600"]
# Flag-style command alone (issue #1044 / #212)
assert combine(["-config.file=/etc/loki/loki-config.yaml"], None) == [
"-config.file=/etc/loki/loki-config.yaml"
]
assert combine(None, ["--path.rootfs=/host"]) == ["--path.rootfs=/host"]
assert combine(["prometheus"], ["--config.file=a.yml"]) == [
"prometheus",
"--config.file=a.yml",
]
def _make_service(
command: list[str] | None = None,
args: list[str] | None = None,
command_as_args: bool = False,
) -> docker_swarm_service.DockerService:
svc = docker_swarm_service.DockerService(
docker_swarm_service.LooseVersion("1.40"),
docker_swarm_service.LooseVersion("5.0.0"),
)
svc.image = "alpine:latest"
svc.command = command
svc.args = args
svc.command_as_args = command_as_args
return svc
def test_build_container_spec_legacy_command_mapping(mocker: t.Any) -> None:
"""Default command_as_args=false keeps historical Command/Args split."""
captured: dict[str, t.Any] = {}
class FakeContainerSpec:
def __init__(self, image: str, **kwargs: t.Any) -> None:
captured["image"] = image
captured.update(kwargs)
mocker.patch.object(docker_swarm_service.types, "ContainerSpec", FakeContainerSpec)
svc = _make_service(command=["sleep"], args=["3600"], command_as_args=False)
svc.build_container_spec()
assert captured.get("command") == ["sleep"]
assert captured.get("args") == ["3600"]
def test_build_container_spec_command_as_args(mocker: t.Any) -> None:
"""command_as_args=true maps command+args to ContainerSpec.Args only."""
captured: dict[str, t.Any] = {}
class FakeContainerSpec:
def __init__(self, image: str, **kwargs: t.Any) -> None:
captured["image"] = image
captured.update(kwargs)
mocker.patch.object(docker_swarm_service.types, "ContainerSpec", FakeContainerSpec)
svc = _make_service(
command=["-config.file=/etc/loki/loki-config.yaml"],
args=None,
command_as_args=True,
)
svc.build_container_spec()
assert "command" not in captured
assert captured.get("args") == ["-config.file=/etc/loki/loki-config.yaml"]
captured.clear()
svc = _make_service(command=["sleep"], args=["3600"], command_as_args=True)
svc.build_container_spec()
assert "command" not in captured
assert captured.get("args") == ["sleep", "3600"]