Add main project class to release_docker_hub 3(7). 30/13730/8
authorBengt Thuree <bthuree@linuxfoundation.org>
Thu, 29 Nov 2018 10:58:12 +0000 (21:58 +1100)
committerBengt Thuree <bthuree@linuxfoundation.org>
Thu, 31 Jan 2019 00:37:06 +0000 (11:37 +1100)
This is the third part, which contains the Project class.
The project class initiates the various tag classes, as well
as contain the code to copy between nexus and docker.

Issue:RELENG-1549
Change-Id: I1b59a2452dd0f415cefe38a2d496f263bdf5d7b8
Signed-off-by: Bengt Thuree <bthuree@linuxfoundation.org>
lftools/nexus/release_docker_hub.py
tests/test_release_docker_hub.py

index 713dc97..d84e9b5 100644 (file)
@@ -44,7 +44,9 @@ import logging
 import re
 import socket
 
+import docker
 import requests
+import tqdm
 import urllib3
 
 log = logging.getLogger(__name__)
@@ -284,3 +286,161 @@ class DockerTagClass (TagClass):
                             org_name, repo_name, tmp_tuple[2]))
         else:
             self.repository_exist = False
+
+
+class ProjectClass:
+    """Main Project class.
+
+    Main Function of this class, is to pull, and push the missing images from
+    Nexus3 to Docker Hub.
+
+    Parameters:
+        nexus_proj : Tuple with 'org' and 'repo'
+            ('onap', 'aaf/aaf_service')
+
+    Upon class Initialize the following happens.
+      * Set Nexus and Docker repository names.
+      * Initialize the Nexus and Docker tag variables.
+      * Find which tags are needed to be copied.
+
+    Main external function is docker_pull_tag_push
+    """
+
+    def __init__(self, nexus_proj):
+        """Initialize this class."""
+        self.org_name = nexus_proj[0]
+        self.nexus_repo_name = nexus_proj[1]
+        self._set_docker_repo_name(self.nexus_repo_name)
+        self.nexus_tags = NexusTagClass(self.org_name, self.nexus_repo_name)
+        self.docker_tags = DockerTagClass(self.org_name, self.docker_repo_name)
+        self.tags_2_copy = TagClass(self.org_name, self.nexus_repo_name)
+        self._populate_tags_to_copy()
+        self.docker_client = docker.from_env()
+
+    def __lt__(self, other):
+        """Implement sort order base on Nexus3 repo name."""
+        return self.nexus_repo_name < other.nexus_repo_name
+
+    def calc_nexus_project_name(self):
+        """Get Nexus3 project name."""
+        return self.org_name + "/" + self.nexus_repo_name
+
+    def calc_docker_project_name(self):
+        """Get Docker Hub project name."""
+        return self.org_name + "/" + self.docker_repo_name
+
+    def _set_docker_repo_name(self, nexus_repo_name):
+        """Set Docker Hub repo name.
+
+        Docker repository will be based on the Nexus3 repo name.
+        But replacing all '/' with '-'
+        """
+        self.docker_repo_name = self.nexus_repo_name.replace('/', '-')
+        log.debug("ProjName = {} ---> Docker name = {}".format(
+            self.nexus_repo_name, self.docker_repo_name))
+
+    def _populate_tags_to_copy(self):
+        """Populate tags_to_copy list.
+
+        Check that all valid Nexus3 tags are among the Docker Hub valid tags.
+        If not, add them to the tags_2_copy list.
+        """
+        log.debug('Populate {} has valid Nexus3 {} and valid Docker Hub {}'.format(
+            self.docker_repo_name,
+            len(self.nexus_tags.valid), len(self.docker_tags.valid)))
+
+        if len(self.nexus_tags.valid) > 0:
+            for nexustag in self.nexus_tags.valid:
+                if not nexustag in self.docker_tags.valid:
+                    log.debug('Need to copy tag {} from {}'.format(nexustag, self.nexus_repo_name))
+                    self.tags_2_copy.add_tag(nexustag)
+
+    def _pull_tag_push_msg(self, info_text, count, retry_text='', progbar=False):
+        """Print a formated message using log.info."""
+        due_to_txt = ''
+        if len(retry_text) > 0:
+            due_to_txt = 'due to {}'.format(retry_text)
+        _attempt_str = 'Attempt '
+        b4_txt_template = _attempt_str + '{:2d}'
+        b4_txt = ''.ljust(len(_attempt_str)+2)
+        if count > 1:
+            b4_txt = b4_txt_template.format(count)
+        if progbar:
+            tqdm.tqdm.write("{}: {} {}".format(b4_txt, info_text, due_to_txt))
+        else:
+            log.info("{}: {} {}".format(b4_txt, info_text, due_to_txt))
+
+    def _docker_pull(self, nexus_image_str, count, tag, retry_text='', progbar=False):
+        """Pull an image from Nexus."""
+        self._pull_tag_push_msg('Pulling  Nexus3 image {} with tag {}'.format(
+            self.calc_nexus_project_name(), tag), count, retry_text)
+        image = self.docker_client.images.pull(nexus_image_str)
+        return image
+
+    def _docker_tag(self, count, image, tag, retry_text='', progbar=False):
+        """Tag the image with proper docker name and version."""
+        self._pull_tag_push_msg('Creating docker image {} with tag {}'.format(
+            self.calc_docker_project_name(), tag), count, retry_text)
+        image.tag(self.calc_docker_project_name(), tag=tag)
+
+    def _docker_push(self, count, image, tag, retry_text, progbar=False):
+        """Push the docker image to Docker Hub."""
+        self._pull_tag_push_msg('Pushing  docker image {} with tag {}'.format(
+            self.calc_docker_project_name(), tag), count, retry_text)
+        self.docker_client.images.push(self.calc_docker_project_name(), tag=tag)
+
+    def _docker_cleanup(self, count, image, tag, retry_text='', progbar=False):
+        """Remove the local copy of the image."""
+        image_id = _format_image_id(image.short_id)
+        self._pull_tag_push_msg('Cleanup  docker image {} with tag {} and id {}'.format(
+            self.calc_docker_project_name(), tag, image_id), count, retry_text)
+        self.docker_client.images.remove(image.id, force=True)
+
+    def docker_pull_tag_push(self, progbar=False):
+        """Copy all missing Docker Hub images from Nexus3.
+
+        This is the main function which will copy a specific tag from Nexu3
+        to Docker Hub repository.
+
+        It has 4 stages, pull, tag, push and cleanup.
+        Each of these stages, will be retried 10 times upon failures.
+        """
+        if len(self.tags_2_copy.valid) == 0:
+            return
+
+        for tag in self.tags_2_copy.valid:
+            org_path = _remove_http_from_url(NEXUS3_BASE)
+            nexus_image_str = '{}/{}/{}:{}'.format(org_path, self.org_name, self.nexus_repo_name, tag)
+            log.debug("Nexus Image Str = {}".format(nexus_image_str))
+            for stage in ['pull', 'tag', 'push', 'cleanup']:
+                cnt_break_loop = 1
+                retry_text = ''
+                while (True):
+                    try:
+                        log.debug('stage = {}. cnt_break_loop {}, reason {}'.format(stage, cnt_break_loop, retry_text))
+                        if stage == 'pull':
+                            image = self._docker_pull(nexus_image_str, cnt_break_loop, tag, retry_text, progbar)
+                            break
+
+                        if stage == 'tag':
+                            self._docker_tag(cnt_break_loop, image, tag, retry_text, progbar)
+                            break
+
+                        if stage == 'push':
+                            self._docker_push(cnt_break_loop, image, tag, retry_text, progbar)
+                            break
+
+                        if stage == 'cleanup':
+                            self._docker_cleanup(cnt_break_loop, image, tag, retry_text, progbar)
+                            break
+                    except socket.timeout:
+                        retry_text = 'Socket Timeout'
+                    except requests.exceptions.ConnectionError:
+                        retry_text = 'Connection Error'
+                    except urllib3.exceptions.ReadTimeoutError:
+                        retry_text = 'Read Timeout Error'
+                    except docker.errors.APIError:
+                        retry_text = 'API Error'
+                    cnt_break_loop = cnt_break_loop + 1
+                    if (cnt_break_loop > 90):
+                        raise requests.HTTPError(retry_text)
index 9880b24..273b255 100644 (file)
@@ -124,3 +124,258 @@ def test_docker_tag_class(responses):
         assert tag in test_tags.invalid
     assert len(test_tags.valid) == len(answer_valid_tags)
     assert len(test_tags.invalid) == len(answer_invalid_tags)
