Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement CRUD API for managing Harbor per-project Quota #3090

Open
wants to merge 41 commits into
base: topic/11-06-feat_implement_per-project_images_api_based_on_rbac
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8d1297c
feat: Implement management API for controlling Harbor per-project Quota
jopemachine Nov 13, 2024
9128ef7
chore: Add news fragment
jopemachine Nov 13, 2024
c60bd90
fix: Disable user quota mutation
jopemachine Nov 13, 2024
af7596d
fix: Rename variables
jopemachine Nov 13, 2024
ed1a4b1
feat: Add `registry_quota` to GroupNode
jopemachine Nov 13, 2024
a1225c2
fix: Update `UpdateQuota` mutation
jopemachine Nov 13, 2024
c8fece0
fix: Update `resolve_registry_quota`
jopemachine Nov 13, 2024
4f3448a
fix: Update `resolve_registry_quota`
jopemachine Nov 14, 2024
780a250
fix: Only authorized groups view the quota
jopemachine Nov 14, 2024
1a4bf70
feat: Add CreateQuota, DeleteQuota mutation
jopemachine Nov 14, 2024
63d5dbb
chore: Rename function
jopemachine Nov 14, 2024
473b125
fix: Add exception handling for each operation
jopemachine Nov 14, 2024
1e8ac1a
chore: Update schema
jopemachine Nov 14, 2024
cfd21fc
chore: Update error msg
jopemachine Nov 14, 2024
a49f1ff
chore: Rename variable
jopemachine Nov 14, 2024
16b45d1
fix: Remove useless strenum
jopemachine Nov 14, 2024
3128b0a
refactor: `mutate_harbor_project_quota`
jopemachine Nov 18, 2024
97a3022
refactor: Add read operation handling for code reuse
jopemachine Nov 18, 2024
1a03789
feat: Add SDK for registry quota mutations
jopemachine Nov 18, 2024
c118c7e
fix: Broken CI
jopemachine Nov 18, 2024
96fc374
fix: Wrong object_name in ContainerRegistryNotFound
jopemachine Nov 18, 2024
a1f3c08
feat: Implement REST API
jopemachine Nov 18, 2024
217a359
fix: Wrong exception handling
jopemachine Nov 18, 2024
89ab7a7
chore: Update comment
jopemachine Nov 19, 2024
896c44d
chore: Rename types
jopemachine Nov 19, 2024
835d0c6
chore: update GraphQL schema dump
jopemachine Nov 19, 2024
0a442c9
chore: Rename news fragment
jopemachine Nov 19, 2024
049dfe0
fix: Use `BigInt`
jopemachine Nov 20, 2024
cae3fee
chore: update GraphQL schema dump
jopemachine Nov 20, 2024
3d1c9cf
refactor: Add `HarborQuotaManager` *(Reflect feedback)
jopemachine Nov 28, 2024
cb68069
fix: Use BigInt
jopemachine Nov 28, 2024
1ec6c50
chore: self -> cls
jopemachine Nov 28, 2024
7ddcad7
chore: update GraphQL schema dump
jopemachine Nov 28, 2024
90eaff7
fix: Improve exception handling
jopemachine Nov 28, 2024
79d85e2
feat: `extra_fixtures` to `database_fixture` for effortless integrati…
jopemachine Nov 28, 2024
15d396f
feat: Add `test_harbor_read_project_quota`
jopemachine Nov 28, 2024
cd0d828
chore: `mock-group` -> `mock_group`
jopemachine Nov 28, 2024
515d39d
fix: Disjoint `FIXTURES_FOR_HARBOR_CRUD_TEST` from `test_harbor_read_…
jopemachine Nov 28, 2024
b40f6e3
chore: Rename variable
jopemachine Nov 28, 2024
1c04e5a
fix: CI
jopemachine Nov 28, 2024
fc3060c
chore: Add registry type
jopemachine Nov 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3090.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement CRUD API for managing Harbor per-project Quota.
99 changes: 99 additions & 0 deletions src/ai/backend/client/func/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from ai.backend.client.output.fields import group_fields
from ai.backend.client.output.types import FieldSpec
from ai.backend.common.utils import b64encode

from ...cli.types import Undefined, undefined
from ..session import api_session
Expand Down Expand Up @@ -311,3 +312,101 @@ async def remove_users(
}
data = await api_session.get().Admin._query(query, variables)
return data["modify_group"]

@api_function
@classmethod
async def get_container_registry_quota(cls, group_id: str) -> int:
"""
Delete Quota Limit for the group's container registry.
jopemachine marked this conversation as resolved.
Show resolved Hide resolved
Currently only HarborV2 registry is supported.

You need an admin privilege for this operation.
"""
query = textwrap.dedent(
"""\
query($id: String!) {
group_node(id: $id) {
registry_quota
}
}
"""
)

variables = {"id": b64encode(f"group_node:{group_id}")}
data = await api_session.get().Admin._query(query, variables)
Copy link
Member Author

@jopemachine jopemachine Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fregataa Registry quota READ operation can be executed even if the user is not an admin.
However, it seems that GQL queries in the current SDK can only be executed through Admin.
What do you think about adding this query, _query functions to User as well?

return data["group_node"]["registry_quota"]

@api_function
@classmethod
async def create_container_registry_quota(cls, group_id: str, quota: int) -> dict:
"""
Create Quota Limit for the group's container registry.
Currently only HarborV2 registry is supported.

You need an admin privilege for this operation.
"""
query = textwrap.dedent(
"""\
mutation($scope_id: ScopeField!, $quota: Int!) {
create_container_registry_quota(
scope_id: $scope_id, quota: $quota) {
ok msg
}
}
"""
)

scope_id = f"project:{group_id}"
variables = {"scope_id": scope_id, "quota": quota}
data = await api_session.get().Admin._query(query, variables)
return data["create_container_registry_quota"]

@api_function
@classmethod
async def update_container_registry_quota(cls, group_id: str, quota: int) -> dict:
"""
Update Quota Limit for the group's container registry.
Currently only HarborV2 registry is supported.

You need an admin privilege for this operation.
"""
query = textwrap.dedent(
"""\
mutation($scope_id: ScopeField!, $quota: Int!) {
update_container_registry_quota(
scope_id: $scope_id, quota: $quota) {
ok msg
}
}
"""
)

scope_id = f"project:{group_id}"
Copy link
Member Author

@jopemachine jopemachine Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fregataa If possible, instead of hardcoding the scope_id like this, I’d prefer to create a ProjectScope object and serialize it. However, since the ProjectScope type is currently located under the models directory, this violates the pants visibility rule.

What do you think about moving the types related to ScopeField under the common directory?

variables = {"scope_id": scope_id, "quota": quota}
data = await api_session.get().Admin._query(query, variables)
return data["update_container_registry_quota"]

@api_function
@classmethod
async def delete_container_registry_quota(cls, group_id: str) -> dict:
"""
Delete Quota Limit for the group's container registry.
Currently only HarborV2 registry is supported.

You need an admin privilege for this operation.
"""
query = textwrap.dedent(
"""\
mutation($scope_id: ScopeField!) {
delete_container_registry_quota(
scope_id: $scope_id) {
ok msg
}
}
"""
)

scope_id = f"project:{group_id}"
variables = {"scope_id": scope_id}
data = await api_session.get().Admin._query(query, variables)
return data["delete_container_registry_quota"]
9 changes: 9 additions & 0 deletions src/ai/backend/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,12 @@ def join_non_empty(*args, sep):
"""
filtered_args = [arg for arg in args if arg]
return sep.join(filtered_args)


def b64encode(s: str) -> str:
"""
base64 encoding method of graphql_relay.
Use it in components where the graphql_relay package is unavailable.
"""
b: bytes = s.encode("utf-8") if isinstance(s, str) else s
return base64.b64encode(b).decode("ascii")
4 changes: 4 additions & 0 deletions src/ai/backend/manager/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ class EndpointTokenNotFound(ObjectNotFound):
object_name = "endpoint_token"


class ContainerRegistryNotFound(ObjectNotFound):
object_name = "container_registry"


class TooManySessionsMatched(BackendError, web.HTTPNotFound):
error_type = "https://api.backend.ai/probs/too-many-sessions-matched"
error_title = "Too many sessions matched."
Expand Down
119 changes: 119 additions & 0 deletions src/ai/backend/manager/api/group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Iterable, Tuple

import aiohttp_cors
import trafaret as t
from aiohttp import web

from ai.backend.common import validators as tx
from ai.backend.logging import BraceStyleAdapter
from ai.backend.manager.models.gql_models.container_registry_utils import (
handle_harbor_project_quota_operation,
)
from ai.backend.manager.models.rbac import ProjectScope

if TYPE_CHECKING:
from .context import RootContext

from .auth import superadmin_required
from .manager import READ_ALLOWED, server_status_required
from .types import CORSOptions, WebMiddleware
from .utils import check_api_params

log = BraceStyleAdapter(logging.getLogger(__spec__.name))


@server_status_required(READ_ALLOWED)
@superadmin_required
@check_api_params(
t.Dict({
tx.AliasedKey(["group_id", "group"]): t.String,
tx.AliasedKey(["quota"]): t.Int,
})
)
async def update_registry_quota(request: web.Request, params: Any) -> web.Response:
log.info("UPDATE_REGISTRY_QUOTA (gr:{})", params["group_id"])
root_ctx: RootContext = request.app["_root.context"]
group_id = params["group_id"]
scope_id = ProjectScope(project_id=group_id, domain_name=None)
quota = int(params["quota"])

async with root_ctx.db.begin_session() as db_sess:
await handle_harbor_project_quota_operation("update", db_sess, scope_id, quota)

return web.json_response({})


@server_status_required(READ_ALLOWED)
@superadmin_required
@check_api_params(
t.Dict({
tx.AliasedKey(["group_id", "group"]): t.String,
})
)
async def delete_registry_quota(request: web.Request, params: Any) -> web.Response:
log.info("DELETE_REGISTRY_QUOTA (gr:{})", params["group_id"])
root_ctx: RootContext = request.app["_root.context"]
group_id = params["group_id"]
scope_id = ProjectScope(project_id=group_id, domain_name=None)

async with root_ctx.db.begin_session() as db_sess:
await handle_harbor_project_quota_operation("delete", db_sess, scope_id, None)

return web.json_response({})


@server_status_required(READ_ALLOWED)
@superadmin_required
@check_api_params(
t.Dict({
tx.AliasedKey(["group_id", "group"]): t.String,
tx.AliasedKey(["quota"]): t.Int,
})
)
async def create_registry_quota(request: web.Request, params: Any) -> web.Response:
log.info("CREATE_REGISTRY_QUOTA (gr:{})", params["group_id"])
root_ctx: RootContext = request.app["_root.context"]
group_id = params["group_id"]
scope_id = ProjectScope(project_id=group_id, domain_name=None)
quota = int(params["quota"])

async with root_ctx.db.begin_session() as db_sess:
await handle_harbor_project_quota_operation("create", db_sess, scope_id, quota)

return web.json_response({})


@server_status_required(READ_ALLOWED)
@superadmin_required
@check_api_params(
t.Dict({
tx.AliasedKey(["group_id", "group"]): t.String,
})
)
async def read_registry_quota(request: web.Request, params: Any) -> web.Response:
log.info("READ_REGISTRY_QUOTA (gr:{})", params["group_id"])
root_ctx: RootContext = request.app["_root.context"]
group_id = params["group_id"]
scope_id = ProjectScope(project_id=group_id, domain_name=None)

async with root_ctx.db.begin_session() as db_sess:
quota = await handle_harbor_project_quota_operation("read", db_sess, scope_id, None)

return web.json_response({"result": quota})


def create_app(
default_cors_options: CORSOptions,
) -> Tuple[web.Application, Iterable[WebMiddleware]]:
app = web.Application()
app["api_versions"] = (1, 2, 3, 4, 5)
app["prefix"] = "group"
cors = aiohttp_cors.setup(app, defaults=default_cors_options)
cors.add(app.router.add_route("POST", "/registry-quota", create_registry_quota))
cors.add(app.router.add_route("GET", "/registry-quota", read_registry_quota))
cors.add(app.router.add_route("PATCH", "/registry-quota", update_registry_quota))
cors.add(app.router.add_route("DELETE", "/registry-quota", delete_registry_quota))
Comment on lines +119 to +122

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use PATCH, DELETE method? @fregataa

return app, []
30 changes: 30 additions & 0 deletions src/ai/backend/manager/api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,9 @@
"""Added in 24.03.7."""
container_registry: JSONString
scaling_groups: [String]

"""Added in 24.12.0."""
registry_quota: Int

Check notice on line 703 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'registry_quota' was added to object type 'GroupNode'

Field 'registry_quota' was added to object type 'GroupNode'
user_nodes(filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): UserConnection
}

Expand Down Expand Up @@ -1844,6 +1847,15 @@

"""Added in 24.12.0"""
disassociate_container_registry_with_group(group_id: String!, registry_id: String!): DisassociateContainerRegistryWithGroup

"""Added in 24.12.0"""
create_container_registry_quota(quota: BigInt!, scope_id: ScopeField!): CreateContainerRegistryQuota

Check notice on line 1852 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'create_container_registry_quota' was added to object type 'Mutations'

Field 'create_container_registry_quota' was added to object type 'Mutations'

"""Added in 24.12.0"""
update_container_registry_quota(quota: BigInt!, scope_id: ScopeField!): UpdateContainerRegistryQuota

Check notice on line 1855 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'update_container_registry_quota' was added to object type 'Mutations'

Field 'update_container_registry_quota' was added to object type 'Mutations'

"""Added in 24.12.0"""
delete_container_registry_quota(scope_id: ScopeField!): DeleteContainerRegistryQuota

Check notice on line 1858 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'delete_container_registry_quota' was added to object type 'Mutations'

Field 'delete_container_registry_quota' was added to object type 'Mutations'
create_container_registry(hostname: String!, props: CreateContainerRegistryInput!): CreateContainerRegistry
modify_container_registry(hostname: String!, props: ModifyContainerRegistryInput!): ModifyContainerRegistry
delete_container_registry(hostname: String!): DeleteContainerRegistry
Expand Down Expand Up @@ -2570,6 +2582,24 @@
msg: String
}

"""Added in 24.12.0."""
type CreateContainerRegistryQuota {

Check notice on line 2586 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'CreateContainerRegistryQuota' was added

Type 'CreateContainerRegistryQuota' was added
ok: Boolean
msg: String
}

"""Added in 24.12.0."""
type UpdateContainerRegistryQuota {

Check notice on line 2592 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'UpdateContainerRegistryQuota' was added

Type 'UpdateContainerRegistryQuota' was added
ok: Boolean
msg: String
}

"""Added in 24.12.0."""
type DeleteContainerRegistryQuota {

Check notice on line 2598 in src/ai/backend/manager/api/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'DeleteContainerRegistryQuota' was added

Type 'DeleteContainerRegistryQuota' was added
ok: Boolean
msg: String
}

type CreateContainerRegistry {
container_registry: ContainerRegistry
}
Expand Down
13 changes: 13 additions & 0 deletions src/ai/backend/manager/models/gql.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@
)
from .gql_models.container_registry import (
AssociateContainerRegistryWithGroup,
CreateContainerRegistryQuota,
DeleteContainerRegistryQuota,
DisassociateContainerRegistryWithGroup,
UpdateContainerRegistryQuota,
)
from .gql_models.domain import (
CreateDomainNode,
Expand Down Expand Up @@ -349,6 +352,16 @@ class Mutations(graphene.ObjectType):
description="Added in 24.12.0"
)

create_container_registry_quota = CreateContainerRegistryQuota.Field(
description="Added in 24.12.0"
)
update_container_registry_quota = UpdateContainerRegistryQuota.Field(
description="Added in 24.12.0"
)
delete_container_registry_quota = DeleteContainerRegistryQuota.Field(
description="Added in 24.12.0"
)

# Legacy mutations
create_container_registry = CreateContainerRegistry.Field()
modify_container_registry = ModifyContainerRegistry.Field()
Expand Down
Loading