Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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/11139.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add project-scoped `createVFolderInProject` GraphQL mutation with RBAC enforcement via ScopeActionProcessor.
27 changes: 27 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -3948,6 +3948,28 @@ type CreateUserV2Payload
user: UserV2!
}

"""
Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument.
"""
input CreateVFolderInScopeInput
@join__type(graph: STRAWBERRY)
{
"""VFolder name."""
name: String!

"""Storage host for the vfolder."""
host: String = null

"""Usage mode of the vfolder."""
usageMode: VFolderUsageMode! = GENERAL

"""Default mount permission of the vfolder."""
permission: VFolderMountPermission! = READ_WRITE

"""Whether the vfolder is cloneable."""
cloneable: Boolean! = false
}

"""Added in 26.4.2. Input for creating a new virtual folder."""
input CreateVFolderV2Input
@join__type(graph: STRAWBERRY)
Expand Down Expand Up @@ -10798,6 +10820,11 @@ type Mutation
"""Added in 26.4.2. Create a new virtual folder."""
createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY)

"""
Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission.
"""
createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY)

"""Added in 26.4.2. Soft-delete a virtual folder (move to trash)."""
deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload! @join__field(graph: STRAWBERRY)

Expand Down
25 changes: 25 additions & 0 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,26 @@ type CreateVFSStoragePayload {
vfsStorage: VFSStorage!
}

"""
Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument.
"""
input CreateVFolderInScopeInput {
"""VFolder name."""
name: String!

"""Storage host for the vfolder."""
host: String = null

"""Usage mode of the vfolder."""
usageMode: VFolderUsageMode! = GENERAL

"""Default mount permission of the vfolder."""
permission: VFolderMountPermission! = READ_WRITE

"""Whether the vfolder is cloneable."""
cloneable: Boolean! = false
}

