From 334ad940e64553e5d7c241a0c6054abeab26ec37 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 10 Jan 2025 05:23:39 +0000 Subject: [PATCH 01/18] refactor: Revamp ContainerRegistryNode API --- docs/manager/graphql-reference/schema.graphql | 150 ++++++++-------- .../backend/client/func/container_registry.py | 60 ++----- .../backend/manager/api/container_registry.py | 129 ++++++++------ .../manager/models/container_registry.py | 166 +++++++++++------- src/ai/backend/manager/models/gql.py | 10 -- .../models/gql_models/container_registry.py | 72 -------- 6 files changed, 262 insertions(+), 325 deletions(-) delete mode 100644 src/ai/backend/manager/models/gql_models/container_registry.py diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index 1107ae9495d..a652c4a3d96 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -1847,69 +1847,17 @@ type Mutations { """Added in 24.09.0.""" create_container_registry_node( - """Added in 24.09.3.""" - extra: JSONString - - """Added in 24.09.0.""" - is_global: Boolean - - """Added in 24.09.0.""" - password: String - - """Added in 24.09.0.""" - project: String - - """Added in 24.09.0.""" - registry_name: String! - - """Added in 24.09.0.""" - ssl_verify: Boolean - - """ - Added in 24.09.0. Registry type. One of ('docker', 'harbor', 'harbor2', 'github', 'gitlab', 'ecr', 'ecr-public', 'local'). - """ - type: ContainerRegistryTypeField! - - """Added in 24.09.0.""" - url: String! - - """Added in 24.09.0.""" - username: String + """Added in 25.1.0.""" + props: CreateContainerRegistryNodeInput! ): CreateContainerRegistryNode """Added in 24.09.0.""" modify_container_registry_node( - """Added in 24.09.3.""" - extra: JSONString - """Object id. Can be either global id or object id. Added in 24.09.0.""" id: String! - """Added in 24.09.0.""" - is_global: Boolean - - """Added in 24.09.0.""" - password: String - - """Added in 24.09.0.""" - project: String - - """Added in 24.09.0.""" - registry_name: String - - """Added in 24.09.0.""" - ssl_verify: Boolean - - """ - Registry type. One of ('docker', 'harbor', 'harbor2', 'github', 'gitlab', 'ecr', 'ecr-public', 'local'). Added in 24.09.0. - """ - type: ContainerRegistryTypeField - - """Added in 24.09.0.""" - url: String - - """Added in 24.09.0.""" - username: String + """Added in 25.1.0.""" + props: ModifyContainerRegistryNodeInput! ): ModifyContainerRegistryNode """Added in 24.09.0.""" @@ -1927,12 +1875,6 @@ type Mutations { """Added in 25.1.0.""" delete_endpoint_auto_scaling_rule_node(id: String!): DeleteEndpointAutoScalingRuleNode - """Added in 25.2.0.""" - associate_container_registry_with_group(group_id: String!, registry_id: String!): AssociateContainerRegistryWithGroup - - """Added in 25.2.0.""" - disassociate_container_registry_with_group(group_id: String!, registry_id: String!): DisassociateContainerRegistryWithGroup - """Deprecated since 24.09.0. use `CreateContainerRegistryNode` instead""" create_container_registry(hostname: String!, props: CreateContainerRegistryInput!): CreateContainerRegistry @@ -2661,11 +2603,83 @@ type CreateContainerRegistryNode { container_registry: ContainerRegistryNode } +input CreateContainerRegistryNodeInput { + """Added in 24.09.0.""" + url: String! + + """Added in 24.09.0.""" + type: ContainerRegistryTypeField! + + """Added in 24.09.0.""" + registry_name: String! + + """Added in 24.09.0.""" + is_global: Boolean + + """Added in 24.09.0.""" + project: String + + """Added in 24.09.0.""" + username: String + + """Added in 24.09.0.""" + password: String + + """Added in 24.09.0.""" + ssl_verify: Boolean + + """Added in 24.09.3.""" + extra: JSONString + + """Added in 25.1.0.""" + allowed_groups: AllowedGroups +} + +input AllowedGroups { + """List of group_ids to add associations. Added in 25.1.0.""" + add: [String] = [] + + """List of group_ids to remove associations. Added in 25.1.0.""" + remove: [String] = [] +} + """Added in 24.09.0.""" type ModifyContainerRegistryNode { container_registry: ContainerRegistryNode } +input ModifyContainerRegistryNodeInput { + """Added in 24.09.0.""" + url: String + + """Added in 24.09.0.""" + type: ContainerRegistryTypeField + + """Added in 24.09.0.""" + registry_name: String + + """Added in 24.09.0.""" + is_global: Boolean + + """Added in 24.09.0.""" + project: String + + """Added in 24.09.0.""" + username: String + + """Added in 24.09.0.""" + password: String + + """Added in 24.09.0.""" + ssl_verify: Boolean + + """Added in 24.09.3.""" + extra: JSONString + + """Added in 25.1.0.""" + allowed_groups: AllowedGroups +} + """Added in 24.09.0.""" type DeleteContainerRegistryNode { container_registry: ContainerRegistryNode @@ -2715,18 +2729,6 @@ type DeleteEndpointAutoScalingRuleNode { msg: String } -"""Added in 25.2.0.""" -type AssociateContainerRegistryWithGroup { - ok: Boolean - msg: String -} - -"""Added in 25.2.0.""" -type DisassociateContainerRegistryWithGroup { - ok: Boolean - msg: String -} - """Deprecated since 24.09.0. use `CreateContainerRegistryNode` instead""" type CreateContainerRegistry { container_registry: ContainerRegistry diff --git a/src/ai/backend/client/func/container_registry.py b/src/ai/backend/client/func/container_registry.py index 570ed80237c..ee5582478b6 100644 --- a/src/ai/backend/client/func/container_registry.py +++ b/src/ai/backend/client/func/container_registry.py @@ -1,8 +1,7 @@ from __future__ import annotations -import textwrap +from ai.backend.client.request import Request -from ..session import api_session from .base import BaseFunction, api_function __all__ = ("ContainerRegistry",) @@ -10,58 +9,25 @@ class ContainerRegistry(BaseFunction): """ - Provides a shortcut of :func:`Admin.query() - ` that fetches, modifies various container registry - information. - - .. note:: - - All methods in this function class require your API access key to - have the *admin* privilege. + Provides functions to manage container registries. """ @api_function @classmethod - async def associate_group(cls, registry_id: str, group_id: str) -> dict: + # TODO: Implement params type + async def patch_container_registry(cls, registry_id: str, params) -> None: """ - Associate container_registry with group. + Updates the container registry information, and return the container registry. :param registry_id: ID of the container registry. - :param group_id: ID of the group. - """ - query = textwrap.dedent( - """\ - mutation($registry_id: String!, $group_id: String!) { - associate_container_registry_with_group( - registry_id: $registry_id, group_id: $group_id) { - ok msg - } - } - """ - ) - variables = {"registry_id": registry_id, "group_id": group_id} - data = await api_session.get().Admin._query(query, variables) - return data["associate_container_registry_with_group"] - - @api_function - @classmethod - async def disassociate_group(cls, registry_id: str, group_id: str) -> dict: + :param params: Parameters to update the container registry. """ - Disassociate container_registry with group. - :param registry_id: ID of the container registry. - :param group_id: ID of the group. - """ - query = textwrap.dedent( - """\ - mutation($registry_id: String!, $group_id: String!) { - disassociate_container_registry_with_group( - registry_id: $registry_id, group_id: $group_id) { - ok msg - } - } - """ + request = Request( + "PATCH", + f"/container-registries/{registry_id}", ) - variables = {"registry_id": registry_id, "group_id": group_id} - data = await api_session.get().Admin._query(query, variables) - return data["disassociate_container_registry_with_group"] + request.set_json(params) + + async with request.fetch() as resp: + await resp.read() diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index ab0c282b1c6..e59374c5e9d 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -1,20 +1,23 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Iterable, Tuple +import uuid +from typing import TYPE_CHECKING, Iterable, Optional, Tuple import aiohttp_cors import sqlalchemy as sa from aiohttp import web -from pydantic import AliasChoices, BaseModel, Field +from pydantic import BaseModel from sqlalchemy.exc import IntegrityError from ai.backend.logging import BraceStyleAdapter from ai.backend.manager.models.association_container_registries_groups import ( AssociationContainerRegistriesGroupsRow, ) +from ai.backend.manager.models.container_registry import ContainerRegistryRow +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from .exceptions import ContainerRegistryNotFound, GenericBadRequest +from .exceptions import GenericBadRequest, InternalServerError if TYPE_CHECKING: from .context import RootContext @@ -27,75 +30,86 @@ log = BraceStyleAdapter(logging.getLogger(__spec__.name)) -class AssociationRequestModel(BaseModel): - registry_id: str = Field( - validation_alias=AliasChoices("registry_id", "registry"), - description="Container registry row's ID", - ) - group_id: str = Field( - validation_alias=AliasChoices("group_id", "group"), - description="Group row's ID", - ) +class AllowedGroups(BaseModel): + add: list[str] = [] + remove: list[str] = [] -@server_status_required(READ_ALLOWED) -@superadmin_required -@pydantic_params_api_handler(AssociationRequestModel) -async def associate_with_group( - request: web.Request, params: AssociationRequestModel -) -> web.Response: - log.info("ASSOCIATE_WITH_GROUP (cr:{}, gr:{})", params.registry_id, params.group_id) - root_ctx: RootContext = request.app["_root.context"] - registry_id = params.registry_id - group_id = params.group_id +class PatchContainerRegistryRequestModel(BaseModel): + url: Optional[str] = None + type: Optional[str] = None + registry_name: Optional[str] = None + is_global: Optional[bool] = None + project: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + ssl_verify: Optional[bool] = None + extra: Optional[str] = None + allowed_groups: Optional[AllowedGroups] = None - async with root_ctx.db.begin_session() as db_sess: - insert_query = sa.insert(AssociationContainerRegistriesGroupsRow).values({ - "registry_id": registry_id, - "group_id": group_id, - }) - try: - await db_sess.execute(insert_query) - except IntegrityError: - raise GenericBadRequest("Association already exists.") +# TODO: Add this. ContainerRegistryRow is not compatible with BaseModel +# class PatchContainerRegistryResponseModel(BaseModel): +# container_registry: ContainerRegistryRow - return web.Response(status=204) +async def handle_allowed_groups_update( + db: ExtendedAsyncSAEngine, registry_id: uuid.UUID, allowed_group_updates: AllowedGroups +): + async with db.begin_session() as db_sess: + if allowed_group_updates.add: + insert_values = [ + {"registry_id": registry_id, "group_id": group_id} + for group_id in allowed_group_updates.add + ] + + insert_query = sa.insert(AssociationContainerRegistriesGroupsRow).values(insert_values) + await db_sess.execute(insert_query) -class DisassociationRequestModel(BaseModel): - registry_id: str = Field( - validation_alias=AliasChoices("registry_id", "registry"), - description="Container registry row's ID", - ) - group_id: str = Field( - validation_alias=AliasChoices("group_id", "group"), - description="Group row's ID", - ) + if allowed_group_updates.remove: + delete_query = ( + sa.delete(AssociationContainerRegistriesGroupsRow) + .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) + .where( + AssociationContainerRegistriesGroupsRow.group_id.in_( + allowed_group_updates.remove + ) + ) + ) + await db_sess.execute(delete_query) @server_status_required(READ_ALLOWED) @superadmin_required -@pydantic_params_api_handler(DisassociationRequestModel) -async def disassociate_with_group( - request: web.Request, params: DisassociationRequestModel +@pydantic_params_api_handler(PatchContainerRegistryRequestModel) +async def patch_container_registry( + request: web.Request, params: PatchContainerRegistryRequestModel ) -> web.Response: - log.info("DISASSOCIATE_WITH_GROUP (cr:{}, gr:{})", params.registry_id, params.group_id) + registry_id = uuid.UUID(request.match_info["registry_id"]) + log.info("PATCH_CONTAINER_REGISTRY (cr:{})", registry_id) root_ctx: RootContext = request.app["_root.context"] - registry_id = params.registry_id - group_id = params.group_id - - async with root_ctx.db.begin_session() as db_sess: - delete_query = ( - sa.delete(AssociationContainerRegistriesGroupsRow) - .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) - .where(AssociationContainerRegistriesGroupsRow.group_id == group_id) + input_config = params.model_dump(exclude={"allowed_groups"}, exclude_none=True) + + async with root_ctx.db.begin_session() as db_session: + update_stmt = ( + sa.update(ContainerRegistryRow) + .where(ContainerRegistryRow.id == registry_id) + .values(input_config) ) + await db_session.execute(update_stmt) + + # select_stmt = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == registry_id) + # updated_container_registry = await db_session.execute(select_stmt) - result = await db_sess.execute(delete_query) - if result.rowcount == 0: - raise ContainerRegistryNotFound() + try: + if params.allowed_groups: + await handle_allowed_groups_update(root_ctx.db, registry_id, params.allowed_groups) + except IntegrityError as e: + raise GenericBadRequest(f"Failed to update allowed groups! Details: {str(e)}") + except Exception as e: + raise InternalServerError(f"Failed to update allowed groups! Details: {str(e)}") + # return PatchContainerRegistryResponseModel(container_registry=updated_container_registry) return web.Response(status=204) @@ -106,6 +120,5 @@ def create_app( app["api_versions"] = (1, 2, 3, 4, 5) app["prefix"] = "container-registries" cors = aiohttp_cors.setup(app, defaults=default_cors_options) - cors.add(app.router.add_route("POST", "/associate-with-group", associate_with_group)) - cors.add(app.router.add_route("POST", "/disassociate-with-group", disassociate_with_group)) + cors.add(app.router.add_route("PATCH", "/{registry_id}", patch_container_registry)) return app, [] diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 61fffad431e..65f28e070e4 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -10,13 +10,17 @@ import graphql import sqlalchemy as sa import yarl -from graphql import Undefined, UndefinedType +from graphql import Undefined from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only, relationship from sqlalchemy.orm.exc import NoResultFound from ai.backend.common.exception import UnknownImageRegistry from ai.backend.common.logging_utils import BraceStyleAdapter +from ai.backend.manager.models.association_container_registries_groups import ( + AssociationContainerRegistriesGroupsRow, +) +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from ..defs import PASSWORD_PLACEHOLDER from .base import ( @@ -286,6 +290,45 @@ async def load_all( return [cls.from_row(ctx, row) for row in rows] +class AllowedGroups(graphene.InputObjectType): + add = graphene.List( + graphene.String, + default_value=[], + description="List of group_ids to add associations. Added in 25.1.0.", + ) + remove = graphene.List( + graphene.String, + default_value=[], + description="List of group_ids to remove associations. Added in 25.1.0.", + ) + + +async def handle_allowed_groups_update( + db: ExtendedAsyncSAEngine, registry_id: uuid.UUID, allowed_group_updates: AllowedGroups +): + async with db.begin_session() as db_sess: + if allowed_group_updates.add: + insert_values = [ + {"registry_id": registry_id, "group_id": group_id} + for group_id in allowed_group_updates.add + ] + + insert_query = sa.insert(AssociationContainerRegistriesGroupsRow).values(insert_values) + await db_sess.execute(insert_query) + + if allowed_group_updates.remove: + delete_query = ( + sa.delete(AssociationContainerRegistriesGroupsRow) + .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) + .where( + AssociationContainerRegistriesGroupsRow.group_id.in_( + allowed_group_updates.remove + ) + ) + ) + await db_sess.execute(delete_query) + + class ContainerRegistryNode(graphene.ObjectType): class Meta: interfaces = (AsyncNode,) @@ -398,6 +441,19 @@ class Meta: description = "Added in 24.09.0." +class CreateContainerRegistryNodeInput(graphene.InputObjectType): + url = graphene.String(required=True, description="Added in 24.09.0.") + type = ContainerRegistryTypeField(required=True, description="Added in 24.09.0.") + registry_name = graphene.String(required=True, description="Added in 24.09.0.") + is_global = graphene.Boolean(description="Added in 24.09.0.") + project = graphene.String(description="Added in 24.09.0.") + username = graphene.String(description="Added in 24.09.0.") + password = graphene.String(description="Added in 24.09.0.") + ssl_verify = graphene.Boolean(description="Added in 24.09.0.") + extra = graphene.JSONString(description="Added in 24.09.3.") + allowed_groups = AllowedGroups(description="Added in 25.1.0.") + + class CreateContainerRegistryNode(graphene.Mutation): class Meta: description = "Added in 24.09.0." @@ -406,52 +462,33 @@ class Meta: container_registry = graphene.Field(ContainerRegistryNode) class Arguments: - url = graphene.String(required=True, description="Added in 24.09.0.") - type = ContainerRegistryTypeField( - required=True, - description=f"Added in 24.09.0. Registry type. One of {ContainerRegistryTypeField.allowed_values}.", - ) - registry_name = graphene.String(required=True, description="Added in 24.09.0.") - is_global = graphene.Boolean(description="Added in 24.09.0.") - project = graphene.String(description="Added in 24.09.0.") - username = graphene.String(description="Added in 24.09.0.") - password = graphene.String(description="Added in 24.09.0.") - ssl_verify = graphene.Boolean(description="Added in 24.09.0.") - extra = graphene.JSONString(description="Added in 24.09.3.") + props = CreateContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") @classmethod async def mutate( cls, root, info: graphene.ResolveInfo, - url: str, - type: ContainerRegistryType, - registry_name: str, - is_global: bool | UndefinedType = Undefined, - project: str | UndefinedType = Undefined, - username: str | UndefinedType = Undefined, - password: str | UndefinedType = Undefined, - ssl_verify: bool | UndefinedType = Undefined, - extra: dict | UndefinedType = Undefined, + props: CreateContainerRegistryNodeInput, ) -> CreateContainerRegistryNode: ctx: GraphQueryContext = info.context input_config: dict[str, Any] = { - "registry_name": registry_name, - "url": url, - "type": type, + "registry_name": props.registry_name, + "url": props.url, + "type": props.type, } def _set_if_set(name: str, val: Any) -> None: if val is not Undefined: input_config[name] = val - _set_if_set("project", project) - _set_if_set("username", username) - _set_if_set("password", password) - _set_if_set("ssl_verify", ssl_verify) - _set_if_set("is_global", is_global) - _set_if_set("extra", extra) + _set_if_set("project", props.project) + _set_if_set("username", props.username) + _set_if_set("password", props.password) + _set_if_set("ssl_verify", props.ssl_verify) + _set_if_set("is_global", props.is_global) + _set_if_set("extra", props.extra) async with ctx.db.begin_session() as db_session: reg_row = ContainerRegistryRow(**input_config) @@ -459,9 +496,25 @@ def _set_if_set(name: str, val: Any) -> None: await db_session.flush() await db_session.refresh(reg_row) - return cls( - container_registry=ContainerRegistryNode.from_row(ctx, reg_row), - ) + if props.allowed_groups: + await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) + + return cls( + container_registry=ContainerRegistryNode.from_row(ctx, reg_row), + ) + + +class ModifyContainerRegistryNodeInput(graphene.InputObjectType): + url = graphene.String(description="Added in 24.09.0.") + type = ContainerRegistryTypeField(description="Added in 24.09.0.") + registry_name = graphene.String(description="Added in 24.09.0.") + is_global = graphene.Boolean(description="Added in 24.09.0.") + project = graphene.String(description="Added in 24.09.0.") + username = graphene.String(description="Added in 24.09.0.") + password = graphene.String(description="Added in 24.09.0.") + ssl_verify = graphene.Boolean(description="Added in 24.09.0.") + extra = graphene.JSONString(description="Added in 24.09.3.") + allowed_groups = AllowedGroups(description="Added in 25.1.0.") class ModifyContainerRegistryNode(graphene.Mutation): @@ -476,17 +529,7 @@ class Arguments: required=True, description="Object id. Can be either global id or object id. Added in 24.09.0.", ) - url = graphene.String(description="Added in 24.09.0.") - type = ContainerRegistryTypeField( - description=f"Registry type. One of {ContainerRegistryTypeField.allowed_values}. Added in 24.09.0." - ) - registry_name = graphene.String(description="Added in 24.09.0.") - is_global = graphene.Boolean(description="Added in 24.09.0.") - project = graphene.String(description="Added in 24.09.0.") - username = graphene.String(description="Added in 24.09.0.") - password = graphene.String(description="Added in 24.09.0.") - ssl_verify = graphene.Boolean(description="Added in 24.09.0.") - extra = graphene.JSONString(description="Added in 24.09.3.") + props = ModifyContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") @classmethod async def mutate( @@ -494,15 +537,7 @@ async def mutate( root, info: graphene.ResolveInfo, id: str, - url: str | UndefinedType = Undefined, - type: ContainerRegistryType | UndefinedType = Undefined, - registry_name: str | UndefinedType = Undefined, - is_global: bool | UndefinedType = Undefined, - project: str | UndefinedType = Undefined, - username: str | UndefinedType = Undefined, - password: str | UndefinedType = Undefined, - ssl_verify: bool | UndefinedType = Undefined, - extra: dict | UndefinedType = Undefined, + props: ModifyContainerRegistryNodeInput, ) -> ModifyContainerRegistryNode: ctx: GraphQueryContext = info.context @@ -512,15 +547,15 @@ def _set_if_set(name: str, val: Any) -> None: if val is not Undefined: input_config[name] = val - _set_if_set("url", url) - _set_if_set("type", type) - _set_if_set("registry_name", registry_name) - _set_if_set("username", username) - _set_if_set("password", password) - _set_if_set("project", project) - _set_if_set("ssl_verify", ssl_verify) - _set_if_set("is_global", is_global) - _set_if_set("extra", extra) + _set_if_set("url", props.url) + _set_if_set("type", props.type) + _set_if_set("registry_name", props.registry_name) + _set_if_set("username", props.username) + _set_if_set("password", props.password) + _set_if_set("project", props.project) + _set_if_set("ssl_verify", props.ssl_verify) + _set_if_set("is_global", props.is_global) + _set_if_set("extra", props.extra) _, _id = AsyncNode.resolve_global_id(info, id) reg_id = uuid.UUID(_id) if _id else uuid.UUID(id) @@ -533,7 +568,10 @@ def _set_if_set(name: str, val: Any) -> None: for field, val in input_config.items(): setattr(reg_row, field, val) - return cls(container_registry=ContainerRegistryNode.from_row(ctx, reg_row)) + if props.allowed_groups: + await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) + + return cls(container_registry=ContainerRegistryNode.from_row(ctx, reg_row)) class DeleteContainerRegistryNode(graphene.Mutation): diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py index 85105103784..5e0190c0c6b 100644 --- a/src/ai/backend/manager/models/gql.py +++ b/src/ai/backend/manager/models/gql.py @@ -74,10 +74,6 @@ AgentSummaryList, ModifyAgent, ) -from .gql_models.container_registry import ( - AssociateContainerRegistryWithGroup, - DisassociateContainerRegistryWithGroup, -) from .gql_models.domain import ( CreateDomainNode, DomainConnection, @@ -355,12 +351,6 @@ class Mutations(graphene.ObjectType): delete_endpoint_auto_scaling_rule_node = DeleteEndpointAutoScalingRuleNode.Field( description="Added in 25.1.0." ) - associate_container_registry_with_group = AssociateContainerRegistryWithGroup.Field( - description="Added in 25.2.0." - ) - disassociate_container_registry_with_group = DisassociateContainerRegistryWithGroup.Field( - description="Added in 25.2.0." - ) # Legacy mutations create_container_registry = CreateContainerRegistry.Field() diff --git a/src/ai/backend/manager/models/gql_models/container_registry.py b/src/ai/backend/manager/models/gql_models/container_registry.py deleted file mode 100644 index 8f995257915..00000000000 --- a/src/ai/backend/manager/models/gql_models/container_registry.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Self - -import graphene -import sqlalchemy as sa - -from ai.backend.logging import BraceStyleAdapter - -from ..association_container_registries_groups import ( - AssociationContainerRegistriesGroupsRow, -) -from ..base import simple_db_mutate -from ..user import UserRole - -log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore - - -class AssociateContainerRegistryWithGroup(graphene.Mutation): - """Added in 25.2.0.""" - - allowed_roles = (UserRole.SUPERADMIN,) - - class Arguments: - registry_id = graphene.String(required=True) - group_id = graphene.String(required=True) - - ok = graphene.Boolean() - msg = graphene.String() - - @classmethod - async def mutate( - cls, - root, - info: graphene.ResolveInfo, - registry_id: str, - group_id: str, - ) -> Self: - insert_query = sa.insert(AssociationContainerRegistriesGroupsRow).values({ - "registry_id": registry_id, - "group_id": group_id, - }) - return await simple_db_mutate(cls, info.context, insert_query) - - -class DisassociateContainerRegistryWithGroup(graphene.Mutation): - """Added in 25.2.0.""" - - allowed_roles = (UserRole.SUPERADMIN,) - - class Arguments: - registry_id = graphene.String(required=True) - group_id = graphene.String(required=True) - - ok = graphene.Boolean() - msg = graphene.String() - - @classmethod - async def mutate( - cls, - root, - info: graphene.ResolveInfo, - registry_id: str, - group_id: str, - ) -> Self: - delete_query = ( - sa.delete(AssociationContainerRegistriesGroupsRow) - .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) - .where(AssociationContainerRegistriesGroupsRow.group_id == group_id) - ) - return await simple_db_mutate(cls, info.context, delete_query) From 1e0f9940b9545c3b36b3e09ca0d192be80c2073d Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 10 Jan 2025 06:11:20 +0000 Subject: [PATCH 02/18] fix: Broken tests --- .../models/test_container_registry_nodes.py | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/manager/models/test_container_registry_nodes.py b/tests/manager/models/test_container_registry_nodes.py index a99d9ae8fd2..cb34a42a0f3 100644 --- a/tests/manager/models/test_container_registry_nodes.py +++ b/tests/manager/models/test_container_registry_nodes.py @@ -74,8 +74,8 @@ async def test_create_container_registry(client: Client, database_engine: Extend context = get_graphquery_context(database_engine) query = """ - mutation CreateContainerRegistryNode($type: ContainerRegistryTypeField!, $registry_name: String!, $url: String!, $project: String!, $username: String!, $password: String!, $ssl_verify: Boolean!, $is_global: Boolean!) { - create_container_registry_node(type: $type, registry_name: $registry_name, url: $url, project: $project, username: $username, password: $password, ssl_verify: $ssl_verify, is_global: $is_global) { + mutation CreateContainerRegistryNode($props: CreateContainerRegistryNodeInput!) { + create_container_registry_node(props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -84,14 +84,16 @@ async def test_create_container_registry(client: Client, database_engine: Extend """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) variables = { - "registry_name": "cr.example.com", - "url": "http://cr.example.com", - "type": ContainerRegistryType.DOCKER, - "project": "default", - "username": "username", - "password": "password", - "ssl_verify": False, - "is_global": False, + "props": { + "registry_name": "cr.example.com", + "url": "http://cr.example.com", + "type": ContainerRegistryType.DOCKER, + "project": "default", + "username": "username", + "password": "password", + "ssl_verify": False, + "is_global": False, + } } response = await client.execute_async(query, variables=variables, context_value=context) @@ -112,7 +114,7 @@ async def test_create_container_registry(client: Client, database_engine: Extend "is_global": False, } - variables["project"] = "default2" + variables["props"]["project"] = "default2" await client.execute_async(query, variables=variables, context_value=context) @@ -151,8 +153,8 @@ async def test_modify_container_registry(client: Client, database_engine: Extend target_container_registry = target_container_registries[0]["node"] query = """ - mutation ModifyContainerRegistryNode($id: String!, $type: ContainerRegistryTypeField, $registry_name: String, $url: String, $project: String, $username: String, $password: String, $ssl_verify: Boolean, $is_global: Boolean) { - modify_container_registry_node(id: $id, type: $type, registry_name: $registry_name, url: $url, project: $project, username: $username, password: $password, ssl_verify: $ssl_verify, is_global: $is_global) { + mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { + modify_container_registry_node(id: $id, props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -162,8 +164,10 @@ async def test_modify_container_registry(client: Client, database_engine: Extend variables = { "id": target_container_registry["row_id"], - "registry_name": "cr.example.com", - "username": "username2", + "props": { + "registry_name": "cr.example.com", + "username": "username2", + }, } response = await client.execute_async(query, variables=variables, context_value=context) @@ -179,10 +183,12 @@ async def test_modify_container_registry(client: Client, database_engine: Extend variables = { "id": target_container_registry["row_id"], - "registry_name": "cr.example.com", - "url": "http://cr2.example.com", - "type": ContainerRegistryType.HARBOR2, - "project": "example", + "props": { + "registry_name": "cr.example.com", + "url": "http://cr2.example.com", + "type": ContainerRegistryType.HARBOR2, + "project": "example", + }, } response = await client.execute_async(query, variables=variables, context_value=context) @@ -233,8 +239,8 @@ async def test_modify_container_registry_allows_empty_string( target_container_registry = target_container_registries[0]["node"] query = """ - mutation ModifyContainerRegistryNode($id: String!, $type: ContainerRegistryTypeField, $registry_name: String, $url: String, $project: String, $username: String, $password: String, $ssl_verify: Boolean, $is_global: Boolean) { - modify_container_registry_node(id: $id, type: $type, registry_name: $registry_name, url: $url, project: $project, username: $username, password: $password, ssl_verify: $ssl_verify, is_global: $is_global) { + mutation ModifyContainerRegistryNode($id: String!, $props: ModifyContainerRegistryNodeInput!) { + modify_container_registry_node(id: $id, props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -245,8 +251,10 @@ async def test_modify_container_registry_allows_empty_string( # Given an empty string to password variables = { "id": target_container_registry["row_id"], - "registry_name": "cr.example.com", - "password": "", + "props": { + "registry_name": "cr.example.com", + "password": "", + }, } # Then password is set to empty string @@ -283,7 +291,7 @@ async def test_modify_container_registry_allows_null_for_unset( } """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - variables: dict[str, str | None] = { + variables: dict[str, dict | str] = { "filter": 'registry_name == "cr.example.com"', } @@ -299,8 +307,8 @@ async def test_modify_container_registry_allows_null_for_unset( target_container_registry = target_container_registries[0]["node"] query = """ - mutation ModifyContainerRegistryNode($id: String!, $type: ContainerRegistryTypeField, $registry_name: String, $url: String, $project: String, $username: String, $password: String, $ssl_verify: Boolean, $is_global: Boolean) { - modify_container_registry_node(id: $id, type: $type, registry_name: $registry_name, url: $url, project: $project, username: $username, password: $password, ssl_verify: $ssl_verify, is_global: $is_global) { + mutation ModifyContainerRegistryNode($id: String!, $props: ModifyContainerRegistryNodeInput!) { + modify_container_registry_node(id: $id, props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -311,8 +319,10 @@ async def test_modify_container_registry_allows_null_for_unset( # Given a null to password variables = { "id": target_container_registry["row_id"], - "registry_name": "cr.example.com", - "password": None, + "props": { + "registry_name": "cr.example.com", + "password": None, + }, } # Then password is unset From 7c9cbfc620a80a43c72eebb0be0b9166d2776861 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 10 Jan 2025 06:43:34 +0000 Subject: [PATCH 03/18] fix: Broken tests --- .../backend/manager/api/container_registry.py | 27 +++++++++++-------- .../manager/models/container_registry.py | 5 +++- .../manager/api/test_container_registries.py | 16 +++++------ 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index e59374c5e9d..da0876405c9 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -17,7 +17,7 @@ from ai.backend.manager.models.container_registry import ContainerRegistryRow from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from .exceptions import GenericBadRequest, InternalServerError +from .exceptions import ContainerRegistryNotFound, GenericBadRequest, InternalServerError if TYPE_CHECKING: from .context import RootContext @@ -76,7 +76,9 @@ async def handle_allowed_groups_update( ) ) ) - await db_sess.execute(delete_query) + result = await db_sess.execute(delete_query) + if result.rowcount == 0: + raise ContainerRegistryNotFound() @server_status_required(READ_ALLOWED) @@ -88,15 +90,16 @@ async def patch_container_registry( registry_id = uuid.UUID(request.match_info["registry_id"]) log.info("PATCH_CONTAINER_REGISTRY (cr:{})", registry_id) root_ctx: RootContext = request.app["_root.context"] - input_config = params.model_dump(exclude={"allowed_groups"}, exclude_none=True) - - async with root_ctx.db.begin_session() as db_session: - update_stmt = ( - sa.update(ContainerRegistryRow) - .where(ContainerRegistryRow.id == registry_id) - .values(input_config) - ) - await db_session.execute(update_stmt) + registry_row_updates = params.model_dump(exclude={"allowed_groups"}, exclude_none=True) + + if registry_row_updates: + async with root_ctx.db.begin_session() as db_session: + update_stmt = ( + sa.update(ContainerRegistryRow) + .where(ContainerRegistryRow.id == registry_id) + .values(registry_row_updates) + ) + await db_session.execute(update_stmt) # select_stmt = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == registry_id) # updated_container_registry = await db_session.execute(select_stmt) @@ -104,6 +107,8 @@ async def patch_container_registry( try: if params.allowed_groups: await handle_allowed_groups_update(root_ctx.db, registry_id, params.allowed_groups) + except ContainerRegistryNotFound as e: + raise e except IntegrityError as e: raise GenericBadRequest(f"Failed to update allowed groups! Details: {str(e)}") except Exception as e: diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 65f28e070e4..fef10a9f674 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -17,6 +17,7 @@ from ai.backend.common.exception import UnknownImageRegistry from ai.backend.common.logging_utils import BraceStyleAdapter +from ai.backend.manager.api.exceptions import ContainerRegistryNotFound from ai.backend.manager.models.association_container_registries_groups import ( AssociationContainerRegistriesGroupsRow, ) @@ -326,7 +327,9 @@ async def handle_allowed_groups_update( ) ) ) - await db_sess.execute(delete_query) + result = await db_sess.execute(delete_query) + if result.rowcount == 0: + raise ContainerRegistryNotFound() class ContainerRegistryNode(graphene.ObjectType): diff --git a/tests/manager/api/test_container_registries.py b/tests/manager/api/test_container_registries.py index 7e8d194c618..526387bf6cc 100644 --- a/tests/manager/api/test_container_registries.py +++ b/tests/manager/api/test_container_registries.py @@ -90,13 +90,13 @@ async def test_associate_container_registry_with_group( group_id = test_case["group_id"] registry_id = test_case["registry_id"] - url = "/container-registries/associate-with-group" - params = {"group_id": group_id, "registry_id": registry_id} + url = f"/container-registries/{registry_id}" + params = {"allowed_groups": {"add": [group_id]}} req_bytes = json.dumps(params).encode() - headers = get_headers("POST", url, req_bytes) + headers = get_headers("PATCH", url, req_bytes) - resp = await client.post(url, data=req_bytes, headers=headers) + resp = await client.patch(url, data=req_bytes, headers=headers) association_exist = "association_container_registries_groups" in extra_fixtures if association_exist: @@ -143,13 +143,13 @@ async def test_disassociate_container_registry_with_group( group_id = test_case["group_id"] registry_id = test_case["registry_id"] - url = "/container-registries/disassociate-with-group" - params = {"group_id": group_id, "registry_id": registry_id} + url = f"/container-registries/{registry_id}" + params = {"allowed_groups": {"remove": [group_id]}} req_bytes = json.dumps(params).encode() - headers = get_headers("POST", url, req_bytes) + headers = get_headers("PATCH", url, req_bytes) - resp = await client.post(url, data=req_bytes, headers=headers) + resp = await client.patch(url, data=req_bytes, headers=headers) association_exist = "association_container_registries_groups" in extra_fixtures if association_exist: From f406aa49e42c816f57b50bbc1fdb566247476bc0 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 10 Jan 2025 07:03:21 +0000 Subject: [PATCH 04/18] docs: Add news fragment --- changes/3424.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3424.fix.md diff --git a/changes/3424.fix.md b/changes/3424.fix.md new file mode 100644 index 00000000000..5004ff7c3c3 --- /dev/null +++ b/changes/3424.fix.md @@ -0,0 +1 @@ +Revamp `ContainerRegistryNode` API. From 482b557e66440e97a73e9d5e0d57788625da60a4 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 00:29:07 +0000 Subject: [PATCH 05/18] fix: Broken tests --- .../manager/models/container_registry.py | 94 +++++---- .../models/test_container_registry_nodes.py | 180 ++++++++++++++++++ 2 files changed, 239 insertions(+), 35 deletions(-) diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index fef10a9f674..4318ff0026a 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -462,11 +462,14 @@ class Meta: description = "Added in 24.09.0." allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistryNode) class Arguments: props = CreateContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") + ok = graphene.Boolean() + msg = graphene.String() + container_registry = graphene.Field(ContainerRegistryNode) + @classmethod async def mutate( cls, @@ -493,18 +496,23 @@ def _set_if_set(name: str, val: Any) -> None: _set_if_set("is_global", props.is_global) _set_if_set("extra", props.extra) - async with ctx.db.begin_session() as db_session: - reg_row = ContainerRegistryRow(**input_config) - db_session.add(reg_row) - await db_session.flush() - await db_session.refresh(reg_row) + try: + async with ctx.db.begin_session() as db_session: + reg_row = ContainerRegistryRow(**input_config) + db_session.add(reg_row) + await db_session.flush() + await db_session.refresh(reg_row) - if props.allowed_groups: - await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) + if props.allowed_groups: + await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) - return cls( - container_registry=ContainerRegistryNode.from_row(ctx, reg_row), - ) + return cls( + ok=True, + msg="success", + container_registry=ContainerRegistryNode.from_row(ctx, reg_row), + ) + except Exception as e: + return cls(ok=False, msg=str(e), container_registry=None) class ModifyContainerRegistryNodeInput(graphene.InputObjectType): @@ -522,11 +530,14 @@ class ModifyContainerRegistryNodeInput(graphene.InputObjectType): class ModifyContainerRegistryNode(graphene.Mutation): allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistryNode) class Meta: description = "Added in 24.09.0." + ok = graphene.Boolean() + msg = graphene.String() + container_registry = graphene.Field(ContainerRegistryNode) + class Arguments: id = graphene.String( required=True, @@ -563,23 +574,28 @@ def _set_if_set(name: str, val: Any) -> None: _, _id = AsyncNode.resolve_global_id(info, id) reg_id = uuid.UUID(_id) if _id else uuid.UUID(id) - async with ctx.db.begin_session() as session: - stmt = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) - reg_row = await session.scalar(stmt) - if reg_row is None: - raise ValueError(f"ContainerRegistry not found (id: {reg_id})") - for field, val in input_config.items(): - setattr(reg_row, field, val) + try: + async with ctx.db.begin_session() as session: + stmt = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) + reg_row = await session.scalar(stmt) + if reg_row is None: + raise ValueError(f"ContainerRegistry not found (id: {reg_id})") + for field, val in input_config.items(): + setattr(reg_row, field, val) - if props.allowed_groups: - await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) + if props.allowed_groups: + await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) - return cls(container_registry=ContainerRegistryNode.from_row(ctx, reg_row)) + except Exception as e: + return cls(ok=False, msg=str(e), container_registry=None) + + return cls( + ok=True, msg="success", container_registry=ContainerRegistryNode.from_row(ctx, reg_row) + ) class DeleteContainerRegistryNode(graphene.Mutation): allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistryNode) class Meta: description = "Added in 24.09.0." @@ -590,6 +606,10 @@ class Arguments: description="Object id. Can be either global id or object id. Added in 24.09.0.", ) + ok = graphene.Boolean() + msg = graphene.String() + container_registry = graphene.Field(ContainerRegistryNode) + @classmethod async def mutate( cls, @@ -601,19 +621,23 @@ async def mutate( _, _id = AsyncNode.resolve_global_id(info, id) reg_id = uuid.UUID(_id) if _id else uuid.UUID(id) - async with ctx.db.begin_session() as db_session: - reg_row = await ContainerRegistryRow.get(db_session, reg_id) - reg_row = await db_session.scalar( - sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) - ) - if reg_row is None: - raise ValueError(f"Container registry not found (id:{reg_id})") - container_registry = ContainerRegistryNode.from_row(ctx, reg_row) - await db_session.execute( - sa.delete(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) - ) - return cls(container_registry=container_registry) + try: + async with ctx.db.begin_session() as db_session: + reg_row = await ContainerRegistryRow.get(db_session, reg_id) + reg_row = await db_session.scalar( + sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) + ) + if reg_row is None: + raise ValueError(f"Container registry not found (id:{reg_id})") + container_registry = ContainerRegistryNode.from_row(ctx, reg_row) + await db_session.execute( + sa.delete(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) + ) + except Exception as e: + return cls(ok=False, msg=str(e), container_registry=None) + + return cls(ok=True, msg="success", container_registry=container_registry) # Legacy mutations diff --git a/tests/manager/models/test_container_registry_nodes.py b/tests/manager/models/test_container_registry_nodes.py index cb34a42a0f3..846532d87b7 100644 --- a/tests/manager/models/test_container_registry_nodes.py +++ b/tests/manager/models/test_container_registry_nodes.py @@ -8,6 +8,7 @@ from ai.backend.manager.models.container_registry import ContainerRegistryType from ai.backend.manager.models.gql import GraphQueryContext, Mutations, Queries from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.server import database_ctx CONTAINER_REGISTRY_FIELDS = """ row_id @@ -22,6 +23,48 @@ """ +FIXTURES_WITH_NOASSOC = [ + { + "groups": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "mock_group", + "description": "", + "is_active": True, + "domain_name": "default", + "resource_policy": "default", + "total_resource_slots": {}, + "allowed_vfolder_hosts": {}, + "type": "general", + } + ], + "container_registries": [ + { + "id": "00000000-0000-0000-0000-000000000002", + "url": "https://mock.registry.com", + "type": "docker", + "project": "mock_project", + "registry_name": "mock_registry", + } + ], + } +] + +FIXTURES_WITH_ASSOC = [ + { + **fixture, + "association_container_registries_groups": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "group_id": "00000000-0000-0000-0000-000000000001", + "registry_id": "00000000-0000-0000-0000-000000000002", + } + ], + } + for fixture in FIXTURES_WITH_NOASSOC +] + + @pytest.fixture(scope="module") def client() -> Client: return Client(Schema(query=Queries, mutation=Mutations, auto_camelcase=False)) @@ -141,6 +184,7 @@ async def test_modify_container_registry(client: Client, database_engine: Extend } response = await client.execute_async(query, variables=variables, context_value=context) + print("response!", response) target_container_registries = list( filter( @@ -408,3 +452,139 @@ async def test_delete_container_registry(client: Client, database_engine: Extend response = await client.execute_async(query, variables=variables, context_value=context) assert response["data"]["container_registry_nodes"] is None + + +@pytest.mark.dependency() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "extra_fixtures", + FIXTURES_WITH_NOASSOC + FIXTURES_WITH_ASSOC, + ids=["(No association)", "(With association)"], +) +@pytest.mark.parametrize( + "test_case", + [ + { + "group_id": "00000000-0000-0000-0000-000000000001", + "registry_id": "00000000-0000-0000-0000-000000000002", + }, + ], + ids=["Associate One group with one container registry"], +) +async def test_associate_container_registry_with_group( + client: Client, database_fixture, extra_fixtures, test_case, create_app_and_client +): + test_app, _ = await create_app_and_client( + [ + database_ctx, + ], + [], + ) + + root_ctx = test_app["_root.context"] + context = get_graphquery_context(root_ctx.db) + + query = """ + mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { + modify_container_registry_node(id: $id, props: $props) { + ok + msg + container_registry { + $CONTAINER_REGISTRY_FIELDS + } + } + } + """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) + + variables = { + "id": test_case["registry_id"], + "props": { + "allowed_groups": { + "add": [test_case["group_id"]], + } + }, + } + + response = await client.execute_async(query, variables=variables, context_value=context) + already_associated = "association_container_registries_groups" in extra_fixtures + + if already_associated: + assert not response["data"]["modify_container_registry_node"]["ok"] + assert not response["data"]["modify_container_registry_node"]["container_registry"] + else: + assert response["data"]["modify_container_registry_node"]["ok"] + assert response["data"]["modify_container_registry_node"]["msg"] == "success" + assert ( + response["data"]["modify_container_registry_node"]["container_registry"][ + "registry_name" + ] + == "mock_registry" + ) + + +@pytest.mark.dependency() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "extra_fixtures", + FIXTURES_WITH_ASSOC + FIXTURES_WITH_NOASSOC, + ids=["(With association)", "(No association)"], +) +@pytest.mark.parametrize( + "test_case", + [ + { + "group_id": "00000000-0000-0000-0000-000000000001", + "registry_id": "00000000-0000-0000-0000-000000000002", + }, + ], + ids=["Disassociate One group with one container registry"], +) +async def test_disassociate_container_registry_with_group( + client: Client, database_fixture, extra_fixtures, test_case, create_app_and_client +): + test_app, _ = await create_app_and_client( + [ + database_ctx, + ], + [], + ) + + root_ctx = test_app["_root.context"] + context = get_graphquery_context(root_ctx.db) + + query = """ + mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { + modify_container_registry_node(id: $id, props: $props) { + ok + msg + container_registry { + $CONTAINER_REGISTRY_FIELDS + } + } + } + """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) + + variables = { + "id": test_case["registry_id"], + "props": { + "allowed_groups": { + "remove": [test_case["group_id"]], + } + }, + } + + response = await client.execute_async(query, variables=variables, context_value=context) + association_exist = "association_container_registries_groups" in extra_fixtures + + if association_exist: + assert response["data"]["modify_container_registry_node"]["ok"] + assert response["data"]["modify_container_registry_node"]["msg"] == "success" + assert ( + response["data"]["modify_container_registry_node"]["container_registry"][ + "registry_name" + ] + == "mock_registry" + ) + else: + assert not response["data"]["modify_container_registry_node"]["ok"] + assert not response["data"]["modify_container_registry_node"]["container_registry"] From a57c7c880be8175570fa20fb597c7c6b1bd26488 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 00:38:03 +0000 Subject: [PATCH 06/18] fix: Update schema --- docs/manager/graphql-reference/schema.graphql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index a652c4a3d96..b710f2708cf 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -2600,6 +2600,8 @@ type UnsetQuotaScope { """Added in 24.09.0.""" type CreateContainerRegistryNode { + ok: Boolean + msg: String container_registry: ContainerRegistryNode } @@ -2645,6 +2647,8 @@ input AllowedGroups { """Added in 24.09.0.""" type ModifyContainerRegistryNode { + ok: Boolean + msg: String container_registry: ContainerRegistryNode } @@ -2682,6 +2686,8 @@ input ModifyContainerRegistryNodeInput { """Added in 24.09.0.""" type DeleteContainerRegistryNode { + ok: Boolean + msg: String container_registry: ContainerRegistryNode } From 29d222c65c031538187f22a1cf22887d36157d20 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 00:42:38 +0000 Subject: [PATCH 07/18] fix: Remove obsolete tests --- .../gql_models/test_container_registries.py | 151 ------------------ 1 file changed, 151 deletions(-) diff --git a/tests/manager/models/gql_models/test_container_registries.py b/tests/manager/models/gql_models/test_container_registries.py index 900bbd3bb1b..fd61b6a9368 100644 --- a/tests/manager/models/gql_models/test_container_registries.py +++ b/tests/manager/models/gql_models/test_container_registries.py @@ -4,7 +4,6 @@ from ai.backend.manager.models.gql import GraphQueryContext, Mutations, Queries from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from ai.backend.manager.server import database_ctx @pytest.fixture(scope="module") @@ -33,153 +32,3 @@ def get_graphquery_context(database_engine: ExtendedAsyncSAEngine) -> GraphQuery idle_checker_host=None, # type: ignore network_plugin_ctx=None, # type: ignore ) - - -FIXTURES_WITH_NOASSOC = [ - { - "groups": [ - { - "id": "00000000-0000-0000-0000-000000000001", - "name": "mock_group", - "description": "", - "is_active": True, - "domain_name": "default", - "resource_policy": "default", - "total_resource_slots": {}, - "allowed_vfolder_hosts": {}, - "type": "general", - } - ], - "container_registries": [ - { - "id": "00000000-0000-0000-0000-000000000002", - "url": "https://mock.registry.com", - "type": "docker", - "project": "mock_project", - "registry_name": "mock_registry", - } - ], - } -] - -FIXTURES_WITH_ASSOC = [ - { - **fixture, - "association_container_registries_groups": [ - { - "id": "00000000-0000-0000-0000-000000000000", - "group_id": "00000000-0000-0000-0000-000000000001", - "registry_id": "00000000-0000-0000-0000-000000000002", - } - ], - } - for fixture in FIXTURES_WITH_NOASSOC -] - - -@pytest.mark.dependency() -@pytest.mark.asyncio -@pytest.mark.parametrize( - "extra_fixtures", - FIXTURES_WITH_NOASSOC + FIXTURES_WITH_ASSOC, - ids=["(No association)", "(With association)"], -) -@pytest.mark.parametrize( - "test_case", - [ - { - "group_id": "00000000-0000-0000-0000-000000000001", - "registry_id": "00000000-0000-0000-0000-000000000002", - }, - ], - ids=["Associate One group with one container registry"], -) -async def test_associate_container_registry_with_group( - client: Client, database_fixture, extra_fixtures, test_case, create_app_and_client -): - test_app, _ = await create_app_and_client( - [ - database_ctx, - ], - [], - ) - - root_ctx = test_app["_root.context"] - context = get_graphquery_context(root_ctx.db) - - query = """ - mutation ($group_id: String!, $registry_id: String!) { - associate_container_registry_with_group(group_id: $group_id, registry_id: $registry_id) { - ok - msg - } - } - """ - - variables = { - "group_id": test_case["group_id"], - "registry_id": test_case["registry_id"], - } - - response = await client.execute_async(query, variables=variables, context_value=context) - already_associated = "association_container_registries_groups" in extra_fixtures - - if already_associated: - assert not response["data"]["associate_container_registry_with_group"]["ok"] - else: - assert response["data"]["associate_container_registry_with_group"]["ok"] - assert response["data"]["associate_container_registry_with_group"]["msg"] == "success" - - -@pytest.mark.dependency() -@pytest.mark.asyncio -@pytest.mark.parametrize( - "extra_fixtures", - FIXTURES_WITH_ASSOC + FIXTURES_WITH_NOASSOC, - ids=["(With association)", "(No association)"], -) -@pytest.mark.parametrize( - "test_case", - [ - { - "group_id": "00000000-0000-0000-0000-000000000001", - "registry_id": "00000000-0000-0000-0000-000000000002", - }, - ], - ids=["Disassociate One group with one container registry"], -) -async def test_disassociate_container_registry_with_group( - client: Client, database_fixture, extra_fixtures, test_case, create_app_and_client -): - test_app, _ = await create_app_and_client( - [ - database_ctx, - ], - [], - ) - - root_ctx = test_app["_root.context"] - context = get_graphquery_context(root_ctx.db) - - query = """ - mutation ($group_id: String!, $registry_id: String!) { - disassociate_container_registry_with_group(group_id: $group_id, registry_id: $registry_id) { - ok - msg - } - } - """ - - variables = { - "group_id": test_case["group_id"], - "registry_id": test_case["registry_id"], - } - - response = await client.execute_async(query, variables=variables, context_value=context) - association_exist = "association_container_registries_groups" in extra_fixtures - - if association_exist: - assert response["data"]["disassociate_container_registry_with_group"]["ok"] - assert response["data"]["disassociate_container_registry_with_group"]["msg"] == "success" - else: - assert not response["data"]["disassociate_container_registry_with_group"]["ok"] From fd13500cbf3ccb07f5a0037994dc05ed69d2c126 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 01:57:45 +0000 Subject: [PATCH 08/18] fix: Define `PatchContainerRegistryRequestModel`, `PatchContainerRegistryResponseModel` --- .../backend/client/func/container_registry.py | 6 ++- .../backend/manager/api/container_registry.py | 52 +++++++++++-------- .../models/test_container_registry_nodes.py | 2 - 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/ai/backend/client/func/container_registry.py b/src/ai/backend/client/func/container_registry.py index ee5582478b6..7956b0be62f 100644 --- a/src/ai/backend/client/func/container_registry.py +++ b/src/ai/backend/client/func/container_registry.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ai.backend.client.request import Request from .base import BaseFunction, api_function @@ -15,7 +17,7 @@ class ContainerRegistry(BaseFunction): @api_function @classmethod # TODO: Implement params type - async def patch_container_registry(cls, registry_id: str, params) -> None: + async def patch_container_registry(cls, registry_id: str, params) -> dict[str, Any]: """ Updates the container registry information, and return the container registry. @@ -30,4 +32,4 @@ async def patch_container_registry(cls, registry_id: str, params) -> None: request.set_json(params) async with request.fetch() as resp: - await resp.read() + return await resp.json() diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index da0876405c9..d7afca04ea7 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -2,7 +2,7 @@ import logging import uuid -from typing import TYPE_CHECKING, Iterable, Optional, Tuple +from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple import aiohttp_cors import sqlalchemy as sa @@ -14,7 +14,7 @@ from ai.backend.manager.models.association_container_registries_groups import ( AssociationContainerRegistriesGroupsRow, ) -from ai.backend.manager.models.container_registry import ContainerRegistryRow +from ai.backend.manager.models.container_registry import ContainerRegistryRow, ContainerRegistryType from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from .exceptions import ContainerRegistryNotFound, GenericBadRequest, InternalServerError @@ -35,22 +35,28 @@ class AllowedGroups(BaseModel): remove: list[str] = [] -class PatchContainerRegistryRequestModel(BaseModel): +class ContainerRegistryRowSchema(BaseModel): + id: Optional[uuid.UUID] = None url: Optional[str] = None - type: Optional[str] = None registry_name: Optional[str] = None - is_global: Optional[bool] = None + type: Optional[ContainerRegistryType] = None project: Optional[str] = None username: Optional[str] = None password: Optional[str] = None ssl_verify: Optional[bool] = None - extra: Optional[str] = None + is_global: Optional[bool] = None + extra: Optional[dict[str, Any]] = None + + class Config: + from_attributes = True + + +class PatchContainerRegistryRequestModel(ContainerRegistryRowSchema): allowed_groups: Optional[AllowedGroups] = None -# TODO: Add this. ContainerRegistryRow is not compatible with BaseModel -# class PatchContainerRegistryResponseModel(BaseModel): -# container_registry: ContainerRegistryRow +class PatchContainerRegistryResponseModel(ContainerRegistryRowSchema): + pass async def handle_allowed_groups_update( @@ -86,23 +92,28 @@ async def handle_allowed_groups_update( @pydantic_params_api_handler(PatchContainerRegistryRequestModel) async def patch_container_registry( request: web.Request, params: PatchContainerRegistryRequestModel -) -> web.Response: +) -> PatchContainerRegistryResponseModel: registry_id = uuid.UUID(request.match_info["registry_id"]) log.info("PATCH_CONTAINER_REGISTRY (cr:{})", registry_id) root_ctx: RootContext = request.app["_root.context"] registry_row_updates = params.model_dump(exclude={"allowed_groups"}, exclude_none=True) if registry_row_updates: - async with root_ctx.db.begin_session() as db_session: - update_stmt = ( - sa.update(ContainerRegistryRow) - .where(ContainerRegistryRow.id == registry_id) - .values(registry_row_updates) - ) - await db_session.execute(update_stmt) + try: + async with root_ctx.db.begin_session() as db_session: + update_stmt = ( + sa.update(ContainerRegistryRow) + .where(ContainerRegistryRow.id == registry_id) + .values(registry_row_updates) + ) + await db_session.execute(update_stmt) - # select_stmt = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == registry_id) - # updated_container_registry = await db_session.execute(select_stmt) + select_stmt = sa.select(ContainerRegistryRow).where( + ContainerRegistryRow.id == registry_id + ) + updated_container_registry = (await db_session.execute(select_stmt)).fetchone()[0] + except Exception as e: + raise InternalServerError(f"Failed to update container registry! Details: {str(e)}") try: if params.allowed_groups: @@ -114,8 +125,7 @@ async def patch_container_registry( except Exception as e: raise InternalServerError(f"Failed to update allowed groups! Details: {str(e)}") - # return PatchContainerRegistryResponseModel(container_registry=updated_container_registry) - return web.Response(status=204) + return PatchContainerRegistryResponseModel.model_validate(updated_container_registry) def create_app( diff --git a/tests/manager/models/test_container_registry_nodes.py b/tests/manager/models/test_container_registry_nodes.py index 846532d87b7..941b1c4057c 100644 --- a/tests/manager/models/test_container_registry_nodes.py +++ b/tests/manager/models/test_container_registry_nodes.py @@ -140,7 +140,6 @@ async def test_create_container_registry(client: Client, database_engine: Extend } response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["create_container_registry_node"]["container_registry"] id = container_registry.pop("row_id", None) @@ -184,7 +183,6 @@ async def test_modify_container_registry(client: Client, database_engine: Extend } response = await client.execute_async(query, variables=variables, context_value=context) - print("response!", response) target_container_registries = list( filter( From 7394dbe09ee85edd6535e0f0155cc1aa31620bef Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 05:23:45 +0000 Subject: [PATCH 09/18] fix: Use `GraphQLError` in exception handling --- docs/manager/graphql-reference/schema.graphql | 6 ----- .../manager/models/container_registry.py | 27 +++++++------------ 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index b710f2708cf..a652c4a3d96 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -2600,8 +2600,6 @@ type UnsetQuotaScope { """Added in 24.09.0.""" type CreateContainerRegistryNode { - ok: Boolean - msg: String container_registry: ContainerRegistryNode } @@ -2647,8 +2645,6 @@ input AllowedGroups { """Added in 24.09.0.""" type ModifyContainerRegistryNode { - ok: Boolean - msg: String container_registry: ContainerRegistryNode } @@ -2686,8 +2682,6 @@ input ModifyContainerRegistryNodeInput { """Added in 24.09.0.""" type DeleteContainerRegistryNode { - ok: Boolean - msg: String container_registry: ContainerRegistryNode } diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 4318ff0026a..9c8ea09d189 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -10,7 +10,7 @@ import graphql import sqlalchemy as sa import yarl -from graphql import Undefined +from graphql import GraphQLError, Undefined from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only, relationship from sqlalchemy.orm.exc import NoResultFound @@ -466,8 +466,6 @@ class Meta: class Arguments: props = CreateContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") - ok = graphene.Boolean() - msg = graphene.String() container_registry = graphene.Field(ContainerRegistryNode) @classmethod @@ -507,12 +505,10 @@ def _set_if_set(name: str, val: Any) -> None: await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) return cls( - ok=True, - msg="success", container_registry=ContainerRegistryNode.from_row(ctx, reg_row), ) except Exception as e: - return cls(ok=False, msg=str(e), container_registry=None) + raise GraphQLError(str(e)) class ModifyContainerRegistryNodeInput(graphene.InputObjectType): @@ -534,8 +530,6 @@ class ModifyContainerRegistryNode(graphene.Mutation): class Meta: description = "Added in 24.09.0." - ok = graphene.Boolean() - msg = graphene.String() container_registry = graphene.Field(ContainerRegistryNode) class Arguments: @@ -586,12 +580,10 @@ def _set_if_set(name: str, val: Any) -> None: if props.allowed_groups: await handle_allowed_groups_update(ctx.db, reg_row.id, props.allowed_groups) - except Exception as e: - return cls(ok=False, msg=str(e), container_registry=None) + return cls(container_registry=ContainerRegistryNode.from_row(ctx, reg_row)) - return cls( - ok=True, msg="success", container_registry=ContainerRegistryNode.from_row(ctx, reg_row) - ) + except Exception as e: + raise GraphQLError(str(e)) class DeleteContainerRegistryNode(graphene.Mutation): @@ -606,8 +598,6 @@ class Arguments: description="Object id. Can be either global id or object id. Added in 24.09.0.", ) - ok = graphene.Boolean() - msg = graphene.String() container_registry = graphene.Field(ContainerRegistryNode) @classmethod @@ -634,10 +624,11 @@ async def mutate( await db_session.execute( sa.delete(ContainerRegistryRow).where(ContainerRegistryRow.id == reg_id) ) - except Exception as e: - return cls(ok=False, msg=str(e), container_registry=None) - return cls(ok=True, msg="success", container_registry=container_registry) + return cls(container_registry=container_registry) + + except Exception as e: + raise GraphQLError(str(e)) # Legacy mutations From 450b9391a14cabe40676979c4698fb67edb02ab0 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 06:06:57 +0000 Subject: [PATCH 10/18] fix: Move ContainerRegistryRowSchema, ContainerRegistryRowModels under `common` package --- .../backend/client/func/container_registry.py | 11 +++-- src/ai/backend/common/container_registry.py | 45 +++++++++++++++++++ .../backend/manager/api/container_registry.py | 41 ++++------------- .../manager/container_registry/__init__.py | 3 +- .../manager/models/container_registry.py | 18 +++----- .../manager/models/gql_models/image.py | 3 +- .../models/test_container_registry_nodes.py | 2 +- 7 files changed, 72 insertions(+), 51 deletions(-) create mode 100644 src/ai/backend/common/container_registry.py diff --git a/src/ai/backend/client/func/container_registry.py b/src/ai/backend/client/func/container_registry.py index 7956b0be62f..8d3af0531ff 100644 --- a/src/ai/backend/client/func/container_registry.py +++ b/src/ai/backend/client/func/container_registry.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import Any - from ai.backend.client.request import Request +from ai.backend.common.container_registry import ( + PatchContainerRegistryRequestModel, + PatchContainerRegistryResponseModel, +) from .base import BaseFunction, api_function @@ -16,8 +18,9 @@ class ContainerRegistry(BaseFunction): @api_function @classmethod - # TODO: Implement params type - async def patch_container_registry(cls, registry_id: str, params) -> dict[str, Any]: + async def patch_container_registry( + cls, registry_id: str, params: PatchContainerRegistryRequestModel + ) -> PatchContainerRegistryResponseModel: """ Updates the container registry information, and return the container registry. diff --git a/src/ai/backend/common/container_registry.py b/src/ai/backend/common/container_registry.py new file mode 100644 index 00000000000..b6deaee0e0e --- /dev/null +++ b/src/ai/backend/common/container_registry.py @@ -0,0 +1,45 @@ +import enum +import uuid +from typing import Any, Optional + +from pydantic import BaseModel + + +class ContainerRegistryType(enum.StrEnum): + DOCKER = "docker" + HARBOR = "harbor" + HARBOR2 = "harbor2" + GITHUB = "github" + GITLAB = "gitlab" + ECR = "ecr" + ECR_PUB = "ecr-public" + LOCAL = "local" + + +class AllowedGroups(BaseModel): + add: list[str] = [] + remove: list[str] = [] + + +class ContainerRegistryRowSchema(BaseModel): + id: Optional[uuid.UUID] = None + url: Optional[str] = None + registry_name: Optional[str] = None + type: Optional[ContainerRegistryType] = None + project: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + ssl_verify: Optional[bool] = None + is_global: Optional[bool] = None + extra: Optional[dict[str, Any]] = None + + class Config: + from_attributes = True + + +class PatchContainerRegistryRequestModel(ContainerRegistryRowSchema): + allowed_groups: Optional[AllowedGroups] = None + + +class PatchContainerRegistryResponseModel(ContainerRegistryRowSchema): + pass diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index d7afca04ea7..f824ec50111 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -2,19 +2,25 @@ import logging import uuid -from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple +from typing import TYPE_CHECKING, Iterable, Tuple import aiohttp_cors import sqlalchemy as sa from aiohttp import web -from pydantic import BaseModel from sqlalchemy.exc import IntegrityError +from ai.backend.common.container_registry import ( + AllowedGroups, + PatchContainerRegistryRequestModel, + PatchContainerRegistryResponseModel, +) from ai.backend.logging import BraceStyleAdapter from ai.backend.manager.models.association_container_registries_groups import ( AssociationContainerRegistriesGroupsRow, ) -from ai.backend.manager.models.container_registry import ContainerRegistryRow, ContainerRegistryType +from ai.backend.manager.models.container_registry import ( + ContainerRegistryRow, +) from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from .exceptions import ContainerRegistryNotFound, GenericBadRequest, InternalServerError @@ -30,35 +36,6 @@ log = BraceStyleAdapter(logging.getLogger(__spec__.name)) -class AllowedGroups(BaseModel): - add: list[str] = [] - remove: list[str] = [] - - -class ContainerRegistryRowSchema(BaseModel): - id: Optional[uuid.UUID] = None - url: Optional[str] = None - registry_name: Optional[str] = None - type: Optional[ContainerRegistryType] = None - project: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None - ssl_verify: Optional[bool] = None - is_global: Optional[bool] = None - extra: Optional[dict[str, Any]] = None - - class Config: - from_attributes = True - - -class PatchContainerRegistryRequestModel(ContainerRegistryRowSchema): - allowed_groups: Optional[AllowedGroups] = None - - -class PatchContainerRegistryResponseModel(ContainerRegistryRowSchema): - pass - - async def handle_allowed_groups_update( db: ExtendedAsyncSAEngine, registry_id: uuid.UUID, allowed_group_updates: AllowedGroups ): diff --git a/src/ai/backend/manager/container_registry/__init__.py b/src/ai/backend/manager/container_registry/__init__.py index cea53c04002..89ba53dde0f 100644 --- a/src/ai/backend/manager/container_registry/__init__.py +++ b/src/ai/backend/manager/container_registry/__init__.py @@ -4,7 +4,8 @@ import yarl -from ai.backend.manager.models.container_registry import ContainerRegistryRow, ContainerRegistryType +from ai.backend.common.container_registry import ContainerRegistryType +from ai.backend.manager.models.container_registry import ContainerRegistryRow if TYPE_CHECKING: from .base import BaseContainerRegistry diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 9c8ea09d189..8a9ca771aeb 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -1,6 +1,5 @@ from __future__ import annotations -import enum import logging import uuid from collections.abc import Sequence @@ -15,6 +14,7 @@ from sqlalchemy.orm import load_only, relationship from sqlalchemy.orm.exc import NoResultFound +from ai.backend.common.container_registry import ContainerRegistryType from ai.backend.common.exception import UnknownImageRegistry from ai.backend.common.logging_utils import BraceStyleAdapter from ai.backend.manager.api.exceptions import ContainerRegistryNotFound @@ -49,20 +49,14 @@ "CreateContainerRegistry", "ModifyContainerRegistry", "DeleteContainerRegistry", + "ContainerRegistryNode", + "ContainerRegistryConnection", + "CreateContainerRegistryNode", + "ModifyContainerRegistryNode", + "DeleteContainerRegistryNode", ) -class ContainerRegistryType(enum.StrEnum): - DOCKER = "docker" - HARBOR = "harbor" - HARBOR2 = "harbor2" - GITHUB = "github" - GITLAB = "gitlab" - ECR = "ecr" - ECR_PUB = "ecr-public" - LOCAL = "local" - - class ContainerRegistryRow(Base): __tablename__ = "container_registries" id = IDColumn() diff --git a/src/ai/backend/manager/models/gql_models/image.py b/src/ai/backend/manager/models/gql_models/image.py index cb081b355ce..382f27f3d27 100644 --- a/src/ai/backend/manager/models/gql_models/image.py +++ b/src/ai/backend/manager/models/gql_models/image.py @@ -21,13 +21,14 @@ from sqlalchemy.orm import load_only, selectinload from ai.backend.common import redis_helper +from ai.backend.common.container_registry import ContainerRegistryType from ai.backend.common.docker import ImageRef from ai.backend.common.exception import UnknownImageReference from ai.backend.common.types import ( ImageAlias, ) from ai.backend.logging import BraceStyleAdapter -from ai.backend.manager.models.container_registry import ContainerRegistryRow, ContainerRegistryType +from ai.backend.manager.models.container_registry import ContainerRegistryRow from ...api.exceptions import ImageNotFound, ObjectNotFound from ...defs import DEFAULT_IMAGE_ARCH diff --git a/tests/manager/models/test_container_registry_nodes.py b/tests/manager/models/test_container_registry_nodes.py index 941b1c4057c..5916cee9f95 100644 --- a/tests/manager/models/test_container_registry_nodes.py +++ b/tests/manager/models/test_container_registry_nodes.py @@ -4,8 +4,8 @@ from graphene import Schema from graphene.test import Client +from ai.backend.common.container_registry import ContainerRegistryType from ai.backend.manager.defs import PASSWORD_PLACEHOLDER -from ai.backend.manager.models.container_registry import ContainerRegistryType from ai.backend.manager.models.gql import GraphQueryContext, Mutations, Queries from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from ai.backend.manager.server import database_ctx From 8987e977d817b4f1a1e67533be87717d812e90e5 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Mon, 13 Jan 2025 06:26:57 +0000 Subject: [PATCH 11/18] refactor: Remove duplicated code --- src/ai/backend/common/container_registry.py | 10 +++--- .../backend/manager/api/container_registry.py | 34 +------------------ .../manager/models/container_registry.py | 6 ++-- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/src/ai/backend/common/container_registry.py b/src/ai/backend/common/container_registry.py index b6deaee0e0e..dd41729dd63 100644 --- a/src/ai/backend/common/container_registry.py +++ b/src/ai/backend/common/container_registry.py @@ -16,12 +16,12 @@ class ContainerRegistryType(enum.StrEnum): LOCAL = "local" -class AllowedGroups(BaseModel): +class AllowedGroupsModel(BaseModel): add: list[str] = [] remove: list[str] = [] -class ContainerRegistryRowSchema(BaseModel): +class ContainerRegistryRowModel(BaseModel): id: Optional[uuid.UUID] = None url: Optional[str] = None registry_name: Optional[str] = None @@ -37,9 +37,9 @@ class Config: from_attributes = True -class PatchContainerRegistryRequestModel(ContainerRegistryRowSchema): - allowed_groups: Optional[AllowedGroups] = None +class PatchContainerRegistryRequestModel(ContainerRegistryRowModel): + allowed_groups: Optional[AllowedGroupsModel] = None -class PatchContainerRegistryResponseModel(ContainerRegistryRowSchema): +class PatchContainerRegistryResponseModel(ContainerRegistryRowModel): pass diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index f824ec50111..94c181dd84e 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -10,18 +10,14 @@ from sqlalchemy.exc import IntegrityError from ai.backend.common.container_registry import ( - AllowedGroups, PatchContainerRegistryRequestModel, PatchContainerRegistryResponseModel, ) from ai.backend.logging import BraceStyleAdapter -from ai.backend.manager.models.association_container_registries_groups import ( - AssociationContainerRegistriesGroupsRow, -) from ai.backend.manager.models.container_registry import ( ContainerRegistryRow, + handle_allowed_groups_update, ) -from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from .exceptions import ContainerRegistryNotFound, GenericBadRequest, InternalServerError @@ -36,34 +32,6 @@ log = BraceStyleAdapter(logging.getLogger(__spec__.name)) -async def handle_allowed_groups_update( - db: ExtendedAsyncSAEngine, registry_id: uuid.UUID, allowed_group_updates: AllowedGroups -): - async with db.begin_session() as db_sess: - if allowed_group_updates.add: - insert_values = [ - {"registry_id": registry_id, "group_id": group_id} - for group_id in allowed_group_updates.add - ] - - insert_query = sa.insert(AssociationContainerRegistriesGroupsRow).values(insert_values) - await db_sess.execute(insert_query) - - if allowed_group_updates.remove: - delete_query = ( - sa.delete(AssociationContainerRegistriesGroupsRow) - .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) - .where( - AssociationContainerRegistriesGroupsRow.group_id.in_( - allowed_group_updates.remove - ) - ) - ) - result = await db_sess.execute(delete_query) - if result.rowcount == 0: - raise ContainerRegistryNotFound() - - @server_status_required(READ_ALLOWED) @superadmin_required @pydantic_params_api_handler(PatchContainerRegistryRequestModel) diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 8a9ca771aeb..9a8a48bcda8 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import load_only, relationship from sqlalchemy.orm.exc import NoResultFound -from ai.backend.common.container_registry import ContainerRegistryType +from ai.backend.common.container_registry import AllowedGroupsModel, ContainerRegistryType from ai.backend.common.exception import UnknownImageRegistry from ai.backend.common.logging_utils import BraceStyleAdapter from ai.backend.manager.api.exceptions import ContainerRegistryNotFound @@ -299,7 +299,9 @@ class AllowedGroups(graphene.InputObjectType): async def handle_allowed_groups_update( - db: ExtendedAsyncSAEngine, registry_id: uuid.UUID, allowed_group_updates: AllowedGroups + db: ExtendedAsyncSAEngine, + registry_id: uuid.UUID, + allowed_group_updates: AllowedGroups | AllowedGroupsModel, ): async with db.begin_session() as db_sess: if allowed_group_updates.add: From 48eff49774d7f64fb0bfc38279b312b5eb226ecf Mon Sep 17 00:00:00 2001 From: jopemachine Date: Tue, 14 Jan 2025 02:09:10 +0000 Subject: [PATCH 12/18] feat: Add `ContainerRegistryNode.allowed_groups` --- docs/manager/graphql-reference/schema.graphql | 3 ++ .../manager/models/container_registry.py | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index a652c4a3d96..7f9f1da3bca 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -1533,6 +1533,9 @@ type ContainerRegistryNode implements Node { """Added in 24.09.3.""" extra: JSONString + + """Added in 25.1.0.""" + allowed_groups(limit: Int, offset: Int): [GroupNode] } """Added in 24.09.0.""" diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 9a8a48bcda8..45f6f567ba5 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -21,6 +21,8 @@ from ai.backend.manager.models.association_container_registries_groups import ( AssociationContainerRegistriesGroupsRow, ) +from ai.backend.manager.models.gql_models.group import GroupNode +from ai.backend.manager.models.group import GroupRow from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from ..defs import PASSWORD_PLACEHOLDER @@ -346,6 +348,9 @@ class Meta: password = graphene.String(description="Added in 24.09.0.") ssl_verify = graphene.Boolean(description="Added in 24.09.0.") extra = graphene.JSONString(description="Added in 24.09.3.") + allowed_groups = graphene.List( + GroupNode, description="Added in 25.1.0.", limit=graphene.Int(), offset=graphene.Int() + ) _queryfilter_fieldspec: dict[str, FieldSpecItem] = { "row_id": ("id", None), @@ -431,6 +436,30 @@ def from_row(cls, ctx: GraphQueryContext, row: ContainerRegistryRow) -> Containe extra=row.extra, ) + async def resolve_allowed_groups( + self, + info: graphene.ResolveInfo, + limit: int, + offset: int, + ) -> list[GroupNode]: + graph_ctx: GraphQueryContext = info.context + registry_id = self.id + + async with graph_ctx.db.begin_readonly() as db_session: + query = ( + sa.select(GroupRow) + .select_from(GroupRow) + .join( + AssociationContainerRegistriesGroupsRow, + GroupRow.id == AssociationContainerRegistriesGroupsRow.group_id, + ) + .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) + .limit(limit) + .offset(offset) + ) + groups = (await db_session.execute(query)).all() + return [GroupNode.from_row(graph_ctx, row) for row in groups] + class ContainerRegistryConnection(Connection): """Added in 24.09.0.""" From 1429ef42a840835b9b349f4c4406e846bbfba334 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Tue, 14 Jan 2025 02:42:59 +0000 Subject: [PATCH 13/18] refactor: `patch_container_registry` --- .../backend/manager/api/container_registry.py | 19 +++++++------------ .../manager/api/test_container_registries.py | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/ai/backend/manager/api/container_registry.py b/src/ai/backend/manager/api/container_registry.py index 94c181dd84e..488fb69c010 100644 --- a/src/ai/backend/manager/api/container_registry.py +++ b/src/ai/backend/manager/api/container_registry.py @@ -43,9 +43,9 @@ async def patch_container_registry( root_ctx: RootContext = request.app["_root.context"] registry_row_updates = params.model_dump(exclude={"allowed_groups"}, exclude_none=True) - if registry_row_updates: - try: - async with root_ctx.db.begin_session() as db_session: + try: + async with root_ctx.db.begin_session() as db_session: + if registry_row_updates: update_stmt = ( sa.update(ContainerRegistryRow) .where(ContainerRegistryRow.id == registry_id) @@ -53,14 +53,9 @@ async def patch_container_registry( ) await db_session.execute(update_stmt) - select_stmt = sa.select(ContainerRegistryRow).where( - ContainerRegistryRow.id == registry_id - ) - updated_container_registry = (await db_session.execute(select_stmt)).fetchone()[0] - except Exception as e: - raise InternalServerError(f"Failed to update container registry! Details: {str(e)}") + query = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == registry_id) + container_registry = (await db_session.execute(query)).fetchone()[0] - try: if params.allowed_groups: await handle_allowed_groups_update(root_ctx.db, registry_id, params.allowed_groups) except ContainerRegistryNotFound as e: @@ -68,9 +63,9 @@ async def patch_container_registry( except IntegrityError as e: raise GenericBadRequest(f"Failed to update allowed groups! Details: {str(e)}") except Exception as e: - raise InternalServerError(f"Failed to update allowed groups! Details: {str(e)}") + raise InternalServerError(f"Failed to update container registry! Details: {str(e)}") - return PatchContainerRegistryResponseModel.model_validate(updated_container_registry) + return PatchContainerRegistryResponseModel.model_validate(container_registry) def create_app( diff --git a/tests/manager/api/test_container_registries.py b/tests/manager/api/test_container_registries.py index 526387bf6cc..81e0a43b99f 100644 --- a/tests/manager/api/test_container_registries.py +++ b/tests/manager/api/test_container_registries.py @@ -102,7 +102,7 @@ async def test_associate_container_registry_with_group( if association_exist: assert resp.status == 400 else: - assert resp.status == 204 + assert resp.status == 200 @pytest.mark.asyncio @@ -153,6 +153,6 @@ async def test_disassociate_container_registry_with_group( association_exist = "association_container_registries_groups" in extra_fixtures if association_exist: - assert resp.status == 204 + assert resp.status == 200 else: assert resp.status == 404 From d84029348ce5ab0bd027b81e9e6308bcdd8fdf42 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Tue, 14 Jan 2025 04:22:24 +0000 Subject: [PATCH 14/18] fix: Broken CI --- .../models/test_container_registry_nodes.py | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/tests/manager/models/test_container_registry_nodes.py b/tests/manager/models/test_container_registry_nodes.py index 5916cee9f95..623719034f5 100644 --- a/tests/manager/models/test_container_registry_nodes.py +++ b/tests/manager/models/test_container_registry_nodes.py @@ -117,7 +117,7 @@ async def test_create_container_registry(client: Client, database_engine: Extend context = get_graphquery_context(database_engine) query = """ - mutation CreateContainerRegistryNode($props: CreateContainerRegistryNodeInput!) { + mutation ($props: CreateContainerRegistryNodeInput!) { create_container_registry_node(props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS @@ -166,7 +166,7 @@ async def test_modify_container_registry(client: Client, database_engine: Extend context = get_graphquery_context(database_engine) query = """ - query ContainerRegistryNodes($filter: String!) { + query ($filter: String!) { container_registry_nodes (filter: $filter) { edges { node { @@ -254,7 +254,7 @@ async def test_modify_container_registry_allows_empty_string( context = get_graphquery_context(database_engine) query = """ - query ContainerRegistryNodes($filter: String!) { + query ($filter: String!) { container_registry_nodes (filter: $filter) { edges { node { @@ -281,7 +281,7 @@ async def test_modify_container_registry_allows_empty_string( target_container_registry = target_container_registries[0]["node"] query = """ - mutation ModifyContainerRegistryNode($id: String!, $props: ModifyContainerRegistryNodeInput!) { + mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { modify_container_registry_node(id: $id, props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS @@ -321,7 +321,7 @@ async def test_modify_container_registry_allows_null_for_unset( context = get_graphquery_context(database_engine) query = """ - query ContainerRegistryNodes($filter: String!) { + query ($filter: String!) { container_registry_nodes (filter: $filter) { edges { node { @@ -349,7 +349,7 @@ async def test_modify_container_registry_allows_null_for_unset( target_container_registry = target_container_registries[0]["node"] query = """ - mutation ModifyContainerRegistryNode($id: String!, $props: ModifyContainerRegistryNodeInput!) { + mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { modify_container_registry_node(id: $id, props: $props) { container_registry { $CONTAINER_REGISTRY_FIELDS @@ -386,7 +386,7 @@ async def test_delete_container_registry(client: Client, database_engine: Extend context = get_graphquery_context(database_engine) query = """ - query ContainerRegistryNodes($filter: String!) { + query ($filter: String!) { container_registry_nodes (filter: $filter) { edges { node { @@ -414,7 +414,7 @@ async def test_delete_container_registry(client: Client, database_engine: Extend target_container_registry = target_container_registries[0]["node"] query = """ - mutation DeleteContainerRegistryNode($id: String!) { + mutation ($id: String!) { delete_container_registry_node(id: $id) { container_registry { $CONTAINER_REGISTRY_FIELDS @@ -432,7 +432,7 @@ async def test_delete_container_registry(client: Client, database_engine: Extend assert container_registry["registry_name"] == "cr.example.com" query = """ - query ContainerRegistryNodes($filter: String!) { + query ($filter: String!) { container_registry_nodes (filter: $filter) { edges { node { @@ -485,8 +485,6 @@ async def test_associate_container_registry_with_group( query = """ mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { modify_container_registry_node(id: $id, props: $props) { - ok - msg container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -507,11 +505,9 @@ async def test_associate_container_registry_with_group( already_associated = "association_container_registries_groups" in extra_fixtures if already_associated: - assert not response["data"]["modify_container_registry_node"]["ok"] - assert not response["data"]["modify_container_registry_node"]["container_registry"] + assert response["data"]["modify_container_registry_node"] is None + assert response["errors"] is not None else: - assert response["data"]["modify_container_registry_node"]["ok"] - assert response["data"]["modify_container_registry_node"]["msg"] == "success" assert ( response["data"]["modify_container_registry_node"]["container_registry"][ "registry_name" @@ -553,8 +549,6 @@ async def test_disassociate_container_registry_with_group( query = """ mutation ($id: String!, $props: ModifyContainerRegistryNodeInput!) { modify_container_registry_node(id: $id, props: $props) { - ok - msg container_registry { $CONTAINER_REGISTRY_FIELDS } @@ -575,8 +569,6 @@ async def test_disassociate_container_registry_with_group( association_exist = "association_container_registries_groups" in extra_fixtures if association_exist: - assert response["data"]["modify_container_registry_node"]["ok"] - assert response["data"]["modify_container_registry_node"]["msg"] == "success" assert ( response["data"]["modify_container_registry_node"]["container_registry"][ "registry_name" @@ -584,5 +576,5 @@ async def test_disassociate_container_registry_with_group( == "mock_registry" ) else: - assert not response["data"]["modify_container_registry_node"]["ok"] - assert not response["data"]["modify_container_registry_node"]["container_registry"] + assert response["data"]["modify_container_registry_node"] is None + assert response["errors"] is not None From 0b440f683011e09cb7062e24f3fb5524cda7e922 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Tue, 14 Jan 2025 06:19:49 +0000 Subject: [PATCH 15/18] docs: Update milestone --- docs/manager/graphql-reference/schema.graphql | 14 +++++++------- .../backend/manager/models/container_registry.py | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index 7f9f1da3bca..c58ec3dfc7f 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -1534,7 +1534,7 @@ type ContainerRegistryNode implements Node { """Added in 24.09.3.""" extra: JSONString - """Added in 25.1.0.""" + """Added in 25.2.0.""" allowed_groups(limit: Int, offset: Int): [GroupNode] } @@ -1850,7 +1850,7 @@ type Mutations { """Added in 24.09.0.""" create_container_registry_node( - """Added in 25.1.0.""" + """Added in 25.2.0.""" props: CreateContainerRegistryNodeInput! ): CreateContainerRegistryNode @@ -1859,7 +1859,7 @@ type Mutations { """Object id. Can be either global id or object id. Added in 24.09.0.""" id: String! - """Added in 25.1.0.""" + """Added in 25.2.0.""" props: ModifyContainerRegistryNodeInput! ): ModifyContainerRegistryNode @@ -2634,15 +2634,15 @@ input CreateContainerRegistryNodeInput { """Added in 24.09.3.""" extra: JSONString - """Added in 25.1.0.""" + """Added in 25.2.0.""" allowed_groups: AllowedGroups } input AllowedGroups { - """List of group_ids to add associations. Added in 25.1.0.""" + """List of group_ids to add associations. Added in 25.2.0.""" add: [String] = [] - """List of group_ids to remove associations. Added in 25.1.0.""" + """List of group_ids to remove associations. Added in 25.2.0.""" remove: [String] = [] } @@ -2679,7 +2679,7 @@ input ModifyContainerRegistryNodeInput { """Added in 24.09.3.""" extra: JSONString - """Added in 25.1.0.""" + """Added in 25.2.0.""" allowed_groups: AllowedGroups } diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 45f6f567ba5..9dcc129cb88 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -291,12 +291,12 @@ class AllowedGroups(graphene.InputObjectType): add = graphene.List( graphene.String, default_value=[], - description="List of group_ids to add associations. Added in 25.1.0.", + description="List of group_ids to add associations. Added in 25.2.0.", ) remove = graphene.List( graphene.String, default_value=[], - description="List of group_ids to remove associations. Added in 25.1.0.", + description="List of group_ids to remove associations. Added in 25.2.0.", ) @@ -349,7 +349,7 @@ class Meta: ssl_verify = graphene.Boolean(description="Added in 24.09.0.") extra = graphene.JSONString(description="Added in 24.09.3.") allowed_groups = graphene.List( - GroupNode, description="Added in 25.1.0.", limit=graphene.Int(), offset=graphene.Int() + GroupNode, description="Added in 25.2.0.", limit=graphene.Int(), offset=graphene.Int() ) _queryfilter_fieldspec: dict[str, FieldSpecItem] = { @@ -479,7 +479,7 @@ class CreateContainerRegistryNodeInput(graphene.InputObjectType): password = graphene.String(description="Added in 24.09.0.") ssl_verify = graphene.Boolean(description="Added in 24.09.0.") extra = graphene.JSONString(description="Added in 24.09.3.") - allowed_groups = AllowedGroups(description="Added in 25.1.0.") + allowed_groups = AllowedGroups(description="Added in 25.2.0.") class CreateContainerRegistryNode(graphene.Mutation): @@ -489,7 +489,7 @@ class Meta: allowed_roles = (UserRole.SUPERADMIN,) class Arguments: - props = CreateContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") + props = CreateContainerRegistryNodeInput(required=True, description="Added in 25.2.0.") container_registry = graphene.Field(ContainerRegistryNode) @@ -546,7 +546,7 @@ class ModifyContainerRegistryNodeInput(graphene.InputObjectType): password = graphene.String(description="Added in 24.09.0.") ssl_verify = graphene.Boolean(description="Added in 24.09.0.") extra = graphene.JSONString(description="Added in 24.09.3.") - allowed_groups = AllowedGroups(description="Added in 25.1.0.") + allowed_groups = AllowedGroups(description="Added in 25.2.0.") class ModifyContainerRegistryNode(graphene.Mutation): @@ -562,7 +562,7 @@ class Arguments: required=True, description="Object id. Can be either global id or object id. Added in 24.09.0.", ) - props = ModifyContainerRegistryNodeInput(required=True, description="Added in 25.1.0.") + props = ModifyContainerRegistryNodeInput(required=True, description="Added in 25.2.0.") @classmethod async def mutate( From c99a090553d78868bb24f5a8706193d0240de358 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 22 Jan 2025 05:29:16 +0000 Subject: [PATCH 16/18] feat: Implement `ProjectPermissionContextBuilder.build_ctx_in_container_registry_scope()` --- docs/manager/graphql-reference/schema.graphql | 2 +- .../manager/models/container_registry.py | 104 +++++++++++++----- src/ai/backend/manager/models/gql.py | 31 ++++-- .../manager/models/gql_models/group.py | 49 ++++++--- src/ai/backend/manager/models/group.py | 50 +++++++++ 5 files changed, 185 insertions(+), 51 deletions(-) diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index c58ec3dfc7f..2c1acd80d43 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -1535,7 +1535,7 @@ type ContainerRegistryNode implements Node { extra: JSONString """Added in 25.2.0.""" - allowed_groups(limit: Int, offset: Int): [GroupNode] + allowed_groups(filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): GroupConnection } """Added in 24.09.0.""" diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 9dcc129cb88..3a1e142aac7 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -1,9 +1,11 @@ from __future__ import annotations +import enum import logging import uuid from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, cast +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional, TypeAlias, cast import graphene import graphql @@ -18,27 +20,28 @@ from ai.backend.common.exception import UnknownImageRegistry from ai.backend.common.logging_utils import BraceStyleAdapter from ai.backend.manager.api.exceptions import ContainerRegistryNotFound -from ai.backend.manager.models.association_container_registries_groups import ( - AssociationContainerRegistriesGroupsRow, -) -from ai.backend.manager.models.gql_models.group import GroupNode -from ai.backend.manager.models.group import GroupRow -from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.models.rbac import SystemScope from ..defs import PASSWORD_PLACEHOLDER +from .association_container_registries_groups import ( + AssociationContainerRegistriesGroupsRow, +) from .base import ( Base, FilterExprArg, IDColumn, OrderExprArg, + PaginatedConnectionField, StrEnumType, generate_sql_info_for_gql_connection, set_if_set, ) +from .gql_models.group import GroupConnection, GroupNode from .gql_relay import AsyncNode, Connection, ConnectionResolverResult from .minilang.ordering import OrderSpecItem, QueryOrderParser from .minilang.queryfilter import FieldSpecItem, QueryFilterParser from .user import UserRole +from .utils import ExtendedAsyncSAEngine if TYPE_CHECKING: from .gql import GraphQueryContext @@ -56,9 +59,45 @@ "CreateContainerRegistryNode", "ModifyContainerRegistryNode", "DeleteContainerRegistryNode", + "ContainerRegistryScope", +) + + +WhereClauseType: TypeAlias = ( + sa.sql.expression.BinaryExpression | sa.sql.expression.BooleanClauseList ) +class ContainerRegistryScopeType(enum.StrEnum): + USER = "user" + PROJECT = "project" + + +@dataclass +class ContainerRegistryScope: + scope_type: ContainerRegistryScopeType + registry_id: uuid.UUID + + def __str__(self) -> str: + match self.registry_id: + case uuid.UUID(): + return f"{self.scope_type}:{str(self.registry_id)}" + case _: + raise ValueError(f"Invalid container registry scope ID: {str(self.registry_id)!r}") + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def parse(cls, raw: str) -> ContainerRegistryScope: + scope_type, _, registry_id = raw.partition(":") + match scope_type.lower(): + case ContainerRegistryScopeType.PROJECT | ContainerRegistryScopeType.USER as t: + return cls(t, uuid.UUID(registry_id)) + case _: + raise ValueError(f"Invalid container registry scope type: {scope_type!r}") + + class ContainerRegistryRow(Base): __tablename__ = "container_registries" id = IDColumn() @@ -348,9 +387,7 @@ class Meta: password = graphene.String(description="Added in 24.09.0.") ssl_verify = graphene.Boolean(description="Added in 24.09.0.") extra = graphene.JSONString(description="Added in 24.09.3.") - allowed_groups = graphene.List( - GroupNode, description="Added in 25.2.0.", limit=graphene.Int(), offset=graphene.Int() - ) + allowed_groups = PaginatedConnectionField(GroupConnection, description="Added in 25.2.0.") _queryfilter_fieldspec: dict[str, FieldSpecItem] = { "row_id": ("id", None), @@ -439,26 +476,35 @@ def from_row(cls, ctx: GraphQueryContext, row: ContainerRegistryRow) -> Containe async def resolve_allowed_groups( self, info: graphene.ResolveInfo, - limit: int, - offset: int, - ) -> list[GroupNode]: - graph_ctx: GraphQueryContext = info.context - registry_id = self.id - - async with graph_ctx.db.begin_readonly() as db_session: - query = ( - sa.select(GroupRow) - .select_from(GroupRow) - .join( - AssociationContainerRegistriesGroupsRow, - GroupRow.id == AssociationContainerRegistriesGroupsRow.group_id, - ) - .where(AssociationContainerRegistriesGroupsRow.registry_id == registry_id) - .limit(limit) - .offset(offset) + filter: Optional[str] = None, + order: Optional[str] = None, + offset: Optional[int] = None, + after: Optional[str] = None, + first: Optional[int] = None, + before: Optional[str] = None, + last: Optional[int] = None, + ) -> ConnectionResolverResult[GroupNode]: + if self.is_global: + scope = SystemScope() + container_registry_scope = None + else: + scope = None + container_registry_scope = ContainerRegistryScope.parse( + f"{ContainerRegistryScopeType.PROJECT}:{self.id}" ) - groups = (await db_session.execute(query)).all() - return [GroupNode.from_row(graph_ctx, row) for row in groups] + + return await GroupNode.get_connection( + info, + scope, + container_registry_scope, + filter_expr=filter, + order_expr=order, + offset=offset, + after=after, + first=first, + before=before, + last=last, + ) class ContainerRegistryConnection(Connection): diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py index 5e0190c0c6b..81d3c562b08 100644 --- a/src/ai/backend/manager/models/gql.py +++ b/src/ai/backend/manager/models/gql.py @@ -52,6 +52,7 @@ from ..idle import IdleCheckerHost from ..models.utils import ExtendedAsyncSAEngine from ..registry import AgentRegistry + from .container_registry import ContainerRegistryScope from .storage import StorageSessionManager from ..api.exceptions import ( @@ -140,7 +141,12 @@ from .keypair import CreateKeyPair, DeleteKeyPair, KeyPair, KeyPairList, ModifyKeyPair from .network import CreateNetwork, DeleteNetwork, ModifyNetwork, NetworkConnection, NetworkNode from .rbac import ProjectScope, ScopeType, SystemScope -from .rbac.permission_defs import AgentPermission, ComputeSessionPermission, DomainPermission +from .rbac.permission_defs import ( + AgentPermission, + ComputeSessionPermission, + DomainPermission, + ProjectPermission, +) from .rbac.permission_defs import VFolderPermission as VFolderRBACPermission from .resource_policy import ( CreateKeyPairResourcePolicy, @@ -464,6 +470,9 @@ class Queries(graphene.ObjectType): description="Added in 24.03.0.", filter=graphene.String(description="Added in 24.09.0."), order=graphene.String(description="Added in 24.09.0."), + # TODO: Add this. + # scope=ScopeType(), + # container_registry_scope=ContainerRegistryScope(), ) group = graphene.Field( @@ -1155,16 +1164,22 @@ async def resolve_group_nodes( root: Any, info: graphene.ResolveInfo, *, - filter: str | None = None, - order: str | None = None, - offset: int | None = None, - after: str | None = None, - first: int | None = None, - before: str | None = None, - last: int | None = None, + scope: Optional[ScopeType] = None, + container_registry_scope: Optional[ContainerRegistryScope] = None, + permission: ProjectPermission = ProjectPermission.READ_ATTRIBUTE, + filter: Optional[str] = None, + order: Optional[str] = None, + offset: Optional[int] = None, + after: Optional[str] = None, + first: Optional[int] = None, + before: Optional[str] = None, + last: Optional[int] = None, ) -> ConnectionResolverResult[GroupNode]: return await GroupNode.get_connection( info, + scope, + container_registry_scope, + permission, filter, order, offset, diff --git a/src/ai/backend/manager/models/gql_models/group.py b/src/ai/backend/manager/models/gql_models/group.py index d4d5fab7fbf..f5bb2f6aede 100644 --- a/src/ai/backend/manager/models/gql_models/group.py +++ b/src/ai/backend/manager/models/gql_models/group.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from typing import ( TYPE_CHECKING, + Optional, Self, Sequence, ) @@ -23,13 +24,17 @@ Connection, ConnectionResolverResult, ) -from ..group import AssocGroupUserRow, GroupRow, ProjectType +from ..group import AssocGroupUserRow, GroupRow, ProjectType, get_permission_ctx from ..minilang.ordering import OrderSpecItem, QueryOrderParser from ..minilang.queryfilter import FieldSpecItem, QueryFilterParser +from ..rbac.context import ClientContext +from ..rbac.permission_defs import ProjectPermission from .user import UserConnection, UserNode if TYPE_CHECKING: + from ..container_registry import ContainerRegistryScope from ..gql import GraphQueryContext + from ..rbac import ScopeType from ..scaling_group import ScalingGroup _queryfilter_fieldspec: Mapping[str, FieldSpecItem] = { @@ -217,13 +222,16 @@ async def get_node(cls, info: graphene.ResolveInfo, id) -> Self: async def get_connection( cls, info: graphene.ResolveInfo, - filter_expr: str | None = None, - order_expr: str | None = None, - offset: int | None = None, - after: str | None = None, - first: int | None = None, - before: str | None = None, - last: int | None = None, + scope: Optional[ScopeType] = None, + container_registry_scope: Optional[ContainerRegistryScope] = None, + permission: ProjectPermission = ProjectPermission.READ_ATTRIBUTE, + filter_expr: Optional[str] = None, + order_expr: Optional[str] = None, + offset: Optional[int] = None, + after: Optional[str] = None, + first: Optional[int] = None, + before: Optional[str] = None, + last: Optional[int] = None, ) -> ConnectionResolverResult[Self]: graph_ctx: GraphQueryContext = info.context _filter_arg = ( @@ -255,11 +263,26 @@ async def get_connection( before=before, last=last, ) - async with graph_ctx.db.begin_readonly_session() as db_session: - group_rows = (await db_session.scalars(query)).all() - result = [cls.from_row(graph_ctx, row) for row in group_rows] - total_cnt = await db_session.scalar(cnt_query) - return ConnectionResolverResult(result, cursor, pagination_order, page_size, total_cnt) + async with graph_ctx.db.connect() as db_conn: + user = graph_ctx.user + client_ctx = ClientContext( + graph_ctx.db, user["domain_name"], user["uuid"], user["role"] + ) + permission_ctx = await get_permission_ctx( + db_conn, client_ctx, permission, scope, container_registry_scope + ) + cond = permission_ctx.query_condition + if cond is None: + return ConnectionResolverResult([], cursor, pagination_order, page_size, 0) + query = query.where(cond) + cnt_query = cnt_query.where(cond) + + async with graph_ctx.db.begin_readonly_session(db_conn) as db_session: + group_rows = (await db_session.scalars(query)).all() + total_cnt = await db_session.scalar(cnt_query) + result = [cls.from_row(graph_ctx, row) for row in group_rows] + + return ConnectionResolverResult(result, cursor, pagination_order, page_size, total_cnt) class GroupConnection(Connection): diff --git a/src/ai/backend/manager/models/group.py b/src/ai/backend/manager/models/group.py index 42273bd8fa6..9dd948537b7 100644 --- a/src/ai/backend/manager/models/group.py +++ b/src/ai/backend/manager/models/group.py @@ -38,6 +38,9 @@ from ai.backend.common import msgpack from ai.backend.common.types import ResourceSlot, VFolderID from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.models.association_container_registries_groups import ( + AssociationContainerRegistriesGroupsRow, +) from ..api.exceptions import VFolderOperationFailed from ..defs import RESERVED_DOTFILES @@ -75,6 +78,7 @@ from .utils import ExtendedAsyncSAEngine, execute_with_retry if TYPE_CHECKING: + from .container_registry import ContainerRegistryScope from .gql import GraphQueryContext from .scaling_group import ScalingGroup from .storage import StorageSessionManager @@ -993,6 +997,10 @@ def verify_dotfile_name(dotfile: str) -> bool: @dataclass class ProjectPermissionContext(AbstractPermissionContext[ProjectPermission, GroupRow, uuid.UUID]): + registry_id_to_additional_permission_map: dict[uuid.UUID, frozenset[ProjectPermission]] = field( + default_factory=dict + ) + @property def query_condition(self) -> WhereClauseType | None: cond: WhereClauseType | None = None @@ -1003,6 +1011,16 @@ def _OR_coalesce( ) -> WhereClauseType: return base_cond | _cond if base_cond is not None else _cond + # TODO: Improve this. (It is just implementaion example.) + if self.registry_id_to_additional_permission_map: + registry_id = list(self.registry_id_to_additional_permission_map)[0] + + cond = _OR_coalesce( + cond, + GroupRow.association_container_registries_groups_rows.any( + AssociationContainerRegistriesGroupsRow.registry_id == registry_id + ), + ) if self.domain_name_to_permission_map: cond = _OR_coalesce( cond, GroupRow.domain_name.in_(self.domain_name_to_permission_map.keys()) @@ -1096,6 +1114,16 @@ async def build_ctx_in_user_scope( ) -> ProjectPermissionContext: return ProjectPermissionContext() + async def build_ctx_in_container_registry_scope( + self, ctx: ClientContext, scope: ContainerRegistryScope + ) -> ProjectPermissionContext: + # TODO: Improve this. + # permissions = await self.calculate_permission(ctx, scope) + permissions = ALL_PROJECT_PERMISSIONS + return ProjectPermissionContext( + registry_id_to_additional_permission_map={scope.registry_id: permissions} + ) + @override @classmethod async def _permission_for_owner( @@ -1156,3 +1184,25 @@ async def get_projects( permissions = await permission_ctx.calculate_final_permission(row) result.append(ProjectModel.from_row(row, permissions)) return result + + +async def get_permission_ctx( + db_conn: SAConnection, + ctx: ClientContext, + requested_permission: ProjectPermission, + target_scope: Optional[ScopeType] = None, + container_registry_scope: Optional[ContainerRegistryScope] = None, +) -> ProjectPermissionContext: + async with ctx.db.begin_readonly_session(db_conn) as db_session: + builder = ProjectPermissionContextBuilder(db_session) + + if target_scope is not None: + permission_ctx = await builder.build(ctx, target_scope, requested_permission) + elif container_registry_scope is not None: + permission_ctx = await builder.build_ctx_in_container_registry_scope( + ctx, container_registry_scope + ) + else: + raise ValueError("either target_scope or container_registry_scope must be provided") + + return permission_ctx From 84328f7d180793eb7510556cf8f0b7a1dc1407ef Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 30 Jan 2025 10:32:58 +0000 Subject: [PATCH 17/18] feat: Implement field restricted graphene object type --- src/ai/backend/manager/models/base.py | 59 +++++++++++++++++++ .../manager/models/container_registry.py | 17 +++++- src/ai/backend/manager/models/gql.py | 2 - 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/ai/backend/manager/models/base.py b/src/ai/backend/manager/models/base.py index e180fda4b2a..2ddacfe26a4 100644 --- a/src/ai/backend/manager/models/base.py +++ b/src/ai/backend/manager/models/base.py @@ -38,6 +38,7 @@ from aiodataloader import DataLoader from aiotools import apartial from graphene.types import Scalar +from graphene.types.objecttype import ObjectTypeMeta from graphene.types.scalars import MAX_INT, MIN_INT from graphql import Undefined from graphql.language.ast import IntValueNode @@ -1013,6 +1014,64 @@ async def wrapped( return wrap +def restricted_field_resolver(field_name: str): + from .user import UserRole + + async def resolver(root, info, *args, **kwargs): + cls = type(root) + required_roles = getattr(cls, "required_roles_for_fields", {}).get(field_name) + + if required_roles is None: + return getattr(root, field_name, None) + + if isinstance(required_roles, UserRole): + required_roles = [required_roles] + + ctx: GraphQueryContext = info.context + user_role: UserRole = ctx.user["role"] + + if user_role not in required_roles: + raise GenericForbidden(f"Access denied for the '{field_name}' field") + + return getattr(root, field_name, None) + + return resolver + + +class FieldRestrictedMeta(ObjectTypeMeta): + def __new__(mcs, name, bases, attrs, **options): + cls = super().__new__(mcs, name, bases, attrs, **options) + + for field_name, field_obj in cls._meta.fields.items(): + # Skip if the field has a custom resolver + if hasattr(cls, f"resolve_{field_name}") or field_obj.resolver: + continue + + field_obj.resolver = restricted_field_resolver(field_name) + + return cls + + +class FieldRestrictedObjectType(graphene.ObjectType, metaclass=FieldRestrictedMeta): + """ + This base class automatically assigns a resolver to each field + that checks if the user's role is in the list (or single value) + defined in `required_roles_for_fields`. + + Usage example in a subclass: + + required_roles_for_fields = { + "some_field": UserRole.SUPERADMIN, + "another_field": [UserRole.SUPERADMIN, UserRole.ADMIN], + } + + If the field is not listed in `required_roles_for_fields`, no special role is required. + Note that if there's already a custom resolver for a field, that field is skipped. + """ + + pass + + def scoped_query( *, autofill_user: bool = False, diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py index 3a1e142aac7..04433af492c 100644 --- a/src/ai/backend/manager/models/container_registry.py +++ b/src/ai/backend/manager/models/container_registry.py @@ -28,6 +28,7 @@ ) from .base import ( Base, + FieldRestrictedObjectType, FilterExprArg, IDColumn, OrderExprArg, @@ -369,7 +370,7 @@ async def handle_allowed_groups_update( raise ContainerRegistryNotFound() -class ContainerRegistryNode(graphene.ObjectType): +class ContainerRegistryNode(FieldRestrictedObjectType): class Meta: interfaces = (AsyncNode,) description = "Added in 24.09.0." @@ -398,6 +399,20 @@ class Meta: "registry_name": ("registry_name", None), } + required_roles_for_fields = { + "row_id": UserRole.SUPERADMIN, + "name": UserRole.SUPERADMIN, + "url": UserRole.SUPERADMIN, + "type": UserRole.SUPERADMIN, + "registry_name": UserRole.SUPERADMIN, + "is_global": UserRole.SUPERADMIN, + "project": UserRole.SUPERADMIN, + "username": UserRole.SUPERADMIN, + "password": UserRole.SUPERADMIN, + "ssl_verify": UserRole.SUPERADMIN, + "extra": UserRole.SUPERADMIN, + } + @classmethod async def get_node(cls, info: graphene.ResolveInfo, id: str) -> ContainerRegistryNode: graph_ctx: GraphQueryContext = info.context diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py index 81d3c562b08..c7fea382350 100644 --- a/src/ai/backend/manager/models/gql.py +++ b/src/ai/backend/manager/models/gql.py @@ -2566,7 +2566,6 @@ async def resolve_container_registries( return await ContainerRegistry.load_all(ctx) @staticmethod - @privileged_query(UserRole.SUPERADMIN) async def resolve_container_registry_node( root: Any, info: graphene.ResolveInfo, @@ -2575,7 +2574,6 @@ async def resolve_container_registry_node( return await ContainerRegistryNode.get_node(info, id) @staticmethod - @privileged_query(UserRole.SUPERADMIN) async def resolve_container_registry_nodes( root: Any, info: graphene.ResolveInfo, From 56157d44134829398f1e7ef261ca27c0288a9951 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Thu, 30 Jan 2025 10:52:08 +0000 Subject: [PATCH 18/18] chore: update api schema dump Co-authored-by: octodog --- docs/manager/rest-reference/openapi.json | 344 +++++++++++++++++++++++ 1 file changed, 344 insertions(+) diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index a4ffe214047..336f51d482d 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -20,6 +20,305 @@ } }, "schemas": { + "AllowedGroupsModel": { + "properties": { + "add": { + "default": [], + "items": { + "type": "string" + }, + "title": "Add", + "type": "array" + }, + "remove": { + "default": [], + "items": { + "type": "string" + }, + "title": "Remove", + "type": "array" + } + }, + "title": "AllowedGroupsModel", + "type": "object" + }, + "ContainerRegistryType": { + "enum": [ + "docker", + "harbor", + "harbor2", + "github", + "gitlab", + "ecr", + "ecr-public", + "local" + ], + "title": "ContainerRegistryType", + "type": "string" + }, + "PatchContainerRegistryRequestModel": { + "properties": { + "id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "registry_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Registry Name" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/ContainerRegistryType" + }, + { + "type": "null" + } + ], + "default": null + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Username" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Password" + }, + "ssl_verify": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ssl Verify" + }, + "is_global": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Global" + }, + "extra": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extra" + }, + "allowed_groups": { + "anyOf": [ + { + "$ref": "#/components/schemas/AllowedGroupsModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "PatchContainerRegistryRequestModel", + "type": "object" + }, + "PatchContainerRegistryResponseModel": { + "properties": { + "id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "registry_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Registry Name" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/ContainerRegistryType" + }, + { + "type": "null" + } + ], + "default": null + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Username" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Password" + }, + "ssl_verify": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ssl Verify" + }, + "is_global": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Global" + }, + "extra": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extra" + } + }, + "title": "PatchContainerRegistryResponseModel", + "type": "object" + }, "VFolderPermission": { "description": "Permissions for a virtual folder given to a specific access key.\nRW_DELETE includes READ_WRITE and READ_WRITE includes READ_ONLY.", "enum": [ @@ -1218,6 +1517,51 @@ "description": "\n**Preconditions:**\n* User privilege required.\n* Manager status required: RUNNING\n" } }, + "/container-registries/{registry_id}": { + "patch": { + "operationId": "container-registries.patch_container_registry", + "tags": [ + "container-registries" + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchContainerRegistryResponseModel" + } + } + } + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchContainerRegistryRequestModel" + } + } + } + }, + "parameters": [ + { + "name": "registry_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "\n**Preconditions:**\n* Superadmin privilege required.\n* Manager status required: one of FROZEN, RUNNING\n" + } + }, "/config/resource-slots": { "get": { "operationId": "config.get_resource_slots",