diff --git a/src/actions.yaml b/src/actions.yaml new file mode 100644 index 0000000..c3abffe --- /dev/null +++ b/src/actions.yaml @@ -0,0 +1,17 @@ +retrofit-image: + description: | + Trigger image retrofitting process + params: + source-image: + type: string + default: '' + description: | + Optionally specify ID of image in Glance to use as source for the + retrofitting. The default is to automatically select the most + recent Ubuntu Server or Minimal daily Cloud image. + force: + type: boolean + default: False + description: | + Force re-retrofitting image despite presence of apparently up to + date target image. diff --git a/src/actions/actions.py b/src/actions/actions.py new file mode 100755 index 0000000..30a59b4 --- /dev/null +++ b/src/actions/actions.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +# Load basic layer module from $CHARM_DIR/lib +sys.path.append('lib') +from charms.layer import basic + +# setup module loading from charm venv +basic.bootstrap_charm_deps() + +import charms.reactive as reactive +import charmhelpers.core as ch_core +import charms_openstack.bus +import charms_openstack.charm as charm + +# load reactive interfaces +reactive.bus.discover() +# load Endpoint based interface data +ch_core.hookenv._run_atstart() + +# load charm class +charms_openstack.bus.discover() + + +def retrofit_image(*args): + """Trigger image retrofitting process.""" + keystone_endpoint = reactive.endpoint_from_flag( + 'identity-credentials.available') + with charm.provide_charm_instance() as instance: + instance.retrofit( + keystone_endpoint, + ch_core.hookenv.action_get('force'), + ch_core.hookenv.action_get('source-image')) + + +ACTIONS = { + 'retrofit-image': retrofit_image, +} + + +def main(args): + action_name = os.path.basename(args[0]) + try: + action = ACTIONS[action_name] + except KeyError: + return 'Action {} is undefined'.format(action_name) + + try: + action(args) + except Exception as e: + ch_core.hookenv.action_fail(str(e)) + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/src/actions/retrofit-image b/src/actions/retrofit-image new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/retrofit-image @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/config.yaml b/src/config.yaml new file mode 100644 index 0000000..ed13714 --- /dev/null +++ b/src/config.yaml @@ -0,0 +1,7 @@ +options: + retrofit-uca-pocket: + type: string + default: 'rocky' + description: | + Name of Ubuntu Cloud Archive pocket to add to the image being + retrofitted. diff --git a/src/layer.yaml b/src/layer.yaml index 48c7f71..8b7962f 100644 --- a/src/layer.yaml +++ b/src/layer.yaml @@ -1,8 +1,10 @@ includes: - layer:openstack - layer:snap + - layer:tls-client - interface:juju-info - interface:keystone-credentials + - interface:tls-certificates options: basic: use_venv: True @@ -10,11 +12,11 @@ options: packages: [ 'libffi-dev', 'libssl-dev' ] snap: octavia-diskimage-retrofit: - comment: | - Using devmode pending resolution of snapd fuse-support issue - https://github.com/openstack-charmers/octavia-diskimage-retrofit/issues/6 channel: edge devmode: True +comment: | + Using devmode pending resolution of snapd fuse-support issue + https://github.com/openstack-charmers/octavia-diskimage-retrofit/issues/6 resources: octavia-diskimage-retrofit: type: file diff --git a/src/lib/charm/openstack/glance_retrofitter.py b/src/lib/charm/openstack/glance_retrofitter.py new file mode 100644 index 0000000..5b61b37 --- /dev/null +++ b/src/lib/charm/openstack/glance_retrofitter.py @@ -0,0 +1,159 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import subprocess + +import glanceclient +import keystoneauth1.loading +import keystoneauth1.session + +SYSTEM_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt' + + +def session_from_identity_credentials(identity_credentials): + """Get Keystone Session from ``identity-credentials`` relation. + + :param identity_credentials: reactive Endpoint + :type identity_credentials: RelationBase + :returns: Keystone session + :rtype: keystoneauth1.session.Session + """ + loader = keystoneauth1.loading.get_plugin_loader('password') + auth = loader.load_from_options( + auth_url='{}://{}:{}/' + .format(identity_credentials.auth_protocol(), + identity_credentials.auth_host(), + identity_credentials.auth_port()), + user_domain_name=identity_credentials.credentials_user_domain_name(), + project_domain_name=( + identity_credentials.credentials_project_domain_name()), + project_name=identity_credentials.credentials_project(), + username=identity_credentials.credentials_username(), + password=identity_credentials.credentials_password()) + session = keystoneauth1.session.Session( + auth=auth, + verify=SYSTEM_CA_BUNDLE) + return session + + +def get_glance_client(session): + """Get Glance Client from Keystone Session. + + :param session: Keystone Session object + :type session: keystoneauth1.session.Session + :returns: Glance Client + :rtype: glanceclient.Client + """ + return glanceclient.Client('2', session=session) + + +def get_product_name(stream='daily', variant='server', release='18.04', + arch=''): + """Build Simple Streams ``product_name`` string. + + :param stream: Stream type. ('daily'|'released') + :type stream: str + :param variant: Image variant. ('server'|'minimal') + :type variant: str + :param release: Release verssion. (e.g. '18.04') + :type release: str + :param arch: Architecture string as Debian would expect it + (Optional: default behaviour is to query dpkg) + :type arch: str + :returns: Simple Streams ``product_name`` + :rtype: str + """ + if not arch: + arch = subprocess.check_output( + ['dpkg', '--print-architecture'], + universal_newlines=True).rstrip() + if stream and stream != 'released': + return ('com.ubuntu.cloud.{}:{}:{}:{}' + .format(stream, variant, release, arch)) + else: + return ('com.ubuntu.cloud:{}:{}:{}' + .format(variant, release, arch)) + + +def find_image(glance, filters): + """Find most recent image based on filters and ``version_name``. + + :param filters: Dictionary with Glance image properties to filter on + :type filters: dict + :returns: Glance image object + """ + candidate = None + for image in glance.images.list(filters=filters, + sort_key='created_at', + sort_dir='desc'): + # glance does not offer ``version_name`` as a sort key. + # iterate over result to make sure we get the most recent image. + if not candidate or candidate.version_name < image.version_name: + candidate = image + return candidate + + +def find_destination_image(glance, product_name, version_name): + """Find previously retrofitted image. + + :param product_name: SimpleStreams ``product_name`` + :type product_name: str + :param version_name: SimpleStreams ``version_name`` + :type version_name: str + :returns: Glance image object + :rtype: generator + """ + return glance.images.list(filters={'source_product_name': product_name, + 'source_version_name': version_name}) + + +def find_source_image(glance): + """Find source image in Glance. + + Attempts to find a image from the ``daily`` stream first and reverts to + the ``released`` stream if none is found there. + + Image variant ``server`` is selected over ``minimal`` as source at the + moment. This is due to it taking a shorter amount of time to retrofit + the standard image and its presence being more commonplace in deployed + clouds. + + :returns: Glance image object or None + :rtype: Option[..., None] + """ + for stream in 'daily', 'released': + for variant in 'server', 'minimal': + product = get_product_name(stream=stream, variant=variant) + image = find_image(glance, filters={'product_name': product}) + if image: + break + else: + continue + break + return image + + +def download_image(glance, image, file_object): + """Download image from glance. + + :param glance: Glance client + :type glance: glanceclient.Client + :param image: Glance image object + :type image: Glance image object + :param file_object: Open file object to write data to + :type file_object: Python file object + """ + with file_object as out: + for chunk in glance.images.data(image.id): + out.write(chunk) diff --git a/src/lib/charm/openstack/octavia_diskimage_retrofit.py b/src/lib/charm/openstack/octavia_diskimage_retrofit.py index c1487c7..5b7de33 100644 --- a/src/lib/charm/openstack/octavia_diskimage_retrofit.py +++ b/src/lib/charm/openstack/octavia_diskimage_retrofit.py @@ -12,17 +12,35 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import subprocess +import tempfile + import charms_openstack.adapters import charms_openstack.charm import charms_openstack.charm.core +import charmhelpers.core as ch_core + +import charm.openstack.glance_retrofitter as glance_retrofitter + +TMPDIR = '/var/snap/octavia-diskimage-retrofit/common/tmp' + + +class SourceImageNotFound(Exception): + pass + + +class DestinationImageExists(Exception): + pass + class OctaviaDiskimageRetrofitCharm(charms_openstack.charm.OpenStackCharm): release = 'rocky' name = 'octavia-diskimage-retrofit' python_version = 3 adapters_class = charms_openstack.adapters.OpenStackRelationAdapters - required_relations = ['identity-credentials'] + required_relations = ['juju-info', 'identity-credentials'] @property def application_version(self): @@ -33,3 +51,87 @@ class OctaviaDiskimageRetrofitCharm(charms_openstack.charm.OpenStackCharm): self.name, project='services', domain='service_domain') + + def retrofit(self, keystone_endpoint, force=False, image_id=''): + """Use ``octavia-diskimage-retrofit`` tool to retrofit an image. + + :param keystone_endpoint: Keystone Credentials endpoint + :type keystone_endpoint: keystone-credentials RelationBase + :param force: Force retrofitting of image despite presence of + apparently up to date target image + :type force: bool + :param image_id: Use specific source image for retrofitting + :type image_id: str + :raises:SourceImageNotFound,DestinationImageExists + """ + session = glance_retrofitter.session_from_identity_credentials( + keystone_endpoint) + glance = glance_retrofitter.get_glance_client(session) + + if image_id: + source_image = next(glance.images.list(filters={'id': image_id})) + else: + source_image = glance_retrofitter.find_source_image(glance) + if not source_image: + raise SourceImageNotFound('unable to find suitable source image') + + if not image_id: + for image in glance_retrofitter.find_destination_image( + glance, + source_image.product_name, + source_image.version_name): + if not force: + raise DestinationImageExists( + 'image with product_name "{}" and ' + 'version_name "{}" already exists: "{}"' + .format(source_image.product_name, + source_image.version_name, image.id)) + + input_file = tempfile.NamedTemporaryFile(delete=False, dir=TMPDIR) + ch_core.hookenv.atexit(os.unlink, input_file.name) + ch_core.hookenv.status_set('maintenance', + 'Downloading {}' + .format(source_image.name)) + glance_retrofitter.download_image(glance, source_image, input_file) + + output_file = tempfile.NamedTemporaryFile(delete=False, dir=TMPDIR) + ch_core.hookenv.atexit(os.unlink, output_file.name) + output_file.close() + ch_core.hookenv.status_set('maintenance', + 'Retrofitting {}' + .format(source_image.name)) + subprocess.check_output( + ['octavia-diskimage-retrofit', '-d', + '-u', ch_core.hookenv.config('retrofit-uca-pocket'), + input_file.name, output_file.name], + stderr=subprocess.STDOUT, universal_newlines=True) + + # NOTE(fnordahl) the manifest is stored within the image itself in + # ``/etc/dib-manifests``. A copy of the manifest is saved on the host + # by the ``octavia-diskimage-retrofit`` tool. With the lack of a place + # to store the copy, remove it. (it does not fit in a Glance image + # property) + manifest_file = output_file.name + '.manifest' + ch_core.hookenv.atexit(os.unlink, manifest_file) + + dest_name = 'amphora-haproxy' + for image_property in (source_image.architecture, + source_image.os_distro, + source_image.os_version, + source_image.version_name): + # build a informative image name + dest_name += '-' + str(image_property) + dest_image = glance.images.create(container_format='bare', + disk_format='qcow2', + name=dest_name) + ch_core.hookenv.status_set('maintenance', + 'Uploading {}' + .format(dest_image.name)) + with open(output_file.name, 'rb') as fin: + glance.images.upload(dest_image.id, fin) + + glance.images.update( + dest_image.id, + source_product_name=source_image.product_name or 'custom', + source_version_name=source_image.version_name or 'custom', + tags=[self.name, 'octavia-amphora']) diff --git a/src/reactive/octavia_diskimage_retrofit_handlers.py b/src/reactive/octavia_diskimage_retrofit_handlers.py index 720f44a..1caa10f 100644 --- a/src/reactive/octavia_diskimage_retrofit_handlers.py +++ b/src/reactive/octavia_diskimage_retrofit_handlers.py @@ -26,6 +26,7 @@ charm.use_defaults( @reactive.when_not('charm.installed') def check_snap_installed(): + # Installation is handled by the ``snap`` layer, just update our status. with charm.provide_charm_instance() as instance: instance.assess_status() reactive.set_flag('charm.installed') @@ -38,3 +39,10 @@ def request_credentials(): 'identity-credentials.connected') with charm.provide_charm_instance() as instance: instance.request_credentials(keystone_endpoint) + instance.assess_status() + + +@reactive.when('identity-credentials.available') +def credentials_available(): + with charm.provide_charm_instance() as instance: + instance.assess_status() diff --git a/src/test-requirements.txt b/src/test-requirements.txt index e4401e4..3640c8b 100644 --- a/src/test-requirements.txt +++ b/src/test-requirements.txt @@ -8,3 +8,4 @@ flake8>=2.2.4,<=2.4.1 stestr>=2.2.0 requests>=2.18.4 git+https://github.com/openstack-charmers/zaza.git#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack diff --git a/src/tests/bundles/bionic-stein.yaml b/src/tests/bundles/bionic-stein.yaml new file mode 100644 index 0000000..a3dbdba --- /dev/null +++ b/src/tests/bundles/bionic-stein.yaml @@ -0,0 +1,67 @@ +series: bionic +relations: +- - glance-simplestreams-sync:juju-info + - octavia-diskimage-retrofit:juju-info +- - mysql:shared-db + - keystone:shared-db +- - mysql:shared-db + - glance:shared-db +- - keystone:identity-service + - glance:identity-service +- - keystone:identity-service + - glance-simplestreams-sync:identity-service +- - glance:amqp + - rabbitmq-server:amqp +- - rabbitmq-server:amqp + - glance-simplestreams-sync:amqp +- - keystone:identity-credentials + - octavia-diskimage-retrofit:identity-credentials +applications: + keystone: + charm: cs:~openstack-charmers-next/keystone + num_units: 1 + options: + openstack-origin: cloud:bionic-stein + mysql: + constraints: mem=3072M + charm: cs:~openstack-charmers-next/percona-cluster + num_units: 1 + rabbitmq-server: + charm: cs:~openstack-charmers-next/rabbitmq-server + num_units: 1 + glance: + charm: cs:~openstack-charmers-next/glance + num_units: 1 + options: + openstack-origin: cloud:bionic-stein + glance-simplestreams-sync: + charm: cs:~openstack-charmers-next/glance-simplestreams-sync + num_units: 1 + options: + source: ppa:simplestreams-dev/trunk + use_swift: False + mirror_list: "[{url: 'http://cloud-images.ubuntu.com/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }, + {url: 'http://cloud-images.ubuntu.com/minimal/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }]" + octavia-diskimage-retrofit: + series: bionic + charm: ../../../octavia-diskimage-retrofit + options: + retrofit-uca-pocket: stein diff --git a/src/tests/bundles/disco.yaml b/src/tests/bundles/disco.yaml new file mode 100644 index 0000000..1540de8 --- /dev/null +++ b/src/tests/bundles/disco.yaml @@ -0,0 +1,65 @@ +series: disco +relations: +- - glance-simplestreams-sync:juju-info + - octavia-diskimage-retrofit:juju-info +- - mysql:shared-db + - keystone:shared-db +- - mysql:shared-db + - glance:shared-db +- - keystone:identity-service + - glance:identity-service +- - keystone:identity-service + - glance-simplestreams-sync:identity-service +- - glance:amqp + - rabbitmq-server:amqp +- - rabbitmq-server:amqp + - glance-simplestreams-sync:amqp +- - keystone:identity-credentials + - octavia-diskimage-retrofit:identity-credentials +applications: + keystone: + charm: cs:~openstack-charmers-next/keystone + num_units: 1 + options: + openstack-origin: distro + mysql: + constraints: mem=3072M + charm: cs:~openstack-charmers-next/percona-cluster + num_units: 1 + rabbitmq-server: + charm: cs:~openstack-charmers-next/rabbitmq-server + num_units: 1 + glance: + charm: cs:~openstack-charmers-next/glance + num_units: 1 + options: + openstack-origin: distro + glance-simplestreams-sync: + charm: cs:~openstack-charmers-next/glance-simplestreams-sync + num_units: 1 + options: + source: ppa:simplestreams-dev/trunk + use_swift: False + mirror_list: "[{url: 'http://cloud-images.ubuntu.com/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }, + {url: 'http://cloud-images.ubuntu.com/minimal/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }]" + octavia-diskimage-retrofit: + series: disco + charm: ../../../octavia-diskimage-retrofit diff --git a/src/tests/bundles/eoan.yaml b/src/tests/bundles/eoan.yaml new file mode 100644 index 0000000..569feda --- /dev/null +++ b/src/tests/bundles/eoan.yaml @@ -0,0 +1,65 @@ +series: eoan +relations: +- - glance-simplestreams-sync:juju-info + - octavia-diskimage-retrofit:juju-info +- - mysql:shared-db + - keystone:shared-db +- - mysql:shared-db + - glance:shared-db +- - keystone:identity-service + - glance:identity-service +- - keystone:identity-service + - glance-simplestreams-sync:identity-service +- - glance:amqp + - rabbitmq-server:amqp +- - rabbitmq-server:amqp + - glance-simplestreams-sync:amqp +- - keystone:identity-credentials + - octavia-diskimage-retrofit:identity-credentials +applications: + keystone: + charm: cs:~openstack-charmers-next/keystone + num_units: 1 + options: + openstack-origin: distro + mysql: + constraints: mem=3072M + charm: cs:~openstack-charmers-next/percona-cluster + num_units: 1 + rabbitmq-server: + charm: cs:~openstack-charmers-next/rabbitmq-server + num_units: 1 + glance: + charm: cs:~openstack-charmers-next/glance + num_units: 1 + options: + openstack-origin: distro + glance-simplestreams-sync: + charm: cs:~openstack-charmers-next/glance-simplestreams-sync + num_units: 1 + options: + source: ppa:simplestreams-dev/trunk + use_swift: False + mirror_list: "[{url: 'http://cloud-images.ubuntu.com/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }, + {url: 'http://cloud-images.ubuntu.com/minimal/daily/', + name_prefix: 'ubuntu:released', + path: 'streams/v1/index.sjson', + max: 1, + item_filters: [ + 'release~(xenial|bionic|eoan)', + 'arch~(x86_64|amd64)', + 'ftype~(disk1.img|disk.img)' + ] + }]" + octavia-diskimage-retrofit: + series: eoan + charm: ../../../octavia-diskimage-retrofit diff --git a/src/tests/tests.yaml b/src/tests/tests.yaml index 68587bd..f7dd08d 100644 --- a/src/tests/tests.yaml +++ b/src/tests/tests.yaml @@ -1,11 +1,15 @@ charm_name: octavia-diskimage-retrofit smoke_bundles: - bionic-rocky +gate_bundles: +- bionic-rocky +- bionic-stein +- disco +dev_bundles: +- eoan target_deploy_status: glance-simplestreams-sync: workload-status: active workload-status-message: Sync completed -configure: -- zaza.charm_tests.noop.setup.basic_setup tests: -- zaza.charm_tests.noop.tests.NoopTest +- zaza.openstack.charm_tests.octavia.diskimage_retrofit.tests.OctaviaDiskimageRetrofitTest diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py index 7b5dac4..a532e40 100644 --- a/unit_tests/__init__.py +++ b/unit_tests/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import mock import sys sys.path.append('src') @@ -20,3 +21,12 @@ sys.path.append('src/lib') # Mock out charmhelpers so that we can test without it. import charms_openstack.test_mocks # noqa charms_openstack.test_mocks.mock_charmhelpers() + +global glanceclient +global keystoneauth1 +glanceclient = mock.MagicMock() +keystoneauth1 = mock.MagicMock() +sys.modules['glanceclient'] = glanceclient +sys.modules['keystoneauth1'] = keystoneauth1 +sys.modules['keystoneauth1.loading'] = keystoneauth1.loading +sys.modules['keystoneauth1.session'] = keystoneauth1.session diff --git a/unit_tests/test_lib_charm_openstack_glance_retrofitter.py b/unit_tests/test_lib_charm_openstack_glance_retrofitter.py new file mode 100644 index 0000000..8fa1463 --- /dev/null +++ b/unit_tests/test_lib_charm_openstack_glance_retrofitter.py @@ -0,0 +1,122 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +import charms_openstack.test_utils as test_utils + +import charm.openstack.glance_retrofitter as glance_retrofitter + + +class TestGlanceRetrofitter(test_utils.PatchHelper): + + def test_session_from_identity_credentials(self): + self.patch_object( + glance_retrofitter.keystoneauth1.loading, 'get_plugin_loader') + self.patch_object( + glance_retrofitter.keystoneauth1.session, 'Session') + loader = mock.MagicMock() + self.get_plugin_loader.return_value = loader + endpoint = mock.MagicMock() + result = glance_retrofitter.session_from_identity_credentials(endpoint) + self.get_plugin_loader.assert_called_once_with('password') + loader.load_from_options.assert_called_once_with( + auth_url='{}://{}:{}/' + .format(endpoint.auth_protocol(), + endpoint.auth_host(), + endpoint.auth_port()), + user_domain_name=endpoint.credentials_user_domain_name(), + project_domain_name=endpoint.credentials_project_domain_name(), + project_name=endpoint.credentials_project(), + username=endpoint.credentials_username(), + password=endpoint.credentials_password()) + self.Session.assert_called_once_with( + auth=loader.load_from_options(), + verify=glance_retrofitter.SYSTEM_CA_BUNDLE) + self.assertEquals(result, self.Session()) + + def test_get_glance_client(self): + self.patch_object(glance_retrofitter.glanceclient, 'Client') + result = glance_retrofitter.get_glance_client('aSession') + self.Client.assert_called_once_with('2', session='aSession') + self.assertEquals(result, self.Client()) + + def test_get_product_name(self): + self.patch_object(glance_retrofitter.subprocess, 'check_output') + self.check_output.return_value = 'aArchitecture' + self.assertEquals(glance_retrofitter.get_product_name(), + 'com.ubuntu.cloud.daily:server:18.04:aArchitecture') + self.check_output.assert_called_once_with( + ['dpkg', '--print-architecture'], + universal_newlines=True) + + def test_find_image(self): + glance = mock.MagicMock() + + class FakeImage1(object): + version_name = '20194242' + fake_image1 = FakeImage1() + + class FakeImage2(object): + version_name = '20195151' + fake_image2 = FakeImage2() + + glance.images.list.return_value = [fake_image1, fake_image2] + self.assertEquals( + glance_retrofitter.find_image(glance, {'fake_property': 'real'}), + fake_image2) + glance.images.list.assert_called_once_with( + filters={'fake_property': 'real'}, + sort_key='created_at', + sort_dir='desc') + + def test_find_destination_image(self): + glance = mock.MagicMock() + result = glance_retrofitter.find_destination_image( + glance, 'aProduct', 'aVersion') + glance.images.list.assert_called_once_with( + filters={'source_product_name': 'aProduct', + 'source_version_name': 'aVersion'}) + self.assertEquals(result, glance.images.list()) + + def test_find_source_image(self): + self.patch_object(glance_retrofitter, 'get_product_name') + self.patch_object(glance_retrofitter, 'find_image') + self.get_product_name.return_value = 'aProduct' + self.find_image.side_effect = [None, None, None, 'aImage'] + self.assertEquals( + glance_retrofitter.find_source_image('aGlance'), + 'aImage') + self.get_product_name.assert_has_calls([ + mock.call(stream='daily', variant='server'), + mock.call(stream='daily', variant='minimal'), + mock.call(stream='released', variant='server'), + mock.call(stream='released', variant='minimal'), + ]) + self.find_image.assert_called_with( + 'aGlance', filters={'product_name': 'aProduct'}) + + def test_download_image(self): + glance = mock.MagicMock() + glance.images.data.return_value = ['two', 'Chunks'] + image = mock.MagicMock() + file_object = mock.MagicMock() + file_handle = mock.MagicMock() + file_object.__enter__.return_value = file_handle + glance_retrofitter.download_image(glance, image, file_object) + glance.images.data.assert_called_once_with(image.id) + file_handle.write.assert_has_calls([ + mock.call('two'), + mock.call('Chunks'), + ]) diff --git a/unit_tests/test_lib_charm_openstack_octavia_diskimage_retrofit.py b/unit_tests/test_lib_charm_openstack_octavia_diskimage_retrofit.py index 99045e2..4610038 100644 --- a/unit_tests/test_lib_charm_openstack_octavia_diskimage_retrofit.py +++ b/unit_tests/test_lib_charm_openstack_octavia_diskimage_retrofit.py @@ -13,6 +13,8 @@ # limitations under the License. import mock +import os +import subprocess import charms_openstack.test_utils as test_utils @@ -39,3 +41,75 @@ class TestOctaviaDiskimageRetrofitCharm(test_utils.PatchHelper): c.name, project='services', domain='service_domain') + + def test_retrofit(self): + self.patch_object(octavia_diskimage_retrofit, 'glance_retrofitter') + glance = mock.MagicMock() + self.glance_retrofitter.get_glance_client.return_value = glance + + class FakeImage(object): + id = 'aId' + name = 'aName' + architecture = 'aArchitecture' + os_distro = 'aOSDistro' + os_version = 'aOSVersion' + version_name = 'aVersionName' + product_name = 'aProductName' + fake_image = FakeImage() + + glance.images.create.return_value = fake_image + self.glance_retrofitter.find_source_image.return_value = fake_image + self.patch_object( + octavia_diskimage_retrofit.tempfile, + 'NamedTemporaryFile') + self.patch_object(octavia_diskimage_retrofit.ch_core, 'hookenv') + self.patch_object(octavia_diskimage_retrofit.subprocess, + 'check_output') + c = octavia_diskimage_retrofit.OctaviaDiskimageRetrofitCharm() + with mock.patch('charm.openstack.octavia_diskimage_retrofit.open', + create=True) as mocked_open: + self.glance_retrofitter.find_destination_image.return_value = \ + [fake_image] + with self.assertRaises(Exception): + c.retrofit('aKeystone') + self.glance_retrofitter.session_from_identity_credentials.\ + assert_called_once_with('aKeystone') + self.glance_retrofitter.get_glance_client.assert_called_once_with( + self.glance_retrofitter.session_from_identity_credentials()) + + self.glance_retrofitter.find_destination_image.return_value = \ + [] + c.retrofit('aKeystone') + self.NamedTemporaryFile.assert_has_calls([ + mock.call(delete=False, + dir=octavia_diskimage_retrofit.TMPDIR), + mock.call(delete=False, + dir=octavia_diskimage_retrofit.TMPDIR), + ]) + self.hookenv.atexit.assert_called_with(os.unlink, mock.ANY) + self.hookenv.status_set.assert_has_calls([ + mock.call('maintenance', 'Downloading aName'), + mock.call('maintenance', 'Retrofitting aName'), + mock.call('maintenance', 'Uploading aName'), + ]) + self.glance_retrofitter.download_image.assert_called_once_with( + glance, fake_image, self.NamedTemporaryFile()) + self.hookenv.config.assert_called_once_with('retrofit-uca-pocket') + self.check_output.assert_called_once_with( + ['octavia-diskimage-retrofit', '-d', '-u', + self.hookenv.config(), self.NamedTemporaryFile().name, + self.NamedTemporaryFile().name], + stderr=subprocess.STDOUT, universal_newlines=True) + glance.images.create.assert_called_once_with( + container_format='bare', + disk_format='qcow2', + name='amphora-haproxy-aArchitecture-aOSDistro-aOSVersion-' + 'aVersionName') + glance.images.upload.assert_called_once_with('aId', mock.ANY) + mocked_open.assert_called_once_with( + self.NamedTemporaryFile().name, 'rb') + glance.images.update.assert_called_once_with( + 'aId', + source_product_name='aProductName', + source_version_name='aVersionName', + tags=['octavia-diskimage-retrofit', 'octavia-amphora']) diff --git a/unit_tests/test_reactive_octavia_diskimage_retrofit_handlers.py b/unit_tests/test_reactive_octavia_diskimage_retrofit_handlers.py index 3a2a2b9..9acf525 100644 --- a/unit_tests/test_reactive_octavia_diskimage_retrofit_handlers.py +++ b/unit_tests/test_reactive_octavia_diskimage_retrofit_handlers.py @@ -29,6 +29,10 @@ class TestRegisteredHooks(test_utils.TestRegisteredHooks): 'when': { 'request_credentials': ( 'identity-credentials.connected',), + 'credentials_available': ( + 'identity-credentials.available',), + 'build': ( + 'octavia-diskimage-retrofit.build',), }, 'when_not': { 'check_snap_installed': ( @@ -67,3 +71,8 @@ class TestOctaviaDiskimageRetrofitHandlers(test_utils.PatchHelper): 'identity-credentials.connected') self.charm_instance.request_credentials.assert_called_once_with( 'endpoint') + self.charm_instance.assess_status.assert_called_once_with() + + def test_credentials_available(self): + handlers.credentials_available() + self.charm_instance.assess_status.assert_called_once_with()