From 0c87d34ad543fd16d8dd6006eef955a34fbf1ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ba=C5=A1ti?= Date: Mon, 25 Feb 2019 17:03:08 +0100 Subject: [PATCH] Allow to unpublish released operators manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoints added: [DELETE] /packages/// # removes particular version [DELETE] /packages// # removes all releases Note: currently implemented version removes all content but keeps empty repository. Note2: added `__init__.py` because coverage needs them * OSBS-6876 Signed-off-by: Martin Bašti --- README.md | 56 ++++++++++++++++++++++ omps/app.py | 2 + omps/packages.py | 49 +++++++++++++++++++ omps/quay.py | 79 +++++++++++++++++++++++++++--- tests/conftest.py | 43 +++++++++++++++++ tests/packages/__init__.py | 0 tests/packages/test_api.py | 56 ++++++++++++++++++++++ tests/push/__init__.py | 0 tests/test_quay.py | 98 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 omps/packages.py create mode 100644 tests/packages/__init__.py create mode 100644 tests/packages/test_api.py create mode 100644 tests/push/__init__.py diff --git a/README.md b/README.md index e0f0c23..6984ad3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,62 @@ or with explicit release version curl -X POST https://example.com/push/myorg/myrepo/zipfile/1.1.5 -F "file=@manifests.zip" ``` +### Removing released operators manifests + + +#### Endpoints + +* [DELETE] `/packages///` +* [DELETE] `/packages//` + +If `` is omitted then all released operator manifests are removed +from the specified application repository, but the repository itself will **not** be +deleted (the feature is out of scope, for now). + +#### Replies + +**OK** + +HTTP code: 200 + +```json +{ + "organization": "organization name", + "repo": "repository name", + "deleted": ["version", "..."] +} + +``` + +**Failures** + +Error messages have following format: +``` +{ + "status": , + "error": "", + "message": "", +} +``` + + +| HTTP Code / `status` | `error` | Explanation | +|-----------|------------------------|---------------------| +|404| OMPSOrganizationNotFound | Requested organization is not configured on server side | +|404| QuayPackageNotFound | Requested package doesn't exist in quay | +|500| QuayLoginError | Server cannot login to quay, probably misconfiguration | +|500| QuayPackageError | Getting information about released packages or deleting failed | + + +#### Examples +```bash +curl -X DELETE https://example.com/packages/myorg/myrepo +``` +or with explicit release version +```bash +curl -X DELETE https://example.com/packages/myorg/myrepo/1.1.5 +``` + ## Development diff --git a/omps/app.py b/omps/app.py index eb0253e..9e1d050 100644 --- a/omps/app.py +++ b/omps/app.py @@ -9,6 +9,7 @@ from .errors import init_errors_handling from .logger import init_logging +from .packages import BLUEPRINT as PACKAGES_BP from .push import BLUEPRINT as PUSH_BP from .settings import init_config from .quay import QUAY_ORG_MANAGER @@ -43,6 +44,7 @@ def _init_errors_handling(app): def _register_blueprints(app): logger.debug('Registering blueprints') app.register_blueprint(PUSH_BP, url_prefix='/push') + app.register_blueprint(PACKAGES_BP, url_prefix='/packages') app = create_app() diff --git a/omps/packages.py b/omps/packages.py new file mode 100644 index 0000000..a1d478c --- /dev/null +++ b/omps/packages.py @@ -0,0 +1,49 @@ +# +# Copyright (C) 2019 Red Hat, Inc +# see the LICENSE file for license +# +import logging + +from flask import Blueprint, jsonify +import requests + +from .quay import QUAY_ORG_MANAGER, ReleaseVersion + +logger = logging.getLogger(__name__) +BLUEPRINT = Blueprint('packages', __name__) + + +@BLUEPRINT.route("//", defaults={'version': None}, + methods=('DELETE',)) +@BLUEPRINT.route("///", methods=('DELETE',)) +def delete_package_release(organization, repo, version=None): + """ + Delete particular version of released package from quay.io + + :param organization: quay.io organization + :param repo: target repository + :param version: version of operator manifest + :return: HTTP response + """ + quay_org = QUAY_ORG_MANAGER.organization_login(organization) + + # quay.io may contain OMPS incompatible release version format string + # but we want to be able to delete everything there, thus using _raw + # method + if version is None: + versions = quay_org.get_releases_raw(repo) + else: + versions = [version] + + for ver in versions: + quay_org.delete_release(repo, ver) + + data = { + 'organization': organization, + 'repo': repo, + 'deleted': versions, + } + + resp = jsonify(data) + resp.status_code = requests.codes.ok + return resp diff --git a/omps/quay.py b/omps/quay.py index 9b628b0..68724c6 100644 --- a/omps/quay.py +++ b/omps/quay.py @@ -237,14 +237,15 @@ def _get_repo_content(self, repo): res.raise_for_status() return res.json() - def get_latest_release_version(self, repo): - """Get the latest release version + def get_releases_raw(self, repo): + """Get raw releases from quay, these release may not be compatible + with OMPS versioning :param repo: repository name :raise QuayPackageNotFound: package doesn't exist :raise QuayPackageError: failed to retrieve info about package - :return: Latest release version - :rtype: PackageRelease + :return: Releases + :rtype: List[str] """ def _raise(exc): raise QuayPackageError( @@ -265,9 +266,22 @@ def _raise(exc): except requests.exceptions.RequestException as e: _raise(e) + releases = [package['release'] for package in res] + return releases + + def get_releases(self, repo): + """Get release versions (only valid) + + :param repo: repository name + :raise QuayPackageNotFound: package doesn't exist + :raise QuayPackageError: failed to retrieve info about package + :return: valid releases + :rtype: List[ReleaseVersion] + """ + releases_raw = self.get_releases_raw(repo) + releases = [] - for package in res: - release = package['release'] + for release in releases_raw: try: version = ReleaseVersion.from_str(release) except ValueError as e: @@ -277,6 +291,18 @@ def _raise(exc): else: releases.append(version) + return releases + + def get_latest_release_version(self, repo): + """Get the latest release version + + :param repo: repository name + :raise QuayPackageNotFound: package doesn't exist + :raise QuayPackageError: failed to retrieve info about package + :return: Latest release version + :rtype: ReleaseVersion + """ + releases = self.get_releases(repo) if not releases: # no valid versions found, assume that this will be first package # uploaded by service @@ -286,7 +312,46 @@ def _raise(exc): ) ) - return max(releases) + return max(self.get_releases(repo)) + + def delete_release(self, repo, version): + """ + Delete specified version of release from repository + + :param str repo: name of repository + :param ReleaseVersion|str version: version of release + :raises QuayPackageNotFound: when package is not found + :raises QuayPackageError: when an error happened during removal + """ + endpoint = '/cnr/api/v1/packages/{org}/{repo}/{version}/helm' + url = '{q}{e}'.format( + q=self._quay_url, + e=endpoint.format( + org=self._organization, + repo=repo, + version=version, + ) + ) + headers = {'Authorization': self._token} + + logger.info('Deleting release %s/%s, v:%s', + self._organization, repo, version) + + r = requests.delete(url, headers=headers) + + if r.status_code != requests.codes.ok: + + try: + msg = r.json()['error']['message'] + except Exception: + msg = "Unknown error" + + if r.status_code == requests.codes.not_found: + logger.info("Delete release (404): %s", msg) + raise QuayPackageNotFound(msg) + + logger.error("Delete release (%s): %s", r.status_code, msg) + raise QuayPackageError(msg) QUAY_ORG_MANAGER = QuayOrganizationManager() diff --git a/tests/conftest.py b/tests/conftest.py index bbee955..5fcaaad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ from flexmock import flexmock import operatorcourier.api import pytest +import requests import requests_mock from omps.app import app @@ -19,6 +20,7 @@ from omps.quay import QuayOrganization +MOCK_VERSION = "0.0.1" EntrypointMeta = namedtuple('EntrypointMeta', 'url_path,org,repo,version') @@ -79,6 +81,26 @@ def endpoint_push_zipfile(request): ) +@pytest.fixture(params=[ + True, # endpoint with version + False, # endpoint without version +]) +def endpoint_packages(request): + """Returns URL for packages endpoints""" + organization = 'testorg' + repo = 'repo-Y' + version = MOCK_VERSION + + url_path = '/packages/{}/{}'.format(organization, repo) + if request.param: + url_path = '{}/{}'.format(url_path, version) + + yield EntrypointMeta( + url_path=url_path, org=organization, + repo=repo, version=version + ) + + @pytest.fixture def mocked_quay_io(): """Mocking quay.io answers""" @@ -94,6 +116,27 @@ def mocked_quay_io(): yield m +@pytest.fixture +def mocked_packages_delete_quay_io(): + """Mocking quay.io answers for retrieving and deleting packages""" + with requests_mock.Mocker() as m: + m.post( + '/cnr/api/v1/users/login', + json={'token': 'faketoken'} + ) + m.get( + re.compile(r'/cnr/api/v1/packages/.*'), + json=[ + {"release": MOCK_VERSION}, + ], + ) + m.delete( + re.compile(r'/cnr/api/v1/packages/.*'), + status_code=requests.codes.ok, + ) + yield m + + @pytest.fixture def mocked_failed_quay_login(): """Returns HTTP 401 with error message""" diff --git a/tests/packages/__init__.py b/tests/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/test_api.py b/tests/packages/test_api.py new file mode 100644 index 0000000..9b68314 --- /dev/null +++ b/tests/packages/test_api.py @@ -0,0 +1,56 @@ +# +# Copyright (C) 2019 Red Hat, Inc +# see the LICENSE file for license +# + +import pytest +import requests + + +def test_delete_released_package( + client, valid_manifests_archive, endpoint_packages, + mocked_packages_delete_quay_io): + """Test REST API for deleting released operators manifest packages""" + + rv = client.delete( + endpoint_packages.url_path, + ) + + assert rv.status_code == requests.codes.ok, rv.get_json() + expected = { + 'organization': endpoint_packages.org, + 'repo': endpoint_packages.repo, + 'deleted': ["0.0.1"], + } + assert rv.get_json() == expected + + +@pytest.mark.parametrize('endpoint', [ + '/packages/organization-X/repo-Y', + '/packages/organization-X/repo-Y/1.0.1', +]) +@pytest.mark.parametrize('method', [ + 'GET', 'PATCH' 'PUT', 'HEAD', 'POST', 'TRACE', +]) +def test_method_not_allowed(client, endpoint, method): + """Specified endpoints currently support only DELETE method, test if other + HTTP methods returns proper error code + + Method OPTIONS is excluded from testing due its special meaning + """ + rv = client.open(endpoint, method=method) + assert rv.status_code == requests.codes.method_not_allowed + + +@pytest.mark.parametrize('endpoint', [ + '/packages', + '/packages/organization-X/', + '/packages/organization-X/repo-Y/version-Z/extra-something', +]) +def test_404_for_mistyped_entrypoints(client, endpoint): + """Test if HTTP 404 is returned for unexpected endpoints""" + rv = client.post(endpoint) + assert rv.status_code == requests.codes.not_found + rv_json = rv.get_json() + assert rv_json['error'] == 'NotFound' + assert rv_json['status'] == requests.codes.not_found diff --git a/tests/push/__init__.py b/tests/push/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_quay.py b/tests/test_quay.py index e46b7db..0d34983 100644 --- a/tests/test_quay.py +++ b/tests/test_quay.py @@ -3,11 +3,14 @@ # see the LICENSE file for license # +import flexmock +import requests import requests_mock import pytest from omps.errors import ( OMPSOrganizationNotFound, + QuayPackageError, QuayPackageNotFound, QuayLoginError ) @@ -164,3 +167,98 @@ def test_get_latest_release_version_not_found(self): qo = QuayOrganization(org, "token") with pytest.raises(QuayPackageNotFound): qo.get_latest_release_version(repo) + + def test_get_latest_release_version_invalid_version_only(self): + """Test if proper exception is raised when packages only with invalid + version are available + + Invalid versions should be ignored, thus QuayPackageNotFound + should be raised as may assume that OMPS haven't managed that packages + previously + """ + org = "test_org" + repo = "test_repo" + + with requests_mock.Mocker() as m: + m.get( + '/cnr/api/v1/packages/{}/{}'.format(org, repo), + json=[ + {'release': "1.0.0-invalid"}, + ], + ) + + qo = QuayOrganization(org, "token") + with pytest.raises(QuayPackageNotFound): + qo.get_latest_release_version(repo) + + def test_get_releases_raw(self): + """Test if all release are returned from quay.io, including format that + is OMPS invalid""" + org = "test_org" + repo = "test_repo" + + with requests_mock.Mocker() as m: + m.get( + '/cnr/api/v1/packages/{}/{}'.format(org, repo), + json=[ + {'release': "1.0.0"}, + {'release': "1.2.0"}, + {'release': "1.0.1-random"}, + ] + ) + + qo = QuayOrganization(org, "token") + releases = qo.get_releases_raw(repo) + assert sorted(releases) == ["1.0.0", "1.0.1-random", "1.2.0"] + + def test_get_releases(self): + """Test if only proper releases are used and returned""" + org = "test_org" + repo = "test_repo" + + qo = QuayOrganization(org, "token") + (flexmock(qo) + .should_receive('get_releases_raw') + .and_return(["1.0.0", "1.0.1-random", "1.2.0"]) + ) + + expected = [ReleaseVersion.from_str(v) for v in ["1.0.0", "1.2.0"]] + + assert qo.get_releases(repo) == expected + + def test_delete_release(self): + """Test of deleting releases""" + org = "test_org" + repo = "test_repo" + version = '1.2.3' + + qo = QuayOrganization(org, "token") + + with requests_mock.Mocker() as m: + m.delete( + '/cnr/api/v1/packages/{}/{}/{}/helm'.format( + org, repo, version), + ) + qo.delete_release(repo, version) + + @pytest.mark.parametrize('code,exc_class', [ + (requests.codes.not_found, QuayPackageNotFound), + (requests.codes.method_not_allowed, QuayPackageError), + (requests.codes.internal_server_error, QuayPackageError), + ]) + def test_delete_release_quay_error(self, code, exc_class): + """Test of error handling from quay errors""" + org = "test_org" + repo = "test_repo" + version = '1.2.3' + + qo = QuayOrganization(org, "token") + + with requests_mock.Mocker() as m: + m.delete( + '/cnr/api/v1/packages/{}/{}/{}/helm'.format( + org, repo, version), + status_code=code + ) + with pytest.raises(exc_class): + qo.delete_release(repo, version)