+
+
+class TestProjectClass:
+    """Test ProjectClass.
+
+    This class contains all the test cases for the ProjectClass.
+    We mock the helper functions _docker_pull, _docker_tag, _docker_push, and
+    _docker_cleanup. This means we do not have to do anything with the actual
+    docker api.
+    """
+
+    _test_image_long_id = 'sha256:3450464d68c9443dedc8bfe3272a23e6441c37f707c42d32fee0ebdbcd319d2c'
+    _test_image_short_id = 'sha256:3450464d68'
+    _expected_nexus_image_str = ['nexus3.onap.org:10002/onap/base/sdc-sanity:1.4.0',
+                                'nexus3.onap.org:10002/onap/base/sdc-sanity:1.4.1']
+
+    class mock_image:
+        id = ''
+        short_id = ''
+        def __init__(self, id, short_id):
+            self.id = id
+            self.short_id = short_id
+
+    class count_mock_hits:
+        pull = 0
+        tag = 0
+        push = 0
+        cleanup = 0
+
+    counter = count_mock_hits
+
+    class nbr_exceptions:
+        pull = 0
+        tag = 0
+        push = 0
+        cleanup = 0
+
+    nbr_exc = nbr_exceptions
+
+    def mocked_docker_pull(self, nexus_image_str, count, tag, retry_text='', progbar=False):
+        """Mocking Pull an image from Nexus."""
+        if not nexus_image_str in self._expected_nexus_image_str:
+            raise ValueError('Wrong nexus project in pull')
+        image = self.mock_image (self._test_image_long_id, self._test_image_short_id)
+        self.counter.pull = self.counter.pull + 1
+        if self.counter.pull > self.nbr_exc.pull:
+            return image
+        else:
+            raise requests.exceptions.ConnectionError('Connection Error')
+
+    def mocked_docker_tag(self, count, image, tag, retry_text='', progbar=False):
+        """Mocking Tag the image with proper docker name and version."""
+        if not image.id == self._test_image_long_id:
+            raise ValueError('Wrong image id in remove')
+        if not tag in ["1.4.0","1.4.1"]:
+            raise ValueError('Wrong tag in docker_tag')
+        self.counter.tag = self.counter.tag + 1
+        if self.counter.tag <= self.nbr_exc.tag:
+            raise requests.exceptions.ConnectionError('Connection Error')
+
+    def mocked_docker_push(self, count, image, tag, retry_text, progbar=False):
+        """Mocking Tag the image with proper docker name and version."""
+        if not image.id == self._test_image_long_id:
+            raise ValueError('Wrong image id in remove')
+        if not tag in ["1.4.0","1.4.1"]:
+            raise ValueError('Wrong tag in push')
+        self.counter.push = self.counter.push + 1
+        if self.counter.push <= self.nbr_exc.push:
+            raise requests.exceptions.ConnectionError('Connection Error')
+
+    def mocked_docker_cleanup(self, count, image, tag, retry_text='', progbar=False):
+        """Mocking Tag the image with proper docker name and version."""
+        if not image.id == self._test_image_long_id:
+            raise ValueError('Wrong image id in remove')
+        self.counter.cleanup = self.counter.cleanup + 1
+        if self.counter.cleanup <= self.nbr_exc.cleanup:
+            raise requests.exceptions.ConnectionError('Connection Error')
+
+    def test_ProjectClass_2_missing(self, responses, mocker):
+        """Test ProjectClass"""
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_pull', side_effect=self.mocked_docker_pull)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_tag', side_effect=self.mocked_docker_tag)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_push', side_effect=self.mocked_docker_push)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_cleanup', side_effect=self.mocked_docker_cleanup)
+
+        project = ('onap', 'base/sdc-sanity')
+
+        nexus_url = 'https://nexus3.onap.org:10002/v2/onap/base/sdc-sanity/tags/list'
+        nexus_answer = '{"name":"onap/base_sdc-sanity","tags":["1.3.0","1.3.1","1.4.0","1.4.1","v1.0.0"]}'
+        docker_url = 'https://registry.hub.docker.com/v1/repositories/onap/base-sdc-sanity/tags'
+        docker_answer = """[{"layer": "", "name": "1.3.0"},
+            {"layer": "", "name": "1.3.1"},
+            {"layer": "", "name": "v1.0.0"}]
+        """
+        nexus_answer_valid_tags = ["1.3.0","1.3.1","1.4.0","1.4.1"]
+        nexus_answer_invalid_tags = ["v1.0.0"]
+        docker_answer_valid_tags = ["1.3.0","1.3.1"]
+        docker_answer_invalid_tags = ["v1.0.0"]
+        docker_missing_tags = ["1.4.0","1.4.1"]
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+
+        responses.add(responses.GET, nexus_url, body=nexus_answer, status=200)
+        responses.add(responses.GET, docker_url, body=docker_answer, status=200)
+
+        rdh.initialize ('onap')
+        test_proj = rdh.ProjectClass (project)
+
+        assert test_proj.org_name == 'onap'
+        assert test_proj.nexus_repo_name == 'base/sdc-sanity'
+        assert test_proj.docker_repo_name == 'base-sdc-sanity'
+        assert test_proj.calc_docker_project_name() == 'onap/base-sdc-sanity'
+
+        assert len(test_proj.nexus_tags.valid) == len(nexus_answer_valid_tags)
+        assert len(test_proj.docker_tags.valid) == len(docker_answer_valid_tags)
+        assert len(test_proj.nexus_tags.invalid) == len(nexus_answer_invalid_tags)
+        assert len(test_proj.docker_tags.invalid) == len(docker_answer_invalid_tags)
+
+        for tag in docker_missing_tags:
+            assert tag in test_proj.tags_2_copy.valid
+        assert len(test_proj.tags_2_copy.valid) == len(docker_missing_tags)
+
+        test_proj.docker_pull_tag_push()
+
+        assert self.counter.pull == 2
+        assert self.counter.tag == 2
+        assert self.counter.push == 2
+        assert self.counter.cleanup == 2
+
+    def test_ProjectClass_1_missing(self, responses, mocker):
+        """Test ProjectClass"""
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_pull', side_effect=self.mocked_docker_pull)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_tag', side_effect=self.mocked_docker_tag)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_push', side_effect=self.mocked_docker_push)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_cleanup', side_effect=self.mocked_docker_cleanup)
+
+        project = ('onap', 'base/sdc-sanity')
+
+        nexus_url = 'https://nexus3.onap.org:10002/v2/onap/base/sdc-sanity/tags/list'
+        nexus_answer = '{"name":"onap/base_sdc-sanity","tags":["1.3.0","1.3.1","1.4.0","v1.0.0"]}'
+        docker_url = 'https://registry.hub.docker.com/v1/repositories/onap/base-sdc-sanity/tags'
+        docker_answer = """[{"layer": "", "name": "1.3.0"},
+            {"layer": "", "name": "1.3.1"},
+            {"layer": "", "name": "v1.0.0"}]
+        """
+        nexus_answer_valid_tags = ["1.3.0","1.3.1","1.4.0"]
+        nexus_answer_invalid_tags = ["v1.0.0"]
+        docker_answer_valid_tags = ["1.3.0","1.3.1"]
+        docker_answer_invalid_tags = ["v1.0.0"]
+        docker_missing_tags = ["1.4.0"]
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+
+        responses.add(responses.GET, nexus_url, body=nexus_answer, status=200)
+        responses.add(responses.GET, docker_url, body=docker_answer, status=200)
+
+        rdh.initialize ('onap')
+        test_proj = rdh.ProjectClass (project)
+
+        assert test_proj.org_name == 'onap'
+        assert test_proj.nexus_repo_name == 'base/sdc-sanity'
+        assert test_proj.docker_repo_name == 'base-sdc-sanity'
+        assert test_proj.calc_docker_project_name() == 'onap/base-sdc-sanity'
+
+        assert len(test_proj.nexus_tags.valid) == len(nexus_answer_valid_tags)
+        assert len(test_proj.docker_tags.valid) == len(docker_answer_valid_tags)
+        assert len(test_proj.nexus_tags.invalid) == len(nexus_answer_invalid_tags)
+        assert len(test_proj.docker_tags.invalid) == len(docker_answer_invalid_tags)
+
+        for tag in docker_missing_tags:
+            assert tag in test_proj.tags_2_copy.valid
+        assert len(test_proj.tags_2_copy.valid) == len(docker_missing_tags)
+
+        test_proj.docker_pull_tag_push()
+
+        assert self.counter.pull == 1
+        assert self.counter.tag == 1
+        assert self.counter.push == 1
+        assert self.counter.cleanup == 1
+
+    def test_ProjectClass_socket_timeout (self, responses, mocker):
+        """Test ProjectClass"""
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_pull', side_effect=self.mocked_docker_pull)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_tag', side_effect=self.mocked_docker_tag)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_push', side_effect=self.mocked_docker_push)
+        mocker.patch('lftools.nexus.release_docker_hub.ProjectClass._docker_cleanup', side_effect=self.mocked_docker_cleanup)
+
+        project = ('onap', 'base/sdc-sanity')
+        nexus_url = 'https://nexus3.onap.org:10002/v2/onap/base/sdc-sanity/tags/list'
+        nexus_answer = '{"name":"onap/base_sdc-sanity","tags":["1.3.0","1.3.1","1.4.0","v1.0.0"]}'
+        docker_url = 'https://registry.hub.docker.com/v1/repositories/onap/base-sdc-sanity/tags'
+        docker_answer = """[{"layer": "", "name": "1.3.0"},
+            {"layer": "", "name": "1.3.1"},
+            {"layer": "", "name": "v1.0.0"}]
+        """
+        nexus_answer_valid_tags = ["1.3.0","1.3.1","1.4.0"]
+        nexus_answer_invalid_tags = ["v1.0.0"]
+        docker_answer_valid_tags = ["1.3.0","1.3.1"]
+        docker_answer_invalid_tags = ["v1.0.0"]
+        docker_missing_tags = ["1.4.0"]
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+
+        responses.add(responses.GET, nexus_url, body=nexus_answer, status=200)
+        responses.add(responses.GET, docker_url, body=docker_answer, status=200)
+
+        rdh.initialize ('onap')
+        test_proj = rdh.ProjectClass (project)
+
+        assert test_proj.org_name == 'onap'
+        assert test_proj.nexus_repo_name == 'base/sdc-sanity'
+        assert test_proj.docker_repo_name == 'base-sdc-sanity'
+        assert test_proj.calc_docker_project_name() == 'onap/base-sdc-sanity'
+
+        assert len(test_proj.nexus_tags.valid) == len(nexus_answer_valid_tags)
+        assert len(test_proj.docker_tags.valid) == len(docker_answer_valid_tags)
+        assert len(test_proj.nexus_tags.invalid) == len(nexus_answer_invalid_tags)
+        assert len(test_proj.docker_tags.invalid) == len(docker_answer_invalid_tags)
+
+        for tag in docker_missing_tags:
+            assert tag in test_proj.tags_2_copy.valid
+        assert len(test_proj.tags_2_copy.valid) == len(docker_missing_tags)
+
+        #Verify that 90 timeout's on any stage failes.
+        self.nbr_exc.pull = 90
+        with pytest.raises(requests.HTTPError) as excinfo:
+            test_proj.docker_pull_tag_push()
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+        self.nbr_exc.pull = 0
+        self.nbr_exc.tag = 90
+        with pytest.raises(requests.HTTPError) as excinfo:
+            test_proj.docker_pull_tag_push()
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+        self.nbr_exc.pull = self.nbr_exc.tag = 0
+        self.nbr_exc.push = 90
+        with pytest.raises(requests.HTTPError) as excinfo:
+            test_proj.docker_pull_tag_push()
+
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+        self.nbr_exc.pull = self.nbr_exc.tag = self.nbr_exc.push = 0
+        self.nbr_exc.cleanup = 90
+        with pytest.raises(requests.HTTPError) as excinfo:
+            test_proj.docker_pull_tag_push()
+
+        #Verify 89 timeouts and the 90 is ok per stage
+        self.counter.pull = self.counter.tag = self.counter.push = self.counter.cleanup = 0
+        self.nbr_exc.pull = self.nbr_exc.tag = self.nbr_exc.push = self.nbr_exc.cleanup = 89
+        test_proj.docker_pull_tag_push()
+
+        assert self.counter.pull == 90
+        assert self.counter.tag == 90
+        assert self.counter.push == 90
+        assert self.counter.cleanup == 90