Skip to content

Commit 9979523

Browse files
committed
Merge pull request #718 from helgi/image_secret
feat(images): use imagePullSecret when pulling a private image
2 parents 94323f6 + ab964ff commit 9979523

12 files changed

Lines changed: 335 additions & 93 deletions

File tree

rootfs/api/models/app.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,12 +333,12 @@ def _scale_pods(self, scale_types):
333333
# fetch application port and inject into ENV Vars as needed
334334
envs['PORT'] = release.get_port(routable)
335335

336-
image = release.image
337336
kwargs = {
338337
'memory': release.config.memory,
339338
'cpu': release.config.cpu,
340339
'tags': release.config.tags,
341340
'envs': envs,
341+
'registry': release.config.registry,
342342
'version': "v{}".format(release.version),
343343
'replicas': replicas,
344344
'app_type': scale_type,
@@ -352,7 +352,7 @@ def _scale_pods(self, scale_types):
352352
self._scheduler.scale(
353353
namespace=self.id,
354354
name=self._get_job_id(scale_type),
355-
image=image,
355+
image=release.image,
356356
command=command,
357357
**kwargs
358358
)
@@ -383,14 +383,15 @@ def deploy(self, release):
383383
# only web / cmd are routable
384384
# http://docs.deis.io/en/latest/using_deis/process-types/#web-vs-cmd-process-types
385385
routable = True if scale_type in ['web', 'cmd'] else False
386-
# fetch application port and inject into ENV Vars as needed
386+
# fetch application port and inject into ENV vars as needed
387387
envs['PORT'] = release.get_port(routable)
388388

389389
deploys[scale_type] = {
390390
'memory': release.config.memory,
391391
'cpu': release.config.cpu,
392392
'tags': release.config.tags,
393393
'envs': envs,
394+
'registry': release.config.registry,
394395
# only used if there is no previous RC
395396
'replicas': replicas,
396397
'version': "v{}".format(release.version),
@@ -583,6 +584,7 @@ def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
583584
'cpu': release.config.cpu,
584585
'tags': release.config.tags,
585586
'envs': release.config.values,
587+
'registry': release.config.registry,
586588
'version': "v{}".format(release.version),
587589
'build_type': release.build.type,
588590
}

rootfs/api/models/release.py

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
from django.conf import settings
44
from django.db import models
55

6-
import docker
7-
from docker import Client
8-
from retrying import retry
9-
10-
from registry import publish_release, RegistryException
6+
from registry import publish_release, get_port as docker_get_port, RegistryException
117
from api.utils import dict_diff
128
from api.models import UuidAuditedModel, log_event, DeisException
139
from scheduler import KubeHTTPException
@@ -40,7 +36,19 @@ def __str__(self):
4036

4137
@property
4238
def image(self):
43-
# return image if it is already in the registry, test host and then host + port
39+
# Builder pushes to internal registry, exclude SHA based images from being returned
40+
registry = self.config.registry
41+
if (
42+
registry.get('username', None) and
43+
registry.get('password', None) and
44+
# SHA means it came from a git push (builder)
45+
not self.build.sha and
46+
# hostname tells Builder where to push images
47+
not registry.get('hostname', None)
48+
):
49+
return self.build.image
50+
51+
# return image if it is already in a registry, test host and then host + port
4452
if (
4553
self.build.image.startswith(settings.REGISTRY_HOST) or
4654
self.build.image.startswith(settings.REGISTRY_URL)
@@ -95,49 +103,80 @@ def publish(self):
95103
if self.build.source_based:
96104
return
97105

98-
source_image = self.build.image
106+
# Builder pushes to internal registry, exclude SHA based images from being returned early
107+
registry = self.config.registry
108+
if (
109+
registry.get('username', None) and
110+
registry.get('password', None) and
111+
# SHA means it came from a git push (builder)
112+
not self.build.sha and
113+
# hostname tells Builder where to push images
114+
not registry.get('hostname', None)
115+
):
116+
log_event(self.app, '{} exists in the target registry. Using image for release {} of app {}'.format(self.build.image, self.version, self.app)) # noqa
117+
return
118+
99119
# return image if it is already in the registry, test host and then host + port
100120
if (
101-
source_image.startswith(settings.REGISTRY_HOST) or
102-
source_image.startswith(settings.REGISTRY_URL)
121+
self.build.image.startswith(settings.REGISTRY_HOST) or
122+
self.build.image.startswith(settings.REGISTRY_URL)
103123
):
104-
log_event(self.app, '{} already exists in the target registry. Using this image for release {} of app {}'.format(source_image, self.version, self.app)) # noqa
124+
log_event(self.app, '{} exists in the target registry. Using image for release {} of app {}'.format(self.build.image, self.version, self.app)) # noqa
105125
return
106126

107127
# add tag if it was not provided
128+
source_image = self.build.image
108129
if ':' not in source_image:
109130
source_image = "{}:{}".format(source_image, self.build.version)
110131

111132
# if build is source based then it was pushed into the deis registry
112133
deis_registry = bool(self.build.source_based)
113134
publish_release(source_image, self.image, deis_registry, self.get_registry_auth())
114135

115-
@retry(stop_max_attempt_number=3, wait_fixed=1000)
116136
def get_port(self, routable=False):
117137
"""
118-
Get a port from a Docker image
138+
Get application port for a given release. If pulling from private registry
139+
then use default port or read from ENV var, otherwise attempt to pull from
140+
the docker image
119141
"""
120-
port = None
121-
# Only care about port for routable application
122-
if not routable:
123-
return port
124-
125-
if self.build.type == "buildpack":
126-
msg = "Using default port 5000 for build pack image {}".format(self.image)
127-
log_event(self.app, msg)
128-
return 5000
129-
130-
# get the target repository name and tag
131-
repo, tag = docker.utils.parse_repository_tag(self.image)
132-
133-
docker_cli = Client(version="auto")
134-
docker_cli.pull(repo, tag=tag, insecure_registry=True)
135-
image_info = docker_cli.inspect_image(self.image)
136-
if 'ExposedPorts' not in image_info['Config']:
137-
return None
142+
try:
143+
deis_registry = bool(self.build.source_based)
144+
envs = self.config.values
145+
creds = self.get_registry_auth()
146+
147+
port = None
148+
# Only care about port for routable application
149+
if not routable:
150+
return port
151+
152+
if self.build.type == "buildpack":
153+
msg = "Using default port 5000 for build pack image {}".format(self.image)
154+
log_event(self.app, msg)
155+
return 5000
156+
157+
# application has registry auth - $PORT is required
158+
if creds is not None:
159+
if envs.get('PORT', None) is None:
160+
log_event(self.app, 'Private registry detected but no $PORT defined. Defaulting to $PORT 5000', logging.WARNING) # noqa
161+
return 5000
162+
163+
# User provided PORT
164+
return envs.get('PORT')
165+
166+
# If the user provides PORT
167+
if envs.get('PORT', None):
168+
return envs.get('PORT')
169+
170+
# discover port from docker image
171+
port = docker_get_port(self.image, deis_registry, creds)
172+
if port is None:
173+
msg = "Expose a port or make the app non routable by changing the process type"
174+
log_event(self.app, msg, logging.ERROR)
175+
raise DeisException(msg)
138176

139-
port = int(list(image_info['Config']['ExposedPorts'].keys())[0].split("/")[0])
140-
return port
177+
return port
178+
except Exception as e:
179+
raise DeisException(str(e)) from e
141180

142181
def get_registry_auth(self):
143182
"""

rootfs/api/tests/test_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def _mock_run(*args, **kwargs):
3131

3232
@requests_mock.Mocker(real_http=True, adapter=adapter)
3333
@mock.patch('api.models.release.publish_release', lambda *args: None)
34-
@mock.patch('api.models.release.Release.get_port', mock_port)
34+
@mock.patch('api.models.release.docker_get_port', mock_port)
3535
class AppTest(APITestCase):
3636
"""Tests creation of applications"""
3737

rootfs/api/tests/test_build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
@requests_mock.Mocker(real_http=True, adapter=adapter)
2727
@mock.patch('api.models.release.publish_release', lambda *args: None)
28-
@mock.patch('api.models.release.Release.get_port', mock_port)
28+
@mock.patch('api.models.release.docker_get_port', mock_port)
2929
class BuildTest(APITransactionTestCase):
3030

3131
"""Tests build notification from build system"""

rootfs/api/tests/test_config.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
@requests_mock.Mocker(real_http=True, adapter=adapter)
2323
@mock.patch('api.models.release.publish_release', lambda *args: None)
24-
@mock.patch('api.models.release.Release.get_port', mock_port)
24+
@mock.patch('api.models.release.docker_get_port', mock_port)
2525
class ConfigTest(APITransactionTestCase):
2626

2727
"""Tests setting and updating config values"""
@@ -665,6 +665,36 @@ def test_registry(self, mock_requests):
665665
response = self.client.delete(url)
666666
self.assertEqual(response.status_code, 405, response.data)
667667

668+
def test_registry_deploy(self, mock_requests):
669+
"""
670+
Test that registry information can be applied
671+
"""
672+
url = '/v2/apps'
673+
response = self.client.post(url)
674+
self.assertEqual(response.status_code, 201, response.data)
675+
app_id = response.data['id']
676+
677+
# Set healthcheck URL to get defaults set
678+
body = {'registry': json.dumps({
679+
'username': 'bob',
680+
'password': 's3cur3pw1'
681+
})}
682+
resp = self.client.post(
683+
'/v2/apps/{app_id}/config'.format(**locals()),
684+
body
685+
)
686+
self.assertEqual(resp.status_code, 201, response.data)
687+
self.assertIn('username', resp.data['registry'])
688+
self.assertIn('password', resp.data['registry'])
689+
self.assertEqual(resp.data['registry']['username'], 'bob')
690+
self.assertEqual(resp.data['registry']['password'], 's3cur3pw1')
691+
692+
# post a new build
693+
url = "/v2/apps/{app_id}/builds".format(**locals())
694+
body = {'image': 'autotest/example'}
695+
response = self.client.post(url, body)
696+
self.assertEqual(response.status_code, 201, response.data)
697+
668698
def test_config_owner_is_requesting_user(self, mock_requests):
669699
"""
670700
Ensure that setting the config value is owned by the requesting user
@@ -724,6 +754,6 @@ def test_healthchecks(self, mock_requests):
724754

725755
# post a new build
726756
url = "/v2/apps/{app_id}/builds".format(**locals())
727-
body = {'image': 'autotest/example'}
757+
body = {'image': 'quay.io/autotest/example'}
728758
response = self.client.post(url, body)
729759
self.assertEqual(response.status_code, 201, response.data)

rootfs/api/tests/test_hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
@requests_mock.Mocker(real_http=True, adapter=adapter)
3737
@mock.patch('api.models.release.publish_release', lambda *args: None)
38-
@mock.patch('api.models.release.Release.get_port', mock_port)
38+
@mock.patch('api.models.release.docker_get_port', mock_port)
3939
class HookTest(APITransactionTestCase):
4040

4141
"""Tests API hooks used to trigger actions from external components"""

rootfs/api/tests/test_pods.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
@requests_mock.Mocker(real_http=True, adapter=adapter)
2626
@mock.patch('api.models.release.publish_release', lambda *args: None)
27-
@mock.patch('api.models.release.Release.get_port', mock_port)
27+
@mock.patch('api.models.release.docker_get_port', mock_port)
2828
class PodTest(APITransactionTestCase):
2929
"""Tests creation of pods on nodes"""
3030

rootfs/api/tests/test_release.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
@requests_mock.Mocker(real_http=True, adapter=adapter)
2525
@mock.patch('api.models.release.publish_release', lambda *args: None)
26-
@mock.patch('api.models.release.Release.get_port', mock_port)
26+
@mock.patch('api.models.release.docker_get_port', mock_port)
2727
class ReleaseTest(APITransactionTestCase):
2828

2929
"""Tests push notification from build system"""

rootfs/registry/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .dockerclient import publish_release, RegistryException # noqa
1+
from .dockerclient import publish_release, get_port, RegistryException # noqa

rootfs/registry/dockerclient.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.conf import settings
88
from rest_framework.exceptions import PermissionDenied
99
from simpleflock import SimpleFlock
10+
from retrying import retry
1011

1112
import docker
1213
import docker.constants
@@ -59,6 +60,28 @@ def login(self, repository, creds=None):
5960

6061
logger.info('Successfully logged into {} with {}'.format(repository, registry_auth['username'])) # noqa
6162

63+
def get_port(self, target, deis_registry=False, creds=None):
64+
"""
65+
Get a port from a Docker image
66+
"""
67+
# get the target repository name and tag
68+
name, _ = docker.utils.parse_repository_tag(target)
69+
70+
# strip any "http://host.domain:port" prefix from the target repository name,
71+
# since we always publish to the Deis registry
72+
repo, name = auth.split_repo_name(name)
73+
74+
# log into pull repo
75+
if not deis_registry:
76+
self.login(repo, creds)
77+
78+
info = self.inspect_image(target, deis_registry)
79+
if 'ExposedPorts' not in info['Config']:
80+
return None
81+
82+
port = int(list(info['Config']['ExposedPorts'].keys())[0].split('/')[0])
83+
return port
84+
6285
def publish_release(self, source, target, deis_registry=False, creds=None):
6386
"""Update a source Docker image with environment config and publish it to deis-registry."""
6487
# get the source repository name and tag
@@ -121,6 +144,22 @@ def tag(self, image, repo, tag):
121144
if not self.client.tag(image, repo, tag=tag, force=True):
122145
raise RegistryException('Tagging {} as {}:{} failed'.format(image, repo, tag))
123146

147+
@retry(stop_max_attempt_number=3, wait_fixed=1000)
148+
def inspect_image(self, target, insecure_registry=True):
149+
"""
150+
Inspect docker image to gather information from it
151+
152+
try thrice to find the port before raising exception as docker-py is flaky
153+
"""
154+
# image already includes the tag, so we split it out here
155+
repo, tag = docker.utils.parse_repository_tag(target)
156+
157+
# make sure image is pulled locally already
158+
self.pull(repo, tag=tag, insecure_registry=insecure_registry)
159+
160+
# inspect the image
161+
return self.client.inspect_image(target)
162+
124163

125164
def check_blacklist(repo):
126165
"""Check a Docker repository name for collision with deis/* components."""
@@ -156,3 +195,7 @@ def stream_error(chunk, operation, repo, tag):
156195

157196
def publish_release(source, target, deis_registry, creds=None):
158197
return DockerClient().publish_release(source, target, deis_registry, creds)
198+
199+
200+
def get_port(target, deis_registry, creds=None):
201+
return DockerClient().get_port(target, deis_registry, creds)

0 commit comments

Comments
 (0)