Skip to content
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
58 changes: 42 additions & 16 deletions src/pyinfra/operations/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,33 @@
"""

from __future__ import annotations
from typing import Any, Dict

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

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


@operation()
def container(
container: str,
image: str = "",
args: list[str] | None = None,
ports: list[str] | None = None,
networks: list[str] | None = None,
volumes: list[str] | None = None,
devices: list[str] | None = None,
env_vars: list[str] | None = None,
pull_always: bool = False,
restart_policy: str | None = None,
privileged: bool = False,
present: bool = True,
force: bool = False,
start: bool = True,
Expand All @@ -30,10 +40,12 @@ 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
+ volumes: volume list to map on container
+ devices: device list to inject on container
+ env_vars: environment variable list to inject on container
+ pull_always: force image pull
+ force: remove a container with same name and create a new one
Expand Down Expand Up @@ -74,28 +86,40 @@ def container(

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

container_spec_changes = want_spec.diff_from_inspect(existent_container)
existent_container: Dict[str, Any] = next(
iter(host.get_fact(DockerContainer, object_id=container)), {}
)

is_running = (
(existent_container[0]["State"]["Status"] == "running")
if existent_container and existent_container[0]
else False
old_hash = (
existent_container.get("Config", {})
.get("Labels", {})
.get(CONTAINER_CONFIG_HASH_LABEL, None)
)
recreating = existent_container and (force or container_spec_changes)

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 = (present and not existent_container) or recreating
do_start = start and (recreating or not is_running)
do_stop = not start and not removing and is_running
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(
Expand Down Expand Up @@ -170,7 +194,9 @@ def image(image, present=True):


@operation()
def volume(volume: str, driver: str = "", labels: list[str] | None = None, present: bool = True):
def volume(
volume: str, driver: str = "", labels: list[str] | None = None, present: bool = True
):
"""
Manage Docker volumes

Expand Down
65 changes: 50 additions & 15 deletions src/pyinfra/operations/util/docker.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,81 @@
import dataclasses
from typing import Any, Dict, List
import hashlib
import json
from typing import Any, List, Optional, Set

from pyinfra.api import OperationError

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


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")


@dataclasses.dataclass
class ContainerSpec:
image: str = ""
ports: List[str] = dataclasses.field(default_factory=list)
networks: List[str] = dataclasses.field(default_factory=list)
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: List[str] = dataclasses.field(default_factory=list)
devices: List[str] = dataclasses.field(default_factory=list)
env_vars: Set[str] = dataclasses.field(default_factory=set)
pull_always: bool = False
restart_policy: Optional[str] = None
privileged: bool = False

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

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

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

for env_var in self.env_vars:
for device in self.devices:
args.append(f"--device={device}")

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

if self.pull_always:
args.append("--pull always")

if self.restart_policy:
args.append(f"--restart {self.restart_policy}")

if self.privileged:
args.append("--privileged")

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

return args

def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]:
# TODO(@minor-fixes): Diff output of "docker inspect" against this spec
# to determine if the container needs to be recreated. Currently, this
# function will never recreate when attributes change, which is
# consistent with prior behavior.
del inspect_dict
return []
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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@
"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
Expand All @@ -14,7 +32,7 @@
}
},
"commands": [
"docker container create --name nginx -p 80:80 nginx:alpine",
"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