Skip to content

Commit 8ef9a23

Browse files
committed
docker.container: Recreate container when args change
This PR allows the `docker.container` operation to tear down and recreate the container when operation arguments change, instead of reporting `No change` and doing nothing. This is intended to reduce the possibility for human error/need for manual intervention when changing args to `docker.container` operations. Since it is not possible to extract all operation args from e.g. `docker inspect` output, this PR takes a similar approach to Docker Compose to tackle this issue - it serializes the operation args in a deterministic way, hashes the serialized bytes, and stores this as a label on the container. If the hash differs from a currently-running container, the container is recreated. Tested: Added additional tests for behavior when args are changing/static in different scenarios
1 parent 3988181 commit 8ef9a23

11 files changed

+1081
-278
lines changed

pyinfra/operations/docker.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
as inventory directly.
55
"""
66

7+
from typing import Any, Dict
8+
79
from pyinfra import host
810
from pyinfra.api import operation
911
from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerVolume
1012

11-
from .util.docker import ContainerSpec, handle_docker
13+
from .util.docker import CONTAINER_CONFIG_HASH_LABEL, ContainerSpec, handle_docker
1214

1315

1416
@operation()
@@ -75,28 +77,36 @@ def container(
7577
want_spec = ContainerSpec(
7678
image,
7779
args or list(),
78-
ports or list(),
79-
networks or list(),
80+
set(ports) if ports else set(),
81+
set(networks) if networks else set(),
8082
volumes or list(),
81-
env_vars or list(),
83+
set(env_vars) if env_vars else set(),
8284
pull_always,
8385
)
84-
existent_container = host.get_fact(DockerContainer, object_id=container)
8586

86-
container_spec_changes = want_spec.diff_from_inspect(existent_container)
87+
existent_container: Dict[str, Any] = next(
88+
iter(host.get_fact(DockerContainer, object_id=container)), {}
89+
)
8790

88-
is_running = (
89-
(existent_container[0]["State"]["Status"] == "running")
90-
if existent_container and existent_container[0]
91-
else False
91+
old_hash = (
92+
existent_container.get("Config", {})
93+
.get("Labels", {})
94+
.get(CONTAINER_CONFIG_HASH_LABEL, None)
9295
)
93-
recreating = existent_container and (force or container_spec_changes)
96+
97+
container_spec_changed = old_hash != want_spec.config_hash()
98+
99+
is_running = existent_container.get("State", {}).get("Status", "") == "running"
100+
recreating = existent_container and (force or container_spec_changed)
94101
removing = existent_container and not present
95102

96103
do_remove = recreating or removing
97-
do_create = (present and not existent_container) or recreating
98-
do_start = start and (recreating or not is_running)
99-
do_stop = not start and not removing and is_running
104+
do_create = not removing and ((present and not existent_container) or recreating)
105+
do_start = present and start and (recreating or not is_running)
106+
do_stop = not start and not removing and is_running and not recreating
107+
108+
if not (do_remove or do_create or do_start or do_stop):
109+
host.noop("container configuration is already correct")
100110

101111
if do_remove:
102112
yield handle_docker(

pyinfra/operations/util/docker.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,49 @@
11
import dataclasses
2-
from typing import Any, Dict, List
2+
import hashlib
3+
import json
4+
from typing import Any, List, Set
35

46
from pyinfra.api import OperationError
57

8+
CONTAINER_CONFIG_HASH_LABEL = "com.github.pyinfra.config-hash"
9+
10+
11+
def _json_repr(obj: Any):
12+
try:
13+
return dataclasses.asdict(obj)
14+
except TypeError:
15+
pass
16+
17+
if isinstance(obj, set):
18+
return sorted(obj)
19+
20+
# If there are other alternative types to try (e.g. dates) then do so here
21+
22+
raise TypeError(f"object {type(obj).__name__} not serializable")
23+
624

725
@dataclasses.dataclass
826
class ContainerSpec:
927
image: str = ""
1028
args: List[str] = dataclasses.field(default_factory=list)
11-
ports: List[str] = dataclasses.field(default_factory=list)
12-
networks: List[str] = dataclasses.field(default_factory=list)
29+
ports: Set[str] = dataclasses.field(default_factory=set)
30+
networks: Set[str] = dataclasses.field(default_factory=set)
1331
volumes: List[str] = dataclasses.field(default_factory=list)
14-
env_vars: List[str] = dataclasses.field(default_factory=list)
32+
env_vars: Set[str] = dataclasses.field(default_factory=set)
1533
pull_always: bool = False
1634

1735
def container_create_args(self):
18-
args = []
19-
for network in self.networks:
36+
args = [f"--label '{CONTAINER_CONFIG_HASH_LABEL}={self.config_hash()}'"]
37+
for network in sorted(self.networks):
2038
args.append("--network {0}".format(network))
2139

22-
for port in self.ports:
40+
for port in sorted(self.ports):
2341
args.append("-p {0}".format(port))
2442

2543
for volume in self.volumes:
2644
args.append("-v {0}".format(volume))
2745

28-
for env_var in self.env_vars:
46+
for env_var in sorted(self.env_vars):
2947
args.append("-e {0}".format(env_var))
3048

3149
if self.pull_always:
@@ -36,13 +54,16 @@ def container_create_args(self):
3654

3755
return args
3856

39-
def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]:
40-
# TODO(@minor-fixes): Diff output of "docker inspect" against this spec
41-
# to determine if the container needs to be recreated. Currently, this
42-
# function will never recreate when attributes change, which is
43-
# consistent with prior behavior.
44-
del inspect_dict
45-
return []
57+
def config_hash(self) -> str:
58+
serialized = json.dumps(
59+
self,
60+
default=_json_repr,
61+
ensure_ascii=False,
62+
sort_keys=True,
63+
indent=None,
64+
separators=(",", ":"),
65+
).encode("utf-8")
66+
return hashlib.sha256(serialized).hexdigest()
4667

4768

4869
def _create_container(**kwargs):

tests/operations/docker.container/add_and_start_no_existent_container.json

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,21 @@
77
"-g",
88
"'daemon off;'"
99
],
10+
"networks": [
11+
"foo",
12+
"bar"
13+
],
14+
"volumes": [
15+
"/host/a:/container/a",
16+
"/host/b:/container/b"
17+
],
1018
"ports": [
11-
"80:80"
19+
"80:80",
20+
"8081:8081"
21+
],
22+
"env_vars": [
23+
"ENV_A=foo",
24+
"ENV_B=bar"
1225
],
1326
"present": true,
1427
"start": true
@@ -19,7 +32,7 @@
1932
}
2033
},
2134
"commands": [
22-
"docker container create --name nginx -p 80:80 nginx:alpine nginx-debug -g 'daemon off;'",
35+
"docker container create --name nginx --label 'com.github.pyinfra.config-hash=72d37ab8f5ea3db48272d045bc10e211bbd628f2b4bab5c54d948b073313c9a3' --network bar --network foo -p 8081:8081 -p 80:80 -v /host/a:/container/a -v /host/b:/container/b -e ENV_A=foo -e ENV_B=bar nginx:alpine nginx-debug -g 'daemon off;'",
2336
"docker container start nginx"
2437
]
2538
}

0 commit comments

Comments
 (0)