Skip to content

Commit

Permalink
Add support for composing containers.
Browse files Browse the repository at this point in the history
fixes fedora-infra#2028

Signed-off-by: Randy Barlow <[email protected]>
  • Loading branch information
bowlofeggs committed Mar 16, 2018
1 parent e097677 commit 6309c65
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 10 deletions.
14 changes: 14 additions & 0 deletions bodhi/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ class BodhiConfig(dict):
'captcha.ttl': {
'value': 300,
'validator': int},
'container.destination_registry': {
'value': 'registry.fedoraproject.org',
'validator': six.text_type},
'container.source_registry': {
'value': 'candidate-registry.fedoraproject.org',
'validator': six.text_type},
'cors_connect_src': {
'value': 'https://*.fedoraproject.org/ wss://hub.fedoraproject.org:9939/',
'validator': six.text_type},
Expand Down Expand Up @@ -547,6 +553,14 @@ class BodhiConfig(dict):
'site_requirements': {
'value': 'dist.rpmdeplint dist.upgradepath',
'validator': six.text_type},
'skopeo.cmd': {
'value': '/usr/bin/skopeo',
'validator': six.text_type,
},
'skopeo.extra_copy_flags': {
'value': '',
'validator': six.text_type,
},
'smtp_server': {
'value': None,
'validator': _validate_none_or(six.text_type)},
Expand Down
48 changes: 46 additions & 2 deletions bodhi/server/consumers/masher.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
from bodhi.server.metadata import UpdateInfoMetadata
from bodhi.server.models import (Compose, ComposeState, Update, UpdateRequest, UpdateType, Release,
UpdateStatus, ReleaseState, ContentType)
from bodhi.server.util import sorted_updates, sanity_check_repodata, transactional_session_maker
from bodhi.server.util import (get_nvr, sorted_updates, sanity_check_repodata,
transactional_session_maker)


def checkpoint(method):
Expand Down Expand Up @@ -304,7 +305,7 @@ def get_masher(content_type):
ComposerThread or None: Either a ContainerComposerThread, RPMComposerThread, or a
ModuleComposerThread, as appropriate, or None if no masher is found.
"""
mashers = [RPMComposerThread, ModuleComposerThread]
mashers = [ContainerComposerThread, RPMComposerThread, ModuleComposerThread]
for possible in mashers:
if possible.ctype is content_type:
return possible
Expand Down Expand Up @@ -859,6 +860,49 @@ def sort_by_days_in_testing(self, updates):
return updates


class ContainerComposerThread(ComposerThread):
"""Use skopeo to copy and tag container images."""

ctype = ContentType.container

def _compose_updates(self):
"""Use skopeo to copy images to the correct repos and tags."""
source_registry = config['container.source_registry']
destination_registry = config['container.destination_registry']

for update in self.compose.updates:

if update.request is UpdateRequest.stable:
destination_tag = 'latest'
else:
destination_tag = 'testing'

for build in update.builds:
image_name = '{}/{}'.format(build.release.branch, build.package.name)
name, version, release = get_nvr(build.nvr)
version_release = '{}-{}'.format(version, release)
source_url = 'docker://{}/{}:{}'.format(source_registry, image_name,
version_release)
for dtag in [version_release, version, destination_tag]:
destination_url = 'docker://{}/{}:{}'.format(destination_registry, image_name,
dtag)
skopeo_cmd = [
config.get('skopeo.cmd'), 'copy', source_url, destination_url]
if config.get('skopeo.extra_copy_flags'):
skopeo_cmd.insert(2, config.get('skopeo.extra_copy_flags'))
log.debug('Running {}'.format(' '.join(skopeo_cmd)))
skopeo_process = subprocess.Popen(
skopeo_cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = skopeo_process.communicate()

if skopeo_process.returncode:
msg = '{} returned a non-0 exit code: {}'.format(
' '.join(skopeo_cmd), skopeo_process.returncode)
log.error(msg)
log.error(out + err)
raise RuntimeError(msg)


class PungiComposerThread(ComposerThread):
"""Compose update with Pungi."""

Expand Down
158 changes: 150 additions & 8 deletions bodhi/tests/server/consumers/test_masher.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@

from bodhi.server import buildsys, exceptions, log, push
from bodhi.server.config import config
from bodhi.server.consumers.masher import (checkpoint, Masher, ComposerThread, RPMComposerThread,
ModuleComposerThread, PungiComposerThread)
from bodhi.server.consumers.masher import (
checkpoint, Masher, ComposerThread, ContainerComposerThread, RPMComposerThread,
ModuleComposerThread, PungiComposerThread)
from bodhi.server.exceptions import LockedUpdateException
from bodhi.server.models import (
Build, BuildrootOverride, Compose, ComposeState, Release, ReleaseState, RpmBuild,
TestGatingStatus, Update, UpdateRequest, UpdateStatus, UpdateType, User, ModuleBuild,
Build, BuildrootOverride, Compose, ComposeState, ContainerBuild, Release, ReleaseState,
RpmBuild, TestGatingStatus, Update, UpdateRequest, UpdateStatus, UpdateType, User, ModuleBuild,
ContentType, Package)
from bodhi.server.util import mkmetadatadir
from bodhi.tests.server import base
Expand Down Expand Up @@ -1755,10 +1756,8 @@ def test_expire_buildroot_overrides_exception(self, expire, exception_log, publi


class ComposerThreadBaseTestCase(base.BaseTestCase):
"""
This test class has common setUp() and tearDown() methods that are useful for testing the
ComposerThread class.
"""
"""Methods that are useful for testing ComposerThread subclasses."""

def setUp(self):
"""
Set up the test conditions.
Expand Down Expand Up @@ -1797,6 +1796,149 @@ def _make_msg(self, extra_push_args=None):
return _make_msg(base.TransactionalSessionMaker(self.Session), extra_push_args)


