Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/constellation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
ConstellationService,
ConstellationVolumeMount,
)
from constellation.util import ImageReference
from constellation.util import BuildSpec, ImageReference

__all__ = [
"BuildSpec",
"Constellation",
"ConstellationBindMount",
"ConstellationContainer",
Expand Down
46 changes: 27 additions & 19 deletions src/constellation/constellation.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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()

Expand Down Expand Up @@ -118,7 +116,7 @@ class ConstellationContainer:
def __init__(
self,
name,
image,
image: Union[ImageReference, BuildSpec],
args=None,
mounts=None,
ports=None,
Expand All @@ -143,20 +141,28 @@ 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))

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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -231,8 +238,8 @@ def __init__(self, name, image, scale, **kwargs):
def name_external(self, prefix):
return f"{self.base.name_external(prefix)}-<i>"

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))
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/constellation/docker_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import docker

from constellation.util import BuildSpec


def ensure_network(name):
client = docker.client.from_env()
Expand Down Expand Up @@ -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)]
Expand Down
15 changes: 11 additions & 4 deletions src/constellation/util.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
path: str
# Path to Dockerfile
path: str



def tabulate(x):
ret = {}
for el in x:
Expand Down
41 changes: 27 additions & 14 deletions tests/test_constellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -200,33 +200,25 @@ 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():
nm = rand_str(n=10, prefix="")
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()
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)


Expand All @@ -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)
Expand Down Expand Up @@ -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()