Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/10093.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Strawberry GraphQL node type for ContainerRegistry to support RBAC entity resolution
54 changes: 53 additions & 1 deletion docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/ai/backend/manager/api/gql/container_registry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""GraphQL container registry module."""

from .types import (
ContainerRegistryGQL,
ContainerRegistryTypeGQL,
)

__all__ = (
"ContainerRegistryGQL",
"ContainerRegistryTypeGQL",
)
96 changes: 96 additions & 0 deletions src/ai/backend/manager/api/gql/container_registry/types.py
Original file line number Diff line number Diff line change
@@ -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,
)
9 changes: 8 additions & 1 deletion src/ai/backend/manager/api/gql/rbac/types/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
fregataa marked this conversation as resolved.
case (
RBACElementType.SESSION
| RBACElementType.VFOLDER
| RBACElementType.KEYPAIR
| RBACElementType.NETWORK
| RBACElementType.CONTAINER_REGISTRY
| RBACElementType.STORAGE_HOST
| RBACElementType.ARTIFACT
| RBACElementType.ARTIFACT_REGISTRY
Expand Down
2 changes: 2 additions & 0 deletions src/ai/backend/manager/api/gql/rbac/types/entity_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +46,7 @@
| NotificationRule
| ModelDeployment
| ResourceGroupGQL
| ContainerRegistryGQL
| ArtifactRevision
| RoleGQL,
strawberry.union("EntityNode"),
Expand Down
9 changes: 8 additions & 1 deletion src/ai/backend/manager/api/gql/rbac/types/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/manager/api/gql/test_container_registry_type_sync.py
Original file line number Diff line number Diff line change
@@ -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
Loading