class TestContainerComposerThread__compose_updates(ComposerThreadBaseTestCase):
"""Test ContainerComposerThread._compose_update()."""

def setUp(self):
super(TestContainerComposerThread__compose_updates, self).setUp()

user = self.db.query(User).first()
release = self.create_release('28C')
release.branch = 'f28'
package1 = Package(name=u'testcontainer1',
type=ContentType.container)
self.db.add(package1)
package2 = Package(name=u'testcontainer2',
type=ContentType.container)
self.db.add(package2)
build1 = ContainerBuild(nvr=u'testcontainer1-2.0.1-71.fc28', release=release, signed=True,
package=package1)
self.db.add(build1)
build2 = ContainerBuild(nvr=u'testcontainer2-1.0.1-1.fc28', release=release, signed=True,
package=package2)
self.db.add(build2)
update = Update(
title=u'testcontainer1-2.0.1-71.fc28, testcontainer2-1.0.1-1.fc28',
builds=[build1, build2], user=user,
status=UpdateStatus.pending,
request=UpdateRequest.testing,
notes=u'Neat I can compose containers now', release=release,
test_gating_status=TestGatingStatus.passed)
update.type = UpdateType.bugfix
self.db.add(update)
# Wipe out the tag cache so it picks up our new release
Release._tag_cache = None
self.db.flush()

@mock.patch('bodhi.server.consumers.masher.subprocess.Popen')
def test_request_not_stable(self, Popen):
"""Ensure that the correct destination tag is used for non-stable updates."""
Popen.return_value.communicate.return_value = ('out', 'err')
Popen.return_value.returncode = 0
msg = self._make_msg(['--releases', 'F28C'])
t = ContainerComposerThread(self.semmock, msg['body']['msg']['composes'][0],
'bowlofeggs', log, self.Session, self.tempdir)
t.compose = Compose.from_dict(self.db, msg['body']['msg']['composes'][0])

t._compose_updates()

# Popen should have been called three times per build, once for each of the destination
# tags. With two builds that is a total of 6 calls to Popen.
expected_mock_calls = []
for source in ('testcontainer1:2.0.1-71.fc28', 'testcontainer2:1.0.1-1.fc28'):
for dtag in [source.split(':')[1], source.split(':')[1].split('-')[0], 'testing']:
mock_call = mock.call(
[config['skopeo.cmd'], 'copy',
'docker://{}/f28/{}'.format(config['container.source_registry'], source),
'docker://{}/f28/{}:{}'.format(config['container.destination_registry'],
source.split(':')[0], dtag)],
shell=False, stderr=-1, stdout=-1)
expected_mock_calls.append(mock_call)
expected_mock_calls.append(mock.call().communicate())
self.assertEqual(Popen.mock_calls, expected_mock_calls)

@mock.patch('bodhi.server.consumers.masher.subprocess.Popen')
def test_request_stable(self, Popen):
"""Ensure that the correct destination tag is used for stable updates."""
Popen.return_value.communicate.return_value = ('out', 'err')
Popen.return_value.returncode = 0
ContainerBuild.query.first().update.request = UpdateRequest.stable
msg = self._make_msg(['--releases', 'F28C'])
t = ContainerComposerThread(self.semmock, msg['body']['msg']['composes'][0],
'bowlofeggs', log, self.Session, self.tempdir)
t.compose = Compose.from_dict(self.db, msg['body']['msg']['composes'][0])

t._compose_updates()

# Popen should have been called three times per build, once for each of the destination
# tags. With two builds that is a total of 6 calls to Popen.
expected_mock_calls = []
for source in ('testcontainer1:2.0.1-71.fc28', 'testcontainer2:1.0.1-1.fc28'):
for dtag in [source.split(':')[1], source.split(':')[1].split('-')[0], 'latest']:
mock_call = mock.call(
[config['skopeo.cmd'], 'copy',
'docker://{}/f28/{}'.format(config['container.source_registry'], source),
'docker://{}/f28/{}:{}'.format(config['container.destination_registry'],
source.split(':')[0], dtag)],
shell=False, stderr=-1, stdout=-1)
expected_mock_calls.append(mock_call)
expected_mock_calls.append(mock.call().communicate())
self.assertEqual(Popen.mock_calls, expected_mock_calls)

