diff --git a/src/constellation/__init__.py b/src/constellation/__init__.py index 6741d62..105bf75 100644 --- a/src/constellation/__init__.py +++ b/src/constellation/__init__.py @@ -5,9 +5,10 @@ ConstellationService, ConstellationVolumeMount, ) -from constellation.util import ImageReference +from constellation.util import BuildSpec, ImageReference __all__ = [ + "BuildSpec", "Constellation", "ConstellationBindMount", "ConstellationContainer", diff --git a/src/constellation/constellation.py b/src/constellation/constellation.py index 9a80fc8..9a68181 100644 --- a/src/constellation/constellation.py +++ b/src/constellation/constellation.py @@ -1,11 +1,11 @@ from abc import abstractmethod from pathlib import Path -from typing import Optional +from typing import Optional, Union import docker from constellation import docker_util, vault -from constellation.util import rand_str, tabulate +from constellation.util import BuildSpec, ImageReference, rand_str, tabulate class Constellation: @@ -65,8 +65,7 @@ def start(self, pull_images=False, subset=None): raise Exception(msg) if self.vault_config: vault.resolve_secrets(self.data, self.vault_config.client()) - if pull_images: - self.containers.pull_images() + self.containers.prepare_images(pull=pull_images) self.network.create() self.volumes.create() self.containers.start( @@ -82,8 +81,7 @@ def stop(self, kill=False, remove_network=False, remove_volumes=False): self.volumes.remove() def restart(self, pull_images=True): - if pull_images: - self.containers.pull_images() + self.containers.prepare_images(pull=pull_images) self.stop() self.start() @@ -118,7 +116,7 @@ class ConstellationContainer: def __init__( self, name, - image, + image: Union[ImageReference, BuildSpec], args=None, mounts=None, ports=None, @@ -143,12 +141,20 @@ def __init__( self.labels = labels self.preconfigure = preconfigure self.network = network + self.image_id = None def name_external(self, prefix): return f"{prefix}-{self.name}" - def pull_image(self): - docker_util.image_pull(self.name, str(self.image)) + def prepare_image(self, *, pull: bool): + if isinstance(self.image, BuildSpec): + self.image_id = docker_util.image_build(self.name, self.image) + elif pull: + docker_util.image_pull(self.name, str(self.image)) + self.image_id = str(self.image) + else: + docker_util.ensure_image(self.name, str(self.image)) + self.image_id = str(self.image) def exists(self, prefix): return docker_util.container_exists(self.name_external(prefix)) @@ -156,7 +162,7 @@ def exists(self, prefix): def start(self, prefix, network, volumes, data=None): cl = docker.client.from_env() nm = self.name_external(prefix) - print(f"Starting {self.name} ({self.image!s})") + print(f"Starting {self.name} ({self.image_id})") mounts = [x.to_mount(volumes) for x in self.mounts] if self.ports_config: @@ -171,9 +177,8 @@ def start(self, prefix, network, volumes, data=None): networking_config = cl.api.create_networking_config( {f"{network.name}": endpoint_config} ) - docker_util.ensure_image(self.name, str(self.image)) x_obj = cl.api.create_container( - str(self.image), + self.image_id, self.args, name=nm, detach=True, @@ -221,7 +226,9 @@ def remove(self, prefix): # This could be achieved by inheriting from ConstellationContainer but # this seems more like a has-a than an is-a relationship. class ConstellationService: - def __init__(self, name, image, scale, **kwargs): + def __init__( + self, name, image: Union[ImageReference, BuildSpec], scale, **kwargs + ): self.name = name self.image = image self.scale = scale @@ -231,8 +238,8 @@ def __init__(self, name, image, scale, **kwargs): def name_external(self, prefix): return f"{self.base.name_external(prefix)}-" - def pull_image(self): - self.base.pull_image() + def prepare_image(self, *, pull: bool): + return self.base.prepare_image(pull=pull) def exists(self, prefix): return bool(self.get(prefix)) @@ -242,6 +249,7 @@ def start(self, prefix, network, volumes, data=None): for _i in range(self.scale): name = f"{self.name}-{rand_str(8)}" container = ConstellationContainer(name, self.image, **self.kwargs) + container.image_id = self.base.image_id container.start(prefix, network, volumes, data) def get(self, prefix, stopped=False): @@ -285,13 +293,13 @@ def get(self, name, prefix): def exists(self, prefix): return [x.exists(prefix) for x in self.collection] - def _apply(self, method, *args, subset=None): + def _apply(self, method, *args, subset=None, **kwargs): for x in self.collection: if subset is None or x.name in subset: - x.__getattribute__(method)(*args) + x.__getattribute__(method)(*args, **kwargs) - def pull_images(self): - self._apply("pull_image") + def prepare_images(self, *, pull): + self._apply("prepare_image", pull=pull) def stop(self, prefix, kill=False): self._apply("stop", prefix, kill) diff --git a/src/constellation/docker_util.py b/src/constellation/docker_util.py index d0aa325..f8c2ab3 100644 --- a/src/constellation/docker_util.py +++ b/src/constellation/docker_util.py @@ -6,6 +6,8 @@ import docker +from constellation.util import BuildSpec + def ensure_network(name): client = docker.client.from_env() @@ -227,6 +229,14 @@ def image_pull(name, ref): return prev != curr +def image_build(name: str, spec: BuildSpec): + client = docker.client.from_env() + print(f"Building docker image for {name} from {spec.path}") + image, _ = client.images.build(path=spec.path) + print(f" `-> {image.id}") + return image.id + + def containers_matching(prefix, stopped): cl = docker.client.from_env() return [x for x in cl.containers.list(stopped) if x.name.startswith(prefix)] diff --git a/src/constellation/util.py b/src/constellation/util.py index 5591615..5e03084 100644 --- a/src/constellation/util.py +++ b/src/constellation/util.py @@ -1,17 +1,24 @@ import random import string +from dataclasses import dataclass +@dataclass class ImageReference: - def __init__(self, repo, name, tag): - self.repo = repo - self.name = name - self.tag = tag + repo: str + name: str + tag: str def __str__(self): return f"{self.repo}/{self.name}:{self.tag}" +@dataclass +class BuildSpec: + # Path to the directory containing the Dockerfile + path: str + + def tabulate(x): ret = {} for el in x: diff --git a/tests/test_constellation.py b/tests/test_constellation.py index f8ddd56..74f1e27 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -20,7 +20,7 @@ port_config, vault, ) -from constellation.util import ImageReference, rand_str +from constellation.util import BuildSpec, ImageReference, rand_str def drop_image(ref): @@ -200,26 +200,17 @@ def test_container_simple(): assert f.getvalue() == "" -def test_pull_missing_container_on_start(capsys): +def test_pull_missing_container_on_prepare(capsys): nm = rand_str(n=10, prefix="") ref = ImageReference("library", "redis", "5.0") drop_image(str(ref)) x = ConstellationContainer(nm, ref) - assert x.name_external("prefix") == f"prefix-{nm}" - assert not x.exists("prefix") - assert x.get("prefix") is None - nw = ConstellationNetwork(constellation_rand_str()) - nw.create() + x.prepare_image(pull=False) - x.start("prefix", network=nw, volumes=None) res = capsys.readouterr() assert "Pulling docker image" in res.out - x.stop("prefix") - x.stop("prefix", True) - x.remove("prefix") - nw.remove() def test_container_start_stop_remove(): @@ -227,6 +218,7 @@ def test_container_start_stop_remove(): cl = docker.client.from_env() cl.images.pull("library/redis:5.0") x = ConstellationContainer(nm, "library/redis:5.0") + x.prepare_image(pull=False) nw = ConstellationNetwork(constellation_rand_str()) try: nw.create() @@ -250,6 +242,7 @@ def configure(container, _data): cl = docker.client.from_env() cl.images.pull("library/redis:5.0") x = ConstellationContainer(nm, "library/redis:5.0", configure=configure) + x.prepare_image(pull=False) nw = ConstellationNetwork(constellation_rand_str()) nw.create() x.start("prefix", nw, None) @@ -268,6 +261,7 @@ def test_container_ports(): x = ConstellationContainer( nm, "library/alpine:latest", ports=[80, (3000, 8080)] ) + x.prepare_image(pull=False) nw = ConstellationNetwork(constellation_rand_str()) nw.create() x.start("prefix", nw, None) @@ -286,7 +280,7 @@ def test_container_pull(): ref = "library/hello-world:latest" x = ConstellationContainer("hello", ref) drop_image(ref) - x.pull_image() + x.prepare_image(pull=True) assert docker_util.image_exists(ref) @@ -306,7 +300,7 @@ def test_container_collection(): obj.get("foo", prefix) assert obj.exists(prefix) == [False, False] - obj.pull_images() + obj.prepare_images(pull=True) obj.start(prefix, nw, []) cl = obj.get("client", prefix) @@ -636,3 +630,22 @@ def test_constellation_can_set_labels(): assert container_label.get("prefix").labels == labels obj.destroy() + + +def test_constellation_can_build_image(tmp_path): + (tmp_path / "Dockerfile").write_text( + """ +FROM alpine:latest +CMD ["echo", "Hello, World"] +""" + ) + + container = ConstellationContainer("container", BuildSpec(str(tmp_path))) + + obj = Constellation("project", "prefix", [container], "network", None) + obj.start() + + log = container.get("prefix").logs().decode("utf-8") + assert "Hello, World\n" == log + + obj.destroy()