Skip to content

docker.container: Recreate container when args change #1277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: 3.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
103 changes: 60 additions & 43 deletions pyinfra/operations/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
as inventory directly.
"""

from typing import Any, Dict

from pyinfra import host
from pyinfra.api import operation
from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerVolume

from .util.docker import handle_docker
from .util.docker import CONTAINER_CONFIG_HASH_LABEL, ContainerSpec, handle_docker


@operation()
def container(
container,
image="",
args=None,
ports=None,
networks=None,
volumes=None,
Expand All @@ -28,6 +31,7 @@ def container(
Manage Docker containers

+ container: name to identify the container
+ args: list of command-line args to supply to the image
+ image: container image and tag ex: nginx:alpine
+ networks: network list to attach on container
+ ports: port list to expose
Expand Down Expand Up @@ -70,56 +74,69 @@ def container(
)
"""

existent_container = host.get_fact(DockerContainer, object_id=container)
want_spec = ContainerSpec(
image,
args or list(),
set(ports) if ports else set(),
set(networks) if networks else set(),
volumes or list(),
set(env_vars) if env_vars else set(),
pull_always,
)

if force:
if existent_container:
yield handle_docker(
resource="container",
command="remove",
container=container,
)
existent_container: Dict[str, Any] = next(
iter(host.get_fact(DockerContainer, object_id=container)), {}
)

if present:
if not existent_container or force:
yield handle_docker(
resource="container",
command="create",
container=container,
image=image,
ports=ports,
networks=networks,
volumes=volumes,
env_vars=env_vars,
pull_always=pull_always,
present=present,
force=force,
start=start,
)

if existent_container and start:
if existent_container[0]["State"]["Status"] != "running":
yield handle_docker(
resource="container",
command="start",
container=container,
)

if existent_container and not start:
if existent_container[0]["State"]["Status"] == "running":
yield handle_docker(
resource="container",
command="stop",
container=container,
)

if existent_container and not present:
old_hash = (
existent_container.get("Config", {})
.get("Labels", {})
.get(CONTAINER_CONFIG_HASH_LABEL, None)
)

container_spec_changed = old_hash != want_spec.config_hash()

is_running = existent_container.get("State", {}).get("Status", "") == "running"
recreating = existent_container and (force or container_spec_changed)
removing = existent_container and not present

do_remove = recreating or removing
do_create = not removing and ((present and not existent_container) or recreating)
do_start = present and start and (recreating or not is_running)
do_stop = not start and not removing and is_running and not recreating

if not (do_remove or do_create or do_start or do_stop):
host.noop("container configuration is already correct")

if do_remove:
yield handle_docker(
resource="container",
command="remove",
container=container,
)

if do_create:
yield handle_docker(
resource="container",
command="create",
container=container,
spec=want_spec,
)

if do_start:
yield handle_docker(
resource="container",
command="start",
container=container,
)

if do_stop:
yield handle_docker(
resource="container",
command="stop",
container=container,
)


@operation(is_idempotent=False)
def image(image, present=True):
Expand Down
89 changes: 67 additions & 22 deletions pyinfra/operations/util/docker.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,83 @@
import dataclasses
import hashlib
import json
from typing import Any, List, Set

from pyinfra.api import OperationError

CONTAINER_CONFIG_HASH_LABEL = "com.github.pyinfra.config-hash"

def _create_container(**kwargs):
command = []

networks = kwargs["networks"] if kwargs["networks"] else []
ports = kwargs["ports"] if kwargs["ports"] else []
volumes = kwargs["volumes"] if kwargs["volumes"] else []
env_vars = kwargs["env_vars"] if kwargs["env_vars"] else []
def _json_repr(obj: Any):
try:
return dataclasses.asdict(obj)
except TypeError:
pass

if isinstance(obj, set):
return sorted(obj)

# If there are other alternative types to try (e.g. dates) then do so here

raise TypeError(f"object {type(obj).__name__} not serializable")


if kwargs["image"] == "":
raise OperationError("missing 1 required argument: 'image'")
@dataclasses.dataclass
class ContainerSpec:
image: str = ""
args: List[str] = dataclasses.field(default_factory=list)
ports: Set[str] = dataclasses.field(default_factory=set)
networks: Set[str] = dataclasses.field(default_factory=set)
volumes: List[str] = dataclasses.field(default_factory=list)
env_vars: Set[str] = dataclasses.field(default_factory=set)
pull_always: bool = False

command.append("docker container create --name {0}".format(kwargs["container"]))
def container_create_args(self):
args = [f"--label '{CONTAINER_CONFIG_HASH_LABEL}={self.config_hash()}'"]
for network in sorted(self.networks):
args.append("--network {0}".format(network))

for network in networks:
command.append("--network {0}".format(network))
for port in sorted(self.ports):
args.append("-p {0}".format(port))

for port in ports:
command.append("-p {0}".format(port))
for volume in self.volumes:
args.append("-v {0}".format(volume))

for volume in volumes:
command.append("-v {0}".format(volume))
for env_var in sorted(self.env_vars):
args.append("-e {0}".format(env_var))

for env_var in env_vars:
command.append("-e {0}".format(env_var))
if self.pull_always:
args.append("--pull always")

args.append(self.image)
args.extend(self.args)

return args

def config_hash(self) -> str:
serialized = json.dumps(
self,
default=_json_repr,
ensure_ascii=False,
sort_keys=True,
indent=None,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(serialized).hexdigest()


def _create_container(**kwargs):
if "spec" not in kwargs:
raise OperationError("missing 1 required argument: 'spec'")

if kwargs["pull_always"]:
command.append("--pull always")
spec = kwargs["spec"]

command.append(kwargs["image"])
if not spec.image:
raise OperationError("Docker image not specified")

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

return " ".join(command)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,37 @@
"kwargs": {
"container": "nginx",
"image": "nginx:alpine",
"args": [
"nginx-debug",
"-g",
"'daemon off;'"
],
"networks": [
"foo",
"bar"
],
"volumes": [
"/host/a:/container/a",
"/host/b:/container/b"
],
"ports": [
"80:80"
"80:80",
"8081:8081"
],
"env_vars": [
"ENV_A=foo",
"ENV_B=bar"
],
"present": true,
"start": true
},
"facts": {
"docker.DockerContainer": {
"object_id=nginx": []
"object_id=nginx": []
}
},
"commands": [
"docker container create --name nginx -p 80:80 nginx:alpine ; docker container start nginx"
"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;'",
"docker container start nginx"
]
}
Loading
Loading