Skip to content
This repository has been archived by the owner on May 24, 2023. It is now read-only.

Commit

Permalink
Allow to unpublish released operators manifests
Browse files Browse the repository at this point in the history
Two endpoints added:
[DELETE] /packages/<org>/<repo>/<version>  # removes particular version
[DELETE] /packages/<org>/<repo>  # 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 <mbasti@redhat.com>
  • Loading branch information
MartinBasti authored and csomh committed Feb 27, 2019
1 parent 7c6b3f6 commit 0c87d34
Show file tree
Hide file tree
Showing 9 changed files with 376 additions and 7 deletions.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<organization>/<repository>/<version>`
* [DELETE] `/packages/<organization>/<repository>`

If `<version>` 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": <http numeric code>,
"error": "<error ID string>",
"message": "<detailed error description>",
}
```


| 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

Expand Down
2 changes: 2 additions & 0 deletions omps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
49 changes: 49 additions & 0 deletions omps/packages.py
Original file line number Diff line number Diff line change
@@ -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("/<organization>/<repo>", defaults={'version': None},
methods=('DELETE',))
@BLUEPRINT.route("/<organization>/<repo>/<version>", 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
79 changes: 72 additions & 7 deletions omps/quay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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()
43 changes: 43 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
from flexmock import flexmock
import operatorcourier.api
import pytest
import requests
import requests_mock

from omps.app import app
from omps.errors import QuayPackageNotFound
from omps.quay import QuayOrganization


MOCK_VERSION = "0.0.1"

EntrypointMeta = namedtuple('EntrypointMeta', 'url_path,org,repo,version')

Expand Down Expand Up @@ -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"""
Expand All @@ -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"""
Expand Down
Empty file added tests/packages/__init__.py
Empty file.
56 changes: 56 additions & 0 deletions tests/packages/test_api.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added tests/push/__init__.py
Empty file.

0 comments on commit 0c87d34

Please sign in to comment.