diff --git a/data/database.py b/data/database.py index 9fb02fb8e..4c2e011e2 100644 --- a/data/database.py +++ b/data/database.py @@ -769,18 +769,23 @@ class RobotAccountToken(BaseModel): fully_migrated = BooleanField(default=False) +class QuotaTypes(object): + WARNING = "Warning" + REJECT = "Reject" + + class QuotaType(BaseModel): name = CharField() class UserOrganizationQuota(BaseModel): - namespace_id = QuayUserField(index=True, unique=True) + namespace = QuayUserField(index=True, unique=True) limit_bytes = BigIntegerField() class QuotaLimits(BaseModel): - quota_id = ForeignKeyField(UserOrganizationQuota) - quota_type_id = ForeignKeyField(QuotaType) + quota = ForeignKeyField(UserOrganizationQuota) + quota_type = ForeignKeyField(QuotaType) percent_of_limit = IntegerField(default=0) diff --git a/data/model/__init__.py b/data/model/__init__.py index c4891e0e4..901d8be92 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -121,6 +121,18 @@ class QuotaExceededException(DataModelException): pass +class InvalidNamespaceQuota(DataModelException): + pass + + +class InvalidNamespaceQuotaLimit(DataModelException): + pass + + +class InvalidNamespaceQuotaType(DataModelException): + pass + + class TooManyLoginAttemptsException(Exception): def __init__(self, message, retry_after): super(TooManyLoginAttemptsException, self).__init__(message) diff --git a/data/model/namespacequota.py b/data/model/namespacequota.py index c0727ce00..381bf2d12 100644 --- a/data/model/namespacequota.py +++ b/data/model/namespacequota.py @@ -12,18 +12,145 @@ from data.database import ( Tag, RepositorySize, User, + QuotaTypes, ) from data import model from data.model import ( + db_transaction, organization, user, InvalidUsernameException, + InvalidOrganizationException, notification, config, InvalidSystemQuotaConfig, + InvalidNamespaceQuota, + InvalidNamespaceQuotaLimit, + InvalidNamespaceQuotaType, ) +def get_namespace_quota_list(namespace_name): + quotas = UserOrganizationQuota.select().join(User).where(User.username == namespace_name) + + return list(quotas) + + +def get_namespace_quota(namespace_name, quota_id): + quota = ( + UserOrganizationQuota.select() + .join(User) + .where( + User.username == namespace_name, + UserOrganizationQuota.id == quota_id, + ) + ) + + quota = quota.first() + return quota + + +def create_namespace_quota(namespace_user, limit_bytes): + try: + UserOrganizationQuota.get(id == namespace_user.id) + raise InvalidNamespaceQuota("Only one quota per namespace is currently supported") + except UserOrganizationQuota.DoesNotExist: + pass + + if limit_bytes > 0: + try: + return UserOrganizationQuota.create(namespace=namespace_user, limit_bytes=limit_bytes) + except model.DataModelException as ex: + return None + else: + raise InvalidNamespaceQuota("Invalid quota size limit value: '%s'" % limit_bytes) + + +def update_namespace_quota_size(quota, limit_bytes): + if limit_bytes > 0: + quota.limit_bytes = limit_bytes + quota.save() + else: + raise InvalidNamespaceQuota("Invalid quota size limit value: '%s'" % limit_bytes) + + +def delete_namespace_quota(quota): + with db_transaction(): + QuotaLimits.delete().where(QuotaLimits.quota == quota).execute() + quota.delete_instance() + + +def _quota_type(type_name): + if type_name.lower() == "warning": + return QuotaTypes.WARNING + elif type_name.lower() == "reject": + return QuotaTypes.REJECT + raise InvalidNamespaceQuotaType( + "Quota type must be one of [{}, {}]".format(QuotaTypes.WARNING, QuotaTypes.REJECT) + ) + + +def get_namespace_quota_limit_list(quota, quota_type=None, percent_of_limit=None): + if percent_of_limit and (not percent_of_limit > 0 or not percent_of_limit <= 100): + raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100") + + query = QuotaLimits.select().join(QuotaType).where(QuotaLimits.quota == quota) + + if quota_type: + quota_type_name = _quota_type(quota_type) + query = query.where(QuotaType.name == quota_type_name) + + if percent_of_limit: + query = query.where(QuotaLimits.percent_of_limit == percent_of_limit) + + return list(query) + + +def get_namespace_quota_limit(quota, limit_id): + try: + quota_limit = QuotaLimits.get(QuotaLimits.id == limit_id) + # This should never happen in theory, as limit ids should be globally unique + if quota_limit.quota != quota: + raise InvalidNamespaceQuota() + + return quota_limit + except QuotaLimits.DoesNotExist: + return None + + +def create_namespace_quota_limit(quota, quota_type, percent_of_limit): + if not percent_of_limit > 0 or not percent_of_limit <= 100: + raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100") + + quota_type_name = _quota_type(quota_type) + + return QuotaLimits.create( + quota=quota, + percent_of_limit=percent_of_limit, + quota_type=(QuotaType.get(QuotaType.name == quota_type_name)), + ) + + +def update_namespace_quota_limit_threshold(limit, percent_of_limit): + if not percent_of_limit > 0 or not percent_of_limit <= 100: + raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100") + + limit.percent_of_limit = percent_of_limit + limit.save() + + +def update_namespace_quota_limit_type(limit, type_name): + quota_type_name = _quota_type(type_name) + quota_type_ref = QuotaType.get(name=quota_type_name) + + limit.quota_type = quota_type_ref + limit.save() + + +def delete_namespace_quota_limit(limit): + limit.delete_instance() + + def verify_namespace_quota(repository_ref): model.repository.get_repository_size_and_cache(repository_ref._db_id) namespace_size = get_namespace_size(repository_ref.namespace_name) @@ -46,14 +173,23 @@ def verify_namespace_quota_during_upload(repository_ref): def check_limits(namespace_name, size): - limits = get_namespace_limits(namespace_name) + namespace_user = model.user.get_user_or_org(namespace_name) + quotas = get_namespace_quota_list(namespace_user.username) + if not quotas: + return {"limit_bytes": 0, "severity_level": None} + + # Currently only one quota per namespace is supported + quota = quotas[0] + limits = get_namespace_quota_limit_list(quota) limit_bytes = 0 severity_level = None + for limit in limits: - if size > limit["bytes_allowed"]: - if limit_bytes < limit["bytes_allowed"]: - limit_bytes = limit["bytes_allowed"] - severity_level = limit["name"] + bytes_allowed = int(limit.quota.limit_bytes * limit.percent_of_limit / 100) + if size > bytes_allowed: + if limit_bytes < bytes_allowed: + limit_bytes = bytes_allowed + severity_level = limit.quota_type.name return {"limit_bytes": limit_bytes, "severity_level": severity_level} @@ -82,241 +218,6 @@ def force_cache_repo_size(repository_ref): return model.repository.force_cache_repo_size(repository_ref._db_id) -def create_namespace_quota(name, limit_bytes): - user_object = user.get_namespace_user(name) - try: - return UserOrganizationQuota.create(namespace_id=user_object.id, limit_bytes=limit_bytes) - except model.DataModelException as ex: - return None - - -def create_namespace_limit(orgname, quota_type_id, percent_of_limit): - quota = get_namespace_quota(orgname) - - if quota is None: - raise InvalidUsernameException("Quota Does Not Exist for : " + orgname) - - new_limit = QuotaLimits.create( - quota_id=quota.id, - percent_of_limit=percent_of_limit, - quota_type_id=quota_type_id, - ) - - return new_limit - - -def get_namespace_quota(name): - try: - space = user.get_namespace_user(name) - if space is None: - raise InvalidUsernameException("This Namespace does not exist : " + name) - - quota = UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == space.id) - # TODO: I dont like this so we will need to find a better way to test if the query is empty. - return quota.get() - except UserOrganizationQuota.DoesNotExist: - return None - - -def check_system_quota_bytes_enabled(): - - if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") < 0: - raise InvalidSystemQuotaConfig( - "Invalid Configuration: Quota bytes must be greater than or equal to 0" - ) - - if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0: - return True - else: - return False - - -def get_namespace_limits(name): - query = ( - UserOrganizationQuota.select( - UserOrganizationQuota.limit_bytes, - QuotaLimits.percent_of_limit, - QuotaLimits.quota_id, - QuotaLimits.id, - QuotaType.name, - QuotaType.id, - ( - UserOrganizationQuota.limit_bytes.cast("decimal") - * (QuotaLimits.percent_of_limit.cast("decimal") / 100.0).cast("decimal") - ).alias("bytes_allowed"), - QuotaType.id.alias("type_id"), - ) - .join(User, on=(UserOrganizationQuota.namespace_id == User.id)) - .join(QuotaLimits, on=(UserOrganizationQuota.id == QuotaLimits.quota_id)) - .join(QuotaType, on=(QuotaLimits.quota_type_id == QuotaType.id)) - .where(User.username == name) - ).dicts() - - # define limits if a system default is defined in config.py and no namespace specific limits are set - if check_system_quota_bytes_enabled() and len(query) == 0: - query = [ - { - "limit_bytes": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"), - "percent_of_limit": 80, - "name": "System Warning Limit", - "bytes_allowed": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") * 0.8, - "type_id": 1, - }, - { - "limit_bytes": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"), - "percent_of_limit": 100, - "name": "System Reject Limit", - "bytes_allowed": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"), - "type_id": 2, - }, - ] - - return query - - -def get_namespace_limit(name, quota_type_id, percent_of_limit): - try: - quota = get_namespace_quota(name) - - if quota is None: - raise InvalidUsernameException("Quota for this namespace does not exist") - - query = ( - QuotaLimits.select() - .join(QuotaType) - .where(QuotaLimits.quota_id == quota.id) - .where(QuotaLimits.quota_type_id == quota_type_id) - .where(QuotaLimits.percent_of_limit == percent_of_limit) - ) - - return query.get() - - except QuotaLimits.DoesNotExist: - return None - - -def get_namespace_limit_from_id(name, quota_limit_id): - try: - quota = get_namespace_quota(name) - - if quota is None: - raise InvalidUsernameException("Quota for this namespace does not exist") - - query = ( - QuotaLimits.select() - .join(QuotaType) - .where(QuotaLimits.quota_id == quota.id) - .where(QuotaLimits.id == quota_limit_id) - ) - - return query.get() - - except QuotaLimits.DoesNotExist: - return None - - -def get_namespace_reject_limit(name): - try: - quota = get_namespace_quota(name) - - if quota is None: - raise InvalidUsernameException("Quota for this namespace does not exist") - - # QuotaType - query = ( - QuotaLimits.select() - .join(QuotaType) - .where(QuotaType.name == "Reject") - .where(QuotaLimits.quota_id == quota.id) - ) - - return query.get() - - except QuotaLimits.DoesNotExist: - return None - - -def get_namespace_limit_types(): - return [{"quota_type_id": qtype.id, "name": qtype.name} for qtype in QuotaType.select()] - - -def fetch_limit_id_from_name(name): - for i in get_namespace_limit_types(): - if name == i["name"]: - return i["quota_type_id"] - return None - - -def is_reject_limit_type(quota_type_id): - if quota_type_id == fetch_limit_id_from_name("Reject"): - return True - return False - - -def get_namespace_limit_types_for_id(quota_limit_type_id): - return QuotaType.select().where(QuotaType.id == quota_limit_type_id).get() - - -def get_namespace_limit_types_for_name(name): - return QuotaType.select().where(QuotaType.name == name).get() - - -def change_namespace_quota(name, limit_bytes): - org = user.get_namespace_user(name) - quota = UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == org.id).get() - - quota.limit_bytes = limit_bytes - quota.save() - - return quota - - -def change_namespace_quota_limit(name, percent_of_limit, quota_type_id, quota_limit_id): - quota_limit = get_namespace_limit_from_id(name, quota_limit_id) - - quota_limit.percent_of_limit = percent_of_limit - quota_limit.quota_type_id = quota_type_id - quota_limit.save() - - return quota_limit - - -def delete_namespace_quota_limit(name, quota_limit_id): - quota_limit = get_namespace_limit_from_id(name, quota_limit_id) - - if quota_limit is not None: - quota_limit.delete_instance() - return 1 - - return 0 - - -def delete_all_namespace_quota_limits(quota): - return QuotaLimits.delete().where(QuotaLimits.quota_id == quota.id).execute() - - -def delete_namespace_quota(name): - org = user.get_namespace_user(name) - - try: - quota = ( - UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == org.id) - ).get() - except UserOrganizationQuota.DoesNotExist: - return 0 - - if quota is not None: - delete_all_namespace_quota_limits(quota) - UserOrganizationQuota.delete().where(UserOrganizationQuota.namespace_id == org.id).execute() - return 1 - - return 0 - - -def get_namespace_repository_sizes_and_cache(namespace_name): - return cache_namespace_repository_sizes(namespace_name) - - def cache_namespace_repository_sizes(namespace_name): namespace = user.get_user_or_org(namespace_name) now_ms = get_epoch_timestamp_ms() @@ -352,19 +253,6 @@ def cache_namespace_repository_sizes(namespace_name): fields=[RepositorySize.repository_id, RepositorySize.size_bytes], ).execute() - output = [] - - for size in namespace_repo_sizes.dicts(): - output.append( - { - "repository_name": size["repository_name"], - "repository_id": size["repository_id"], - "repository_size": str(size["repository_size"]), - } - ) - - return json.dumps(output) - def get_namespace_size(namespace_name): namespace = user.get_user_or_org(namespace_name) @@ -388,33 +276,51 @@ def get_namespace_size(namespace_name): return namespace_size -def get_repo_quota_for_view(repo_id, namespace): - repo_quota = model.repository.get_repository_size_and_cache(repo_id).get("repository_size", 0) - namespace_quota = get_namespace_quota(namespace) - percent_consumed = None - if namespace_quota: - percent_consumed = str(round((repo_quota / namespace_quota.limit_bytes) * 100, 2)) +def get_repo_quota_for_view(namespace_name, repo_name): + repository_ref = model.repository.get_repository(namespace_name, repo_name) + if not repository_ref: + return None - return { - "percent_consumed": percent_consumed, - "quota_bytes": repo_quota, - } + quotas = get_namespace_quota_list(repository_ref.namespace_user.username) + if not quotas: + return { + "quota_bytes": None, + "configured_quota": None, + } + # Currently only one quota per namespace is supported + quota = quotas[0] + configured_namespace_quota = quota.limit_bytes -def get_org_quota_for_view(namespace): - namespace_quota_consumed = get_namespace_size(namespace) or 0 - configured_namespace_quota = get_namespace_quota(namespace) - configured_namespace_quota = ( - configured_namespace_quota.limit_bytes if configured_namespace_quota else None + repo_size = model.repository.get_repository_size_and_cache(repository_ref.id).get( + "repository_size", 0 ) - percent_consumed = None - if configured_namespace_quota: - percent_consumed = str( - round((namespace_quota_consumed / configured_namespace_quota) * 100, 2) - ) return { - "percent_consumed": percent_consumed, - "quota_bytes": str(namespace_quota_consumed), + "quota_bytes": repo_size, + "configured_quota": configured_namespace_quota, + } + + +def get_quota_for_view(namespace_name): + cache_namespace_repository_sizes(namespace_name) + + namespace_user = model.user.get_user_or_org(namespace_name) + quotas = get_namespace_quota_list(namespace_user.username) + if not quotas: + return { + "quota_bytes": None, + "configured_quota": None, + } + + # Currently only one quota per namespace is supported + quota = quotas[0] + configured_namespace_quota = quota.limit_bytes + + namespace_quota_consumed = get_namespace_size(namespace_name) + namespace_quota_consumed = int(namespace_quota_consumed) if namespace_quota_consumed else 0 + + return { + "quota_bytes": namespace_quota_consumed, "configured_quota": configured_namespace_quota, } diff --git a/data/model/test/test_quota_model_config.py b/data/model/test/test_quota_model_config.py index c9ceb3f6a..4ca97d1cd 100644 --- a/data/model/test/test_quota_model_config.py +++ b/data/model/test/test_quota_model_config.py @@ -16,7 +16,8 @@ def test_create_quota(initialized_db): limit_bytes = 2048 new_org = create_org(user_name, user_email, org_name, org_email) - new_quota = namespacequota.create_namespace_quota(org_name, limit_bytes) + new_quota = namespacequota.create_namespace_quota(new_org, limit_bytes) assert new_quota.limit_bytes == limit_bytes - assert new_quota.namespace_id.id == new_org.id + assert new_quota.namespace == new_org + assert new_quota.namespace.id == new_org.id diff --git a/data/model/user.py b/data/model/user.py index ed0453384..77bb2f510 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -1291,7 +1291,9 @@ def _delete_user_linked_data(user): trigger.delete_instance(recursive=True, delete_nullable=False) with db_transaction(): - namespacequota.delete_namespace_quota(user.username) + quotas = namespacequota.get_namespace_quota_list(user.username) + for quota in quotas: + namespacequota.delete_namespace_quota(quota) # Delete any mirrors with robots owned by this user. with db_transaction(): diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index 87196247f..21be10e01 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -131,7 +131,24 @@ def swagger_route_data(include_internal=False, compact=False): logger.debug("Unable to find method for %s in class %s", method_name, view_class) continue - operationId = method_metadata(method, "nickname") + _operationId = method_metadata(method, "nickname") + + if isinstance(_operationId, list): + operationId = None + for oid in _operationId: + if oid in operationIds: + continue + else: + operationId = oid + + break + + if operationId is None: + raise Exception("Duplicate operation Id: %s" % operationId) + + else: + operationId = _operationId + operation_swagger = { "operationId": operationId, "parameters": [], diff --git a/endpoints/api/namespacequota.py b/endpoints/api/namespacequota.py index c1cc2244a..1cd28b24a 100644 --- a/endpoints/api/namespacequota.py +++ b/endpoints/api/namespacequota.py @@ -1,7 +1,3 @@ -""" -Manage organizations, members and OAuth applications. -""" - import logging from flask import request @@ -13,7 +9,9 @@ from auth.permissions import ( OrganizationMemberPermission, UserReadPermission, ) +from auth.auth_context import get_authenticated_user from data import model +from data.database import QuotaTypes from data.model import config from endpoints.api import ( resource, @@ -22,45 +20,45 @@ from endpoints.api import ( validate_json_request, request_error, require_user_admin, + require_scope, show_if, + log_action, ) -from endpoints.exception import InvalidToken, Unauthorized +from endpoints.exception import InvalidToken, Unauthorized, NotFound +from auth import scopes + logger = logging.getLogger(__name__) -def quota_view(orgname: str, quota, quota_limit_types): +def quota_view(quota): + quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota)) + return { - "orgname": orgname, - "limit_bytes": quota.limit_bytes if quota else None, - "quota_limit_types": quota_limit_types, + "id": quota.id, + "limit_bytes": quota.limit_bytes, + "limits": [limit_view(limit) for limit in quota_limits], } -def quota_limit_view(orgname: str, quota_limit): +def limit_view(limit): return { - "percent_of_limit": quota_limit["percent_of_limit"], - "limit_type": { - "name": quota_limit["name"], - "quota_limit_id": quota_limit["id"], - "quota_type_id": quota_limit["type_id"], - }, + "id": limit.id, + "type": limit.quota_type.name, + "limit_percent": limit.percent_of_limit, } -def namespace_size_view(orgname: str, repo): - return { - "orgname": orgname, - "repository_name": repo.name, - "repository_size": repo.repositorysize.size_bytes, - "repository_id": repo.id, - } +def get_quota(namespace_name, quota_id): + quota = model.namespacequota.get_namespace_quota(namespace_name, quota_id) + if quota is None: + raise NotFound() + return quota -@resource("/v1/namespacequota//quota") +@resource("/v1/organization//quota") @show_if(features.QUOTA_MANAGEMENT) -class OrganizationQuota(ApiResource): - +class OrganizationQuotaList(ApiResource): schemas = { "NewOrgQuota": { "type": "object", @@ -75,252 +73,323 @@ class OrganizationQuota(ApiResource): }, } - @nickname("getNamespaceQuota") - def get(self, namespace): - orgperm = OrganizationMemberPermission(namespace) - userperm = UserReadPermission(namespace) - - if not orgperm.can() and not userperm.can(): + @nickname("listOrganizationQuota") + def get(self, orgname): + orgperm = OrganizationMemberPermission(orgname) + if not orgperm.can(): raise Unauthorized() - quota = model.namespacequota.get_namespace_quota(namespace) - quota_limit_types = model.namespacequota.get_namespace_limit_types() - return quota_view(namespace, quota, quota_limit_types) + try: + org = model.organization.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() - @nickname("createNamespaceQuota") + quotas = model.namespacequota.get_namespace_quota_list(orgname) + + return [quota_view(quota) for quota in quotas] + + @nickname("createOrganizationQuota") @validate_json_request("NewOrgQuota") - def post(self, namespace): + def post(self, orgname): """ Create a new organization quota. """ - orgperm = AdministerOrganizationPermission(namespace) - superperm = SuperUserPermission() + orgperm = AdministerOrganizationPermission(orgname) - if not superperm.can(): - if orgperm.can(): - if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0: - raise Unauthorized() - else: + if not features.SUPER_USERS or not SuperUserPermission().can(): + if ( + not orgperm.can() + or not config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0 + ): + raise Unauthorized() + + quota_data = request.get_json() + limit_bytes = quota_data["limit_bytes"] + + try: + org = model.organization.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + # Currently only supporting one quota definition per namespace + quotas = model.namespacequota.get_namespace_quota_list(orgname) + if quotas: + raise request_error(message="Organization quota for '%s' already exists" % orgname) + + try: + model.namespacequota.create_namespace_quota(org, limit_bytes) + return "Created", 201 + except model.DataModelException as ex: + raise request_error(exception=ex) + + +@resource("/v1/organization//quota/") +@show_if(features.QUOTA_MANAGEMENT) +class OrganizationQuota(ApiResource): + schemas = { + "UpdateOrgQuota": { + "type": "object", + "description": "Description of a new organization quota", + "properties": { + "limit_bytes": { + "type": "integer", + "description": "Number of bytes the organization is allowed", + }, + }, + }, + } + + @nickname("getOrganizationQuota") + def get(self, orgname, quota_id): + orgperm = OrganizationMemberPermission(orgname) + if not orgperm.can(): + raise Unauthorized() + + quota = get_quota(orgname, quota_id) + + return quota_view(quota) + + @nickname("changeOrganizationQuota") + @validate_json_request("UpdateOrgQuota") + def put(self, orgname, quota_id): + orgperm = AdministerOrganizationPermission(orgname) + + if not features.SUPER_USERS or not SuperUserPermission().can(): + if not orgperm.can(): raise Unauthorized() quota_data = request.get_json() - quota = model.namespacequota.get_namespace_quota(namespace) - - if quota is not None: - msg = "quota already exists" - raise request_error(message=msg) + quota = get_quota(orgname, quota_id) try: - newquota = model.namespacequota.create_namespace_quota( - name=namespace, limit_bytes=quota_data["limit_bytes"] - ) - if newquota is not None: - return "Created", 201 - else: - raise request_error("Quota Failed to Create") + if "limit_bytes" in quota_data: + limit_bytes = quota_data["limit_bytes"] + model.namespacequota.update_namespace_quota_size(quota, limit_bytes) except model.DataModelException as ex: raise request_error(exception=ex) - @nickname("changeOrganizationQuota") - @validate_json_request("NewOrgQuota") - def put(self, namespace): - - superperm = SuperUserPermission() - - if not superperm.can(): - raise Unauthorized() - - quota_data = request.get_json() - - quota = model.namespacequota.get_namespace_quota(namespace) - - if quota is None: - msg = "quota does not exist" - raise request_error(message=msg) - - try: - model.namespacequota.change_namespace_quota(namespace, quota_data["limit_bytes"]) - return "Updated", 201 - except model.DataModelException as ex: - raise request_error(exception=ex) + return quota_view(quota) @nickname("deleteOrganizationQuota") - def delete(self, namespace): - superperm = SuperUserPermission() + def delete(self, orgname, quota_id): + orgperm = AdministerOrganizationPermission(orgname) - if not superperm.can(): - raise Unauthorized() + if not features.SUPER_USERS or not SuperUserPermission().can(): + if not orgperm.can(): + raise Unauthorized() - quota = model.namespacequota.get_namespace_quota(namespace) + quota = get_quota(orgname, quota_id) - if quota is None: - msg = "quota does not exist" - raise request_error(message=msg) + # Exceptions by`delete_instance` are unexpected and raised + model.namespacequota.delete_namespace_quota(quota) - try: - success = model.namespacequota.delete_namespace_quota(namespace) - if success == 1: - return "Deleted", 201 - - msg = "quota failed to delete" - raise request_error(message=msg) - except model.DataModelException as ex: - raise request_error(exception=ex) + return "", 204 -@resource("/v1/namespacequota//quotalimits") +@resource("/v1/organization//quota//limit") @show_if(features.QUOTA_MANAGEMENT) -class OrganizationQuotaLimits(ApiResource): - +class OrganizationQuotaLimitList(ApiResource): schemas = { "NewOrgQuotaLimit": { "type": "object", - "description": "Description of a new organization quota limit threshold", - "required": ["percent_of_limit", "quota_type_id"], + "description": "Description of a new organization quota limit", + "required": ["type", "threshold_percent"], "properties": { - "percent_of_limit": { - "type": "integer", - "description": "Percentage of quota at which to do something", + "type": { + "type": "string", + "description": 'Type of quota limit: "Warning" or "Reject"', }, - "quota_type_id": { + "threshold_percent": { "type": "integer", - "description": "Quota type Id", + "description": "Quota threshold, in percent of quota", + }, + }, + }, + } + + @nickname("listOrganizationQuotaLimit") + def get(self, orgname, quota_id): + orgperm = OrganizationMemberPermission(orgname) + if not orgperm.can(): + raise Unauthorized() + + quota = get_quota(orgname, quota_id) + return [ + limit_view(limit) + for limit in model.namespacequota.get_namespace_quota_limit_list(quota) + ] + + @nickname("createOrganizationQuotaLimit") + @validate_json_request("NewOrgQuotaLimit") + def post(self, orgname, quota_id): + orgperm = AdministerOrganizationPermission(orgname) + + if not features.SUPER_USERS or not SuperUserPermission().can(): + if ( + not orgperm.can() + or not config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0 + ): + raise Unauthorized() + + quota_limit_data = request.get_json() + quota_type = quota_limit_data["type"] + quota_limit_threshold = quota_limit_data["threshold_percent"] + + quota = get_quota(orgname, quota_id) + + quota_limit = model.namespacequota.get_namespace_quota_limit_list( + quota, + quota_type=quota_type, + percent_of_limit=quota_limit_threshold, + ) + + if quota_limit: + msg = "Quota limit already exists" + raise request_error(message=msg) + + if quota_limit_data["type"].lower() == "reject" and quota_limit: + raise request_error(message="Only one quota limit of type 'Reject' allowed.") + + try: + model.namespacequota.create_namespace_quota_limit( + quota, + quota_type, + quota_limit_threshold, + ) + return "Created", 201 + except model.DataModelException as ex: + raise request_error(exception=ex) + + +@resource("/v1/organization//quota//limit/") +@show_if(features.QUOTA_MANAGEMENT) +class OrganizationQuotaLimit(ApiResource): + schemas = { + "UpdateOrgQuotaLimit": { + "type": "object", + "description": "Description of changing organization quota limit", + "properties": { + "type": { + "type": "string", + "description": 'Type of quota limit: "Warning" or "Reject"', + }, + "threshold_percent": { + "type": "integer", + "description": "Quota threshold, in percent of quota", }, }, }, } @nickname("getOrganizationQuotaLimit") - def get(self, namespace): - orgperm = OrganizationMemberPermission(namespace) - userperm = UserReadPermission(namespace) - - if not orgperm.can() and not userperm.can(): + def get(self, orgname, quota_id, limit_id): + orgperm = OrganizationMemberPermission(orgname) + if not orgperm.can(): raise Unauthorized() - quota_limits = list(model.namespacequota.get_namespace_limits(namespace)) + quota = get_quota(orgname, quota_id) + quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id) + if quota_limit is None: + raise NotFound() - return {"quota_limits": [quota_limit_view(namespace, limit) for limit in quota_limits]}, 200 + return limit_view(quota_limit) - @nickname("createOrganizationQuotaLimit") - @validate_json_request("NewOrgQuotaLimit") - def post(self, namespace): - """ - Create a new organization quota. - """ + @nickname("changeOrganizationQuotaLimit") + @validate_json_request("UpdateOrgQuotaLimit") + def put(self, orgname, quota_id, limit_id): + orgperm = AdministerOrganizationPermission(orgname) - orgperm = AdministerOrganizationPermission(namespace) - superperm = SuperUserPermission() - - if not superperm.can(): - if orgperm.can(): - if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0: - raise Unauthorized() - else: + if not features.SUPER_USERS or not SuperUserPermission().can(): + if not orgperm.can(): raise Unauthorized() quota_limit_data = request.get_json() - quota = model.namespacequota.get_namespace_limit( - namespace, quota_limit_data["quota_type_id"], quota_limit_data["percent_of_limit"] - ) - if quota is not None: - msg = "quota limit already exists" - raise request_error(message=msg) + quota = get_quota(orgname, quota_id) + quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id) + if quota_limit is None: + raise NotFound() - reject_quota = model.namespacequota.get_namespace_reject_limit(namespace) - if reject_quota is not None and model.namespacequota.is_reject_limit_type( - quota_limit_data["quota_type_id"] - ): - msg = "You can only have one Reject type of quota limit" - raise request_error(message=msg) + if "type" in quota_limit_data: + new_type = quota_limit_data["type"] + model.namespacequota.update_namespace_quota_limit_type(quota_limit, new_type) + if "threshold_percent" in quota_limit_data: + new_threshold = quota_limit_data["threshold_percent"] + model.namespacequota.update_namespace_quota_limit_threshold(quota_limit, new_threshold) - try: - model.namespacequota.create_namespace_limit( - orgname=namespace, - percent_of_limit=quota_limit_data["percent_of_limit"], - quota_type_id=quota_limit_data["quota_type_id"], - ) - return "Created", 201 - except model.DataModelException as ex: - raise request_error(exception=ex) - - @nickname("changeOrganizationQuotaLimit") - @validate_json_request("NewOrgQuotaLimit") - def put(self, namespace): - - superperm = SuperUserPermission() - - if not superperm.can(): - raise Unauthorized() - - quota_limit_data = request.get_json() - - try: - quota_limit_id = quota_limit_data["quota_limit_id"] - except KeyError: - msg = "Must supply quota_limit_id for updates" - raise request_error(message=msg) - - quota = model.namespacequota.get_namespace_limit_from_id(namespace, quota_limit_id) - - if quota is None: - msg = "quota limit does not exist" - raise request_error(message=msg) - - try: - model.namespacequota.change_namespace_quota_limit( - namespace, - quota_limit_data["percent_of_limit"], - quota_limit_data["quota_type_id"], - quota_limit_data["quota_limit_id"], - ) - return "Updated", 201 - except model.DataModelException as ex: - raise request_error(exception=ex) + return quota_view(quota) @nickname("deleteOrganizationQuotaLimit") - def delete(self, namespace): + def delete(self, orgname, quota_id, limit_id): + orgperm = AdministerOrganizationPermission(orgname) - superperm = SuperUserPermission() + if not features.SUPER_USERS or not SuperUserPermission().can(): + if not orgperm.can(): + raise Unauthorized() - if not superperm.can(): - raise Unauthorized() - - quota_limit_id = request.args.get("quota_limit_id", None) - if quota_limit_id is None: - msg = "Bad request to delete quota limit. Missing quota limit identifier." - raise request_error(message=msg) - - quota = model.namespacequota.get_namespace_limit_from_id(namespace, quota_limit_id) - - if quota is None: - msg = "quota does not exist" - raise request_error(message=msg) + quota = get_quota(orgname, quota_id) + quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id) + if quota_limit is None: + raise NotFound() try: - success = model.namespacequota.delete_namespace_quota_limit(namespace, quota_limit_id) - if success == 1: - return "Deleted", 201 - - msg = "quota failed to delete" - raise request_error(message=msg) + # Exceptions by`delete_instance` are unexpected and raised + model.namespacequota.delete_namespace_quota_limit(quota_limit) + return "", 204 except model.DataModelException as ex: raise request_error(exception=ex) -@resource("/v1/namespacequota//quotareport") +@resource("/v1/user/quota") @show_if(features.QUOTA_MANAGEMENT) -class OrganizationQuotaReport(ApiResource): - @nickname("getOrganizationSizeReporting") - def get(self, namespace): - orgperm = OrganizationMemberPermission(namespace) - userperm = UserReadPermission(namespace) +class UserQuotaList(ApiResource): + @require_user_admin + @nickname("listUserQuota") + def get(self): + parent = get_authenticated_user() + user_quotas = model.namespacequota.get_namespace_quota_list(parent.username) - if not orgperm.can() and not userperm.can(): - raise Unauthorized() + return [quota_view(quota) for quota in user_quotas] - return { - "response": model.namespacequota.get_namespace_repository_sizes_and_cache(namespace) - }, 200 + +@resource("/v1/user/quota/") +@show_if(features.QUOTA_MANAGEMENT) +class UserQuota(ApiResource): + @require_user_admin + @nickname("getUserQuota") + def get(self, quota_id): + parent = get_authenticated_user() + quota = get_quota(parent.username, quota_id) + + return quota_view(quota) + + +@resource("/v1/user/quota//limit") +@show_if(features.QUOTA_MANAGEMENT) +class UserQuotaLimitList(ApiResource): + @require_user_admin + @nickname("listUserQuotaLimit") + def get(self, quota_id): + parent = get_authenticated_user() + quota = get_quota(parent.username, quota_id) + + return [ + limit_view(limit) + for limit in model.namespacequota.get_namespace_quota_limit_list(quota) + ] + + +@resource("/v1/user/quota//limit/") +@show_if(features.QUOTA_MANAGEMENT) +class UserQuotaLimit(ApiResource): + @require_user_admin + @nickname("getUserQuotaLimit") + def get(self, quota_id, limit_id): + parent = get_authenticated_user() + quota = get_quota(parent.username, quota_id) + quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id) + if quota_limit is None: + raise NotFound() + + return quota_view(quota) diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index ae392dc4f..4883b06ac 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -53,6 +53,24 @@ from proxy import Proxy, UpstreamRegistryError logger = logging.getLogger(__name__) +def quota_view(quota): + quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota)) + + return { + "id": quota.id, # Generate uuid instead? + "limit_bytes": quota.limit_bytes, + "limits": [limit_view(limit) for limit in quota_limits], + } + + +def limit_view(limit): + return { + "id": limit.id, + "type": limit.quota_type.name, + "limit_percent": limit.percent_of_limit, + } + + def team_view(orgname, team): return { "name": team.name, @@ -66,7 +84,7 @@ def team_view(orgname, team): } -def org_view(o, teams, quota=None): +def org_view(o, teams): is_admin = AdministerOrganizationPermission(o.username).can() is_member = OrganizationMemberPermission(o.username).can() @@ -76,7 +94,6 @@ def org_view(o, teams, quota=None): "avatar": avatar.get_data_for_user(o), "is_admin": is_admin, "is_member": is_member, - "quota": quota, } if teams is not None: @@ -90,6 +107,11 @@ def org_view(o, teams, quota=None): view["tag_expiration_s"] = o.removed_tag_expiration_s view["is_free_account"] = o.stripe_id is None + if features.QUOTA_MANAGEMENT: + quotas = model.namespacequota.get_namespace_quota_list(o.username) + view["quotas"] = [quota_view(quota) for quota in quotas] if quotas else [] + view["quota_report"] = model.namespacequota.get_quota_for_view(o.username) + return view @@ -218,15 +240,11 @@ class Organization(ApiResource): raise NotFound() teams = None - quota = None if OrganizationMemberPermission(orgname).can(): has_syncing = features.TEAM_SYNCING and bool(authentication.federated_service) teams = model.team.get_teams_within_org(org, has_syncing) - if features.QUOTA_MANAGEMENT: - quota = model.namespacequota.get_org_quota_for_view(org.username) - - return org_view(org, teams, quota) + return org_view(org, teams) @require_scope(scopes.ORG_ADMIN) @nickname("changeOrganizationDetails") diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 7515bf20b..608ed40ae 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -211,12 +211,6 @@ class RepositoryList(ApiResource): type=truthy_bool, default=False, ) - @query_param( - "quota", - "Whether to include the repository's consumed quota.", - type=truthy_bool, - default=False, - ) @query_param("repo_kind", "The kind of repositories to return", type=str, default="image") @page_support() def get(self, page_token, parsed_args): @@ -237,7 +231,6 @@ class RepositoryList(ApiResource): username = user.username if user else None last_modified = parsed_args["last_modified"] popularity = parsed_args["popularity"] - quota = parsed_args["quota"] if parsed_args["starred"] and not username: # No repositories should be returned, as there is no user. @@ -253,7 +246,6 @@ class RepositoryList(ApiResource): page_token, last_modified, popularity, - quota, ) return {"repositories": [repo.to_dict() for repo in repos]}, next_page_token diff --git a/endpoints/api/repository_models_interface.py b/endpoints/api/repository_models_interface.py index ca54f09ca..0fc9a2015 100644 --- a/endpoints/api/repository_models_interface.py +++ b/endpoints/api/repository_models_interface.py @@ -6,6 +6,7 @@ from six import add_metaclass import features from data.database import RepositoryState +from data import model from endpoints.api import format_date @@ -28,7 +29,6 @@ class RepositoryBaseElement( "should_is_starred", "is_free_account", "state", - "quota", ], ) ): @@ -45,7 +45,6 @@ class RepositoryBaseElement( :type should_last_modified: boolean :type should_popularity: boolean :type should_is_starred: boolean - :type: quota: dictionary """ def to_dict(self): @@ -56,9 +55,13 @@ class RepositoryBaseElement( "is_public": self.is_public, "kind": self.kind_name, "state": self.state.name if self.state is not None else None, - "quota": self.quota if features.QUOTA_MANAGEMENT else None, } + if features.QUOTA_MANAGEMENT: + repo["quota_report"] = model.namespacequota.get_repo_quota_for_view( + self.namespace_name, self.repository_name + ) + if self.should_last_modified: repo["last_modified"] = self.last_modified diff --git a/endpoints/api/repository_models_pre_oci.py b/endpoints/api/repository_models_pre_oci.py index 321673c61..1f72cd34c 100644 --- a/endpoints/api/repository_models_pre_oci.py +++ b/endpoints/api/repository_models_pre_oci.py @@ -90,7 +90,6 @@ class PreOCIModel(RepositoryDataInterface): page_token, last_modified, popularity, - quota, ): next_page_token = None @@ -134,7 +133,6 @@ class PreOCIModel(RepositoryDataInterface): # and/or last modified. last_modified_map = {} action_sum_map = {} - quota_map = {} if last_modified or popularity: repository_refs = [RepositoryReference.for_id(repo.rid) for repo in repos] repository_ids = [repo.rid for repo in repos] @@ -151,12 +149,6 @@ class PreOCIModel(RepositoryDataInterface): if popularity: action_sum_map = model.log.get_repositories_action_sums(repository_ids) - if features.QUOTA_MANAGEMENT and quota: - for repo_id in repository_ids: - quota_map[repo_id] = model.namespacequota.get_repo_quota_for_view( - repo_id, namespace - ) - # Collect the IDs of the repositories that are starred for the user, so we can mark them # in the returned results. star_set = set() @@ -182,7 +174,6 @@ class PreOCIModel(RepositoryDataInterface): username, None, repo.state, - quota_map.get(repo.rid), ) for repo in repos ], @@ -242,7 +233,6 @@ class PreOCIModel(RepositoryDataInterface): False, repo.namespace_user.stripe_id is None, repo.state, - features.QUOTA_MANAGEMENT is True, ) if base.kind_name == "application": diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index f17e68c14..52d9762c1 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -21,7 +21,7 @@ from auth.auth_context import get_authenticated_user from auth.permissions import SuperUserPermission from data.database import ServiceKeyApprovalType from data.logs_model import logs_model -from data.model import namespacequota +from data.model import user, namespacequota, InvalidNamespaceQuota, DataModelException from endpoints.api import ( ApiResource, nickname, @@ -43,7 +43,9 @@ from endpoints.api import ( Unauthorized, InvalidResponse, ) +from endpoints.api import request_error from endpoints.api.build import get_logs_or_log_url +from endpoints.api.namespacequota import quota_view, limit_view, get_quota from endpoints.api.superuser_models_pre_oci import ( pre_oci_model, ServiceKeyDoesNotExist, @@ -214,33 +216,108 @@ class SuperUserOrganizationList(ApiResource): raise Unauthorized() -@resource("/v1/superuser/quota/") +@resource( + "/v1/superuser/users//quota/", + "/v1/superuser/organization//quota/", +) @internal_only @show_if(features.SUPER_USERS) @show_if(features.QUOTA_MANAGEMENT) -class SuperUserOrganizationQuotaReport(ApiResource): - """ - Resource for listing organizations in the system. - """ +class SuperUserUserQuotaList(ApiResource): + + schemas = { + "NewNamespaceQuota": { + "type": "object", + "description": "Description of a new organization quota", + "required": ["limit_bytes"], + "properties": { + "limit_bytes": { + "type": "integer", + "description": "Number of bytes the organization is allowed", + }, + }, + }, + } @require_fresh_login @verify_not_prod - @nickname("allOrganizationQuotaReport") + @nickname(["createUserQuotaSuperUser", "createOrganizationQuotaSuperUser"]) @require_scope(scopes.SUPERUSER) - def get(self): - """ - Returns a list of all organizations in the system. - """ + @validate_json_request("NewNamespaceQuota") + def post(self, namespace): if SuperUserPermission().can(): - return { - "organizations": [ - { - "organization": org.username, - "size": namespacequota.get_namespace_size(org.username), - } - for org in pre_oci_model.get_organizations() - ] - } + quota_data = request.get_json() + limit_bytes = quota_data["limit_bytes"] + + namespace_user = user.get_user_or_org(namespace) + quotas = namespacequota.get_namespace_quota_list(namespace_user.username) + + if quotas: + raise request_error(message="Quota for '%s' already exists" % namespace) + + try: + newquota = namespacequota.create_namespace_quota(namespace_user, limit_bytes) + return "Created", 201 + except DataModelException as ex: + raise request_error(exception=ex) + + raise Unauthorized() + + +@resource( + "/v1/superuser/users//quota/", + "/v1/superuser/organization//quota/", +) +@internal_only +@show_if(features.SUPER_USERS) +@show_if(features.QUOTA_MANAGEMENT) +class SuperUserUserQuota(ApiResource): + + schemas = { + "UpdateNamespaceQuota": { + "type": "object", + "description": "Description of a new organization quota", + "properties": { + "limit_bytes": { + "type": "integer", + "description": "Number of bytes the organization is allowed", + }, + }, + }, + } + + @require_fresh_login + @verify_not_prod + @nickname(["changeUserQuotaSuperUser", "changeOrganizationQuotaSuperUser"]) + @require_scope(scopes.SUPERUSER) + @validate_json_request("UpdateNamespaceQuota") + def put(self, namespace, quota_id): + if SuperUserPermission().can(): + quota_data = request.get_json() + + namespace_user = user.get_user_or_org(namespace) + quota = get_quota(namespace_user.username, quota_id) + + try: + if "limit_bytes" in quota_data: + limit_bytes = quota_data["limit_bytes"] + model.namespacequota.update_namespace_quota_size(quota, limit_bytes) + except model.DataModelException as ex: + raise request_error(exception=ex) + + return quota_view(quota) + + raise Unauthorized() + + @nickname(["deleteUserQuotaSuperUser", "deleteOrganizationQuotaSuperUser"]) + @require_scope(scopes.SUPERUSER) + def delete(self, namespace, quota_id): + if SuperUserPermission().can(): + namespace_user = user.get_user_or_org(namespace) + quota = get_quota(namespace_user.username, quota_id) + namespacequota.delete_namespace_quota(quota) + + return "", 204 raise Unauthorized() diff --git a/endpoints/api/superuser_models_interface.py b/endpoints/api/superuser_models_interface.py index 75c439453..09214b4ea 100644 --- a/endpoints/api/superuser_models_interface.py +++ b/endpoints/api/superuser_models_interface.py @@ -23,6 +23,24 @@ def user_view(user): } +def quota_view(quota): + quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota)) + + return { + "id": quota.id, # Generate uuid instead? + "limit_bytes": quota.limit_bytes, + "limits": [limit_view(limit) for limit in quota_limits], + } + + +def limit_view(limit): + return { + "id": limit.id, + "type": limit.quota_type.name, + "limit_percent": limit.percent_of_limit, + } + + class BuildTrigger( namedtuple("BuildTrigger", ["trigger", "pull_robot", "can_read", "can_admin", "for_build"]) ): @@ -205,7 +223,7 @@ class ServiceKey( } -class User(namedtuple("User", ["username", "email", "verified", "enabled", "robot"])): +class User(namedtuple("User", ["username", "email", "verified", "enabled", "robot", "quotas"])): """ User represents a single user. @@ -227,28 +245,37 @@ class User(namedtuple("User", ["username", "email", "verified", "enabled", "robo "super_user": superusers.is_superuser(self.username), "enabled": self.enabled, } + if features.QUOTA_MANAGEMENT and self.quotas is not None: + user_data["quotas"] = ( + [quota_view(quota) for quota in self.quotas] if self.quotas else [] + ) + user_data["quota_report"] = model.namespacequota.get_quota_for_view(self.username) return user_data -class Organization(namedtuple("Organization", ["username", "email"])): +class Organization(namedtuple("Organization", ["username", "email", "quotas"])): """ Organization represents a single org. :type username: string :type email: string + :type quotas: [UserOrganizationQuota] | None """ def to_dict(self): - return { + d = { "name": self.username, "email": self.email, "avatar": avatar.get_data_for_org(self), - "quota": model.namespacequota.get_org_quota_for_view(self.username) - if features.QUOTA_MANAGEMENT - else None, } + if features.QUOTA_MANAGEMENT and self.quotas is not None: + d["quotas"] = [quota_view(quota) for quota in self.quotas] if self.quotas else [] + d["quota_report"] = model.namespacequota.get_quota_for_view(self.username) + + return d + @add_metaclass(ABCMeta) class SuperuserDataInterface(object): diff --git a/endpoints/api/superuser_models_pre_oci.py b/endpoints/api/superuser_models_pre_oci.py index 370a35098..07d1f83af 100644 --- a/endpoints/api/superuser_models_pre_oci.py +++ b/endpoints/api/superuser_models_pre_oci.py @@ -22,10 +22,20 @@ from endpoints.api.superuser_models_interface import ( from util.request import get_request_ip +def _get_namespace_quotas(namespace_user): + if not features.QUOTA_MANAGEMENT: + return None + + return model.namespacequota.get_namespace_quota_list(namespace_user.username) + + def _create_user(user): if user is None: return None - return User(user.username, user.email, user.verified, user.enabled, user.robot) + + quotas = _get_namespace_quotas(user) + + return User(user.username, user.email, user.verified, user.enabled, user.robot, quotas) def _create_key(key): @@ -163,7 +173,9 @@ class PreOCIModel(SuperuserDataInterface): if new_org_name is not None: org = model.user.change_username(org.id, new_org_name) - return Organization(org.username, org.email) + quotas = _get_namespace_quotas(org) + + return Organization(org.username, org.email, quotas) def mark_organization_for_deletion(self, name): org = model.organization.get_organization(name) @@ -235,7 +247,8 @@ class PreOCIModel(SuperuserDataInterface): def get_organizations(self): return [ - Organization(org.username, org.email) for org in model.organization.get_organizations() + Organization(org.username, org.email, _get_namespace_quotas(org)) + for org in model.organization.get_organizations() ] diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 0c2e72f1f..a5379c2a5 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -5522,51 +5522,6 @@ SECURITY_TESTS: List[ (RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "devtable", 400), (RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "freshuser", 403), (RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "reader", 403), - ( - OrganizationQuota, - "POST", - {"namespace": "buynlarge"}, - {"limit_bytes": 1, "bytes_unit": "GB"}, - None, - 401, - ), - (OrganizationQuota, "GET", {"namespace": "buynlarge"}, None, None, 401), - ( - OrganizationQuota, - "PUT", - {"namespace": "buynlarge"}, - {"limit_bytes": 1, "bytes_unit": "GB"}, - None, - 401, - ), - ( - OrganizationQuotaLimits, - "POST", - {"namespace": "buynlarge"}, - {"percent_of_limit": 10, "quota_type_id": 2}, - None, - 401, - ), - (OrganizationQuotaLimits, "GET", {"namespace": "buynlarge"}, None, None, 401), - ( - OrganizationQuotaLimits, - "PUT", - {"namespace": "buynlarge"}, - {"percent_of_limit": 10, "quota_type_id": 2}, - None, - 401, - ), - ( - OrganizationQuotaLimits, - "DELETE", - {"namespace": "buynlarge"}, - {"quota_limit_id": 1}, - None, - 401, - ), - (OrganizationQuota, "DELETE", {"namespace": "buynlarge"}, {}, None, 401), - (OrganizationQuotaReport, "GET", {"namespace": "buynlarge"}, {}, None, 401), - (SuperUserOrganizationQuotaReport, "GET", {"namespace": "buynlarge"}, {}, None, 401), ( OrganizationProxyCacheConfig, "GET", @@ -5671,6 +5626,252 @@ SECURITY_TESTS: List[ "devtable", 202, ), + (OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, "devtable", 200), + (OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, "randomuser", 403), + (OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, None, 401), + (OrganizationQuotaList, "POST", {"orgname": "buynlarge"}, {"limit_bytes": 200000}, None, 401), + ( + OrganizationQuotaList, + "POST", + {"orgname": "buynlarge"}, + {"limit_bytes": 200000}, + "devtable", + 400, + ), # Quota already exists in test db + ( + OrganizationQuotaList, + "POST", + {"orgname": "buynlarge"}, + {"limit_bytes": 200000}, + "randomuser", + 403, + ), + ( + OrganizationQuotaList, + "POST", + {"orgname": "library"}, + {"limit_bytes": 200000}, + "devtable", + 201, + ), + (OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401), + (OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, "randomuser", 403), + (OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, "devtable", 200), + (OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 2}, None, "devtable", 404), + (OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, None, 401), + (OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, "randomuser", 403), + (OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, "devtable", 200), + (OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401), + (OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, "randomuser", 403), + (OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, "devtable", 204), + (OrganizationQuotaLimitList, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401), + ( + OrganizationQuotaLimitList, + "GET", + {"orgname": "buynlarge", "quota_id": 1}, + None, + "randomuser", + 403, + ), + ( + OrganizationQuotaLimitList, + "GET", + {"orgname": "buynlarge", "quota_id": 1}, + None, + "devtable", + 200, + ), + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "warning", "threshold_percent": 50}, + None, + 401, + ), + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "warning", "threshold_percent": 50}, + "randomuser", + 403, + ), + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "warning", "threshold_percent": 50}, + "devtable", + 400, + ), # Exact same configuration already exists + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "undfinedtype", "threshold_percent": 60}, + "devtable", + 400, + ), + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "warning", "threshold_percent": 60}, + "devtable", + 201, + ), + ( + OrganizationQuotaLimitList, + "POST", + {"orgname": "buynlarge", "quota_id": 1}, + {"type": "reject", "threshold_percent": 60}, + "devtable", + 201, + ), + ( + OrganizationQuotaLimit, + "GET", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + None, + 401, + ), + ( + OrganizationQuotaLimit, + "GET", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + "randomuser", + 403, + ), + ( + OrganizationQuotaLimit, + "GET", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + "devtable", + 200, + ), + ( + OrganizationQuotaLimit, + "PUT", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + {"type": "reject", "threshold_percent": 60}, + None, + 401, + ), + ( + OrganizationQuotaLimit, + "PUT", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + {"type": "reject", "threshold_percent": 60}, + "randomuser", + 403, + ), + ( + OrganizationQuotaLimit, + "PUT", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + {"type": "reject", "threshold_percent": 60}, + "devtable", + 200, + ), + ( + OrganizationQuotaLimit, + "PUT", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + {"type": "undefinedtype", "threshold_percent": 60}, + "devtable", + 400, + ), + ( + OrganizationQuotaLimit, + "DELETE", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + None, + 401, + ), + ( + OrganizationQuotaLimit, + "DELETE", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + "randomuser", + 403, + ), + ( + OrganizationQuotaLimit, + "DELETE", + {"orgname": "buynlarge", "quota_id": 1, "limit_id": 1}, + None, + "devtable", + 204, + ), + (UserQuotaList, "GET", {}, None, None, 401), + (UserQuotaList, "GET", {}, None, "freshuser", 200), + (UserQuotaList, "GET", {}, None, "devtable", 200), + (UserQuota, "GET", {"quota_id": 2}, None, "freshuser", 404), + (UserQuota, "GET", {"quota_id": 2}, None, "devtable", 404), + (UserQuota, "GET", {"quota_id": 2}, None, "randomuser", 200), + (UserQuotaLimitList, "GET", {"quota_id": 2}, None, "devtable", 404), + (UserQuotaLimitList, "GET", {"quota_id": 2}, None, "randomuser", 200), + (UserQuotaLimit, "GET", {"quota_id": 2, "limit_id": 2}, None, "devtable", 404), + (UserQuotaLimit, "GET", {"quota_id": 2, "limit_id": 2}, None, "randomuser", 200), + (SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, {"limit_bytes": 5000}, None, 401), + (SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, None, None, 401), + ( + SuperUserUserQuotaList, + "POST", + {"namespace": "randomuser"}, + {"limit_bytes": 5000}, + "freshuser", + 403, + ), + ( + SuperUserUserQuotaList, + "POST", + {"namespace": "randomuser"}, + {"limit_bytes": 5000}, + "devtable", + 400, + ), # Quota for this user already exists + ( + SuperUserUserQuotaList, + "POST", + {"namespace": "freshuser"}, + {"limit_bytes": 5000}, + "devtable", + 201, + ), + (SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, None, "devtable", 400), + (SuperUserUserQuota, "PUT", {"namespace": "randomuser", "quota_id": 2}, {}, "randomuser", 403), + (SuperUserUserQuota, "PUT", {"namespace": "randomuser", "quota_id": 2}, {}, "devtable", 200), + ( + SuperUserUserQuota, + "DELETE", + {"namespace": "randomuser", "quota_id": 2}, + None, + "freshuser", + 403, + ), + ( + SuperUserUserQuota, + "DELETE", + {"namespace": "randomuser", "quota_id": 2}, + None, + "devtable", + 204, + ), + ( + SuperUserUserQuota, + "DELETE", + {"namespace": "randomuser", "quota_id": 1}, + None, + "devtable", + 404, + ), ] diff --git a/endpoints/api/user.py b/endpoints/api/user.py index e2ca390d4..cfac4f6d2 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -184,6 +184,11 @@ def user_view(user, previous_username=None): } ) + if features.QUOTA_MANAGEMENT: + quotas = model.namespacequota.get_namespace_quota_list(user.username) + user_response["quotas"] = [quota_view(quota) for quota in quotas] if quotas else [] + user_response["quota_report"] = model.namespacequota.get_quota_for_view(user.username) + user_view_perm = UserReadPermission(user.username) if user_view_perm.can(): user_response.update( @@ -217,6 +222,24 @@ def notification_view(note): } +def quota_view(quota): + quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota)) + + return { + "id": quota.id, # Generate uuid instead? + "limit_bytes": quota.limit_bytes, + "limits": [limit_view(limit) for limit in quota_limits], + } + + +def limit_view(limit): + return { + "id": limit.id, + "type": limit.quota_type.name, + "limit_percent": limit.percent_of_limit, + } + + @resource("/v1/user/") class User(ApiResource): """ diff --git a/initdb.py b/initdb.py index a0e838c2b..a36a284b9 100644 --- a/initdb.py +++ b/initdb.py @@ -866,10 +866,12 @@ def populate_database(minimal=False): QuotaType.create(name="Warning") QuotaType.create(name="Reject") - model.namespacequota.create_namespace_quota(org.username, 3050) + quota1 = model.namespacequota.create_namespace_quota(org, 3000) + model.namespacequota.create_namespace_quota_limit(quota1, "warning", 50) model.repository.force_cache_repo_size(publicrepo.id) - model.namespacequota.create_namespace_limit(org.username, 1, 50) + quota2 = model.namespacequota.create_namespace_quota(new_user_4, 6000) + model.namespacequota.create_namespace_quota_limit(quota2, "reject", 90) liborg = model.organization.create_organization( "library", "quay+library@devtable.com", new_user_1 diff --git a/static/directives/manage-users-tab.html b/static/directives/manage-users-tab.html index fe31213e8..6ae9a480e 100644 --- a/static/directives/manage-users-tab.html +++ b/static/directives/manage-users-tab.html @@ -58,7 +58,7 @@ + quay-show="Config.AUTHENTICATION_TYPE == 'Database' || Config.AUTHENTICATION_TYPE == 'AppToken'"> Change E-mail Address - - - - + + + + + + + + + + + + + + + + + + + +
- Quota Management: - -
-
+
+ + + + + + + + + - -
+ Quota Management: + + + + + +
+ + + + + +
+
+ Limits: + + - - - - - - - - -
- - - - - - - - - - - - - - - -
- - - - - - - -
- - - - -
+
+ + + + + + + + + + +
+ + + + + + + +
+ + + + + + diff --git a/static/directives/repo-list-table.html b/static/directives/repo-list-table.html index 4bd4ac6d8..c70b39eaf 100644 --- a/static/directives/repo-list-table.html +++ b/static/directives/repo-list-table.html @@ -38,10 +38,10 @@ - Quota Consumed + Quota Consumed - - ({{::repository.quota.percent_consumed}}%) + + + {{ ::quotaPercentConsumed(repository) }}% + + + -- - - - { - $scope.prevQuotaConfig['limit_bytes'] = $scope.currentQuotaConfig['limit_bytes'] = resp["limit_bytes"]; - let { result, byte_unit } = bytes_to_human_readable_string(resp["limit_bytes"]); - $scope.prevQuotaConfig['quota'] = $scope.currentQuotaConfig['quota'] = result - $scope.prevQuotaConfig['bytes_unit'] = $scope.currentQuotaConfig['bytes_unit'] = byte_unit; + $scope.prevQuotaConfig = {'quota': null, 'limits': {}}; + $scope.currentQuotaConfig = {'quota': null, 'limits': {}}; + $scope.newLimitConfig = {'type': null, 'limit_percent': null} - if (fresh) { - for (let i = 0; i < resp["quota_limit_types"].length; i++) { - let temp = resp["quota_limit_types"][i]; - temp["quota_limit_id"] = null; - $scope.quotaLimitTypes.push(temp); - } - } + $scope.defer = null; + $scope.disk_size_units = { + 'KB': 1024, + 'MB': 1024**2, + 'GB': 1024**3, + 'TB': 1024**4, + }; + $scope.quotaUnits = Object.keys($scope.disk_size_units); + $scope.rejectLimitType = 'Reject'; - if (resp["limit_bytes"] != null) { - $scope.prevquotaEnabled = true; - } - }); + var loadOrgQuota = function () { + $scope.nameSpaceResource = ApiService.listOrganizationQuota( + null, {'orgname': $scope.organization.name} + ).then((resp) => { + if (resp.length > 0) { + quota = resp[0]; + $scope.prevQuotaConfig['id'] = quota["id"]; + $scope.currentQuotaConfig['id'] = quota["id"]; + + for (i in quota['limits']) { + limitId = quota['limits'][i]['id']; + $scope.prevQuotaConfig['limits'][limitId] = $.extend({}, quota["limits"][i]); + $scope.currentQuotaConfig['limits'][limitId] = $.extend({}, quota["limits"][i]); + } + + let { result, byte_unit } = normalizeLimitBytes(quota["limit_bytes"]); + $scope.prevQuotaConfig['quota'] = result; + $scope.currentQuotaConfig['quota'] = result; + $scope.prevQuotaConfig['byte_unit'] = byte_unit; + $scope.currentQuotaConfig['byte_unit'] = byte_unit; + + if (quota["limit_bytes"] != null) { + $scope.prevquotaEnabled = true; + } + + $scope.organization.quota_report.configured_quota = quota["limit_bytes"]; + $scope.organization.quota_report.percent_consumed = (parseInt($scope.organization.quota_report.quota_bytes) / $scope.organization.quota_report.configured_quota * 100).toFixed(2); } + }); + }; - var human_readable_string_to_bytes = function(quota, bytes_unit) { - return Number(quota*$scope.disk_size_units[bytes_unit]); - }; + var humanReadableStringToBytes = function(quota, bytes_unit) { + return Number(quota*$scope.disk_size_units[bytes_unit]); + }; - var bytes_to_human_readable_string = function (bytes) { - let units = Object.keys($scope.disk_size_units).reverse(); - let result = null; - let byte_unit = null; - for (const key in units) { - byte_unit = units[key]; - if (bytes >= $scope.disk_size_units[byte_unit]) { - result = bytes / $scope.disk_size_units[byte_unit]; - return { result, byte_unit }; - } - } - return { result, byte_unit }; - }; + var normalizeLimitBytes = function (bytes) { + let units = Object.keys($scope.disk_size_units).reverse(); + let result = null; + let byte_unit = null; - var loadQuotaLimits = function (fresh) { - $scope.nameSpaceQuotaLimitsResource = ApiService.getOrganizationQuotaLimit(null, - {'namespace': $scope.organization.name}).then((resp) => { - $scope.prevQuotaConfig['limits'] = []; - $scope.currentQuotaConfig['limits'] = []; - for (let i = 0; i < resp['quota_limits'].length; i ++) { - $scope.prevQuotaConfig['limits'].push({...resp['quota_limits'][i]}); - $scope.currentQuotaConfig['limits'].push({...resp['quota_limits'][i]}); - } - - if (fresh) { - if ($scope.currentQuotaConfig['limits']) { - for (let i = 0; i < $scope.currentQuotaConfig['limits'].length; i++) { - populateQuotaLimit(); - } - } - } - }); - } - - var updateOrganizationQuota = function(params) { - - if (!$scope.prevquotaEnabled || $scope.prevQuotaConfig['quota'] != $scope.currentQuotaConfig['quota'] - || $scope.prevQuotaConfig['bytes_unit'] != $scope.currentQuotaConfig['bytes_unit'] ) { - let quotaMethod = ApiService.createNamespaceQuota; - let m1 = "createNamespaceQuota"; - let limit_bytes = human_readable_string_to_bytes($scope.currentQuotaConfig['quota'], $scope.currentQuotaConfig['bytes_unit']); - let data = { - 'limit_bytes': limit_bytes, - }; - - if ($scope.prevquotaEnabled) { - quotaMethod = ApiService.changeOrganizationQuota; - m1 = "changeOrganizationQuota"; - } - - quotaMethod(data, params).then((resp) => { - $scope.updating = false; - loadOrgQuota(false); - }, displayError()); - } - } - - var createOrgQuotaLimit = function(data, params) { - for (let i = 0; i < data.length; i++) { - let to_send = { - 'percent_of_limit': data[i]['percent_of_limit'], - 'quota_type_id': data[i]['limit_type']['quota_type_id'] - }; - ApiService.createOrganizationQuotaLimit(to_send, params).then((resp) => { - $scope.prevquotaEnabled = true; - }, displayError()); - } - } - - var updateOrgQuotaLimit = function(data, params) { - if (!data) { - return; - } - for (let i = 0; i < data.length; i++) { - let to_send = { - 'percent_of_limit': data[i]['percent_of_limit'], - 'quota_type_id': data[i]['limit_type']['quota_type_id'], - 'quota_limit_id': data[i]['limit_type']['quota_limit_id'] - }; - ApiService.changeOrganizationQuotaLimit(to_send, params).then((resp) => { - $scope.prevquotaEnabled = true; - }, displayError()); - } - } - - var deleteOrgQuotaLimit = function(data, params) { - if (!data) { - return; - } - for (let i = 0; i < data.length; i++) { - params['quota_limit_id'] = data[i]['limit_type']['quota_limit_id']; - ApiService.deleteOrganizationQuotaLimit(null, params).then((resp) => { - $scope.prevquotaEnabled = true; - }, displayError()); - } - } - - var similarLimits =function() { - return JSON.stringify($scope.prevQuotaConfig['limits']) === JSON.stringify($scope.currentQuotaConfig['limits']); - } - - var fetchLimitsToDelete = function() { - // In prev but not in current => to be deleted - let currentQuotaConfig = $scope.currentQuotaConfig['limits']; - let prevQuotaConfig = $scope.prevQuotaConfig['limits']; - return prevQuotaConfig.filter(function(obj1) { - return obj1.limit_type.quota_limit_id != null && !currentQuotaConfig.some(function(obj2) { - return obj1.limit_type.quota_limit_id === obj2.limit_type.quota_limit_id; - }); - }); - } - - var fetchLimitsToAdd = function() { - // In current but not in prev => to add - let currentQuotaConfig = $scope.currentQuotaConfig['limits']; - let prevQuotaConfig = $scope.prevQuotaConfig['limits']; - return currentQuotaConfig.filter(function(obj1) { - return obj1.limit_type.quota_limit_id == null && !prevQuotaConfig.some(function(obj2) { - return obj1.limit_type.name === obj2.limit_type.name && obj1.percent_of_limit === obj2.percent_of_limit; - }); - }); - } - - var fetchLimitsToUpdate = function() { - // In current and prev but different values - let currentQuotaConfig = $scope.currentQuotaConfig['limits']; - let prevQuotaConfig = $scope.prevQuotaConfig['limits']; - return currentQuotaConfig.filter(function(obj1) { - return prevQuotaConfig.some(function(obj2) { - return obj1.limit_type.quota_limit_id == obj2.limit_type.quota_limit_id && - (obj1.percent_of_limit != obj2.percent_of_limit || obj1.limit_type.name != obj2.limit_type.name); - }); - }); - - } - - var updateQuotaLimits = function(params) { - if (similarLimits()) { - return; - } - - let toDelete = fetchLimitsToDelete(); - let toAdd = fetchLimitsToAdd(); - let toUpdate = fetchLimitsToUpdate(); - - createOrgQuotaLimit(toAdd, params); - updateOrgQuotaLimit(toUpdate, params); - deleteOrgQuotaLimit(toDelete, params); - } - - var displayError = function(message = 'Could not update quota details') { - $scope.updating = true; - let errorDisplay = ApiService.errorDisplay(message, () => { - $scope.updating = false; - }); - return errorDisplay; - } - - var validLimits = function() { - let valid = true; - let rejectCount = 0; - for (let i = 0; i < $scope.currentQuotaConfig['limits'].length; i++) { - if ($scope.currentQuotaConfig['limits'][i]['limit_type']['name'] === $scope.rejectLimitType) { - rejectCount++; - - if (rejectCount > 1) { - let alert = displayError('You can only have one Reject type of Quota Limits. Please remove to proceed'); - alert(); - valid = false; - break; - } - } - - } - return valid; - } - - $scope.disableSave = function() { - return $scope.prevQuotaConfig['quota'] === $scope.currentQuotaConfig['quota'] && - $scope.prevQuotaConfig['bytes_unit'] === $scope.currentQuotaConfig['bytes_unit'] && - similarLimits(); - } - - var updateQuotaDetails = function() { - // Validate correctness - if (!validLimits()) { - $scope.defer.resolve(); - return; - } - - let params = { - 'namespace': $scope.organization.name - }; - - updateOrganizationQuota(params); - updateQuotaLimits(params); - $scope.defer.resolve(); - $scope.isUpdateable = true; - } - - $scope.updateQuotaDetailsOnSave = function() { - $scope.defer = $q.defer(); - updateQuotaDetails(); - if ($scope.isUpdateable) { - $scope.defer.promise.then(function() { - loadOrgQuota(false); - loadQuotaLimits(false); - }); - } - $scope.isUpdateable = false; - } - - $scope.addQuotaLimit = function($event) { - $scope.limitCounter++; - let temp = {'percent_of_limit': '', 'limit_type': $scope.quotaLimitTypes[0]}; - $scope.currentQuotaConfig['limits'].push(temp); - $event.preventDefault(); - } - - var populateQuotaLimit = function() { - $scope.limitCounter++; - } - - $scope.removeQuotaLimit = function(index) { - $scope.currentQuotaConfig['limits'].splice(index, 1); - $scope.limitCounter--; - } - - loadOrgQuota(true); - loadQuotaLimits(true); + for (const key in units) { + byte_unit = units[key]; + result = bytes / $scope.disk_size_units[byte_unit]; + if (bytes >= $scope.disk_size_units[byte_unit]) { + return { result, byte_unit }; + } } - } + + return { result, byte_unit }; + }; + + var updateOrganizationQuota = function(params) { + let limit_bytes = humanReadableStringToBytes($scope.currentQuotaConfig['quota'], $scope.currentQuotaConfig['byte_unit']); + let data = {'limit_bytes': limit_bytes}; + let quotaMethod = null; - return directiveDefinitionObject; + if (!$scope.prevquotaEnabled || + $scope.prevQuotaConfig['quota'] != $scope.currentQuotaConfig['quota'] || + $scope.prevQuotaConfig['byte_unit'] != $scope.currentQuotaConfig['byte_unit']) { + if ($scope.prevquotaEnabled) { + quotaMethod = ApiService.changeOrganizationQuota; + } else { + quotaMethod = ApiService.createOrganizationQuota; + } + quotaMethod(data, params).then((resp) => { + loadOrgQuota(); + }, displayError()); + } + } + + var displayError = function(message = 'Could not update quota details') { + $scope.updating = true; + let errorDisplay = ApiService.errorDisplay(message, () => { + $scope.updating = false; + }); + return errorDisplay; + } + + var validLimits = function() { + let valid = true; + let rejectCount = 0; + for (let i = 0; i < $scope.currentQuotaConfig['limits'].length; i++) { + if ($scope.currentQuotaConfig['limits'][i]['type'] === $scope.rejectLimitType) { + rejectCount++; + + if (rejectCount > 1) { + let alert = displayError('You can only have one Reject type of Quota Limits. Please remove to proceed'); + alert(); + valid = false; + break; + } + } + + } + return valid; + } + + $scope.updateQuotaConfig = function() { + // Validate correctness + if (!validLimits()) { + $scope.defer.resolve(); + return; + } + + let params = { + 'orgname': $scope.organization.name, + 'quota_id': $scope.currentQuotaConfig.id, + }; + + updateOrganizationQuota(params); + } + + $scope.addQuotaLimit = function() { + var params = { + 'orgname': $scope.organization.name, + 'quota_id': $scope.currentQuotaConfig.id, + }; + + var data = { + 'type': $scope.newLimitConfig['type'], + 'threshold_percent': $scope.newLimitConfig['limit_percent'], + }; + + ApiService.createOrganizationQuotaLimit(data, params).then((resp) => { + $scope.newLimitConfig['type'] = null; + $scope.newLimitConfig['limit_percent'] = null; + loadOrgQuota(); + }); + } + + $scope.updateQuotaLimit = function(limitId) { + var params = { + 'orgname': $scope.organization.name, + 'quota_id': $scope.currentQuotaConfig.id, + 'limit_id': limitId, + }; + + var data = { + 'type': $scope.currentQuotaConfig['limits'][limitId]['type'], + 'threshold_percent': $scope.currentQuotaConfig['limits'][limitId]['limit_percent'], + }; + + ApiService.changeOrganizationQuotaLimit(data, params).then((resp) => { + $scope.prevQuotaConfig['limits'][limitId]['type'] = $scope.currentQuotaConfig['limits'][limitId]['type']; + $scope.prevQuotaConfig['limits'][limitId]['limit_percent'] = $scope.currentQuotaConfig['limits'][limitId]['limit_percent']; + }); + } + + $scope.deleteQuotaLimit = function(limitId) { + params = { + 'orgname': $scope.organization.name, + 'quota_id': $scope.currentQuotaConfig.id, + 'limit_id': limitId, + } + + ApiService.deleteOrganizationQuotaLimit(null, params).then((resp) => { + delete $scope.currentQuotaConfig['limits'][limitId]; + delete $scope.prevQuotaConfig['limits'][limitId]; + }); + } + + $scope.disableSaveQuota = function() { + return $scope.prevQuotaConfig['quota'] === $scope.currentQuotaConfig['quota'] && + $scope.prevQuotaConfig['byte_unit'] === $scope.currentQuotaConfig['byte_unit']; + } + + $scope.disableUpdateQuota = function(limitId) { + return $scope.prevQuotaConfig['limits'][limitId]['type'] === $scope.currentQuotaConfig['limits'][limitId]['type'] && + $scope.prevQuotaConfig['limits'][limitId]['limit_percent'] === $scope.currentQuotaConfig['limits'][limitId]['limit_percent']; + } + + loadOrgQuota(); + /* loadQuotaLimits(true); */ + $scope.$watch('isEnabled', loadOrgQuota); + $scope.$watch('organization', loadOrgQuota); + } + } + + return directiveDefinitionObject; }); diff --git a/static/js/directives/ui/repo-list-table.js b/static/js/directives/ui/repo-list-table.js index 11a2253f7..a4e515fc5 100644 --- a/static/js/directives/ui/repo-list-table.js +++ b/static/js/directives/ui/repo-list-table.js @@ -30,6 +30,7 @@ angular.module('quay').directive('repoListTable', function () { 'page': 0 }; $scope.disk_size_units = { + 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4, @@ -41,7 +42,7 @@ angular.module('quay').directive('repoListTable', function () { $scope.orderedRepositories = TableService.buildOrderedItems($scope.repositories, $scope.options, - ['namespace', 'name', 'state'], ['last_modified_datetime', 'popularity', 'quota']) + ['namespace', 'name', 'state'], ['last_modified_datetime', 'popularity', 'quota_report']) }; $scope.tablePredicateClass = function(name, predicate, reverse) { @@ -67,13 +68,21 @@ angular.module('quay').directive('repoListTable', function () { let result = null; let byte_unit = null; for (const key in units) { - byte_unit = units[key]; - if (bytes >= $scope.disk_size_units[byte_unit]) { - result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2); - return result.toString() + " " + byte_unit; - } + byte_unit = units[key]; + result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2); + if (bytes >= $scope.disk_size_units[byte_unit]) { + return result.toString() + " " + byte_unit; + } } - return null + + return result.toString() + " " + byte_unit; + }; + + $scope.quotaPercentConsumed = function(repository) { + if (repository.quota_report) { + return (repository.quota_report.quota_bytes / repository.quota_report.configured_quota * 100).toFixed(2); + } + return 0; }; $scope.getAvatarData = function(namespace) { diff --git a/static/js/pages/org-view.js b/static/js/pages/org-view.js index e5761d75a..e52c1cb9d 100644 --- a/static/js/pages/org-view.js +++ b/static/js/pages/org-view.js @@ -33,8 +33,7 @@ 'organizationEmail': '' }; $scope.disk_size_units = { - 'Bytes': 1, - 'KB': 1024**1, + 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4, @@ -47,17 +46,26 @@ }); $scope.bytesToHumanReadableString = function(bytes) { - let units = Object.keys($scope.disk_size_units).reverse(); - let result = null; - let byte_unit = null; - for (const key in units) { - byte_unit = units[key]; - if (bytes >= $scope.disk_size_units[byte_unit]) { - result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2); - return result.toString() + " " + byte_unit; - } + let units = Object.keys($scope.disk_size_units).reverse(); + let result = null; + let byte_unit = null; + + for (const key in units) { + byte_unit = units[key]; + result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2); + if (bytes >= $scope.disk_size_units[byte_unit]) { + return result.toString() + " " + byte_unit; } - return null + } + + return result.toString() + " " + byte_unit; + }; + + $scope.quotaPercentConsumed = function(organization) { + if (organization.quota_report) { + return (organization.quota_report.quota_bytes / organization.quota_report.configured_quota * 100).toFixed(2); + } + return 0; }; var loadRepositories = function() { diff --git a/static/js/pages/superuser.js b/static/js/pages/superuser.js index c50fd420e..8f6e1f48f 100644 --- a/static/js/pages/superuser.js +++ b/static/js/pages/superuser.js @@ -42,11 +42,12 @@ 'page': 0, } $scope.disk_size_units = { - 'MB': 1024**2, - 'GB': 1024**3, - 'TB': 1024**4, - }; - $scope.quotaUnits = Object.keys($scope.disk_size_units); + 'KB': 1024, + 'MB': 1024**2, + 'GB': 1024**3, + 'TB': 1024**4, + }; + $scope.quotaUnits = Object.keys($scope.disk_size_units); $scope.showQuotaConfig = function (org) { if (StateService.inReadOnlyMode()) { @@ -67,8 +68,8 @@ return result.toString() + " " + byte_unit; } } - return null - }; + return (bytes / $scope.disk_size_units["MB"]).toFixed(2).toString() + " MB"; + }; $scope.loadMessageOfTheDay = function () { $scope.globalMessagesActive = true; diff --git a/static/partials/org-view.html b/static/partials/org-view.html index 43cedd3ae..cd7e268d7 100644 --- a/static/partials/org-view.html +++ b/static/partials/org-view.html @@ -58,18 +58,15 @@

Repositories

- -

- Total Quota Consumed: - - {{ organization.quota.percent_consumed ? - (bytesToHumanReadableString(organization.quota.quota_bytes) + "(" + organization.quota.percent_consumed + "%)") : - bytesToHumanReadableString(organization.quota.quota_bytes) }} - - -- - {{ " of " + bytesToHumanReadableString(organization.quota.configured_quota) }} -

-
+ +

+ Total Quota Consumed: + + {{ bytesToHumanReadableString(organization.quota_report.quota_bytes) + "(" + quotaPercentConsumed(organization) + "%)" }} + + -- +

+
diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 5d14b59ef..4c7779203 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -93,7 +93,7 @@ - Quota Consumed + Quota Consumed @@ -112,16 +112,16 @@ {{ current_org.email }} - + {{ - current_org.quota.percent_consumed ? - ( bytesToHumanReadableString(current_org.quota.quota_bytes) + "(" + current_org.quota.percent_consumed + "%)") : - bytesToHumanReadableString(current_org.quota.quota_bytes) + current_org.quota_report.percent_consumed ? + ( bytesToHumanReadableString(current_org.quota_report.quota_bytes) + "(" + current_org.quota_report.percent_consumed + "%)") : + bytesToHumanReadableString(current_org.quota_report.quota_bytes) }} - -- - - {{ " of " + bytesToHumanReadableString(current_org.quota.configured_quota) }} + -- + + {{ " of " + bytesToHumanReadableString(current_org.quota_report.configured_quota) }} diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 9609ad5b9..9ca0e1aac 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -2107,9 +2107,11 @@ class TestListRepos(ApiTestCase): def test_listrepos_asguest(self): # Queries: Base + the list query - with assert_query_count(BASE_QUERY_COUNT + 1): - json = self.getJsonResponse(RepositoryList, params=dict(public=True)) - self.assertEqual(len(json["repositories"]), 1) + # TODO: Add quota queries + with patch("features.QUOTA_MANAGEMENT", False): + with assert_query_count(BASE_QUERY_COUNT + 1): + json = self.getJsonResponse(RepositoryList, params=dict(public=True)) + self.assertEqual(len(json["repositories"]), 1) def assertPublicRepos(self, has_extras=False): public_user = model.user.get_user("public") @@ -2173,13 +2175,15 @@ class TestListRepos(ApiTestCase): self.login(ADMIN_ACCESS_USER) # Queries: Base + the list query + the popularity and last modified queries + full perms load - with assert_query_count(BASE_LOGGEDIN_QUERY_COUNT + 5): - json = self.getJsonResponse( - RepositoryList, - params=dict( - namespace=ORGANIZATION, public=False, last_modified=True, popularity=True - ), - ) + # TODO: Add quota queries + with patch("features.QUOTA_MANAGEMENT", False): + with assert_query_count(BASE_LOGGEDIN_QUERY_COUNT + 5): + json = self.getJsonResponse( + RepositoryList, + params=dict( + namespace=ORGANIZATION, public=False, last_modified=True, popularity=True + ), + ) self.assertGreater(len(json["repositories"]), 0)