"""Added in 26.4.2. Input for creating a new virtual folder."""
input CreateVFolderV2Input {
"""VFolder name."""
Expand Down Expand Up @@ -6788,6 +6808,11 @@ type Mutation {
"""Added in 26.4.2. Create a new virtual folder."""
createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload!

"""
Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission.
"""
createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload!

"""Added in 26.4.2. Soft-delete a virtual folder (move to trash)."""
deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload!

Expand Down
41 changes: 41 additions & 0 deletions src/ai/backend/client/cli/v2/vfolder/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,47 @@ async def _run() -> None:
asyncio.run(_run())


@vfolder.command(name="project-create")
@click.argument("project_id", type=click.UUID)
@click.option("--name", required=True, help="VFolder name.")
@click.option(
"--usage-mode",
default="general",
type=click.Choice(["general", "model", "data"], case_sensitive=False),
help="Usage mode of the vfolder.",
)
@click.option("--host", default=None, type=str, help="Storage host.")
@click.option("--cloneable", is_flag=True, default=False, help="Allow cloning.")
def project_create(
project_id: UUID,
name: str,
usage_mode: str,
host: str | None,
cloneable: bool,
) -> None:
"""Create a vfolder owned by a project."""

from ai.backend.common.dto.manager.v2.vfolder.request import CreateVFolderInScopeInput
from ai.backend.common.dto.manager.v2.vfolder.types import VFolderUsageMode

input_dto = CreateVFolderInScopeInput(
name=name,
usage_mode=VFolderUsageMode(usage_mode),
host=host,
cloneable=cloneable,
)

async def _run() -> None:
registry = await create_v2_registry(load_v2_config())
try:
result = await registry.vfolder.create_in_project(project_id, input_dto)
print_result(result)
finally:
await registry.close()

asyncio.run(_run())


@vfolder.command()
@click.argument("vfolder_id", type=click.UUID)
def delete(vfolder_id: UUID) -> None:
Expand Down
14 changes: 14 additions & 0 deletions src/ai/backend/client/v2/domains_v2/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CreateDownloadSessionInput,
CreateUploadSessionInput,
CreateVFolderInput,
CreateVFolderInScopeInput,
DeleteFilesInput,
DeployVFolderInput,
ListFilesInput,
Expand Down Expand Up @@ -78,6 +79,19 @@ async def create(self, request: CreateVFolderInput) -> CreateVFolderPayload:
response_model=CreateVFolderPayload,
)

async def create_in_project(
self,
project_id: UUID,
request: CreateVFolderInScopeInput,
) -> CreateVFolderPayload:
"""Create a vfolder owned by ``project_id``."""
return await self._client.typed_request(
"POST",
f"{_PATH}/projects/{project_id}/create",
request=request,
response_model=CreateVFolderPayload,
)

async def create_upload_session(
self,
vfolder_id: UUID,
Expand Down
33 changes: 33 additions & 0 deletions src/ai/backend/common/dto/manager/v2/vfolder/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"CloneVFolderInput",
"CreateDownloadSessionInput",
"CreateUploadSessionInput",
"CreateVFolderInScopeInput",
"CreateVFolderInput",
"DeleteFilesInput",
"DeleteInvitationInput",
Expand Down Expand Up @@ -84,6 +85,38 @@ def strip_and_validate_name(cls, v: object) -> object:
return v


class CreateVFolderInScopeInput(BaseRequestModel):
"""Scope-agnostic body for vfolder creation under a specific scope.

The owning scope (project, user, domain, …) is supplied externally by
the transport layer (REST path segment, GraphQL mutation argument)
and is NOT part of this body. This keeps the body reusable across
scope-specific endpoints without forcing clients to duplicate the
scope identifier.
"""

name: VFolderName = Field(description="VFolder name")
Comment thread
fregataa marked this conversation as resolved.
host: str | None = Field(default=None, description="Storage host for the vfolder")
usage_mode: VFolderUsageMode = Field(
default=VFolderUsageMode.GENERAL, description="Usage mode of the vfolder"
)
permission: VFolderPermissionField = Field(
default=VFolderPermissionField.READ_WRITE,
description="Default permission of the vfolder",
)
cloneable: bool = Field(default=False, description="Whether the vfolder is cloneable")

@field_validator("name", mode="before")
@classmethod
def strip_and_validate_name(cls, v: object) -> object:
if isinstance(v, str):
stripped = v.strip()
if not stripped:
raise ValueError("name must not be blank or whitespace-only")
return stripped
return v


class UpdateVFolderInput(BaseRequestModel):
"""Input for updating a virtual folder."""

Expand Down
30 changes: 30 additions & 0 deletions src/ai/backend/manager/api/adapters/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CreateDownloadSessionInput,
CreateUploadSessionInput,
CreateVFolderInput,
CreateVFolderInScopeInput,
DeleteFilesInput,
DeployVFolderInput,
ListFilesInput,
Expand Down Expand Up @@ -127,6 +128,9 @@
from ai.backend.manager.services.vfolder.actions.upload_session_v2 import (
CreateUploadSessionV2Action,
)
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
CreateVFolderInProjectAction,
)
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
DeleteVFolderV2Action,
PurgeVFolderV2Action,
Expand Down Expand Up @@ -363,6 +367,32 @@ async def create(self, input: CreateVFolderInput) -> CreateVFolderPayload:
result = await self._processors.vfolder.create_vfolder_v2.wait_for_complete(action)
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))

async def create_in_project(
self,
project_id: UUID,
input: CreateVFolderInScopeInput,
) -> CreateVFolderPayload:
"""Create a vfolder owned by the given project.

Uses ``CreateVFolderInProjectAction`` which is PROJECT-scoped so the
caller must hold CREATE permission on the project.
"""
me = current_user()
if me is None:
raise UnreachableError("User context is not available")
Comment thread
fregataa marked this conversation as resolved.
action = CreateVFolderInProjectAction(
project_id=project_id,
user_id=me.user_id,
domain_name=me.domain_name,
name=input.name,
host=input.host,
usage_mode=VFolderUsageMode(input.usage_mode.value),
permission=VFolderPermission(input.permission.value),
cloneable=input.cloneable,
)
result = await self._processors.vfolder.create_vfolder_in_project.wait_for_complete(action)
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))

