diff --git a/changes/10093.feature.md b/changes/10093.feature.md new file mode 100644 index 00000000000..3b9edab77d7 --- /dev/null +++ b/changes/10093.feature.md @@ -0,0 +1 @@ +Add Strawberry GraphQL node type for ContainerRegistry to support RBAC entity resolution diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 1ca1d447765..cf64ea1ddc0 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -2391,10 +2391,61 @@ input ContainerRegistryScope scalar ContainerRegistryScopeField @join__type(graph: GRAPHENE) +"""Added in 26.4.0. Container registry type.""" +enum ContainerRegistryType + @join__type(graph: STRAWBERRY) +{ + DOCKER @join__enumValue(graph: STRAWBERRY) + HARBOR @join__enumValue(graph: STRAWBERRY) + HARBOR2 @join__enumValue(graph: STRAWBERRY) + GITHUB @join__enumValue(graph: STRAWBERRY) + GITLAB @join__enumValue(graph: STRAWBERRY) + ECR @join__enumValue(graph: STRAWBERRY) + ECR_PUB @join__enumValue(graph: STRAWBERRY) + LOCAL @join__enumValue(graph: STRAWBERRY) + OCP @join__enumValue(graph: STRAWBERRY) +} + """Added in 24.09.0.""" scalar ContainerRegistryTypeField @join__type(graph: GRAPHENE) +"""Added in 26.4.0. Container registry node.""" +type ContainerRegistryV2 implements Node + @join__implements(graph: STRAWBERRY, interface: "Node") + @join__type(graph: STRAWBERRY) +{ + """The Globally Unique ID of this object""" + id: ID! + + """URL of the container registry""" + url: String! + + """Name of the container registry""" + registryName: String! + + """Type of the container registry""" + type: ContainerRegistryType! + + """Project or namespace within the registry""" + project: String + + """Username for registry authentication""" + username: String + + """Masked password for registry authentication""" + password: String + + """Whether SSL verification is enabled""" + sslVerify: Boolean + + """Whether this registry is globally accessible""" + isGlobal: Boolean + + """Extra metadata for the container registry""" + extra: JSON +} + """Added in 25.6.0.""" type ContainerUtilizationMetric @join__type(graph: GRAPHENE) @@ -4595,9 +4646,10 @@ union EntityNode @join__unionMember(graph: STRAWBERRY, member: "NotificationRule") @join__unionMember(graph: STRAWBERRY, member: "ModelDeployment") @join__unionMember(graph: STRAWBERRY, member: "ResourceGroup") + @join__unionMember(graph: STRAWBERRY, member: "ContainerRegistryV2") @join__unionMember(graph: STRAWBERRY, member: "ArtifactRevision") @join__unionMember(graph: STRAWBERRY, member: "Role") - = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ArtifactRevision | Role + = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role """Added in 26.3.0. Order by specification for entity associations""" input EntityOrderBy diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 0d6c4e6e35d..a3cc9a3d833 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -1415,6 +1415,52 @@ input ContainerRegistryScope { registryId: UUID! } +"""Added in 26.4.0. Container registry type.""" +enum ContainerRegistryType { + DOCKER + HARBOR + HARBOR2 + GITHUB + GITLAB + ECR + ECR_PUB + LOCAL + OCP +} + +"""Added in 26.4.0. Container registry node.""" +type ContainerRegistryV2 implements Node { + """The Globally Unique ID of this object""" + id: ID! + + """URL of the container registry""" + url: String! + + """Name of the container registry""" + registryName: String! + + """Type of the container registry""" + type: ContainerRegistryType! + + """Project or namespace within the registry""" + project: String + + """Username for registry authentication""" + username: String + + """Masked password for registry authentication""" + password: String + + """Whether SSL verification is enabled""" + sslVerify: Boolean + + """Whether this registry is globally accessible""" + isGlobal: Boolean + + """Extra metadata for the container registry""" + extra: JSON +} + input CreateAccessTokenInput { """ Added in 25.16.0: The ID of the model deployment for which the access token is created. @@ -2631,7 +2677,7 @@ input EntityFilter { NOT: [EntityFilter!] = null } -union EntityNode = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ArtifactRevision | Role +union EntityNode = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role """Added in 26.3.0. Order by specification for entity associations""" input EntityOrderBy { diff --git a/src/ai/backend/manager/api/gql/container_registry/__init__.py b/src/ai/backend/manager/api/gql/container_registry/__init__.py new file mode 100644 index 00000000000..fd6fa27f7cd --- /dev/null +++ b/src/ai/backend/manager/api/gql/container_registry/__init__.py @@ -0,0 +1,11 @@ +"""GraphQL container registry module.""" + +from .types import ( + ContainerRegistryGQL, + ContainerRegistryTypeGQL, +) + +__all__ = ( + "ContainerRegistryGQL", + "ContainerRegistryTypeGQL", +) diff --git a/src/ai/backend/manager/api/gql/container_registry/types.py b/src/ai/backend/manager/api/gql/container_registry/types.py new file mode 100644 index 00000000000..a10f0ab569c --- /dev/null +++ b/src/ai/backend/manager/api/gql/container_registry/types.py @@ -0,0 +1,96 @@ +"""GraphQL types for container registry.""" + +from __future__ import annotations + +from collections.abc import Iterable +from enum import StrEnum +from typing import Self +from uuid import UUID + +import strawberry +from strawberry import Info +from strawberry.relay import Node, NodeID +from strawberry.scalars import JSON + +from ai.backend.common.container_registry import ContainerRegistryType +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.data.container_registry.types import ContainerRegistryData +from ai.backend.manager.defs import PASSWORD_PLACEHOLDER + + +@strawberry.enum( + name="ContainerRegistryType", description="Added in 26.4.0. Container registry type." +) +class ContainerRegistryTypeGQL(StrEnum): + DOCKER = "docker" + HARBOR = "harbor" + HARBOR2 = "harbor2" + GITHUB = "github" + GITLAB = "gitlab" + ECR = "ecr" + ECR_PUB = "ecr-public" + LOCAL = "local" + OCP = "ocp" + + @classmethod + def from_enum(cls, value: ContainerRegistryType) -> ContainerRegistryTypeGQL: + return cls(value.value) + + +@strawberry.type( + name="ContainerRegistryV2", + description="Added in 26.4.0. Container registry node.", +) +class ContainerRegistryGQL(Node): + id: NodeID[str] = strawberry.field( + description="Relay-style global node identifier for the container registry" + ) + url: str = strawberry.field(description="URL of the container registry") + registry_name: str = strawberry.field(description="Name of the container registry") + type: ContainerRegistryTypeGQL = strawberry.field(description="Type of the container registry") + project: str | None = strawberry.field( + description="Project or namespace within the registry", default=None + ) + username: str | None = strawberry.field( + description="Username for registry authentication", default=None + ) + password: str | None = strawberry.field( + description="Masked password for registry authentication", default=None + ) + ssl_verify: bool | None = strawberry.field( + description="Whether SSL verification is enabled", default=None + ) + is_global: bool | None = strawberry.field( + description="Whether this registry is globally accessible", default=None + ) + extra: JSON | None = strawberry.field( + description="Extra metadata for the container registry", default=None + ) + + @classmethod + async def resolve_nodes( # type: ignore[override] # Strawberry Node uses AwaitableOrValue overloads incompatible with async def + cls, + *, + info: Info[StrawberryGQLContext], + node_ids: Iterable[str], + required: bool = False, + ) -> Iterable[Self | None]: + results = await info.context.data_loaders.container_registry_loader.load_many([ + UUID(nid) for nid in node_ids + ]) + return [cls.from_data(data) if data is not None else None for data in results] + + @classmethod + def from_data(cls, data: ContainerRegistryData) -> Self: + return cls( + id=str(data.id), + url=data.url, + registry_name=data.registry_name, + type=ContainerRegistryTypeGQL.from_enum(data.type), + project=data.project, + username=data.username, + password=PASSWORD_PLACEHOLDER if data.password is not None else None, + ssl_verify=data.ssl_verify, + is_global=data.is_global, + extra=data.extra, + ) diff --git a/src/ai/backend/manager/api/gql/rbac/types/entity.py b/src/ai/backend/manager/api/gql/rbac/types/entity.py index 4969daf7994..08fff1758a6 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/entity.py +++ b/src/ai/backend/manager/api/gql/rbac/types/entity.py @@ -65,6 +65,7 @@ async def entity( ) -> EntityNode | None: from ai.backend.common.types import ImageID from ai.backend.manager.api.gql.artifact.types import ArtifactRevision + from ai.backend.manager.api.gql.container_registry.types import ContainerRegistryGQL from ai.backend.manager.api.gql.deployment.types.deployment import ModelDeployment from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL from ai.backend.manager.api.gql.image.types import ImageV2GQL @@ -138,12 +139,18 @@ async def entity( if rev_data is None: return None return ArtifactRevision.from_dataclass(rev_data) + case RBACElementType.CONTAINER_REGISTRY: + cr_data = await data_loaders.container_registry_loader.load( + uuid.UUID(self.entity_id) + ) + if cr_data is None: + return None + return ContainerRegistryGQL.from_data(cr_data) case ( RBACElementType.SESSION | RBACElementType.VFOLDER | RBACElementType.KEYPAIR | RBACElementType.NETWORK - | RBACElementType.CONTAINER_REGISTRY | RBACElementType.STORAGE_HOST | RBACElementType.ARTIFACT | RBACElementType.ARTIFACT_REGISTRY diff --git a/src/ai/backend/manager/api/gql/rbac/types/entity_node.py b/src/ai/backend/manager/api/gql/rbac/types/entity_node.py index f2c98403b8e..95e8936c785 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/entity_node.py +++ b/src/ai/backend/manager/api/gql/rbac/types/entity_node.py @@ -13,6 +13,7 @@ from ai.backend.manager.api.gql.app_config import AppConfig from ai.backend.manager.api.gql.artifact.types import Artifact, ArtifactRevision from ai.backend.manager.api.gql.artifact_registry import ArtifactRegistry +from ai.backend.manager.api.gql.container_registry.types import ContainerRegistryGQL from ai.backend.manager.api.gql.deployment.types.deployment import ModelDeployment from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL from ai.backend.manager.api.gql.image.types import ImageV2GQL @@ -45,6 +46,7 @@ | NotificationRule | ModelDeployment | ResourceGroupGQL + | ContainerRegistryGQL | ArtifactRevision | RoleGQL, strawberry.union("EntityNode"), diff --git a/src/ai/backend/manager/api/gql/rbac/types/permission.py b/src/ai/backend/manager/api/gql/rbac/types/permission.py index 1f1d46ae181..8729b255040 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/permission.py +++ b/src/ai/backend/manager/api/gql/rbac/types/permission.py @@ -187,6 +187,7 @@ async def scope( info: Info[StrawberryGQLContext], ) -> EntityNode | None: from ai.backend.manager.api.gql.artifact.types import ArtifactRevision + from ai.backend.manager.api.gql.container_registry.types import ContainerRegistryGQL from ai.backend.manager.api.gql.deployment.types.deployment import ModelDeployment from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL from ai.backend.manager.api.gql.project_v2.types.node import ProjectV2GQL @@ -234,13 +235,19 @@ async def scope( if rev_data is None: return None return ArtifactRevision.from_dataclass(rev_data) + case RBACElementType.CONTAINER_REGISTRY: + cr_data = await data_loaders.container_registry_loader.load( + uuid.UUID(self.scope_id) + ) + if cr_data is None: + return None + return ContainerRegistryGQL.from_data(cr_data) case ( RBACElementType.SESSION | RBACElementType.VFOLDER | RBACElementType.KEYPAIR | RBACElementType.NOTIFICATION_CHANNEL | RBACElementType.NETWORK - | RBACElementType.CONTAINER_REGISTRY | RBACElementType.STORAGE_HOST | RBACElementType.IMAGE | RBACElementType.ARTIFACT diff --git a/tests/unit/manager/api/gql/test_container_registry_type_sync.py b/tests/unit/manager/api/gql/test_container_registry_type_sync.py new file mode 100644 index 00000000000..77f29e4806d --- /dev/null +++ b/tests/unit/manager/api/gql/test_container_registry_type_sync.py @@ -0,0 +1,26 @@ +"""Sync guard: ContainerRegistryTypeGQL must cover all ContainerRegistryType members. + +Prevents enum drift between the internal ContainerRegistryType and its GraphQL +mirror from silently reaching production as a runtime ValueError. +""" + +from __future__ import annotations + +from ai.backend.common.container_registry import ContainerRegistryType +from ai.backend.manager.api.gql.container_registry.types import ContainerRegistryTypeGQL + + +class TestContainerRegistryTypeEnumSync: + def test_gql_enum_covers_all_internal_members(self) -> None: + internal_values = {member.value for member in ContainerRegistryType} + gql_values = {member.value for member in ContainerRegistryTypeGQL} + missing = internal_values - gql_values + assert not missing, ( + f"ContainerRegistryTypeGQL is missing members present in ContainerRegistryType: {missing}. " + f"Add them to ContainerRegistryTypeGQL to keep the enums in sync." + ) + + def test_from_enum_roundtrip_for_all_members(self) -> None: + for member in ContainerRegistryType: + gql_member = ContainerRegistryTypeGQL.from_enum(member) + assert gql_member.value == member.value