@mock.patch('bodhi.server.consumers.masher.subprocess.Popen')
def test_skopeo_error_code(self, Popen):
"""Assert that a RuntimeError is raised if skopeo returns a non-0 exit code."""
Popen.return_value.communicate.return_value = ('out', 'err')
Popen.return_value.returncode = 1
ContainerBuild.query.first().update.request = UpdateRequest.stable
msg = self._make_msg(['--releases', 'F28C'])
t = ContainerComposerThread(self.semmock, msg['body']['msg']['composes'][0],
'bowlofeggs', log, self.Session, self.tempdir)
t.compose = Compose.from_dict(self.db, msg['body']['msg']['composes'][0])

with self.assertRaises(RuntimeError) as exc:
t._compose_updates()

# Popen should have been called once.
skopeo_cmd = [
config['skopeo.cmd'], 'copy',
'docker://{}/f28/testcontainer1:2.0.1-71.fc28'.format(
config['container.source_registry']),
'docker://{}/f28/testcontainer1:2.0.1-71.fc28'.format(
config['container.destination_registry'])]
Popen.assert_called_once_with(skopeo_cmd, shell=False, stderr=-1, stdout=-1)
self.assertEqual(str(exc.exception),
'{} returned a non-0 exit code: 1'.format(' '.join(skopeo_cmd)))

@mock.patch.dict(config, {'skopeo.extra_copy_flags': '--dest-tls-verify=false'})
@mock.patch('bodhi.server.consumers.masher.subprocess.Popen')
def test_skopeo_extra_copy_flags(self, Popen):
"""Test the skopeo.extra_copy_flags setting."""
Popen.return_value.communicate.return_value = ('out', 'err')
Popen.return_value.returncode = 0
msg = self._make_msg(['--releases', 'F28C'])
t = ContainerComposerThread(self.semmock, msg['body']['msg']['composes'][0],
'bowlofeggs', log, self.Session, self.tempdir)
t.compose = Compose.from_dict(self.db, msg['body']['msg']['composes'][0])

t._compose_updates()

# Popen should have been called three times per build, once for each of the destination
# tags. With two builds that is a total of 6 calls to Popen.
expected_mock_calls = []
for source in ('testcontainer1:2.0.1-71.fc28', 'testcontainer2:1.0.1-1.fc28'):
for dtag in [source.split(':')[1], source.split(':')[1].split('-')[0], 'testing']:
mock_call = mock.call(
[config['skopeo.cmd'], 'copy', '--dest-tls-verify=false',
'docker://{}/f28/{}'.format(config['container.source_registry'], source),
'docker://{}/f28/{}:{}'.format(config['container.destination_registry'],
source.split(':')[0], dtag)],
shell=False, stderr=-1, stdout=-1)
expected_mock_calls.append(mock_call)
expected_mock_calls.append(mock.call().communicate())
self.assertEqual(Popen.mock_calls, expected_mock_calls)


class TestPungiComposerThread__get_master_repomd_url(ComposerThreadBaseTestCase):
"""This class contains tests for the PungiComposerThread._get_master_repomd_url() method."""
@mock.patch.dict(
Expand Down
1 change: 1 addition & 0 deletions devel/ansible/roles/dev/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
- python3-pydocstyle
- python3-tox
- redhat-rpm-config
- skopeo
- vim-enhanced
- zlib-devel

Expand Down
9 changes: 9 additions & 0 deletions devel/development.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ cache.second.expire = 1
cache.short_term.expire = 60
cache.default_term.expire = 300
cache.long_term.expire = 3600
# If you want to test composing containers in development, it can be handy to run your own container
# registry locally. To do that, you can run a container registry like this:
#
# $ sudo docker run -it -d -p 5000:5000 --restart=always --name registry registry:2
#
# The following settings should work with a local container registry as described above:
container.destination_registry = localhost:5000
skopeo.extra_copy_flags = --dest-tls-verify=false


[server:main]
use = egg:waitress#main
Expand Down
1 change: 1 addition & 0 deletions docs/user/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Dependency changes
^^^^^^^^^^^^^^^^^^

* Pungi 4.1.20 or higher is now required.
* Skopeo is now a required dependency for Bodhi installations that compose containers.


Bugs
Expand Down
12 changes: 12 additions & 0 deletions production.ini
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ use = egg:bodhi-server
# What to pass to Pungi's --label flag, which is metadata included in its composeinfo.json.
# pungi.labeltype = Update

# The skopeo executable to use to copy container images.
# You can put credentials for skopeo to use in $HOME/.docker/config.json
# https://github.com/projectatomic/skopeo#private-registries-with-authentication
# skopeo.cmd = /usr/bin/skopeo

# Extra flags to pass to the skopeo copy command.
# skopeo.extra_copy_flags =

# Container hostnames. You can specify a port as well, using the traditional syntax (i.e., localhost:5000).
# container.destination_registry = registry.fedoraproject.org
# container.source_registry = candidate-registry.fedoraproject.org


##
## Mirror settings
Expand Down

0 comments on commit 6309c65

Please sign in to comment.