Skip to content

Commit e96323e

Browse files
committedJan 17, 2025
docker.container: Refactor to support container recreation
This change refactors the way the `docker.container` operation manages containers, in preparation for work to make recreation more intelligent. This change is (mostly) a pure refactor; future changes will diff the current container against the operation parameters to determine if a container needs to be recreated. This will fix an issue where changing any of the operation arguments does not result in actual container changes upon execution. In this refactor, container parameters are moved to a dedicated class, which centralizes the `docker container` command-line argument generation logic and the future diffing logic. The diff function is roughed in, though it currently reports "no diff" (to match the operation's current behavior). Since conditional recreation complicates the operation's logic on which commands to execute, the decisions are boosted into boolean variables to increase readability. As a side benefit, supporting additional docker container parameters should be more straightforward due to the centralization in said dedicated class (I'm planning on adding support for container args, uid, and other Docker params currently not supported). The only behavioral change is that creating and starting a container is no longer done in one exec (joined by `;`) but rather two separate docker commands. This sidesteps questions about whether `;` is the correct joiner (as opposed to `&&`) and reduces the amount of `kwargs` fishing in the implementation. Tested: * `scripts/dev-test.sh` and `scripts/dev-test-e2e.sh` both pass, save for warnings also present prior to this change
1 parent 935ce07 commit e96323e

File tree

4 files changed

+323
-298
lines changed

4 files changed

+323
-298
lines changed
 

‎pyinfra/operations/docker.py

+46-42
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pyinfra.api import operation
99
from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerVolume
1010

11-
from .util.docker import handle_docker
11+
from .util.docker import ContainerSpec, handle_docker
1212

1313

1414
@operation()
@@ -70,56 +70,60 @@ def container(
7070
)
7171
"""
7272

73+
want_spec = ContainerSpec(
74+
image,
75+
ports or list(),
76+
networks or list(),
77+
volumes or list(),
78+
env_vars or list(),
79+
pull_always,
80+
)
7381
existent_container = host.get_fact(DockerContainer, object_id=container)
7482

75-
if force:
76-
if existent_container:
77-
yield handle_docker(
78-
resource="container",
79-
command="remove",
80-
container=container,
81-
)
83+
container_spec_changes = want_spec.diff_from_inspect(existent_container)
8284

83-
if present:
84-
if not existent_container or force:
85-
yield handle_docker(
86-
resource="container",
87-
command="create",
88-
container=container,
89-
image=image,
90-
ports=ports,
91-
networks=networks,
92-
volumes=volumes,
93-
env_vars=env_vars,
94-
pull_always=pull_always,
95-
present=present,
96-
force=force,
97-
start=start,
98-
)
99-
100-
if existent_container and start:
101-
if existent_container[0]["State"]["Status"] != "running":
102-
yield handle_docker(
103-
resource="container",
104-
command="start",
105-
container=container,
106-
)
107-
108-
if existent_container and not start:
109-
if existent_container[0]["State"]["Status"] == "running":
110-
yield handle_docker(
111-
resource="container",
112-
command="stop",
113-
container=container,
114-
)
115-
116-
if existent_container and not present:
85+
is_running = (
86+
(existent_container[0]["State"]["Status"] == "running")
87+
if existent_container and existent_container[0]
88+
else False
89+
)
90+
recreating = existent_container and (force or container_spec_changes)
91+
removing = existent_container and not present
92+
93+
do_remove = recreating or removing
94+
do_create = (present and not existent_container) or recreating
95+
do_start = start and (recreating or not is_running)
96+
do_stop = not start and not removing and is_running
97+
98+
if do_remove:
11799
yield handle_docker(
118100
resource="container",
119101
command="remove",
120102
container=container,
121103
)
122104

105+
if do_create:
106+
yield handle_docker(
107+
resource="container",
108+
command="create",
109+
container=container,
110+
spec=want_spec,
111+
)
112+
113+
if do_start:
114+
yield handle_docker(
115+
resource="container",
116+
command="start",
117+
container=container,
118+
)
119+
120+
if do_stop:
121+
yield handle_docker(
122+
resource="container",
123+
command="stop",
124+
container=container,
125+
)
126+
123127

124128
@operation(is_idempotent=False)
125129
def image(image, present=True):

‎pyinfra/operations/util/docker.py

+44-22
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,60 @@
1+
import dataclasses
2+
from typing import Any
3+
14
from pyinfra.api import OperationError
25

36

4-
def _create_container(**kwargs):
5-
command = []
7+
@dataclasses.dataclass
8+
class ContainerSpec:
9+
image: str = ""
10+
ports: list[str] = dataclasses.field(default_factory=list)
11+
networks: list[str] = dataclasses.field(default_factory=list)
12+
volumes: list[str] = dataclasses.field(default_factory=list)
13+
env_vars: list[str] = dataclasses.field(default_factory=list)
14+
pull_always: bool = False
15+
16+
def container_create_args(self):
17+
args = []
18+
for network in self.networks:
19+
args.append("--network {0}".format(network))
620

7-
networks = kwargs["networks"] if kwargs["networks"] else []
8-
ports = kwargs["ports"] if kwargs["ports"] else []
9-
volumes = kwargs["volumes"] if kwargs["volumes"] else []
10-
env_vars = kwargs["env_vars"] if kwargs["env_vars"] else []
21+
for port in self.ports:
22+
args.append("-p {0}".format(port))
1123

12-
if kwargs["image"] == "":
13-
raise OperationError("missing 1 required argument: 'image'")
24+
for volume in self.volumes:
25+
args.append("-v {0}".format(volume))
1426

15-
command.append("docker container create --name {0}".format(kwargs["container"]))
27+
for env_var in self.env_vars:
28+
args.append("-e {0}".format(env_var))
1629

17-
for network in networks:
18-
command.append("--network {0}".format(network))
30+
if self.pull_always:
31+
args.append("--pull always")
1932

20-
for port in ports:
21-
command.append("-p {0}".format(port))
33+
args.append(self.image)
2234

23-
for volume in volumes:
24-
command.append("-v {0}".format(volume))
35+
return args
2536

26-
for env_var in env_vars:
27-
command.append("-e {0}".format(env_var))
37+
def diff_from_inspect(self, inspect_dict: dict[str, Any]) -> list[str]:
38+
# TODO(@minor-fixes): Diff output of "docker inspect" against this spec
39+
# to determine if the container needs to be recreated. Currently, this
40+
# function will never recreate when attributes change, which is
41+
# consistent with prior behavior.
42+
del inspect_dict
43+
return []
44+
45+
46+
def _create_container(**kwargs):
47+
if "spec" not in kwargs:
48+
raise OperationError("missing 1 required argument: 'spec'")
2849

29-
if kwargs["pull_always"]:
30-
command.append("--pull always")
50+
spec = kwargs["spec"]
3151

32-
command.append(kwargs["image"])
52+
if not spec.image:
53+
raise OperationError("Docker image not specified")
3354

34-
if kwargs["start"]:
35-
command.append("; {0}".format(_start_container(container=kwargs["container"])))
55+
command = [
56+
"docker container create --name {0}".format(kwargs["container"])
57+
] + spec.container_create_args()
3658

3759
return " ".join(command)
3860

‎tests/operations/docker.container/add_and_start_no_existent_container.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
},
1111
"facts": {
1212
"docker.DockerContainer": {
13-
"object_id=nginx": []
13+
"object_id=nginx": []
1414
}
1515
},
1616
"commands": [
17-
"docker container create --name nginx -p 80:80 nginx:alpine ; docker container start nginx"
17+
"docker container create --name nginx -p 80:80 nginx:alpine",
18+
"docker container start nginx"
1819
]
1920
}

0 commit comments

Comments
 (0)
Failed to load comments.