async def create_upload_session(
self, vfolder_id: UUID, input: CreateUploadSessionInput
) -> CreateUploadSessionPayload:
Expand Down
2 changes: 2 additions & 0 deletions src/ai/backend/manager/api/gql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@
bulk_delete_vfolders_v2,
bulk_purge_vfolders_v2,
clone_vfolder_v2,
create_vfolder_in_project,
create_vfolder_v2,
delete_vfolder_v2,
deploy_vfolder_v2,
Expand Down Expand Up @@ -841,6 +842,7 @@ class Mutation:
deploy_model_card_v2 = deploy_model_card_v2
# VFolder V2 mutations
create_vfolder_v2 = create_vfolder_v2
create_vfolder_in_project = create_vfolder_in_project
delete_vfolder_v2 = delete_vfolder_v2
purge_vfolder_v2 = purge_vfolder_v2
restore_vfolder_v2 = restore_vfolder_v2
Expand Down
2 changes: 2 additions & 0 deletions src/ai/backend/manager/api/gql/vfolder_v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
bulk_delete_vfolders_v2,
bulk_purge_vfolders_v2,
clone_vfolder_v2,
create_vfolder_in_project,
create_vfolder_v2,
delete_vfolder_v2,
deploy_vfolder_v2,
Expand Down Expand Up @@ -48,6 +49,7 @@
# Mutations
"bulk_delete_vfolders_v2",
"bulk_purge_vfolders_v2",
"create_vfolder_in_project",
"create_vfolder_v2",
"delete_vfolder_v2",
"deploy_vfolder_v2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
bulk_delete_vfolders_v2,
bulk_purge_vfolders_v2,
clone_vfolder_v2,
create_vfolder_in_project,
create_vfolder_v2,
delete_vfolder_v2,
deploy_vfolder_v2,
Expand All @@ -27,6 +28,7 @@
# Mutations
"bulk_delete_vfolders_v2",
"bulk_purge_vfolders_v2",
"create_vfolder_in_project",
"create_vfolder_v2",
"delete_vfolder_v2",
"deploy_vfolder_v2",
Expand Down
21 changes: 21 additions & 0 deletions src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
CloneVFolderInputGQL,
CloneVFolderPayloadGQL,
CreateVFolderInputGQL,
CreateVFolderInScopeInputGQL,
CreateVFolderPayloadGQL,
DeleteFilesInputGQL,
DeleteFilesPayloadGQL,
Expand Down Expand Up @@ -245,6 +246,26 @@ async def bulk_delete_vfolders_v2(
return BulkDeleteVFoldersPayloadGQL.from_pydantic(payload)


@gql_mutation(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description=(
"Create a virtual folder owned by the specified project. "
"Requires project-scoped CREATE permission."
),
),
name="createVFolderInProject",
) # type: ignore[misc]
async def create_vfolder_in_project(
info: Info[StrawberryGQLContext],
project_id: UUID,
input: CreateVFolderInScopeInputGQL,
) -> CreateVFolderPayloadGQL:
"""Create a new virtual folder scoped to a project."""
payload = await info.context.adapters.vfolder.create_in_project(project_id, input.to_pydantic())
return CreateVFolderPayloadGQL.from_pydantic(payload)
Comment thread
fregataa marked this conversation as resolved.


@gql_mutation(
BackendAIGQLMeta(
added_version="26.4.2",
Expand Down
30 changes: 30 additions & 0 deletions src/ai/backend/manager/api/gql/vfolder_v2/types/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
from ai.backend.common.dto.manager.v2.vfolder.request import (
CreateVFolderInput as CreateInputDTO,
)
from ai.backend.common.dto.manager.v2.vfolder.request import (
CreateVFolderInScopeInput as CreateInScopeInputDTO,
)
from ai.backend.common.dto.manager.v2.vfolder.request import (
DeleteFilesInput as DeleteFilesInputDTO,
)
Expand Down Expand Up @@ -94,6 +97,10 @@
PresetDeploymentStrategyInputGQL,
)
from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin
from ai.backend.manager.api.gql.vfolder_v2.types.enum import (
VFolderMountPermissionGQL,
VFolderUsageModeGQL,
)
from ai.backend.manager.api.gql.vfolder_v2.types.node import VFolderGQL

# ============================================================
Expand Down Expand Up @@ -123,6 +130,29 @@ class CreateVFolderInputGQL(PydanticInputMixin[CreateInputDTO]):
cloneable: bool = gql_field(default=False, description="Whether the vfolder is cloneable.")


@gql_pydantic_input(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description=(
"Scope-agnostic body for vfolder creation. The owning scope is "
"supplied as a separate mutation argument."
),
),
name="CreateVFolderInScopeInput",
)
class CreateVFolderInScopeInputGQL(PydanticInputMixin[CreateInScopeInputDTO]):
name: str = gql_field(description="VFolder name.")
host: str | None = gql_field(default=None, description="Storage host for the vfolder.")
usage_mode: VFolderUsageModeGQL = gql_field(
default=VFolderUsageModeGQL.GENERAL, description="Usage mode of the vfolder."
)
permission: VFolderMountPermissionGQL = gql_field(
default=VFolderMountPermissionGQL.READ_WRITE,
description="Default mount permission of the vfolder.",
)
cloneable: bool = gql_field(default=False, description="Whether the vfolder is cloneable.")


@gql_pydantic_input(
BackendAIGQLMeta(
added_version="26.4.2",
Expand Down
Loading
Loading