diff --git a/endpoints/appr/__init__.py b/endpoints/appr/__init__.py new file mode 100644 index 000000000..83c8bd060 --- /dev/null +++ b/endpoints/appr/__init__.py @@ -0,0 +1,72 @@ +import logging + +from functools import wraps + +from cnr.exception import UnauthorizedAccess +from flask import Blueprint + +from app import metric_queue +from auth.permissions import (AdministerRepositoryPermission, ReadRepositoryPermission, + ModifyRepositoryPermission) +from data import model # TODO: stop using model directly +from util.metrics.metricqueue import time_blueprint + + +appr_bp = Blueprint('appr', __name__) +time_blueprint(appr_bp, metric_queue) +logger = logging.getLogger(__name__) + + +def _raise_unauthorized(repository, scopes): + raise StandardError("Unauthorized acces to %s", repository) + + +def _get_reponame_kwargs(*args, **kwargs): + return [kwargs['namespace_name'], kwargs['repo_name']] + + +def require_repo_permission(permission_class, scopes=None, allow_public=False, + raise_method=_raise_unauthorized, + get_reponame_method=_get_reponame_kwargs): + def wrapper(func): + @wraps(func) + def wrapped(*args, **kwargs): + namespace_name, repo_name = get_reponame_method(*args, **kwargs) + + logger.debug('Checking permission %s for repo: %s/%s', permission_class, + namespace_name, repo_name) + permission = permission_class(namespace_name, repo_name) + if (permission.can() or + (allow_public and + model.repository.repository_is_public(namespace_name, repo_name))): + return func(*args, **kwargs) + repository = namespace_name + '/' + repo_name + raise_method(repository, scopes) + return wrapped + return wrapper + + +def _raise_method(repository, scopes): + raise UnauthorizedAccess("Unauthorized access for: %s" % repository, + {"package": repository, "scopes": scopes}) + + +def _get_reponame_kwargs(*args, **kwargs): + return [kwargs['namespace'], kwargs['package_name']] + + +require_app_repo_read = require_repo_permission(ReadRepositoryPermission, + scopes=['pull'], + allow_public=True, + raise_method=_raise_method, + get_reponame_method=_get_reponame_kwargs) + +require_app_repo_write = require_repo_permission(ModifyRepositoryPermission, + scopes=['pull', 'push'], + raise_method=_raise_method, + get_reponame_method=_get_reponame_kwargs) + +require_app_repo_admin = require_repo_permission(AdministerRepositoryPermission, + scopes=['pull', 'push'], + raise_method=_raise_method, + get_reponame_method=_get_reponame_kwargs) diff --git a/endpoints/appr/cnr_backend.py b/endpoints/appr/cnr_backend.py new file mode 100644 index 000000000..7449dc4c8 --- /dev/null +++ b/endpoints/appr/cnr_backend.py @@ -0,0 +1,160 @@ +import base64 + +from cnr.exception import raise_package_not_found +from cnr.models.blob_base import BlobBase +from cnr.models.channel_base import ChannelBase +from cnr.models.db_base import CnrDB +from cnr.models.package_base import PackageBase, manifest_media_type + +from app import storage +from data.interfaces.appr import oci_app_model +from data.oci_model import blob # TODO these calls should be through oci_app_model + + +class Blob(BlobBase): + @classmethod + def upload_url(cls, digest): + return "cnr/blobs/sha256/%s/%s" % (digest[0:2], digest) + + def save(self, content_media_type): + oci_app_model.store_blob(self, content_media_type) + + @classmethod + def delete(cls, package_name, digest): + pass + + @classmethod + def _fetch_b64blob(cls, package_name, digest): + blobpath = cls.upload_url(digest) + locations = blob.get_blob_locations(digest) + if not locations: + raise_package_not_found(package_name, digest) + return base64.b64encode(storage.get_content(locations, blobpath)) + + @classmethod + def download_url(cls, package_name, digest): + blobpath = cls.upload_url(digest) + locations = blob.get_blob_locations(digest) + if not locations: + raise_package_not_found(package_name, digest) + return storage.get_direct_download_url(locations, blobpath) + + +class Channel(ChannelBase): + """ CNR Channel model implemented against the Quay data model. """ + def __init__(self, name, package, current=None): + super(Channel, self).__init__(name, package, current=current) + self._channel_data = None + + def _exists(self): + """ Check if the channel is saved already """ + return oci_app_model.channel_exists(self.package, self.name) + + @classmethod + def get(cls, name, package): + chanview = oci_app_model.fetch_channel(package, name, with_releases=False) + return cls(name, package, chanview.current) + + def save(self): + oci_app_model.update_channel(self.package, self.name, self.current) + + def delete(self): + oci_app_model.delete_channel(self.package, self.name) + + @classmethod + def all(cls, package_name): + return [Channel(c.name, package_name, c.current) + for c in oci_app_model.list_channels(package_name)] + + @property + def _channel(self): + if self._channel_data is None: + self._channel_data = oci_app_model.fetch_channel(self.package, self.name) + return self._channel_data + + def releases(self): + """ Returns the list of versions """ + return self._channel.releases + + def _add_release(self, release): + return oci_app_model.update_channel(self.package, self.name, release)._asdict + + def _remove_release(self, release): + oci_app_model.delete_channel(self.package, self.name) + + +class Package(PackageBase): + """ CNR Package model implemented against the Quay data model. """ + + @classmethod + def _apptuple_to_dict(cls, apptuple): + return {'release': apptuple.release, + 'created_at': apptuple.created_at, + 'digest': apptuple.manifest.digest, + 'mediaType': apptuple.manifest.mediaType, + 'package': apptuple.name, + 'content': apptuple.manifest.content._asdict()} + + @classmethod + def create_repository(cls, package_name, visibility, owner): + oci_app_model.create_application(package_name, visibility, owner) + + @classmethod + def exists(cls, package_name): + return oci_app_model.application_exists(package_name) + + @classmethod + def all(cls, organization=None, media_type=None, search=None, username=None, **kwargs): + return [dict(x._asdict()) for x in oci_app_model.list_applications(namespace=organization, + media_type=media_type, + search=search, + username=username)] + + @classmethod + def _fetch(cls, package_name, release, media_type): + data = oci_app_model.fetch_release(package_name, release, manifest_media_type(media_type)) + return cls._apptuple_to_dict(data) + + @classmethod + def all_releases(cls, package_name, media_type=None): + return oci_app_model.list_releases(package_name, media_type) + + @classmethod + def search(cls, query, username=None): + return oci_app_model.basic_search(query, username=username) + + def _save(self, force=False, **kwargs): + user = kwargs['user'] + visibility = kwargs['visibility'] + oci_app_model.create_release(self, user, visibility, force) + + @classmethod + def _delete(cls, package_name, release, media_type): + oci_app_model.delete_release(package_name, release, manifest_media_type(media_type)) + + @classmethod + def isdeleted_release(cls, package, release): + return oci_app_model.release_exists(package, release) + + def channels(self, channel_class, iscurrent=True): + return [c.name for c in oci_app_model.list_release_channels(self.package, self.release, + active=iscurrent)] + + @classmethod + def manifests(cls, package, release=None): + return oci_app_model.list_manifests(package, release) + + @classmethod + def dump_all(cls, blob_cls): + raise NotImplementedError + + +class QuayDB(CnrDB): + """ Wrapper Class to embed all CNR Models """ + Channel = Channel + Package = Package + Blob = Blob + + @classmethod + def reset_db(cls, force=False): + pass diff --git a/endpoints/appr/registry.py b/endpoints/appr/registry.py new file mode 100644 index 000000000..a6e39d87c --- /dev/null +++ b/endpoints/appr/registry.py @@ -0,0 +1,269 @@ +import logging + +from base64 import b64encode + +import cnr + +from cnr.api.impl import registry as cnr_registry +from cnr.api.registry import repo_name, _pull +from cnr.exception import (CnrException, InvalidUsage, InvalidParams, InvalidRelease, + UnableToLockResource, UnauthorizedAccess, Unsupported, ChannelNotFound, + PackageAlreadyExists, PackageNotFound, PackageReleaseNotFound) +from flask import request, jsonify + +from auth.process import process_auth +from auth.auth_context import get_authenticated_user +from auth.permissions import CreateRepositoryPermission, ModifyRepositoryPermission +from endpoints.appr import appr_bp, require_app_repo_read, require_app_repo_write +from endpoints.appr.cnr_backend import Package, Channel, Blob +from endpoints.decorators import anon_allowed, anon_protect + + +logger = logging.getLogger(__name__) + + +@appr_bp.errorhandler(Unsupported) +@appr_bp.errorhandler(PackageAlreadyExists) +@appr_bp.errorhandler(InvalidRelease) +@appr_bp.errorhandler(UnableToLockResource) +@appr_bp.errorhandler(UnauthorizedAccess) +@appr_bp.errorhandler(PackageNotFound) +@appr_bp.errorhandler(PackageReleaseNotFound) +@appr_bp.errorhandler(CnrException) +@appr_bp.errorhandler(InvalidUsage) +@appr_bp.errorhandler(InvalidParams) +@appr_bp.errorhandler(ChannelNotFound) +def render_error(error): + response = jsonify({"error": error.to_dict()}) + response.status_code = error.status_code + return response + + +@appr_bp.route("/version") +@anon_allowed +def version(): + return jsonify({"cnr-api": cnr.__version__}) + + +@appr_bp.route("/api/v1/users/login", methods=['POST']) +@anon_allowed +def login(): + """ + Todo: + * Implement better login protocol + """ + values = request.get_json(force=True, silent=True) + return jsonify({'token': "basic " + b64encode("%s:%s" % (values['user']['username'], + values['user']['password']))}) + + +# @TODO: Redirect to S3 url +@appr_bp.route( + "/api/v1/packages///blobs/sha256/", + methods=['GET'], + strict_slashes=False, +) +def blobs(namespace, package_name, digest): + reponame = repo_name(namespace, package_name) + data = cnr_registry.pull_blob(reponame, digest, blob_class=Blob) + json_format = request.args.get('format', None) == 'json' + return _pull(data, json_format=json_format) + + +@appr_bp.route("/api/v1/packages", methods=['GET'], strict_slashes=False) +@process_auth +@anon_protect +def list_packages(): + namespace = request.args.get('namespace', None) + media_type = request.args.get('media_type', None) + query = request.args.get('query', None) + user = get_authenticated_user() + username = None + if user: + username = user.username + result_data = cnr_registry.list_packages(namespace, + package_class=Package, + search=query, + media_type=media_type, + username=username) + return jsonify(result_data) + + +@appr_bp.route( + "/api/v1/packages////", + methods=['DELETE'], strict_slashes=False) +@process_auth +@require_app_repo_write +@anon_protect +def delete_package(namespace, package_name, release, media_type): + reponame = repo_name(namespace, package_name) + result = cnr_registry.delete_package(reponame, + release, + media_type, + package_class=Package) + return jsonify(result) + + +@appr_bp.route( + "/api/v1/packages////", + methods=['GET'], + strict_slashes=False +) +def show_package(namespace, package_name, release, media_type): + reponame = repo_name(namespace, package_name) + result = cnr_registry.show_package(reponame, release, + media_type, + channel_class=Channel, + package_class=Package) + return jsonify(result) + + + +@appr_bp.route("/api/v1/packages//", methods=['GET'], + strict_slashes=False) +@process_auth +@require_app_repo_read +@anon_protect +def show_package_releases(namespace, package_name): + reponame = repo_name(namespace, package_name) + media_type = request.args.get('media_type', None) + result = cnr_registry.show_package_releases(reponame, + media_type=media_type, + package_class=Package) + return jsonify(result) + + +@appr_bp.route("/api/v1/packages///", + methods=['GET'], strict_slashes=False) +@process_auth +@require_app_repo_read +@anon_protect +def show_package_releasse_manifests(namespace, package_name, release): + reponame = repo_name(namespace, package_name) + result = cnr_registry.show_package_manifests(reponame, + release, + package_class=Package) + return jsonify(result) + + +@appr_bp.route( + "/api/v1/packages/////pull", + methods=['GET'], + strict_slashes=False, +) +@process_auth +@require_app_repo_read +@anon_protect +def pull(namespace, package_name, release, media_type): + reponame = repo_name(namespace, package_name) + logger.info("pull %s", reponame) + data = cnr_registry.pull(reponame, release, media_type, Package, blob_class=Blob) + return _pull(data) + + +@appr_bp.route("/api/v1/packages//", methods=['POST'], + strict_slashes=False) +@process_auth +@anon_protect +def push(namespace, package_name): + reponame = repo_name(namespace, package_name) + values = request.get_json(force=True, silent=True) + release_version = values['release'] + media_type = values['media_type'] + force = request.args.get('force', 'false') == 'true' + private = values.get('visibility', 'public') + owner = get_authenticated_user() + if not Package.exists(reponame): + if not CreateRepositoryPermission(namespace).can(): + raise UnauthorizedAccess("Unauthorized access for: %s" % reponame, + {"package": reponame, "scopes": ['create']}) + Package.create_repository(reponame, private, owner) + + + if not ModifyRepositoryPermission(namespace, package_name).can(): + raise UnauthorizedAccess("Unauthorized access for: %s" % reponame, + {"package": reponame, "scopes": ['push']}) + + blob = Blob(reponame, values['blob']) + app_release = cnr_registry.push(reponame, release_version, media_type, blob, force, + package_class=Package, user=owner, visibility=private) + return jsonify(app_release) + + +@appr_bp.route("/api/v1/packages/search", methods=['GET'], strict_slashes=False) +@process_auth +@anon_protect +def search_packages(): + query = request.args.get("q") + user = get_authenticated_user() + username = None + if user: + username = user.username + + search_results = cnr_registry.search(query, Package, username=username) + return jsonify(search_results) + + +# CHANNELS +@appr_bp.route("/api/v1/packages///channels", + methods=['GET'], strict_slashes=False) +@process_auth +@require_app_repo_read +@anon_protect +def list_channels(namespace, package_name): + reponame = repo_name(namespace, package_name) + return jsonify(cnr_registry.list_channels(reponame, channel_class=Channel)) + + +@appr_bp.route("/api/v1/packages///channels/", methods=['GET'], strict_slashes=False) +@process_auth +@require_app_repo_read +@anon_protect +def show_channel(namespace, package_name, channel_name): + reponame = repo_name(namespace, package_name) + channel = cnr_registry.show_channel(reponame, channel_name, channel_class=Channel) + return jsonify(channel) + + +@appr_bp.route( + "/api/v1/packages///channels//", + methods=['POST'], + strict_slashes=False, +) +@process_auth +@require_app_repo_write +@anon_protect +def add_channel_release(namespace, package_name, channel_name, release): + reponame = repo_name(namespace, package_name) + result = cnr_registry.add_channel_release(reponame, channel_name, release, channel_class=Channel, + package_class=Package) + return jsonify(result) + + +@appr_bp.route( + "/api/v1/packages///channels//", + methods=['DELETE'], + strict_slashes=False, +) +@process_auth +@require_app_repo_write +@anon_protect +def delete_channel_release(namespace, package_name, channel_name, release): + reponame = repo_name(namespace, package_name) + result = cnr_registry.delete_channel_release(reponame, channel_name, release, + channel_class=Channel, package_class=Package) + return jsonify(result) + + +@appr_bp.route( + "/api/v1/packages///channels/", + methods=['DELETE'], + strict_slashes=False, +) +@process_auth +@require_app_repo_write +@anon_protect +def delete_channel(namespace, package_name, channel_name): + reponame = repo_name(namespace, package_name) + result = cnr_registry.delete_channel(reponame, channel_name, channel_class=Channel) + return jsonify(result)