From 5bc0dfd87be041057098e665383ee7349e031e9d Mon Sep 17 00:00:00 2001 From: HyeockJinKim Date: Sun, 26 Oct 2025 15:22:26 +0900 Subject: [PATCH 1/4] feat(BA-2735): Add domain-level app configuration GraphQL API Implement domain-level and user-level app configuration support for frontend with complete GraphQL API and RBAC. Key changes: - Add app_configs table with UUID primary key - Implement separate domain and user configuration actions - Add GraphQL queries: domainAppConfig, userAppConfig, mergedAppConfig - Add GraphQL mutations: upsertDomainAppConfig, upsertUserAppConfig, deleteDomainAppConfig, deleteUserAppConfig - Implement RBAC: domain operations require admin, user operations allow access to own config or admin - Use current_user context for mergedAppConfig (no user_id parameter) - Complete replacement of extra_config on upsert (not partial update) --- docs/manager/graphql-reference/schema.graphql | 10 +- .../graphql-reference/supergraph.graphql | 175 +++++++- .../graphql-reference/v2-schema.graphql | 147 +++++++ src/ai/backend/manager/api/admin.py | 12 +- src/ai/backend/manager/api/gql/app_config.py | 378 ++++++++++++++++++ src/ai/backend/manager/api/gql/schema.py | 16 + .../backend/manager/data/app_config/types.py | 3 +- ...fc_add_app_configs_table_for_frontend_.py} | 16 +- src/ai/backend/manager/models/app_config.py | 4 +- .../repositories/app_config/repositories.py | 18 + .../manager/repositories/repositories.py | 4 + .../manager/services/app_config/__init__.py | 9 + .../services/app_config/actions/__init__.py | 41 ++ .../services/app_config/actions/base.py | 9 + .../services/app_config/actions/domain.py | 109 +++++ .../services/app_config/actions/get_merged.py | 44 ++ .../services/app_config/actions/user.py | 109 +++++ .../manager/services/app_config/processors.py | 73 ++++ .../manager/services/app_config/service.py | 130 ++++++ src/ai/backend/manager/services/processors.py | 11 + 20 files changed, 1299 insertions(+), 19 deletions(-) create mode 100644 src/ai/backend/manager/api/gql/app_config.py rename src/ai/backend/manager/models/alembic/versions/{c3b9dacd4f79_add_app_configs_table_for_frontend_.py => d811b103dbfc_add_app_configs_table_for_frontend_.py} (67%) create mode 100644 src/ai/backend/manager/repositories/app_config/repositories.py create mode 100644 src/ai/backend/manager/services/app_config/__init__.py create mode 100644 src/ai/backend/manager/services/app_config/actions/__init__.py create mode 100644 src/ai/backend/manager/services/app_config/actions/base.py create mode 100644 src/ai/backend/manager/services/app_config/actions/domain.py create mode 100644 src/ai/backend/manager/services/app_config/actions/get_merged.py create mode 100644 src/ai/backend/manager/services/app_config/actions/user.py create mode 100644 src/ai/backend/manager/services/app_config/processors.py create mode 100644 src/ai/backend/manager/services/app_config/service.py diff --git a/docs/manager/graphql-reference/schema.graphql b/docs/manager/graphql-reference/schema.graphql index b581080da89..2fbe1f376c0 100644 --- a/docs/manager/graphql-reference/schema.graphql +++ b/docs/manager/graphql-reference/schema.graphql @@ -128,7 +128,7 @@ type Query { """Added in 24.03.1""" id: String reference: String - architecture: String = "x86_64" + architecture: String = "aarch64" ): Image images( """ @@ -2341,7 +2341,7 @@ type Mutation { ): RescanImages preload_image(references: [String]!, target_agents: [String]!): PreloadImage unload_image(references: [String]!, target_agents: [String]!): UnloadImage - modify_image(architecture: String = "x86_64", props: ModifyImageInput!, target: String!): ModifyImage + modify_image(architecture: String = "aarch64", props: ModifyImageInput!, target: String!): ModifyImage """Added in 25.6.0""" clear_image_custom_resource_limit(key: ClearImageCustomResourceLimitKey!): ClearImageCustomResourceLimitPayload @@ -2350,7 +2350,7 @@ type Mutation { forget_image_by_id(image_id: String!): ForgetImageById """Deprecated since 25.4.0. Use `forget_image_by_id` instead.""" - forget_image(architecture: String = "x86_64", reference: String!): ForgetImage @deprecated(reason: "Deprecated since 25.4.0. Use `forget_image_by_id` instead.") + forget_image(architecture: String = "aarch64", reference: String!): ForgetImage @deprecated(reason: "Deprecated since 25.4.0. Use `forget_image_by_id` instead.") """Added in 25.4.0""" purge_image_by_id( @@ -2362,7 +2362,7 @@ type Mutation { """Added in 24.03.1""" untag_image_from_registry(image_id: String!): UntagImageFromRegistry - alias_image(alias: String!, architecture: String = "x86_64", target: String!): AliasImage + alias_image(alias: String!, architecture: String = "aarch64", target: String!): AliasImage dealias_image(alias: String!): DealiasImage clear_images(registry: String): ClearImages @@ -2937,7 +2937,7 @@ type ClearImageCustomResourceLimitPayload { """Added in 25.6.0.""" input ClearImageCustomResourceLimitKey { image_canonical: String! - architecture: String! = "x86_64" + architecture: String! = "aarch64" } """Added in 24.03.0.""" diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index bce6104960c..a43f5771c3f 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -290,6 +290,13 @@ input AllowedGroups remove: [String] = [] } +"""Added in 25.16.0. App configuration data""" +type AppConfig + @join__type(graph: STRAWBERRY) +{ + extraConfig: JSON! +} + """ Added in 25.14.0. @@ -1036,7 +1043,7 @@ input ClearImageCustomResourceLimitKey @join__type(graph: GRAPHENE) { image_canonical: String! - architecture: String! = "x86_64" + architecture: String! = "aarch64" } """Added in 25.6.0.""" @@ -2044,6 +2051,24 @@ type DeleteDomain msg: String } +"""Added in 25.16.0. Input for deleting domain-level app configuration""" +input DeleteDomainConfigInput + @join__type(graph: STRAWBERRY) +{ + domainName: String! +} + +""" +Added in 25.16.0. +Payload returned after deleting domain-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteDomainConfigPayload + @join__type(graph: STRAWBERRY) +{ + deleted: Boolean! +} + """Added in 25.1.0.""" type DeleteEndpointAutoScalingRuleNode @join__type(graph: GRAPHENE) @@ -2171,6 +2196,24 @@ type DeleteUser msg: String } +"""Added in 25.16.0. Input for deleting user-level app configuration""" +input DeleteUserConfigInput + @join__type(graph: STRAWBERRY) +{ + userId: ID! +} + +""" +Added in 25.16.0. +Payload returned after deleting user-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteUserConfigPayload + @join__type(graph: STRAWBERRY) +{ + deleted: Boolean! +} + type DeleteUserResourcePolicy @join__type(graph: GRAPHENE) { @@ -4263,7 +4306,7 @@ type Mutation ): RescanImages @join__field(graph: GRAPHENE) preload_image(references: [String]!, target_agents: [String]!): PreloadImage @join__field(graph: GRAPHENE) unload_image(references: [String]!, target_agents: [String]!): UnloadImage @join__field(graph: GRAPHENE) - modify_image(architecture: String = "x86_64", props: ModifyImageInput!, target: String!): ModifyImage @join__field(graph: GRAPHENE) + modify_image(architecture: String = "aarch64", props: ModifyImageInput!, target: String!): ModifyImage @join__field(graph: GRAPHENE) """Added in 25.6.0""" clear_image_custom_resource_limit(key: ClearImageCustomResourceLimitKey!): ClearImageCustomResourceLimitPayload @join__field(graph: GRAPHENE) @@ -4272,7 +4315,7 @@ type Mutation forget_image_by_id(image_id: String!): ForgetImageById @join__field(graph: GRAPHENE) """Deprecated since 25.4.0. Use `forget_image_by_id` instead.""" - forget_image(architecture: String = "x86_64", reference: String!): ForgetImage @join__field(graph: GRAPHENE) @deprecated(reason: "Deprecated since 25.4.0. Use `forget_image_by_id` instead.") + forget_image(architecture: String = "aarch64", reference: String!): ForgetImage @join__field(graph: GRAPHENE) @deprecated(reason: "Deprecated since 25.4.0. Use `forget_image_by_id` instead.") """Added in 25.4.0""" purge_image_by_id( @@ -4284,7 +4327,7 @@ type Mutation """Added in 24.03.1""" untag_image_from_registry(image_id: String!): UntagImageFromRegistry @join__field(graph: GRAPHENE) - alias_image(alias: String!, architecture: String = "x86_64", target: String!): AliasImage @join__field(graph: GRAPHENE) + alias_image(alias: String!, architecture: String = "aarch64", target: String!): AliasImage @join__field(graph: GRAPHENE) dealias_image(alias: String!): DealiasImage @join__field(graph: GRAPHENE) clear_images(registry: String): ClearImages @join__field(graph: GRAPHENE) @@ -4523,6 +4566,46 @@ type Mutation """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! @join__field(graph: STRAWBERRY) + """ Added in 25.15.0. @@ -5101,7 +5184,7 @@ type Query """Added in 24.03.1""" id: String reference: String - architecture: String = "x86_64" + architecture: String = "aarch64" ): Image @join__field(graph: GRAPHENE) images( """ @@ -5405,6 +5488,38 @@ type Query """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection! @join__field(graph: STRAWBERRY) + """ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + domainAppConfig(domainName: String!): AppConfig @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Users can only access their own configuration, but admins can access any user's configuration. + """ + userAppConfig(userId: ID!): AppConfig @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + mergedAppConfig: AppConfig! @join__field(graph: STRAWBERRY) + """Added in 25.16.0""" deployments(filter: DeploymentFilter = null, orderBy: [DeploymentOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelDeploymentConnection! @join__field(graph: STRAWBERRY) @@ -6435,6 +6550,56 @@ type UpdateVFSStoragePayload vfsStorage: VFSStorage! } +""" +Added in 25.16.0. +Input for creating or updating domain-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +All users in this domain will be affected by these settings when their configurations are merged. +""" +input UpsertDomainConfigInput + @join__type(graph: STRAWBERRY) +{ + domainName: String! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting domain-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertDomainConfigPayload + @join__type(graph: STRAWBERRY) +{ + appConfig: AppConfig! +} + +""" +Added in 25.16.0. +Input for creating or updating user-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +These settings will override domain-level settings when configurations are merged for this user. +""" +input UpsertUserConfigInput + @join__type(graph: STRAWBERRY) +{ + userId: ID! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting user-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertUserConfigPayload + @join__type(graph: STRAWBERRY) +{ + appConfig: AppConfig! +} + type User implements Item @join__implements(graph: GRAPHENE, interface: "Item") @join__type(graph: GRAPHENE) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index ad853229088..bc9cb851340 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -91,6 +91,11 @@ type AgentStats { totalResource: AgentResource! } +"""Added in 25.16.0. App configuration data""" +type AppConfig { + extraConfig: JSON! +} + """ Added in 25.14.0. @@ -790,6 +795,20 @@ type DeleteAutoScalingRulePayload { id: ID! } +"""Added in 25.16.0. Input for deleting domain-level app configuration""" +input DeleteDomainConfigInput { + domainName: String! +} + +""" +Added in 25.16.0. +Payload returned after deleting domain-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteDomainConfigPayload { + deleted: Boolean! +} + """Added in 25.14.0""" input DeleteHuggingFaceRegistryInput { id: ID! @@ -830,6 +849,20 @@ type DeleteReservoirRegistryPayload { id: ID! } +"""Added in 25.16.0. Input for deleting user-level app configuration""" +input DeleteUserConfigInput { + userId: ID! +} + +""" +Added in 25.16.0. +Payload returned after deleting user-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteUserConfigPayload { + deleted: Boolean! +} + """Added in 25.16.0. Input for deleting VFS storage""" input DeleteVFSStorageInput { id: ID! @@ -1331,6 +1364,46 @@ type Mutation { """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! + """ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! + + """ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! + + """ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! + + """ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! + """ Added in 25.15.0. @@ -1635,6 +1708,38 @@ type Query { """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection! + """ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + domainAppConfig(domainName: String!): AppConfig + + """ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Users can only access their own configuration, but admins can access any user's configuration. + """ + userAppConfig(userId: ID!): AppConfig + + """ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + mergedAppConfig: AppConfig! + """Added in 25.16.0""" deployments(filter: DeploymentFilter = null, orderBy: [DeploymentOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelDeploymentConnection! @@ -2206,6 +2311,48 @@ type UpdateVFSStoragePayload { vfsStorage: VFSStorage! } +""" +Added in 25.16.0. +Input for creating or updating domain-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +All users in this domain will be affected by these settings when their configurations are merged. +""" +input UpsertDomainConfigInput { + domainName: String! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting domain-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertDomainConfigPayload { + appConfig: AppConfig! +} + +""" +Added in 25.16.0. +Input for creating or updating user-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +These settings will override domain-level settings when configurations are merged for this user. +""" +input UpsertUserConfigInput { + userId: ID! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting user-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertUserConfigPayload { + appConfig: AppConfig! +} + extend type UserNode @key(fields: "id") { id: ID! @external } diff --git a/src/ai/backend/manager/api/admin.py b/src/ai/backend/manager/api/admin.py index 6331d10c079..dc97ceac004 100644 --- a/src/ai/backend/manager/api/admin.py +++ b/src/ai/backend/manager/api/admin.py @@ -21,6 +21,7 @@ from ai.backend.logging import BraceStyleAdapter from ai.backend.manager.api.gql.data_loader.registry import DataLoaderRegistry from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.errors.auth import AuthorizationFailed from ..api.gql.schema import schema as strawberry_schema from ..errors.api import GraphQLError as BackendGQLError @@ -35,7 +36,7 @@ from .auth import auth_required from .manager import GQLMutationUnfrozenRequiredMiddleware from .types import CORSOptions, WebMiddleware -from .utils import check_api_params +from .utils import check_api_params, set_handler_attr if TYPE_CHECKING: from graphql import FieldNode @@ -56,6 +57,15 @@ def __init__(self, *args, **kwargs) -> None: Supports both query/mutation via POST and subscriptions via WebSocket. """ + # Set handler attributes for middleware compatibility + set_handler_attr(self, "auth_required", True) + set_handler_attr(self, "auth_scope", "user") + + @auth_required + async def __call__(self, request: web.Request) -> web.StreamResponse: + if request.get("is_authorized", False): + return await super().__call__(request) + raise AuthorizationFailed("Unauthorized access to GraphQL endpoint") async def get_context( # type: ignore[override] self, request: web.Request, response: web.Response | web.WebSocketResponse diff --git a/src/ai/backend/manager/api/gql/app_config.py b/src/ai/backend/manager/api/gql/app_config.py new file mode 100644 index 00000000000..4feac01fbaf --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config.py @@ -0,0 +1,378 @@ +"""GraphQL types and operations for app configuration.""" + +from __future__ import annotations + +from typing import Optional + +import strawberry +from strawberry import ID, Info + +from ai.backend.common.contexts.user import current_user +from ai.backend.manager.api.gql.utils import dedent_strip +from ai.backend.manager.data.app_config.types import AppConfigModifier +from ai.backend.manager.errors.auth import InsufficientPrivilege +from ai.backend.manager.services.app_config.actions import ( + DeleteDomainConfigAction, + DeleteUserConfigAction, + GetDomainConfigAction, + GetMergedAppConfigAction, + GetUserConfigAction, + UpsertDomainConfigAction, + UpsertUserConfigAction, +) +from ai.backend.manager.types import OptionalState + +from .types import StrawberryGQLContext + + +@strawberry.type(description="Added in 25.16.0. App configuration data") +class AppConfig: + """GraphQL type for app configuration.""" + + extra_config: strawberry.scalars.JSON + + +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for creating or updating domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged. + """ + ) +) +class UpsertDomainConfigInput: + """Input type for upserting domain-level app configuration.""" + + domain_name: str + extra_config: strawberry.scalars.JSON + + def to_modifier(self) -> AppConfigModifier: + return AppConfigModifier(extra_config=OptionalState.update(self.extra_config)) + + +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for creating or updating user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + """ + ) +) +class UpsertUserConfigInput: + """Input type for upserting user-level app configuration.""" + + user_id: ID + extra_config: strawberry.scalars.JSON + + def to_modifier(self) -> AppConfigModifier: + return AppConfigModifier(extra_config=OptionalState.update(self.extra_config)) + + +@strawberry.input(description="Added in 25.16.0. Input for deleting domain-level app configuration") +class DeleteDomainConfigInput: + """Input type for deleting domain-level app configuration.""" + + domain_name: str + + +@strawberry.input(description="Added in 25.16.0. Input for deleting user-level app configuration") +class DeleteUserConfigInput: + """Input type for deleting user-level app configuration.""" + + user_id: ID + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after upserting domain-level app configuration. + Contains the resulting configuration that was stored. + """ + ) +) +class UpsertDomainConfigPayload: + """Payload returned after upserting domain-level app configuration.""" + + app_config: AppConfig + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after upserting user-level app configuration. + Contains the resulting configuration that was stored. + """ + ) +) +class UpsertUserConfigPayload: + """Payload returned after upserting user-level app configuration.""" + + app_config: AppConfig + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after deleting domain-level app configuration. + Indicates whether the deletion was successful. + """ + ) +) +class DeleteDomainConfigPayload: + """Payload returned after deleting domain-level app configuration.""" + + deleted: bool + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after deleting user-level app configuration. + Indicates whether the deletion was successful. + """ + ) +) +class DeleteUserConfigPayload: + """Payload returned after deleting user-level app configuration.""" + + deleted: bool + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + ) +) +async def domain_app_config( + domain_name: str, + info: Info[StrawberryGQLContext], +) -> Optional[AppConfig]: + """Get domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to access domain configuration") + + action_result = await processors.app_config.get_domain_config.wait_for_complete( + GetDomainConfigAction(domain_name=domain_name) + ) + + if not action_result.result: + return None + + return AppConfig(extra_config=action_result.result.extra_config) + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Users can only access their own configuration, but admins can access any user's configuration. + """ + ) +) +async def user_app_config( + user_id: ID, + info: Info[StrawberryGQLContext], +) -> Optional[AppConfig]: + """Get user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + if str(me.user_id) != str(user_id) and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot access another user's app configuration") + + action_result = await processors.app_config.get_user_config.wait_for_complete( + GetUserConfigAction(user_id=str(user_id)) + ) + + if not action_result.result: + return None + + return AppConfig(extra_config=action_result.result.extra_config) + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + ) +) +async def merged_app_config( + info: Info[StrawberryGQLContext], +) -> AppConfig: + """Get merged app configuration for the current user.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + + action_result = await processors.app_config.get_merged_config.wait_for_complete( + GetMergedAppConfigAction(user_id=str(me.user_id)) + ) + + return AppConfig(extra_config=action_result.merged_config) + + +@strawberry.mutation( + name="upsertDomainAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + ), +) +async def upsert_domain_app_config( + input: UpsertDomainConfigInput, + info: Info[StrawberryGQLContext], +) -> UpsertDomainConfigPayload: + """Create or update domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to modify domain configuration") + + action_result = await processors.app_config.upsert_domain_config.wait_for_complete( + UpsertDomainConfigAction( + domain_name=input.domain_name, + modifier=input.to_modifier(), + ) + ) + + return UpsertDomainConfigPayload( + app_config=AppConfig(extra_config=action_result.result.extra_config) + ) + + +@strawberry.mutation( + name="upsertUserAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + ), +) +async def upsert_user_app_config( + input: UpsertUserConfigInput, + info: Info[StrawberryGQLContext], +) -> UpsertUserConfigPayload: + """Create or update user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + if str(me.user_id) != str(input.user_id) and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot modify another user's app configuration") + + action_result = await processors.app_config.upsert_user_config.wait_for_complete( + UpsertUserConfigAction( + user_id=str(input.user_id), + modifier=input.to_modifier(), + ) + ) + + return UpsertUserConfigPayload( + app_config=AppConfig(extra_config=action_result.result.extra_config) + ) + + +@strawberry.mutation( + name="deleteDomainAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + ), +) +async def delete_domain_app_config( + input: DeleteDomainConfigInput, + info: Info[StrawberryGQLContext], +) -> DeleteDomainConfigPayload: + """Delete domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to delete domain configuration") + + action_result = await processors.app_config.delete_domain_config.wait_for_complete( + DeleteDomainConfigAction(domain_name=input.domain_name) + ) + + return DeleteDomainConfigPayload(deleted=action_result.deleted) + + +@strawberry.mutation( + name="deleteUserAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + ), +) +async def delete_user_app_config( + input: DeleteUserConfigInput, + info: Info[StrawberryGQLContext], +) -> DeleteUserConfigPayload: + """Delete user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + if str(me.user_id) != str(input.user_id) and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot delete another user's app configuration") + + action_result = await processors.app_config.delete_user_config.wait_for_complete( + DeleteUserConfigAction(user_id=str(input.user_id)) + ) + + return DeleteUserConfigPayload(deleted=action_result.deleted) diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 9a6bd0668de..ddbf5c0f0ad 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -5,6 +5,15 @@ from .agent_stats import ( agent_stats, ) +from .app_config import ( + delete_domain_app_config, + delete_user_app_config, + domain_app_config, + merged_app_config, + upsert_domain_app_config, + upsert_user_app_config, + user_app_config, +) from .artifact import ( approve_artifact_revision, artifact, @@ -96,6 +105,9 @@ class Query: artifacts = artifacts artifact_revision = artifact_revision artifact_revisions = artifact_revisions + domain_app_config = domain_app_config + user_app_config = user_app_config + merged_app_config = merged_app_config deployments = deployments deployment = deployment revisions = revisions @@ -121,6 +133,10 @@ class Mutation: scan_artifacts = scan_artifacts scan_artifact_models = scan_artifact_models import_artifacts = import_artifacts + upsert_domain_app_config = upsert_domain_app_config + upsert_user_app_config = upsert_user_app_config + delete_domain_app_config = delete_domain_app_config + delete_user_app_config = delete_user_app_config delegate_scan_artifacts = delegate_scan_artifacts delegate_import_artifacts = delegate_import_artifacts update_artifact = update_artifact diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py index b0f8bf73dcc..da121a92ba6 100644 --- a/src/ai/backend/manager/data/app_config/types.py +++ b/src/ai/backend/manager/data/app_config/types.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Any, override +from uuid import UUID from ai.backend.manager.types import Creator, OptionalState, PartialModifier @@ -24,7 +25,7 @@ class MergedAppConfig: @dataclass class AppConfigData: - id: int + id: UUID scope_type: AppConfigScopeType scope_id: str extra_config: dict[str, Any] diff --git a/src/ai/backend/manager/models/alembic/versions/c3b9dacd4f79_add_app_configs_table_for_frontend_.py b/src/ai/backend/manager/models/alembic/versions/d811b103dbfc_add_app_configs_table_for_frontend_.py similarity index 67% rename from src/ai/backend/manager/models/alembic/versions/c3b9dacd4f79_add_app_configs_table_for_frontend_.py rename to src/ai/backend/manager/models/alembic/versions/d811b103dbfc_add_app_configs_table_for_frontend_.py index 74fbc8dd83a..7665d8e0da2 100644 --- a/src/ai/backend/manager/models/alembic/versions/c3b9dacd4f79_add_app_configs_table_for_frontend_.py +++ b/src/ai/backend/manager/models/alembic/versions/d811b103dbfc_add_app_configs_table_for_frontend_.py @@ -1,8 +1,8 @@ -"""add app_configs table for frontend configuration +"""Add app_configs table for frontend configuration -Revision ID: c3b9dacd4f79 +Revision ID: d811b103dbfc Revises: 09206ac04fd3 -Create Date: 2025-10-24 12:35:03.509221 +Create Date: 2025-10-26 15:11:41.781361 """ @@ -10,8 +10,10 @@ from alembic import op from sqlalchemy.dialects import postgresql +from ai.backend.manager.models.base import GUID + # revision identifiers, used by Alembic. -revision = "c3b9dacd4f79" +revision = "d811b103dbfc" down_revision = "09206ac04fd3" branch_labels = None depends_on = None @@ -21,7 +23,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( "app_configs", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("id", GUID(), server_default=sa.text("uuid_generate_v4()"), nullable=False), sa.Column( "scope_type", sa.Enum("DOMAIN", "PROJECT", "USER", name="app_config_scope_type"), @@ -41,10 +43,14 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id", name=op.f("pk_app_configs")), sa.UniqueConstraint("scope_type", "scope_id", name="uq_app_configs_scope"), ) + op.create_index(op.f("ix_app_configs_scope_id"), "app_configs", ["scope_id"], unique=False) + op.create_index(op.f("ix_app_configs_scope_type"), "app_configs", ["scope_type"], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_app_configs_scope_type"), table_name="app_configs") + op.drop_index(op.f("ix_app_configs_scope_id"), table_name="app_configs") op.drop_table("app_configs") # ### end Alembic commands ### diff --git a/src/ai/backend/manager/models/app_config.py b/src/ai/backend/manager/models/app_config.py index f2cffe9a416..5ff27ecae2b 100644 --- a/src/ai/backend/manager/models/app_config.py +++ b/src/ai/backend/manager/models/app_config.py @@ -9,7 +9,7 @@ from ai.backend.logging import BraceStyleAdapter from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigScopeType -from .base import Base +from .base import Base, IDColumn log = BraceStyleAdapter(logging.getLogger(__spec__.name)) @@ -23,7 +23,7 @@ class AppConfigRow(Base): __tablename__ = "app_configs" - id = sa.Column("id", sa.Integer, primary_key=True, autoincrement=True) + id = IDColumn() scope_type = sa.Column( "scope_type", sa.Enum(AppConfigScopeType, name="app_config_scope_type"), diff --git a/src/ai/backend/manager/repositories/app_config/repositories.py b/src/ai/backend/manager/repositories/app_config/repositories.py new file mode 100644 index 00000000000..f140d547ad7 --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config/repositories.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Self + +from ai.backend.manager.repositories.app_config.repository import AppConfigRepository +from ai.backend.manager.repositories.types import RepositoryArgs + + +@dataclass +class AppConfigRepositories: + repository: AppConfigRepository + + @classmethod + def create(cls, args: RepositoryArgs) -> Self: + repository = AppConfigRepository(args.db, args.valkey_stat_client) + + return cls( + repository=repository, + ) diff --git a/src/ai/backend/manager/repositories/repositories.py b/src/ai/backend/manager/repositories/repositories.py index 6af4bc6313e..c13dbcf5008 100644 --- a/src/ai/backend/manager/repositories/repositories.py +++ b/src/ai/backend/manager/repositories/repositories.py @@ -2,6 +2,7 @@ from typing import Self from ai.backend.manager.repositories.agent.repositories import AgentRepositories +from ai.backend.manager.repositories.app_config.repositories import AppConfigRepositories from ai.backend.manager.repositories.artifact.repositories import ArtifactRepositories from ai.backend.manager.repositories.artifact_registry.repositories import ( ArtifactRegistryRepositories, @@ -48,6 +49,7 @@ @dataclass class Repositories: agent: AgentRepositories + app_config: AppConfigRepositories auth: AuthRepositories container_registry: ContainerRegistryRepositories deployment: DeploymentRepositories @@ -76,6 +78,7 @@ class Repositories: @classmethod def create(cls, args: RepositoryArgs) -> Self: agent_repositories = AgentRepositories.create(args) + app_config_repositories = AppConfigRepositories.create(args) auth_repositories = AuthRepositories.create(args) container_registry_repositories = ContainerRegistryRepositories.create(args) deployment_repositories = DeploymentRepositories.create(args) @@ -103,6 +106,7 @@ def create(cls, args: RepositoryArgs) -> Self: return cls( agent=agent_repositories, + app_config=app_config_repositories, auth=auth_repositories, container_registry=container_registry_repositories, deployment=deployment_repositories, diff --git a/src/ai/backend/manager/services/app_config/__init__.py b/src/ai/backend/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..a07577a5012 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/__init__.py @@ -0,0 +1,9 @@ +"""App configuration service.""" + +from .processors import AppConfigProcessors +from .service import AppConfigService + +__all__ = [ + "AppConfigService", + "AppConfigProcessors", +] diff --git a/src/ai/backend/manager/services/app_config/actions/__init__.py b/src/ai/backend/manager/services/app_config/actions/__init__.py new file mode 100644 index 00000000000..88abf4b4dcf --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/__init__.py @@ -0,0 +1,41 @@ +"""Actions for app configuration service.""" + +from .base import AppConfigAction +from .domain import ( + DeleteDomainConfigAction, + DeleteDomainConfigActionResult, + GetDomainConfigAction, + GetDomainConfigActionResult, + UpsertDomainConfigAction, + UpsertDomainConfigActionResult, +) +from .get_merged import GetMergedAppConfigAction, GetMergedAppConfigActionResult +from .user import ( + DeleteUserConfigAction, + DeleteUserConfigActionResult, + GetUserConfigAction, + GetUserConfigActionResult, + UpsertUserConfigAction, + UpsertUserConfigActionResult, +) + +__all__ = [ + "AppConfigAction", + # Domain config actions + "GetDomainConfigAction", + "GetDomainConfigActionResult", + "UpsertDomainConfigAction", + "UpsertDomainConfigActionResult", + "DeleteDomainConfigAction", + "DeleteDomainConfigActionResult", + # User config actions + "GetUserConfigAction", + "GetUserConfigActionResult", + "UpsertUserConfigAction", + "UpsertUserConfigActionResult", + "DeleteUserConfigAction", + "DeleteUserConfigActionResult", + # Merged config action + "GetMergedAppConfigAction", + "GetMergedAppConfigActionResult", +] diff --git a/src/ai/backend/manager/services/app_config/actions/base.py b/src/ai/backend/manager/services/app_config/actions/base.py new file mode 100644 index 00000000000..d0b86aa2e8e --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/base.py @@ -0,0 +1,9 @@ +"""Base action class for app_config service.""" + +from __future__ import annotations + +from ai.backend.manager.actions.action import BaseAction + + +class AppConfigAction(BaseAction): + """Base class for all app_config actions.""" diff --git a/src/ai/backend/manager/services/app_config/actions/domain.py b/src/ai/backend/manager/services/app_config/actions/domain.py new file mode 100644 index 00000000000..119a6b8e359 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/domain.py @@ -0,0 +1,109 @@ +"""Domain-level app configuration actions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigModifier + +from .base import AppConfigAction + + +@dataclass +class GetDomainConfigAction(AppConfigAction): + """Action to get domain-level app configuration.""" + + domain_name: str + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_domain" + + @override + def entity_id(self) -> Optional[str]: + return self.domain_name + + @override + @classmethod + def operation_type(cls) -> str: + return "get_domain_config" + + +@dataclass +class GetDomainConfigActionResult(BaseActionResult): + """Result of get domain config action.""" + + result: Optional[AppConfigData] + + @override + def entity_id(self) -> Optional[str]: + return self.result.scope_id if self.result else None + + +@dataclass +class UpsertDomainConfigAction(AppConfigAction): + """Action to create or update domain-level app configuration.""" + + domain_name: str + modifier: AppConfigModifier + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_domain" + + @override + def entity_id(self) -> Optional[str]: + return self.domain_name + + @override + @classmethod + def operation_type(cls) -> str: + return "upsert_domain_config" + + +@dataclass +class UpsertDomainConfigActionResult(BaseActionResult): + """Result of upsert domain config action.""" + + result: AppConfigData + + @override + def entity_id(self) -> Optional[str]: + return self.result.scope_id + + +@dataclass +class DeleteDomainConfigAction(AppConfigAction): + """Action to delete domain-level app configuration.""" + + domain_name: str + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_domain" + + @override + def entity_id(self) -> Optional[str]: + return self.domain_name + + @override + @classmethod + def operation_type(cls) -> str: + return "delete_domain_config" + + +@dataclass +class DeleteDomainConfigActionResult(BaseActionResult): + """Result of delete domain config action.""" + + deleted: bool + domain_name: str + + @override + def entity_id(self) -> Optional[str]: + return self.domain_name if self.deleted else None diff --git a/src/ai/backend/manager/services/app_config/actions/get_merged.py b/src/ai/backend/manager/services/app_config/actions/get_merged.py new file mode 100644 index 00000000000..ce83e63d4c4 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/get_merged.py @@ -0,0 +1,44 @@ +"""Get merged configuration action.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Optional, override + +from ai.backend.manager.actions.action import BaseActionResult + +from .base import AppConfigAction + + +@dataclass +class GetMergedAppConfigAction(AppConfigAction): + """Action to get merged app configuration for a user.""" + + user_id: str + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_user" + + @override + def entity_id(self) -> Optional[str]: + return self.user_id + + @override + @classmethod + def operation_type(cls) -> str: + return "get_merged_app_config" + + +@dataclass +class GetMergedAppConfigActionResult(BaseActionResult): + """Result of get merged app configuration action.""" + + user_id: str + merged_config: Mapping[str, Any] + + @override + def entity_id(self) -> Optional[str]: + return self.user_id diff --git a/src/ai/backend/manager/services/app_config/actions/user.py b/src/ai/backend/manager/services/app_config/actions/user.py new file mode 100644 index 00000000000..26f3b3c0ee6 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/user.py @@ -0,0 +1,109 @@ +"""User-level app configuration actions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigModifier + +from .base import AppConfigAction + + +@dataclass +class GetUserConfigAction(AppConfigAction): + """Action to get user-level app configuration.""" + + user_id: str + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_user" + + @override + def entity_id(self) -> Optional[str]: + return self.user_id + + @override + @classmethod + def operation_type(cls) -> str: + return "get_user_config" + + +@dataclass +class GetUserConfigActionResult(BaseActionResult): + """Result of get user config action.""" + + result: Optional[AppConfigData] + + @override + def entity_id(self) -> Optional[str]: + return self.result.scope_id if self.result else None + + +@dataclass +class UpsertUserConfigAction(AppConfigAction): + """Action to create or update user-level app configuration.""" + + user_id: str + modifier: AppConfigModifier + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_user" + + @override + def entity_id(self) -> Optional[str]: + return self.user_id + + @override + @classmethod + def operation_type(cls) -> str: + return "upsert_user_config" + + +@dataclass +class UpsertUserConfigActionResult(BaseActionResult): + """Result of upsert user config action.""" + + result: AppConfigData + + @override + def entity_id(self) -> Optional[str]: + return self.result.scope_id + + +@dataclass +class DeleteUserConfigAction(AppConfigAction): + """Action to delete user-level app configuration.""" + + user_id: str + + @override + @classmethod + def entity_type(cls) -> str: + return "app_config_user" + + @override + def entity_id(self) -> Optional[str]: + return self.user_id + + @override + @classmethod + def operation_type(cls) -> str: + return "delete_user_config" + + +@dataclass +class DeleteUserConfigActionResult(BaseActionResult): + """Result of delete user config action.""" + + deleted: bool + user_id: str + + @override + def entity_id(self) -> Optional[str]: + return self.user_id if self.deleted else None diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py new file mode 100644 index 00000000000..d03d59c2efe --- /dev/null +++ b/src/ai/backend/manager/services/app_config/processors.py @@ -0,0 +1,73 @@ +"""Processors for app configuration service.""" + +from __future__ import annotations + +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor import ActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec + +from .actions import ( + DeleteDomainConfigAction, + DeleteDomainConfigActionResult, + DeleteUserConfigAction, + DeleteUserConfigActionResult, + GetDomainConfigAction, + GetDomainConfigActionResult, + GetMergedAppConfigAction, + GetMergedAppConfigActionResult, + GetUserConfigAction, + GetUserConfigActionResult, + UpsertDomainConfigAction, + UpsertDomainConfigActionResult, + UpsertUserConfigAction, + UpsertUserConfigActionResult, +) +from .service import AppConfigService + + +class AppConfigProcessors(AbstractProcessorPackage): + """Processors for app configuration operations.""" + + # Domain config processors + get_domain_config: ActionProcessor[GetDomainConfigAction, GetDomainConfigActionResult] + upsert_domain_config: ActionProcessor[UpsertDomainConfigAction, UpsertDomainConfigActionResult] + delete_domain_config: ActionProcessor[DeleteDomainConfigAction, DeleteDomainConfigActionResult] + + # User config processors + get_user_config: ActionProcessor[GetUserConfigAction, GetUserConfigActionResult] + upsert_user_config: ActionProcessor[UpsertUserConfigAction, UpsertUserConfigActionResult] + delete_user_config: ActionProcessor[DeleteUserConfigAction, DeleteUserConfigActionResult] + + # Merged config processor + get_merged_config: ActionProcessor[GetMergedAppConfigAction, GetMergedAppConfigActionResult] + + def __init__(self, service: AppConfigService, action_monitors: list[ActionMonitor]) -> None: + # Domain config processors + self.get_domain_config = ActionProcessor(service.get_domain_config, action_monitors) + self.upsert_domain_config = ActionProcessor(service.upsert_domain_config, action_monitors) + self.delete_domain_config = ActionProcessor(service.delete_domain_config, action_monitors) + + # User config processors + self.get_user_config = ActionProcessor(service.get_user_config, action_monitors) + self.upsert_user_config = ActionProcessor(service.upsert_user_config, action_monitors) + self.delete_user_config = ActionProcessor(service.delete_user_config, action_monitors) + + # Merged config processor + self.get_merged_config = ActionProcessor(service.get_merged_config, action_monitors) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + # Domain config actions + GetDomainConfigAction.spec(), + UpsertDomainConfigAction.spec(), + DeleteDomainConfigAction.spec(), + # User config actions + GetUserConfigAction.spec(), + UpsertUserConfigAction.spec(), + DeleteUserConfigAction.spec(), + # Merged config action + GetMergedAppConfigAction.spec(), + ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py new file mode 100644 index 00000000000..e6a9e940488 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/service.py @@ -0,0 +1,130 @@ +"""Service layer for app configuration operations.""" + +from __future__ import annotations + +import logging + +from ai.backend.logging.utils import BraceStyleAdapter +from ai.backend.manager.models.app_config import AppConfigScopeType +from ai.backend.manager.repositories.app_config import AppConfigRepository + +from .actions import ( + DeleteDomainConfigAction, + DeleteDomainConfigActionResult, + DeleteUserConfigAction, + DeleteUserConfigActionResult, + GetDomainConfigAction, + GetDomainConfigActionResult, + GetMergedAppConfigAction, + GetMergedAppConfigActionResult, + GetUserConfigAction, + GetUserConfigActionResult, + UpsertDomainConfigAction, + UpsertDomainConfigActionResult, + UpsertUserConfigAction, + UpsertUserConfigActionResult, +) + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class AppConfigService: + """Service for app configuration operations.""" + + _app_config_repository: AppConfigRepository + + def __init__( + self, + app_config_repository: AppConfigRepository, + ) -> None: + self._app_config_repository = app_config_repository + + # Domain config operations + + async def get_domain_config(self, action: GetDomainConfigAction) -> GetDomainConfigActionResult: + """Get domain-level app configuration.""" + log.info("Getting domain config for: {}", action.domain_name) + config_data = await self._app_config_repository.get_config( + AppConfigScopeType.DOMAIN, + action.domain_name, + ) + return GetDomainConfigActionResult(result=config_data) + + async def upsert_domain_config( + self, action: UpsertDomainConfigAction + ) -> UpsertDomainConfigActionResult: + """Create or update domain-level app configuration.""" + log.info("Upserting domain config for: {}", action.domain_name) + config_data = await self._app_config_repository.upsert_config( + AppConfigScopeType.DOMAIN, + action.domain_name, + action.modifier, + ) + return UpsertDomainConfigActionResult(result=config_data) + + async def delete_domain_config( + self, action: DeleteDomainConfigAction + ) -> DeleteDomainConfigActionResult: + """Delete domain-level app configuration.""" + log.info("Deleting domain config for: {}", action.domain_name) + deleted = await self._app_config_repository.delete_config( + AppConfigScopeType.DOMAIN, + action.domain_name, + ) + return DeleteDomainConfigActionResult( + deleted=deleted, + domain_name=action.domain_name, + ) + + # User config operations + + async def get_user_config(self, action: GetUserConfigAction) -> GetUserConfigActionResult: + """Get user-level app configuration.""" + log.info("Getting user config for: {}", action.user_id) + config_data = await self._app_config_repository.get_config( + AppConfigScopeType.USER, + action.user_id, + ) + return GetUserConfigActionResult(result=config_data) + + async def upsert_user_config( + self, action: UpsertUserConfigAction + ) -> UpsertUserConfigActionResult: + """Create or update user-level app configuration.""" + log.info("Upserting user config for: {}", action.user_id) + config_data = await self._app_config_repository.upsert_config( + AppConfigScopeType.USER, + action.user_id, + action.modifier, + ) + return UpsertUserConfigActionResult(result=config_data) + + async def delete_user_config( + self, action: DeleteUserConfigAction + ) -> DeleteUserConfigActionResult: + """Delete user-level app configuration.""" + log.info("Deleting user config for: {}", action.user_id) + deleted = await self._app_config_repository.delete_config( + AppConfigScopeType.USER, + action.user_id, + ) + return DeleteUserConfigActionResult( + deleted=deleted, + user_id=action.user_id, + ) + + # Merged config operation + + async def get_merged_config( + self, action: GetMergedAppConfigAction + ) -> GetMergedAppConfigActionResult: + """ + Get merged app configuration for a user. + Domain config is merged with user config (user overrides domain). + """ + log.info("Getting merged app config for user: {}", action.user_id) + merged_config = await self._app_config_repository.get_merged_config(action.user_id) + return GetMergedAppConfigActionResult( + user_id=action.user_id, + merged_config=merged_config, + ) diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index 0174e12268f..479897bd8fb 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -21,6 +21,8 @@ from ai.backend.manager.repositories.repositories import Repositories from ai.backend.manager.services.agent.processors import AgentProcessors from ai.backend.manager.services.agent.service import AgentService +from ai.backend.manager.services.app_config.processors import AppConfigProcessors +from ai.backend.manager.services.app_config.service import AppConfigService from ai.backend.manager.services.artifact.processors import ArtifactProcessors from ai.backend.manager.services.artifact.service import ArtifactService from ai.backend.manager.services.artifact_registry.processors import ArtifactRegistryProcessors @@ -114,6 +116,7 @@ class ServiceArgs: @dataclass class Services: agent: AgentService + app_config: AppConfigService domain: DomainService group: GroupService user: UserService @@ -152,6 +155,9 @@ def create(cls, args: ServiceArgs) -> Self: args.event_producer, args.agent_cache, ) + app_config_service = AppConfigService( + app_config_repository=repositories.app_config.repository, + ) domain_service = DomainService( repositories.domain.repository, repositories.domain.admin_repository ) @@ -288,6 +294,7 @@ def create(cls, args: ServiceArgs) -> Self: return cls( agent=agent_service, + app_config=app_config_service, domain=domain_service, group=group_service, user=user_service, @@ -323,6 +330,7 @@ class ProcessorArgs: @dataclass class Processors(AbstractProcessorPackage): agent: AgentProcessors + app_config: AppConfigProcessors domain: DomainProcessors group: GroupProcessors user: UserProcessors @@ -352,6 +360,7 @@ class Processors(AbstractProcessorPackage): def create(cls, args: ProcessorArgs, action_monitors: list[ActionMonitor]) -> Self: services = Services.create(args.service_args) agent_processors = AgentProcessors(services.agent, action_monitors) + app_config_processors = AppConfigProcessors(services.app_config, action_monitors) domain_processors = DomainProcessors(services.domain, action_monitors) group_processors = GroupProcessors(services.group, action_monitors) user_processors = UserProcessors(services.user, action_monitors) @@ -408,6 +417,7 @@ def create(cls, args: ProcessorArgs, action_monitors: list[ActionMonitor]) -> Se return cls( agent=agent_processors, + app_config=app_config_processors, domain=domain_processors, group=group_processors, user=user_processors, @@ -438,6 +448,7 @@ def create(cls, args: ProcessorArgs, action_monitors: list[ActionMonitor]) -> Se def supported_actions(self) -> list[ActionSpec]: return [ *self.agent.supported_actions(), + *self.app_config.supported_actions(), *self.domain.supported_actions(), *self.group.supported_actions(), *self.user.supported_actions(), From dca53361162cc53db5d0495485dc02f39da3b81b Mon Sep 17 00:00:00 2001 From: HyeockJinKim Date: Sun, 26 Oct 2025 16:03:11 +0900 Subject: [PATCH 2/4] feat: Update user-level app configuration inputs to allow optional user_id and enhance documentation --- .../graphql-reference/supergraph.graphql | 16 +++++-- .../graphql-reference/v2-schema.graphql | 16 +++++-- src/ai/backend/manager/api/admin.py | 1 - src/ai/backend/manager/api/gql/app_config.py | 44 ++++++++++++++----- src/ai/backend/manager/api/spec.py | 2 +- 5 files changed, 59 insertions(+), 20 deletions(-) diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index a43f5771c3f..431192ff92a 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -2196,11 +2196,15 @@ type DeleteUser msg: String } -"""Added in 25.16.0. Input for deleting user-level app configuration""" +""" +Added in 25.16.0. +Input for deleting user-level app configuration. +If user_id is not provided, the current user's configuration will be deleted. +""" input DeleteUserConfigInput @join__type(graph: STRAWBERRY) { - userId: ID! + userId: ID = null } """ @@ -4583,6 +4587,7 @@ type Mutation The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration. """ upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! @join__field(graph: STRAWBERRY) @@ -4602,6 +4607,7 @@ type Mutation Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration. """ deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! @join__field(graph: STRAWBERRY) @@ -5506,9 +5512,10 @@ type Query This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration. """ - userAppConfig(userId: ID!): AppConfig @join__field(graph: STRAWBERRY) + userAppConfig(userId: ID = null): AppConfig @join__field(graph: STRAWBERRY) """ Added in 25.16.0. @@ -6581,12 +6588,13 @@ Input for creating or updating user-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. +If user_id is not provided, the current user's configuration will be updated. """ input UpsertUserConfigInput @join__type(graph: STRAWBERRY) { - userId: ID! extraConfig: JSON! + userId: ID = null } """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index bc9cb851340..4d90b4c083a 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -849,9 +849,13 @@ type DeleteReservoirRegistryPayload { id: ID! } -"""Added in 25.16.0. Input for deleting user-level app configuration""" +""" +Added in 25.16.0. +Input for deleting user-level app configuration. +If user_id is not provided, the current user's configuration will be deleted. +""" input DeleteUserConfigInput { - userId: ID! + userId: ID = null } """ @@ -1381,6 +1385,7 @@ type Mutation { The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration. """ upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! @@ -1400,6 +1405,7 @@ type Mutation { Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration. """ deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! @@ -1726,9 +1732,10 @@ type Query { This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration. """ - userAppConfig(userId: ID!): AppConfig + userAppConfig(userId: ID = null): AppConfig """ Added in 25.16.0. @@ -2338,10 +2345,11 @@ Input for creating or updating user-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. +If user_id is not provided, the current user's configuration will be updated. """ input UpsertUserConfigInput { - userId: ID! extraConfig: JSON! + userId: ID = null } """ diff --git a/src/ai/backend/manager/api/admin.py b/src/ai/backend/manager/api/admin.py index dc97ceac004..98386ee0b64 100644 --- a/src/ai/backend/manager/api/admin.py +++ b/src/ai/backend/manager/api/admin.py @@ -61,7 +61,6 @@ def __init__(self, *args, **kwargs) -> None: set_handler_attr(self, "auth_required", True) set_handler_attr(self, "auth_scope", "user") - @auth_required async def __call__(self, request: web.Request) -> web.StreamResponse: if request.get("is_authorized", False): return await super().__call__(request) diff --git a/src/ai/backend/manager/api/gql/app_config.py b/src/ai/backend/manager/api/gql/app_config.py index 4feac01fbaf..e2025669b1d 100644 --- a/src/ai/backend/manager/api/gql/app_config.py +++ b/src/ai/backend/manager/api/gql/app_config.py @@ -61,14 +61,15 @@ def to_modifier(self) -> AppConfigModifier: The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. """ ) ) class UpsertUserConfigInput: """Input type for upserting user-level app configuration.""" - user_id: ID extra_config: strawberry.scalars.JSON + user_id: Optional[ID] = None def to_modifier(self) -> AppConfigModifier: return AppConfigModifier(extra_config=OptionalState.update(self.extra_config)) @@ -81,11 +82,19 @@ class DeleteDomainConfigInput: domain_name: str -@strawberry.input(description="Added in 25.16.0. Input for deleting user-level app configuration") +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for deleting user-level app configuration. + If user_id is not provided, the current user's configuration will be deleted. + """ + ) +) class DeleteUserConfigInput: """Input type for deleting user-level app configuration.""" - user_id: ID + user_id: Optional[ID] = None @strawberry.type( @@ -190,24 +199,29 @@ async def domain_app_config( This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration. """ ) ) async def user_app_config( - user_id: ID, info: Info[StrawberryGQLContext], + user_id: Optional[ID] = None, ) -> Optional[AppConfig]: """Get user-level app configuration.""" processors = info.context.processors me = current_user() if me is None: raise InsufficientPrivilege("Authentication required") - if str(me.user_id) != str(user_id) and not (me.is_admin or me.is_superadmin): + + # Use current user's ID if user_id is not provided + target_user_id = str(user_id) if user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): raise InsufficientPrivilege("Cannot access another user's app configuration") action_result = await processors.app_config.get_user_config.wait_for_complete( - GetUserConfigAction(user_id=str(user_id)) + GetUserConfigAction(user_id=target_user_id) ) if not action_result.result: @@ -289,6 +303,7 @@ async def upsert_domain_app_config( The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration. """ ), @@ -302,12 +317,16 @@ async def upsert_user_app_config( me = current_user() if me is None: raise InsufficientPrivilege("Authentication required") - if str(me.user_id) != str(input.user_id) and not (me.is_admin or me.is_superadmin): + + # Use current user's ID if user_id is not provided + target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): raise InsufficientPrivilege("Cannot modify another user's app configuration") action_result = await processors.app_config.upsert_user_config.wait_for_complete( UpsertUserConfigAction( - user_id=str(input.user_id), + user_id=target_user_id, modifier=input.to_modifier(), ) ) @@ -355,6 +374,7 @@ async def delete_domain_app_config( Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration. """ ), @@ -368,11 +388,15 @@ async def delete_user_app_config( me = current_user() if me is None: raise InsufficientPrivilege("Authentication required") - if str(me.user_id) != str(input.user_id) and not (me.is_admin or me.is_superadmin): + + # Use current user's ID if user_id is not provided + target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): raise InsufficientPrivilege("Cannot delete another user's app configuration") action_result = await processors.app_config.delete_user_config.wait_for_complete( - DeleteUserConfigAction(user_id=str(input.user_id)) + DeleteUserConfigAction(user_id=target_user_id) ) return DeleteUserConfigPayload(deleted=action_result.deleted) diff --git a/src/ai/backend/manager/api/spec.py b/src/ai/backend/manager/api/spec.py index d73c6ef001a..ed7a4e44fe4 100644 --- a/src/ai/backend/manager/api/spec.py +++ b/src/ai/backend/manager/api/spec.py @@ -90,7 +90,7 @@