Skip to content

Commit 55b587f

Browse files
committed
feat(controller): use Docker for last-mile layer
1 parent 8452cec commit 55b587f

11 files changed

Lines changed: 242 additions & 217 deletions

File tree

controller/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ test-style: setup-venv
7878
shellcheck $(SHELL_SCRIPTS)
7979

8080
test-unit: setup-venv test-style
81-
venv/bin/coverage run manage.py test --noinput web api
81+
venv/bin/coverage run manage.py test --noinput web registry api
8282
venv/bin/coverage report -m
8383

8484
test-functional:

controller/api/models.py

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -880,29 +880,13 @@ def new(self, user, config, build, summary=None, source_version='latest'):
880880
def publish(self, source_version='latest'):
881881
if self.build is None:
882882
raise EnvironmentError('No build associated with this release to publish')
883-
source_tag = 'git-{}'.format(self.build.sha) if self.build.sha else source_version
884-
source_image = '{}:{}'.format(self.build.image, source_tag)
885-
# IOW, this image did not come from the builder
886-
if not self.build.sha:
887-
# we assume that the image is not present on our registry,
888-
# so shell out a task to pull in the repository
889-
data = {
890-
'src': self.build.image
891-
}
892-
requests.post(
893-
'{}/v1/repositories/{}/tags'.format(settings.REGISTRY_URL,
894-
self.app.id),
895-
data=data,
896-
)
897-
# update the source image to the repository we just imported
898-
source_image = self.app.id
899-
# if the image imported had a tag specified, use that tag as the source
900-
if ':' in self.build.image:
901-
if '/' not in self.build.image[self.build.image.rfind(':') + 1:]:
902-
source_image += self.build.image[self.build.image.rfind(':'):]
903-
publish_release(source_image,
904-
self.config.values,
905-
self.image)
883+
source_image = self.build.image
884+
if ':' not in source_image:
885+
source_tag = 'git-{}'.format(self.build.sha) if self.build.sha else source_version
886+
source_image = "{}:{}".format(source_image, source_tag)
887+
# If the build has a SHA, assume it's from deis-builder and in the deis-registry already
888+
deis_registry = bool(self.build.sha)
889+
publish_release(source_image, self.config.values, self.image, deis_registry)
906890

