Skip to content

Commit

Permalink
api: human-readable limit requests for quotas (PROJQUAY-7122) (quay#2847
Browse files Browse the repository at this point in the history
)

* human-readable limit requests for quotas

Signed-off-by: dmesser <[email protected]>

* guidance on limit format errors

Signed-off-by: dmesser <[email protected]>

---------

Signed-off-by: dmesser <[email protected]>
  • Loading branch information
dmesser authored May 3, 2024
1 parent 6bf6c2b commit 470141e
Show file tree
Hide file tree
Showing 3 changed files with 221 additions and 37 deletions.
102 changes: 78 additions & 24 deletions endpoints/api/namespacequota.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import logging

import bitmath
from flask import request

import features
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import (
AdministerOrganizationPermission,
OrganizationMemberPermission,
SuperUserPermission,
UserReadPermission,
)
from auth.permissions import OrganizationMemberPermission, SuperUserPermission
from data import model
from data.model import config
from endpoints.api import (
ApiResource,
log_action,
nickname,
request_error,
require_scope,
Expand All @@ -24,7 +19,7 @@
show_if,
validate_json_request,
)
from endpoints.exception import InvalidToken, NotFound, Unauthorized
from endpoints.exception import NotFound, Unauthorized

logger = logging.getLogger(__name__)

Expand All @@ -43,11 +38,12 @@ def quota_view(quota, default_config=False):
return {
"id": quota.id,
"limit_bytes": quota.limit_bytes,
"limit": bitmath.Byte(quota.limit_bytes).best_prefix().format("{value:.1f} {unit}"),
"default_config": default_config,
"limits": [limit_view(limit) for limit in quota_limits],
"default_config_exists": True
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0
else False,
"default_config_exists": (
True if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0 else False
),
}


Expand All @@ -74,13 +70,27 @@ class OrganizationQuotaList(ApiResource):
"NewOrgQuota": {
"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",
"oneOf": [
{
"required": ["limit_bytes"],
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
},
{
"required": ["limit"],
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
},
],
},
}

Expand Down Expand Up @@ -116,7 +126,16 @@ def post(self, orgname):
raise Unauthorized()

quota_data = request.get_json()
limit_bytes = quota_data["limit_bytes"]

if "limit" in quota_data:
try:
limit_bytes = bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
except ValueError:
raise request_error(
message="Invalid limit format, use a number followed by a unit (e.g. 1GiB)"
)
else:
limit_bytes = quota_data["limit_bytes"]

try:
org = model.organization.get_organization(orgname)
Expand All @@ -143,12 +162,36 @@ class OrganizationQuota(ApiResource):
"UpdateOrgQuota": {
"type": "object",
"description": "Description of a new organization quota",
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
"oneOf": [
{
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
"required": ["limit_bytes"],
"additionalProperties": False,
},
},
{
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
"required": ["limit"],
"additionalProperties": False,
},
{
"properties": {
"limit_bytes": {"not": {}},
"limit": {"not": {}},
},
"additionalProperties": False,
},
],
},
}

Expand All @@ -173,8 +216,19 @@ def put(self, orgname, quota_id):
quota = get_quota(orgname, quota_id)

try:
if "limit_bytes" in quota_data:
limit_bytes = None

if "limit" in quota_data:
try:
limit_bytes = bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
except ValueError:
raise request_error(
message="Invalid limit format, use a number followed by a unit (e.g. 1GiB)"
)
elif "limit_bytes" in quota_data:
limit_bytes = quota_data["limit_bytes"]

if limit_bytes:
model.namespacequota.update_namespace_quota_size(quota, limit_bytes)
except model.DataModelException as ex:
raise request_error(exception=ex)
Expand Down
84 changes: 71 additions & 13 deletions endpoints/api/superuser.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""
Superuser API.
"""

import logging
import os
import socket
import string
from datetime import datetime
from random import SystemRandom

import bitmath
from cryptography.hazmat.primitives import serialization
from flask import jsonify, make_response, request

Expand Down Expand Up @@ -295,13 +297,27 @@ class SuperUserUserQuotaList(ApiResource):
"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",
"oneOf": [
{
"required": ["limit_bytes"],
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
},
{
"required": ["limit"],
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
},
],
},
}

Expand Down Expand Up @@ -333,7 +349,14 @@ def get(self, namespace):
def post(self, namespace):
if SuperUserPermission().can():
quota_data = request.get_json()
limit_bytes = quota_data["limit_bytes"]

if "limit" in quota_data:
try:
limit_bytes = bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
except ValueError:
raise request_error(message="Invalid limit format")
else:
limit_bytes = quota_data["limit_bytes"]

namespace_user = user.get_user_or_org(namespace)
quotas = namespacequota.get_namespace_quota_list(namespace_user.username)
Expand Down Expand Up @@ -362,12 +385,36 @@ class SuperUserUserQuota(ApiResource):
"UpdateNamespaceQuota": {
"type": "object",
"description": "Description of a new organization quota",
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
"oneOf": [
{
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
"required": ["limit_bytes"],
"additionalProperties": False,
},
},
{
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
"required": ["limit"],
"additionalProperties": False,
},
{
"properties": {
"limit_bytes": {"not": {}},
"limit": {"not": {}},
},
"additionalProperties": False,
},
],
},
}

Expand All @@ -384,8 +431,19 @@ def put(self, namespace, quota_id):
quota = get_quota(namespace_user.username, quota_id)

try:
if "limit_bytes" in quota_data:
limit_bytes = None

if "limit" in quota_data:
try:
limit_bytes = (
bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
)
except ValueError:
raise request_error(message="Invalid limit format")
elif "limit_bytes" in quota_data:
limit_bytes = quota_data["limit_bytes"]

if limit_bytes:
namespacequota.update_namespace_quota_size(quota, limit_bytes)
except DataModelException as ex:
raise request_error(exception=ex)
Expand Down
72 changes: 72 additions & 0 deletions endpoints/api/test/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -5805,6 +5805,46 @@
"devtable",
201,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "titi"},
{"limit": "200M"},
"devtable",
201,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "sellnsmall"},
{"limit": "512Mi"},
"devtable",
201,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "sellnsmall"},
{"limit": "512Mi"},
"devtable",
201,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "proxyorg"},
{"limit_bytes": 20000, "limit": "512Mi"},
"devtable",
400,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "proxyorg"},
{},
"devtable",
400,
),
(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),
Expand Down Expand Up @@ -6000,6 +6040,38 @@
"devtable",
201,
),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "reader"},
{"limit": "5G"},
"devtable",
201,
),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "creator"},
{"limit": "1TiB"},
"devtable",
201,
),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "outsideorg"},
{"limit": "200 Mi"},
"devtable",
201,
),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "subscriptionsorg"},
{"limit_bytes": 100000, "limit": "200 Mi"},
"devtable",
400,
),
(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),
Expand Down

0 comments on commit 470141e

Please sign in to comment.