907891
def previous(self):
908892
"""

controller/deis/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
'corsheaders',
153153
# Deis apps
154154
'api',
155+
'registry',
155156
'web',
156157
)
157158

@@ -272,6 +273,11 @@
272273
'level': 'INFO',
273274
'propagate': True,
274275
},
276+
'registry': {
277+
'handlers': ['console', 'mail_admins', 'rsyslog'],
278+
'level': 'INFO',
279+
'propagate': True,
280+
},
275281
}
276282
}
277283
TEST_RUNNER = 'api.tests.SilentDjangoTestSuiteRunner'

controller/registry/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from private import publish_release # noqa
1+
from dockerclient import publish_release # noqa
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# -*- coding: utf-8 -*-
2+
"""Support the Deis workflow by manipulating and publishing Docker images."""
3+
4+
from __future__ import unicode_literals
5+
import io
6+
import logging
7+
8+
from django.conf import settings
9+
from rest_framework.exceptions import PermissionDenied
10+
from simpleflock import SimpleFlock
11+
import docker
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class DockerClient(object):
17+
"""Use the Docker API to pull, tag, build, and push images to deis-registry."""
18+
19+
FLOCKFILE = '/tmp/controller-pull'
20+
21+
def __init__(self):
22+
self.client = docker.Client(version='auto')
23+
self.registry = settings.REGISTRY_HOST + ':' + str(settings.REGISTRY_PORT)
24+
25+
def publish_release(self, source, config, target, deis_registry):
26+
"""Update a source Docker image with environment config and publish it to deis-registry."""
27+
# get the source repository name and tag
28+
src_name, src_tag = docker.utils.parse_repository_tag(source)
29+
# get the target repository name and tag
30+
name, tag = docker.utils.parse_repository_tag(target)
31+
# strip any "http://host.domain:port" prefix from the target repository name,
32+
# since we always publish to the Deis registry
33+
name = strip_prefix(name)
34+
35+
# pull the source image from the registry
36+
# NOTE: this relies on an implementation detail of deis-builder, that
37+
# the image has been uploaded already to deis-registry
38+
if deis_registry:
39+
repo = "{}/{}".format(self.registry, src_name)
40+
else:
41+
repo = src_name
42+
self.pull(repo, src_tag)
43+
44+
# tag the image locally without the repository URL
45+
image = "{}:{}".format(repo, src_tag)
46+
self.tag(image, src_name, tag=src_tag)
47+
48+
# build a Docker image that adds a "last-mile" layer of environment
49+
config.update({'DEIS_APP': name, 'DEIS_RELEASE': tag})
50+
self.build(source, config, name, tag)
51+
52+
# push the image to deis-registry
53+
self.push("{}/{}".format(self.registry, name), tag)
54+
55+
def build(self, source, config, repo, tag):
56+
"""Add a "last-mile" layer of environment config to a Docker image for deis-registry."""
57+
check_blacklist(repo)
58+
env = ' '.join("{}='{}'".format(
59+
k, v.encode('unicode-escape').replace("'", "\\'")) for k, v in config.viewitems())
60+
dockerfile = "FROM {}\nENV {}".format(source, env)
61+
f = io.BytesIO(dockerfile.encode('utf-8'))
62+
target_repo = "{}/{}:{}".format(self.registry, repo, tag)
63+
logger.info("Building Docker image {}".format(target_repo))
64+
with SimpleFlock(self.FLOCKFILE, timeout=1200):
65+
stream = self.client.build(fileobj=f, tag=target_repo, stream=True, rm=True)
66+
log_output(stream)
67+
68+
def pull(self, repo, tag):
69+
"""Pull a Docker image into the local storage graph."""
70+
check_blacklist(repo)
71+
logger.info("Pulling Docker image {}:{}".format(repo, tag))
72+
with SimpleFlock(self.FLOCKFILE, timeout=1200):
73+
stream = self.client.pull(repo, tag=tag, stream=True, insecure_registry=True)
74+
log_output(stream)
75+
76+
def push(self, repo, tag):
77+
"""Push a local Docker image to a registry."""
78+
logger.info("Pushing Docker image {}:{}".format(repo, tag))
79+
stream = self.client.push(repo, tag=tag, stream=True, insecure_registry=True)
80+
log_output(stream)
81+
82+
def tag(self, image, repo, tag):
83+
"""Tag a local Docker image with a new name and tag."""
84+
check_blacklist(repo)
85+
logger.info("Tagging Docker image {} as {}:{}".format(image, repo, tag))
86+
if not self.client.tag(image, repo, tag=tag, force=True):
87+
raise docker.errors.DockerException("tagging failed")
88+
89+
90+
def check_blacklist(repo):
91+
"""Check a Docker repository name for collision with deis/* components."""
92+
blacklisted = [ # NOTE: keep this list up to date!
93+
'builder', 'cache', 'controller', 'database', 'logger', 'logspout',
94+
'publisher', 'registry', 'router', 'store-admin', 'store-daemon',
95+
'store-gateway', 'store-metadata', 'store-monitor', 'swarm', 'mesos-master',
96+
'mesos-marathon', 'mesos-slave', 'zookeeper',
97+
]
98+
if any("deis/{}".format(c) in repo for c in blacklisted):
99+
raise PermissionDenied("Repository name {} is not allowed".format(repo))
100+
101+
102+
def log_output(stream):
103+
"""Log a stream at DEBUG level, and raise DockerException if it contains "error"."""
104+
for chunk in stream:
105+
logger.debug(chunk)
106+
# error handling requires looking at the response body
107+
if '"error"' in chunk.lower():
108+
raise docker.errors.DockerException(chunk)
109+
110+
111+
def strip_prefix(name):
112+
"""Strip the schema and host:port from a Docker repository name."""
113+
paths = name.split('/')
114+
return '/'.join(p for p in paths if p and '.' not in p and ':' not in p)
115+
116+
117+
def publish_release(source, config, target, deis_registry):
118+
119+
client = DockerClient()
120+
return client.publish_release(source, config, target, deis_registry)

controller/registry/models.py

Whitespace-only changes.

controller/registry/private.py

Lines changed: 0 additions & 189 deletions
This file was deleted.

0 commit comments

Comments
 (0)