diff --git a/changes/11285.feature.md b/changes/11285.feature.md new file mode 100644 index 00000000000..05558be5bec --- /dev/null +++ b/changes/11285.feature.md @@ -0,0 +1 @@ +Add `AppConfigFragment` Strawberry GraphQL surface — `appConfigFragment` (by natural key), `scopedAppConfigFragments`, `adminAppConfigFragments`, and admin `create` / `update` / `purge` mutations. diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 650ed1f12a5..b015d56e2ed 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -191,6 +191,17 @@ type AddRevisionPayload revision: ModelRevision! } +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigFragmentItemInput + @join__type(graph: STRAWBERRY) +{ + """Natural-key identifier.""" + key: AppConfigFragmentKeyInput! + + """Raw configuration payload.""" + config: JSON! +} + """ Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. """ @@ -217,6 +228,25 @@ input AdminAppConfigPolicyUpdateItemInput scopeSources: [String!]! } +"""Added in UNRELEASED. Admin bulk create input — items carry any scope.""" +input AdminBulkCreateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """Rows to create.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkCreateAppConfigFragments`.""" +type AdminBulkCreateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Created fragments.""" + created: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkCreateAppConfigPolicies`.""" type AdminBulkCreateAppConfigPoliciesPayload @join__type(graph: STRAWBERRY) @@ -236,6 +266,27 @@ input AdminBulkCreateAppConfigPolicyInput items: [AdminAppConfigPolicyCreateItemInput!]! } +""" +Added in UNRELEASED. Admin bulk purge input — keyed on `AppConfigFragmentKey`. +""" +input AdminBulkPurgeAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """Natural keys to purge.""" + keys: [AppConfigFragmentKeyInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigFragments`.""" +type AdminBulkPurgeAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Keys of rows actually removed (absent keys are no-oped).""" + purged: [PurgeAppConfigFragmentKey!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigPolicies`.""" type AdminBulkPurgeAppConfigPoliciesPayload @join__type(graph: STRAWBERRY) @@ -257,6 +308,25 @@ input AdminBulkPurgeAppConfigPolicyInput ids: [UUID!]! } +"""Added in UNRELEASED. Admin bulk update input.""" +input AdminBulkUpdateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """Rows to update.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigFragments`.""" +type AdminBulkUpdateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Updated fragments.""" + updated: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigPolicies`.""" type AdminBulkUpdateAppConfigPoliciesPayload @join__type(graph: STRAWBERRY) @@ -980,6 +1050,162 @@ type AllowedResourceGroupsPayload items: [String!]! } +""" +Added in UNRELEASED. Merged per-user AppConfig view. `fragments` are ordered low → high merge priority; `config` is the deep-merge result and is null when every contributing fragment is empty. +""" +type AppConfig + @join__type(graph: STRAWBERRY) +{ + """Target user's UUID.""" + userId: UUID! + + """Policy / config name.""" + name: String! + + """Contributing fragments in merge order (low → high).""" + fragments: [AppConfigFragment!]! + + """Deep-merged configuration, or null when every fragment is empty.""" + config: JSON +} + +"""Added in UNRELEASED. Filter input for querying merged AppConfigs.""" +input AppConfigFilter + @join__type(graph: STRAWBERRY) +{ + """Filter by policy name.""" + name: StringFilter = null + + """Filter by target user id (admin cross-user search only).""" + userId: UUIDFilter = null + + """Filter by the oldest contributing fragment's creation timestamp.""" + createdAt: DateTimeFilter = null + + """Filter by the latest contributing fragment's update timestamp.""" + updatedAt: DateTimeFilter = null +} + +"""Added in UNRELEASED. Raw per-scope app-config fragment.""" +type AppConfigFragment + @join__type(graph: STRAWBERRY) +{ + """Row ID.""" + id: UUID! + + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name (FK target).""" + name: String! + + """Raw configuration payload, or null.""" + config: JSON + + """Creation timestamp.""" + createdAt: DateTime! + + """Last update timestamp.""" + updatedAt: DateTime +} + +""" +Added in UNRELEASED. Per-item failure info for bulk Fragment mutations. +""" +type AppConfigFragmentBulkError + @join__type(graph: STRAWBERRY) +{ + """Original position in the input list.""" + index: Int! + + """Scope type of the failed row.""" + scopeType: AppConfigScopeType! + + """Scope id of the failed row.""" + scopeId: String! + + """Policy name of the failed row.""" + name: String! + + """Reason for the failure.""" + message: String! +} + +"""Added in UNRELEASED. Filter input for querying app-config fragments.""" +input AppConfigFragmentFilter + @join__type(graph: STRAWBERRY) +{ + """Filter by policy name.""" + name: StringFilter = null + + """Filter by scope_id.""" + scopeId: StringFilter = null +} + +"""Added in UNRELEASED. Natural key for an app-config fragment row.""" +input AppConfigFragmentKeyInput + @join__type(graph: STRAWBERRY) +{ + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + +""" +Added in UNRELEASED. Specifies ordering for app-config fragment results. +""" +input AppConfigFragmentOrderBy + @join__type(graph: STRAWBERRY) +{ + """The field to order by.""" + field: AppConfigFragmentOrderField! + + """Sort direction.""" + direction: OrderDirection! = DESC +} + +""" +Added in UNRELEASED. Fields available for ordering app-config fragments. +""" +enum AppConfigFragmentOrderField + @join__type(graph: STRAWBERRY) +{ + SCOPE_TYPE @join__enumValue(graph: STRAWBERRY) + SCOPE_ID @join__enumValue(graph: STRAWBERRY) + NAME @join__enumValue(graph: STRAWBERRY) + CREATED_AT @join__enumValue(graph: STRAWBERRY) + UPDATED_AT @join__enumValue(graph: STRAWBERRY) +} + +"""Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" +input AppConfigOrderBy + @join__type(graph: STRAWBERRY) +{ + """The field to order by.""" + field: AppConfigOrderField! + + """Sort direction.""" + direction: OrderDirection! = ASC +} + +"""Added in UNRELEASED. Fields available for ordering merged AppConfigs.""" +enum AppConfigOrderField + @join__type(graph: STRAWBERRY) +{ + USER_ID @join__enumValue(graph: STRAWBERRY) + NAME @join__enumValue(graph: STRAWBERRY) + CREATED_AT @join__enumValue(graph: STRAWBERRY) + UPDATED_AT @join__enumValue(graph: STRAWBERRY) +} + """Added in UNRELEASED. Scoped app-config policy.""" type AppConfigPolicy implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @@ -1069,6 +1295,15 @@ enum AppConfigPolicyOrderField UPDATED_AT @join__enumValue(graph: STRAWBERRY) } +enum AppConfigScopeType + @join__type(graph: STRAWBERRY) +{ + PUBLIC @join__enumValue(graph: STRAWBERRY) + DOMAIN @join__enumValue(graph: STRAWBERRY) + DOMAIN_USER_DEFAULTS @join__enumValue(graph: STRAWBERRY) + USER @join__enumValue(graph: STRAWBERRY) +} + """ Added in 24.09.0. Input for approving an artifact revision. @@ -11056,6 +11291,31 @@ type Mutation """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! @join__field(graph: STRAWBERRY) + """ + Added in UNRELEASED. Strict insert across any scope; each item runs in its own transaction and failures are collected per-item (admin only). + """ + adminBulkCreateAppConfigFragments(input: AdminBulkCreateAppConfigFragmentInput!): AdminBulkCreateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Wholesale JSON replacement; items with no existing row are returned as failures (admin only). + """ + adminBulkUpdateAppConfigFragments(input: AdminBulkUpdateAppConfigFragmentInput!): AdminBulkUpdateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cleanup-only deletion; absent keys are no-oped (admin only). + """ + adminBulkPurgeAppConfigFragments(input: AdminBulkPurgeAppConfigFragmentInput!): AdminBulkPurgeAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. + """ + myBulkCreateAppConfigFragments(input: MyBulkCreateAppConfigFragmentInput!): MyBulkCreateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. + """ + myBulkUpdateAppConfigFragments(input: MyBulkUpdateAppConfigFragmentInput!): MyBulkUpdateAppConfigFragmentsPayload! @join__field(graph: STRAWBERRY) + """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! @join__field(graph: STRAWBERRY) @@ -11275,6 +11535,63 @@ type Mutation terminateProjectSessionsV2(scope: ProjectSessionV2Scope!, sessionIds: [ID!]!, forced: Boolean! = false): TerminateSessionsPayload! @join__field(graph: STRAWBERRY) } +"""Added in UNRELEASED. Per-item input for self-service (`my`) bulk.""" +input MyAppConfigFragmentItemInput + @join__type(graph: STRAWBERRY) +{ + """Policy name.""" + name: String! + + """Raw configuration payload.""" + config: JSON! +} + +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input MyBulkCreateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type MyBulkCreateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input MyBulkUpdateAppConfigFragmentInput + @join__type(graph: STRAWBERRY) +{ + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type MyBulkUpdateAppConfigFragmentsPayload + @join__type(graph: STRAWBERRY) +{ + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ @@ -12779,6 +13096,20 @@ input ProjectWeightInputItem weight: Decimal = null } +"""Added in UNRELEASED. Natural key of a purged fragment row.""" +type PurgeAppConfigFragmentKey + @join__type(graph: STRAWBERRY) +{ + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + """ Completely delete domain from DB. @@ -13583,6 +13914,31 @@ type Query """ appConfigPolicies(filter: AppConfigPolicyFilter = null, orderBy: [AppConfigPolicyOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): AppConfigPolicyConnection! @join__field(graph: STRAWBERRY) + """ + Added in UNRELEASED. Get a single app-config fragment by natural key `(scope_type, scope_id, name)`. Available to any authenticated user — service-layer authorization gates cross-scope reads. + """ + appConfigFragment(scopeType: AppConfigScopeType!, scopeId: String!, name: String!): AppConfigFragment @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cross-scope admin search across all app-config fragments (admin only). + """ + adminAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of raw fragments that carry no personally-scoped data. + """ + publicAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Caller's own merged AppConfig list (auth required). Chain per policy ; the adapter pins `(USER, current_user)` internally. + """ + myAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! @join__field(graph: STRAWBERRY) + + """ + Added in UNRELEASED. Cross-user merged-view search (admin only). Resolves any user's AppConfig for audit / support. Pin to a single user with `filter.userId`; otherwise paginates across all users. + """ + adminAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! @join__field(graph: STRAWBERRY) + """ Added in 26.4.2. Get a single prometheus query preset by ID. Available to any authenticated user since presets are a shared catalog of metric query templates. """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index ed4b191cf9e..d73edf37a13 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -145,6 +145,15 @@ type AddRevisionPayload { revision: ModelRevision! } +"""Added in UNRELEASED. Per-item input for admin bulk create / update.""" +input AdminAppConfigFragmentItemInput { + """Natural-key identifier.""" + key: AppConfigFragmentKeyInput! + + """Raw configuration payload.""" + config: JSON! +} + """ Added in UNRELEASED. Per-item input for admin bulk create — `config_name` + initial `scope_sources`. """ @@ -167,6 +176,21 @@ input AdminAppConfigPolicyUpdateItemInput { scopeSources: [String!]! } +"""Added in UNRELEASED. Admin bulk create input — items carry any scope.""" +input AdminBulkCreateAppConfigFragmentInput { + """Rows to create.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkCreateAppConfigFragments`.""" +type AdminBulkCreateAppConfigFragmentsPayload { + """Created fragments.""" + created: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkCreateAppConfigPolicies`.""" type AdminBulkCreateAppConfigPoliciesPayload { """Created policies.""" @@ -182,6 +206,23 @@ input AdminBulkCreateAppConfigPolicyInput { items: [AdminAppConfigPolicyCreateItemInput!]! } +""" +Added in UNRELEASED. Admin bulk purge input — keyed on `AppConfigFragmentKey`. +""" +input AdminBulkPurgeAppConfigFragmentInput { + """Natural keys to purge.""" + keys: [AppConfigFragmentKeyInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigFragments`.""" +type AdminBulkPurgeAppConfigFragmentsPayload { + """Keys of rows actually removed (absent keys are no-oped).""" + purged: [PurgeAppConfigFragmentKey!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkPurgeAppConfigPolicies`.""" type AdminBulkPurgeAppConfigPoliciesPayload { """Ids of policies actually removed (absent ids no-oped).""" @@ -199,6 +240,21 @@ input AdminBulkPurgeAppConfigPolicyInput { ids: [UUID!]! } +"""Added in UNRELEASED. Admin bulk update input.""" +input AdminBulkUpdateAppConfigFragmentInput { + """Rows to update.""" + items: [AdminAppConfigFragmentItemInput!]! +} + +"""Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigFragments`.""" +type AdminBulkUpdateAppConfigFragmentsPayload { + """Updated fragments.""" + updated: [AppConfigFragment!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """Added in UNRELEASED. Payload for `adminBulkUpdateAppConfigPolicies`.""" type AdminBulkUpdateAppConfigPoliciesPayload { """Updated policies.""" @@ -678,6 +734,142 @@ type AllowedResourceGroupsPayload { items: [String!]! } +""" +Added in UNRELEASED. Merged per-user AppConfig view. `fragments` are ordered low → high merge priority; `config` is the deep-merge result and is null when every contributing fragment is empty. +""" +type AppConfig { + """Target user's UUID.""" + userId: UUID! + + """Policy / config name.""" + name: String! + + """Contributing fragments in merge order (low → high).""" + fragments: [AppConfigFragment!]! + + """Deep-merged configuration, or null when every fragment is empty.""" + config: JSON +} + +"""Added in UNRELEASED. Filter input for querying merged AppConfigs.""" +input AppConfigFilter { + """Filter by policy name.""" + name: StringFilter = null + + """Filter by target user id (admin cross-user search only).""" + userId: UUIDFilter = null + + """Filter by the oldest contributing fragment's creation timestamp.""" + createdAt: DateTimeFilter = null + + """Filter by the latest contributing fragment's update timestamp.""" + updatedAt: DateTimeFilter = null +} + +"""Added in UNRELEASED. Raw per-scope app-config fragment.""" +type AppConfigFragment { + """Row ID.""" + id: UUID! + + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name (FK target).""" + name: String! + + """Raw configuration payload, or null.""" + config: JSON + + """Creation timestamp.""" + createdAt: DateTime! + + """Last update timestamp.""" + updatedAt: DateTime +} + +""" +Added in UNRELEASED. Per-item failure info for bulk Fragment mutations. +""" +type AppConfigFragmentBulkError { + """Original position in the input list.""" + index: Int! + + """Scope type of the failed row.""" + scopeType: AppConfigScopeType! + + """Scope id of the failed row.""" + scopeId: String! + + """Policy name of the failed row.""" + name: String! + + """Reason for the failure.""" + message: String! +} + +"""Added in UNRELEASED. Filter input for querying app-config fragments.""" +input AppConfigFragmentFilter { + """Filter by policy name.""" + name: StringFilter = null + + """Filter by scope_id.""" + scopeId: StringFilter = null +} + +"""Added in UNRELEASED. Natural key for an app-config fragment row.""" +input AppConfigFragmentKeyInput { + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + +""" +Added in UNRELEASED. Specifies ordering for app-config fragment results. +""" +input AppConfigFragmentOrderBy { + """The field to order by.""" + field: AppConfigFragmentOrderField! + + """Sort direction.""" + direction: OrderDirection! = DESC +} + +""" +Added in UNRELEASED. Fields available for ordering app-config fragments. +""" +enum AppConfigFragmentOrderField { + SCOPE_TYPE + SCOPE_ID + NAME + CREATED_AT + UPDATED_AT +} + +"""Added in UNRELEASED. Specifies ordering for merged AppConfig results.""" +input AppConfigOrderBy { + """The field to order by.""" + field: AppConfigOrderField! + + """Sort direction.""" + direction: OrderDirection! = ASC +} + +"""Added in UNRELEASED. Fields available for ordering merged AppConfigs.""" +enum AppConfigOrderField { + USER_ID + NAME + CREATED_AT + UPDATED_AT +} + """Added in UNRELEASED. Scoped app-config policy.""" type AppConfigPolicy implements Node { """The Globally Unique ID of this object""" @@ -752,6 +944,13 @@ enum AppConfigPolicyOrderField { UPDATED_AT } +enum AppConfigScopeType { + PUBLIC + DOMAIN + DOMAIN_USER_DEFAULTS + USER +} + """ Added in 24.09.0. Input for approving an artifact revision. @@ -6982,6 +7181,31 @@ type Mutation { """ adminBulkPurgeAppConfigPolicies(input: AdminBulkPurgeAppConfigPolicyInput!): AdminBulkPurgeAppConfigPoliciesPayload! + """ + Added in UNRELEASED. Strict insert across any scope; each item runs in its own transaction and failures are collected per-item (admin only). + """ + adminBulkCreateAppConfigFragments(input: AdminBulkCreateAppConfigFragmentInput!): AdminBulkCreateAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Wholesale JSON replacement; items with no existing row are returned as failures (admin only). + """ + adminBulkUpdateAppConfigFragments(input: AdminBulkUpdateAppConfigFragmentInput!): AdminBulkUpdateAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Cleanup-only deletion; absent keys are no-oped (admin only). + """ + adminBulkPurgeAppConfigFragments(input: AdminBulkPurgeAppConfigFragmentInput!): AdminBulkPurgeAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Strict insert on the caller's USER row; duplicates fail per-item. Returns recomputed merged `AppConfig` views. + """ + myBulkCreateAppConfigFragments(input: MyBulkCreateAppConfigFragmentInput!): MyBulkCreateAppConfigFragmentsPayload! + + """ + Added in UNRELEASED. Wholesale replacement on the caller's USER row; missing rows are returned as failures. Returns recomputed merged `AppConfig` views. + """ + myBulkUpdateAppConfigFragments(input: MyBulkUpdateAppConfigFragmentInput!): MyBulkUpdateAppConfigFragmentsPayload! + """Added in 26.3.0. Create a new query definition (admin only)""" adminCreatePrometheusQueryPreset(input: CreateQueryDefinitionInput!): CreateQueryDefinitionPayload! @@ -7201,6 +7425,53 @@ type Mutation { terminateProjectSessionsV2(scope: ProjectSessionV2Scope!, sessionIds: [ID!]!, forced: Boolean! = false): TerminateSessionsPayload! } +"""Added in UNRELEASED. Per-item input for self-service (`my`) bulk.""" +input MyAppConfigFragmentItemInput { + """Policy name.""" + name: String! + + """Raw configuration payload.""" + config: JSON! +} + +""" +Added in UNRELEASED. Self-service bulk create — scope is `USER` / `current_user`. +""" +input MyBulkCreateAppConfigFragmentInput { + """USER-scope rows to create.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views). +""" +type MyBulkCreateAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each created USER fragment.""" + created: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + +""" +Added in UNRELEASED. Self-service bulk update — scope is `USER` / `current_user`. +""" +input MyBulkUpdateAppConfigFragmentInput { + """USER-scope rows to update.""" + items: [MyAppConfigFragmentItemInput!]! +} + +""" +Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views). +""" +type MyBulkUpdateAppConfigFragmentsPayload { + """Recomputed merged AppConfig views for each updated USER fragment.""" + updated: [AppConfig!]! + + """Per-item failures.""" + failed: [AppConfigFragmentBulkError!]! +} + """ Added in 26.4.2. Query result returning the current client's IP address. """ @@ -8410,6 +8681,18 @@ input ProjectWeightInputItem { weight: Decimal = null } +"""Added in UNRELEASED. Natural key of a purged fragment row.""" +type PurgeAppConfigFragmentKey { + """Scope type.""" + scopeType: AppConfigScopeType! + + """Scope id.""" + scopeId: String! + + """Policy name.""" + name: String! +} + """Added in 26.4.2. Payload for domain permanent deletion mutation.""" type PurgeDomainPayloadGQL { """Whether the purge was successful.""" @@ -8724,6 +9007,31 @@ type Query { """ appConfigPolicies(filter: AppConfigPolicyFilter = null, orderBy: [AppConfigPolicyOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): AppConfigPolicyConnection! + """ + Added in UNRELEASED. Get a single app-config fragment by natural key `(scope_type, scope_id, name)`. Available to any authenticated user — service-layer authorization gates cross-scope reads. + """ + appConfigFragment(scopeType: AppConfigScopeType!, scopeId: String!, name: String!): AppConfigFragment + + """ + Added in UNRELEASED. Cross-scope admin search across all app-config fragments (admin only). + """ + adminAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! + + """ + Added in UNRELEASED. Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of raw fragments that carry no personally-scoped data. + """ + publicAppConfigFragments(filter: AppConfigFragmentFilter = null, orderBy: [AppConfigFragmentOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfigFragment!]! + + """ + Added in UNRELEASED. Caller's own merged AppConfig list (auth required). Chain per policy ; the adapter pins `(USER, current_user)` internally. + """ + myAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! + + """ + Added in UNRELEASED. Cross-user merged-view search (admin only). Resolves any user's AppConfig for audit / support. Pin to a single user with `filter.userId`; otherwise paginates across all users. + """ + adminAppConfigs(filter: AppConfigFilter = null, orderBy: [AppConfigOrderBy!] = null, first: Int = null, after: String = null, last: Int = null, before: String = null, limit: Int = null, offset: Int = null): [AppConfig!]! + """ Added in 26.4.2. Get a single prometheus query preset by ID. Available to any authenticated user since presets are a shared catalog of metric query templates. """ diff --git a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py new file mode 100644 index 00000000000..dd862cf2319 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py @@ -0,0 +1,39 @@ +""" +AppConfig (merged view) DTOs v2 for Manager API. +""" + +from .request import ( + AppConfigFilter, + AppConfigOrder, + GetUserAppConfigInput, + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from .response import ( + AppConfigNode, + GetUserAppConfigPayload, + MyBulkCreateAppConfigFragmentsPayload, + MyBulkUpdateAppConfigFragmentsPayload, + SearchAppConfigsPayload, +) +from .types import ( + AppConfigOrderField, + AppConfigScopeType, + OrderDirection, +) + +__all__ = ( + "AppConfigFilter", + "AppConfigNode", + "AppConfigOrder", + "AppConfigOrderField", + "AppConfigScopeType", + "MyBulkCreateAppConfigFragmentsPayload", + "MyBulkUpdateAppConfigFragmentsPayload", + "GetUserAppConfigInput", + "GetUserAppConfigPayload", + "OrderDirection", + "SearchAppConfigsInput", + "SearchAppConfigsPayload", + "SearchMyAppConfigsInput", +) diff --git a/src/ai/backend/common/dto/manager/v2/app_config/request.py b/src/ai/backend/common/dto/manager/v2/app_config/request.py new file mode 100644 index 00000000000..8610dab5c59 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/request.py @@ -0,0 +1,87 @@ +""" +Request DTOs for AppConfig (merged view) DTO v2. +""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel +from ai.backend.common.dto.manager.query import DateTimeFilter, StringFilter, UUIDFilter + +from .types import AppConfigOrderField, OrderDirection + +__all__ = ( + "AppConfigFilter", + "AppConfigOrder", + "GetUserAppConfigInput", + "SearchAppConfigsInput", + "SearchMyAppConfigsInput", +) + + +class GetUserAppConfigInput(BaseRequestModel): + """Input for reading a single merged AppConfig for a target user + (admin path — the `my` variant resolves the user internally).""" + + user_id: UUID = Field(description="Target user's UUID.") + name: str = Field(description="Policy / config name.") + + +class AppConfigFilter(BaseRequestModel): + """Filter for AppConfig merged-view search. + + `created_at` / `updated_at` filter against the **oldest** / + **latest** timestamp across the contributing fragments — the + natural projection of "when was this config first created" and + "when was it last touched". + """ + + name: StringFilter | None = Field(default=None, description="Filter by policy name.") + user_id: UUIDFilter | None = Field( + default=None, + description="Filter by target user id (admin cross-user search only).", + ) + created_at: DateTimeFilter | None = Field( + default=None, + description=("Filter by the oldest contributing fragment's creation timestamp."), + ) + updated_at: DateTimeFilter | None = Field( + default=None, + description=("Filter by the latest contributing fragment's update timestamp."), + ) + + +class AppConfigOrder(BaseRequestModel): + """Order specification for AppConfig merged-view results.""" + + field: AppConfigOrderField = Field(description="Field to order by.") + direction: OrderDirection = Field(default=OrderDirection.ASC, description="Order direction.") + + +class _AppConfigSearchInputBase(BaseRequestModel): + filter: AppConfigFilter | None = Field(default=None, description="Filter conditions.") + order: list[AppConfigOrder] | None = Field(default=None, description="Order specifications.") + first: int | None = Field(default=None, ge=1, description="Number of items from the start.") + after: str | None = Field(default=None, description="Cursor to paginate forward from.") + last: int | None = Field(default=None, ge=1, description="Number of items from the end.") + before: str | None = Field(default=None, description="Cursor to paginate backward from.") + limit: int | None = Field(default=None, ge=1, le=1000, description="Maximum items to return.") + offset: int | None = Field(default=None, ge=0, description="Number of items to skip.") + + +class SearchMyAppConfigsInput(_AppConfigSearchInputBase): + """Input for self-service merged-view search (`/v2/app-configs/my/search`). + + The adapter pins the caller as the user scope; no `user_id` argument + is accepted here. + """ + + +class SearchAppConfigsInput(_AppConfigSearchInputBase): + """Input for admin cross-user merged-view search. + + Optional `filter.user_id` pins the search to a single user. + """ diff --git a/src/ai/backend/common/dto/manager/v2/app_config/response.py b/src/ai/backend/common/dto/manager/v2/app_config/response.py new file mode 100644 index 00000000000..7369aab242d --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/response.py @@ -0,0 +1,84 @@ +""" +Response DTOs for AppConfig (merged view) DTO v2. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError, + AppConfigFragmentNode, +) + +__all__ = ( + "AppConfigNode", + "MyBulkCreateAppConfigFragmentsPayload", + "MyBulkUpdateAppConfigFragmentsPayload", + "GetUserAppConfigPayload", + "SearchAppConfigsPayload", +) + + +class AppConfigNode(BaseResponseModel): + """Merged per-user AppConfig view. + + `fragments` are ordered low → high merge priority (matching the + policy's `scope_sources`). `config` is the deep-merged result, + projected to `None` when every contributing fragment is empty + (§3 null projection). + """ + + user_id: UUID = Field(description="Target user's UUID.") + name: str = Field(description="Policy / config name.") + fragments: list[AppConfigFragmentNode] = Field( + description="Contributing fragments in merge order (low → high).", + ) + config: dict[str, Any] | None = Field( + default=None, + description="Deep-merged configuration, or null when every fragment is empty.", + ) + + +class GetUserAppConfigPayload(BaseResponseModel): + """Payload for reading a single merged AppConfig.""" + + item: AppConfigNode | None = Field( + default=None, + description="Merged AppConfig, or null when no fragments exist for the user.", + ) + + +class SearchAppConfigsPayload(BaseResponseModel): + """Payload for paginated merged-view AppConfig search.""" + + items: list[AppConfigNode] = Field(description="AppConfigs matching the filter.") + total_count: int = Field(description="Total number of AppConfigs matching the filter.") + has_next_page: bool = Field(default=False, description="Whether there is a next page.") + has_previous_page: bool = Field(default=False, description="Whether there is a previous page.") + + +class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `bulkCreateMyAppConfigFragments`. + + Each successfully created row produces a recomputed merged + `AppConfigNode`; failures are collected per-item. + """ + + created: list[AppConfigNode] = Field( + description="Recomputed merged AppConfig views for each created USER fragment.", + ) + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") + + +class MyBulkUpdateAppConfigFragmentsPayload(BaseResponseModel): + """Payload for `bulkUpdateMyAppConfigFragments`.""" + + updated: list[AppConfigNode] = Field( + description="Recomputed merged AppConfig views for each updated USER fragment.", + ) + failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config/types.py b/src/ai/backend/common/dto/manager/v2/app_config/types.py new file mode 100644 index 00000000000..c8cfe46d209 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/types.py @@ -0,0 +1,25 @@ +""" +Common types for AppConfig (merged view) DTO v2. +""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.dto.manager.v2.common import OrderDirection + +__all__ = ( + "AppConfigOrderField", + "AppConfigScopeType", + "OrderDirection", +) + + +class AppConfigOrderField(StrEnum): + """Fields available for ordering AppConfig merged-view results.""" + + USER_ID = "user_id" + NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" diff --git a/src/ai/backend/manager/api/adapters/app_config.py b/src/ai/backend/manager/api/adapters/app_config.py new file mode 100644 index 00000000000..62182e9cd87 --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config.py @@ -0,0 +1,286 @@ +"""AppConfig (merged view) domain adapter + +Reads the per-user merged AppConfig view and writes the underlying USER +fragments via the same `app_config_fragment` service processors. The +merged-view surface lives on its own adapter (separate from +`AppConfigFragmentAdapter`) so each adapter handles a single domain +DTO surface — convention in this repo. +""" + +from __future__ import annotations + +from ai.backend.common.contexts.user import current_user +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter, + AppConfigOrder, + GetUserAppConfigInput, + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + AppConfigNode, + GetUserAppConfigPayload, + MyBulkCreateAppConfigFragmentsPayload, + MyBulkUpdateAppConfigFragmentsPayload, + SearchAppConfigsPayload, +) +from ai.backend.common.dto.manager.v2.app_config.types import AppConfigOrderField, OrderDirection +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyBulkCreateAppConfigFragmentsInput, + MyBulkUpdateAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError, + AppConfigFragmentNode, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigScopeType as DTOAppConfigScopeType, +) +from ai.backend.common.exception import UnreachableError +from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.bulk_types import ( + AppConfigFragmentBulkItemError, + MyAppConfigFragmentBulkItem, +) +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions +from ai.backend.manager.models.app_config_fragment.orders import AppConfigFragmentOrders +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.app_config_fragment.types import UserAppConfigSearchScope +from ai.backend.manager.repositories.base import BatchQuerier, QueryCondition, QueryOrder +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( + GetUserAppConfigAction, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( + MyBulkCreateAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.my_bulk_update import ( + MyBulkUpdateAppConfigFragmentsAction, +) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, +) + +from .base import BaseAdapter + + +class AppConfigAdapter(BaseAdapter): + """Adapter for the merged AppConfig view. + + Backed by the `app_config_fragment` service processors — the merged + view is computed from raw fragments — but exposed as a separate + transport-layer surface so the Fragment adapter stays focused on + raw-row operations. + """ + + # ── Reads ──────────────────────────────────────────────────────── + + async def my_app_config(self, name: str) -> GetUserAppConfigPayload: + """Read the caller's own merged AppConfig for `name`. + + Resolves the current user from the context; there is no way to + target another user through this method. + """ + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=me.user_id, config_name=name) + ) + return GetUserAppConfigPayload(item=self._data_to_dto(result.app_config)) + + async def admin_get_user_app_config( + self, input: GetUserAppConfigInput + ) -> GetUserAppConfigPayload: + """Read a specific user's merged AppConfig (admin only).""" + result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete( + GetUserAppConfigAction(user_id=input.user_id, config_name=input.name) + ) + return GetUserAppConfigPayload(item=self._data_to_dto(result.app_config)) + + async def my_search_app_configs( + self, input: SearchMyAppConfigsInput + ) -> SearchAppConfigsPayload: + """Paginated merged-view search over the caller's own AppConfigs.""" + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + querier = self._build_querier_from_input(input) + result = ( + await self._processors.app_config_fragment.search_user_app_configs.wait_for_complete( + SearchUserAppConfigsAction( + scope=UserAppConfigSearchScope(user_id=me.user_id), + querier=querier, + ) + ) + ) + return SearchAppConfigsPayload( + items=[self._data_to_dto(item) for item in result.items], + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def admin_search_app_configs( + self, input: SearchAppConfigsInput + ) -> SearchAppConfigsPayload: + """Cross-user merged-view search (admin only). + + `filter.user_id` pins the query to a single user; otherwise + pagination walks across every user. + """ + querier = self._build_querier_from_input(input) + result = ( + await self._processors.app_config_fragment.admin_search_app_configs.wait_for_complete( + AdminSearchAppConfigsAction(querier=querier) + ) + ) + return SearchAppConfigsPayload( + items=[self._data_to_dto(item) for item in result.items], + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + # ── Self-service bulk writes ─────────────────────── + # + # Each bulk processor returns a `BulkProcessResult[T]` whose + # `.result` field is the underlying `*ActionResult` produced by the + # service. We discard the validator-decision trail here — RBAC + # reasons travel back through the per-item `failed` list. + + async def my_bulk_create( + self, input: MyBulkCreateAppConfigFragmentsInput + ) -> MyBulkCreateAppConfigFragmentsPayload: + items = [ + MyAppConfigFragmentBulkItem(name=item.name, config=dict(item.config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.my_bulk_create.wait_for_complete( + MyBulkCreateAppConfigFragmentsAction(entity_ids=[], items=items) + ) + result = wrapper.result + return MyBulkCreateAppConfigFragmentsPayload( + created=[self._data_to_dto(item) for item in result.created], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + async def my_bulk_update( + self, input: MyBulkUpdateAppConfigFragmentsInput + ) -> MyBulkUpdateAppConfigFragmentsPayload: + items = [ + MyAppConfigFragmentBulkItem(name=item.name, config=dict(item.config)) + for item in input.items + ] + wrapper = await self._processors.app_config_fragment.my_bulk_update.wait_for_complete( + MyBulkUpdateAppConfigFragmentsAction(entity_ids=[], items=items) + ) + result = wrapper.result + return MyBulkUpdateAppConfigFragmentsPayload( + updated=[self._data_to_dto(item) for item in result.updated], + failed=[self._bulk_error_to_dto(err) for err in result.failed], + ) + + # ── Querier / DTO helpers ──────────────────────────────────────── + + _PAGINATION_SPEC = PaginationSpec( + forward_order=AppConfigFragmentOrders.created_at(ascending=False), + backward_order=AppConfigFragmentOrders.created_at(ascending=True), + forward_condition_factory=AppConfigFragmentConditions.by_cursor_forward, + backward_condition_factory=AppConfigFragmentConditions.by_cursor_backward, + tiebreaker_order=AppConfigFragmentRow.id.asc(), + ) + + def _build_querier_from_input( + self, + input: SearchMyAppConfigsInput | SearchAppConfigsInput, + ) -> BatchQuerier: + """Querier builder for the merged-view searches. + + The merged-view SQL resolves cursor / order internally via the + repository layer; this helper forwards only the filter / order / + pagination fields so cursor tiebreakers stay consistent with + the raw-fragment querier. + """ + conditions = self._convert_filter(input.filter) if input.filter else [] + orders = self._convert_orders(input.order) if input.order else [] + return self._build_querier( + conditions=conditions, + orders=orders, + pagination_spec=self._PAGINATION_SPEC, + first=input.first, + after=input.after, + last=input.last, + before=input.before, + limit=input.limit, + offset=input.offset, + ) + + def _convert_filter(self, filter: AppConfigFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + if filter.name is not None: + condition = self.convert_string_filter( + filter.name, + contains_factory=AppConfigFragmentConditions.by_name_contains, + equals_factory=AppConfigFragmentConditions.by_name_equals, + starts_with_factory=AppConfigFragmentConditions.by_name_starts_with, + ends_with_factory=AppConfigFragmentConditions.by_name_ends_with, + in_factory=AppConfigFragmentConditions.by_name_in, + ) + if condition is not None: + conditions.append(condition) + # `filter.user_id` handling lives inside the merged-view SQL + # (repository layer) rather than in a BatchQuerier condition — + # see `AppConfigFragmentDBSource.admin_search_app_configs`. + return conditions + + @staticmethod + def _convert_orders(orders: list[AppConfigOrder]) -> list[QueryOrder]: + result: list[QueryOrder] = [] + for order in orders: + ascending = order.direction == OrderDirection.ASC + match order.field: + case AppConfigOrderField.NAME: + result.append(AppConfigFragmentOrders.name(ascending)) + case AppConfigOrderField.USER_ID: + # USER_ID ordering is applied inside the merged-view SQL + # because the raw `app_config_fragments` row does not + # carry a user_id column directly. + continue + return result + + def _data_to_dto(self, data: AppConfigData) -> AppConfigNode: + return AppConfigNode( + user_id=data.user_id, + name=data.name, + fragments=[self._fragment_data_to_dto(fragment) for fragment in data.fragments], + config=dict(data.config) if data.config is not None else None, + ) + + @staticmethod + def _fragment_data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: + return AppConfigFragmentNode( + id=data.id, + scope_type=DTOAppConfigScopeType(data.scope_type.value), + scope_id=data.scope_id, + name=data.name, + config=dict(data.config) if data.config is not None else None, + created_at=data.created_at, + updated_at=data.updated_at, + ) + + @staticmethod + def _bulk_error_to_dto( + err: AppConfigFragmentBulkItemError, + ) -> AppConfigFragmentBulkError: + return AppConfigFragmentBulkError( + index=err.index, + scope_type=DTOAppConfigScopeType(err.scope_type), + scope_id=err.scope_id, + name=err.name, + message=err.message, + ) diff --git a/src/ai/backend/manager/api/adapters/app_config_fragment.py b/src/ai/backend/manager/api/adapters/app_config_fragment.py index f587579a444..293cbe2b292 100644 --- a/src/ai/backend/manager/api/adapters/app_config_fragment.py +++ b/src/ai/backend/manager/api/adapters/app_config_fragment.py @@ -1,4 +1,8 @@ -"""AppConfigFragment domain adapter — Pydantic-in / Pydantic-out transport layer.""" +"""AppConfigFragment domain adapter — Pydantic-in / Pydantic-out transport layer. + +Raw fragment-row operations only. The merged-view (AppConfig) surface +and the self-service `my_bulk_*` writes live on `AppConfigAdapter`. +""" from __future__ import annotations @@ -41,9 +45,7 @@ from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions from ai.backend.manager.models.app_config_fragment.orders import AppConfigFragmentOrders from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow -from ai.backend.manager.repositories.app_config_fragment.types import ( - AppConfigFragmentSearchScope, -) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigFragmentSearchScope from ai.backend.manager.repositories.base import BatchQuerier, QueryCondition, QueryOrder from ai.backend.manager.services.app_config_fragment.actions.admin_bulk_create import ( AdminBulkCreateAppConfigFragmentsAction, @@ -66,11 +68,12 @@ class AppConfigFragmentAdapter(BaseAdapter): - """Adapter for AppConfigFragment domain operations. + """Adapter for AppConfigFragment raw-row operations. - Writes are bulk-only; single-item create / update / purge entry - points are intentionally absent. Self-service my_bulk methods are - added with the merged-view DTOs in BA-5829. + Writes are bulk-only; single-item create / update / + purge entry points are intentionally absent. Self-service my_bulk + writes (which return the recomputed merged view) live on + `AppConfigAdapter` alongside the merged-view reads. """ async def get(self, key_input: AppConfigFragmentKeyInput) -> GetAppConfigFragmentPayload: @@ -222,7 +225,7 @@ def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode: updated_at=data.updated_at, ) - # ── Bulk mutations ───────────────────────────────────────────── + # ── Bulk mutations ─────────────────────────────── # # Each bulk processor returns a `BulkProcessResult[T]` whose # `.result` field is the underlying `*ActionResult` produced by the @@ -270,7 +273,7 @@ async def admin_bulk_update( async def admin_bulk_purge( self, input: AdminBulkPurgeAppConfigFragmentsInput ) -> AdminBulkPurgeAppConfigFragmentsPayload: - keys = [self._input_to_key(key) for key in input.keys] + keys = [self._input_to_key(key_input) for key_input in input.keys] wrapper = await self._processors.app_config_fragment.admin_bulk_purge.wait_for_complete( AdminBulkPurgeAppConfigFragmentsAction(entity_ids=[], keys=keys) ) @@ -288,7 +291,10 @@ async def admin_bulk_purge( ) @staticmethod - def _bulk_error_to_dto(err: AppConfigFragmentBulkItemError) -> AppConfigFragmentBulkError: + def _bulk_error_to_dto( + err: AppConfigFragmentBulkItemError, + ) -> AppConfigFragmentBulkError: + """Convert the service-layer error dataclass to its DTO mirror.""" return AppConfigFragmentBulkError( index=err.index, scope_type=DTOAppConfigScopeType(err.scope_type), diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index 460ff3f89ad..7bedef690b6 100644 --- a/src/ai/backend/manager/api/adapters/registry.py +++ b/src/ai/backend/manager/api/adapters/registry.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from ai.backend.manager.api.adapters.agent.adapter import AgentAdapter +from ai.backend.manager.api.adapters.app_config import AppConfigAdapter from ai.backend.manager.api.adapters.app_config_fragment import AppConfigFragmentAdapter from ai.backend.manager.api.adapters.app_config_policy import AppConfigPolicyAdapter from ai.backend.manager.api.adapters.artifact.adapter import ArtifactAdapter @@ -73,6 +74,7 @@ class Adapters: def __init__( self, agent: AgentAdapter, + app_config: AppConfigAdapter, app_config_fragment: AppConfigFragmentAdapter, app_config_policy: AppConfigPolicyAdapter, artifact: ArtifactAdapter, @@ -115,6 +117,7 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent + self.app_config = app_config self.app_config_fragment = app_config_fragment self.app_config_policy = app_config_policy self.artifact = artifact @@ -176,6 +179,7 @@ def create( """ return cls( agent=AgentAdapter(processors), + app_config=AppConfigAdapter(processors), app_config_fragment=AppConfigFragmentAdapter(processors), app_config_policy=AppConfigPolicyAdapter(processors), artifact=ArtifactAdapter(processors), diff --git a/src/ai/backend/manager/api/gql/app_config/__init__.py b/src/ai/backend/manager/api/gql/app_config/__init__.py new file mode 100644 index 00000000000..ae6d637d8a0 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/__init__.py @@ -0,0 +1,25 @@ +"""AppConfig (merged view) GraphQL API package.""" + +from .resolver import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) +from .types import ( + AppConfigFilterGQL, + AppConfigGQL, + AppConfigOrderByGQL, + AppConfigOrderFieldGQL, +) + +__all__ = [ + # Queries + "my_app_configs", + "admin_app_configs", + "public_app_config_fragments", + # Types + "AppConfigGQL", + "AppConfigFilterGQL", + "AppConfigOrderByGQL", + "AppConfigOrderFieldGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py new file mode 100644 index 00000000000..5d16c074459 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/resolver/__init__.py @@ -0,0 +1,11 @@ +from .query import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) + +__all__ = [ + "admin_app_configs", + "my_app_configs", + "public_app_config_fragments", +] diff --git a/src/ai/backend/manager/api/gql/app_config/resolver/query.py b/src/ai/backend/manager/api/gql/app_config/resolver/query.py new file mode 100644 index 00000000000..755ca4bdc0d --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/resolver/query.py @@ -0,0 +1,140 @@ +"""AppConfig (merged view) GQL query resolvers.""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.dto.manager.v2.app_config.request import ( + SearchAppConfigsInput, + SearchMyAppConfigsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + SearchAppConfigFragmentsInput, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types import ( + AppConfigFilterGQL, + AppConfigGQL, + AppConfigOrderByGQL, +) +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentGQL, + AppConfigFragmentOrderByGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_root_field, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only +from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Caller's own merged AppConfig list (auth required). Chain per policy " + "; the adapter pins `(USER, current_user)` internally." + ), + ) +) # type: ignore[misc] +async def my_app_configs( + info: Info[StrawberryGQLContext], + filter: AppConfigFilterGQL | None = None, + order_by: list[AppConfigOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigGQL]: + payload = await info.context.adapters.app_config.my_search_app_configs( + SearchMyAppConfigsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigGQL.from_pydantic(node) for node in payload.items] + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Cross-user merged-view search (admin only). Resolves any user's AppConfig " + "for audit / support. Pin to a single user with `filter.userId`; otherwise " + "paginates across all users." + ), + ) +) # type: ignore[misc] +async def admin_app_configs( + info: Info[StrawberryGQLContext], + filter: AppConfigFilterGQL | None = None, + order_by: list[AppConfigOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigGQL]: + check_admin_only() + payload = await info.context.adapters.app_config.admin_search_app_configs( + SearchAppConfigsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigGQL.from_pydantic(node) for node in payload.items] + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of " + "raw fragments that carry no personally-scoped data." + ), + ) +) # type: ignore[misc] +async def public_app_config_fragments( + info: Info[StrawberryGQLContext], + filter: AppConfigFragmentFilterGQL | None = None, + order_by: list[AppConfigFragmentOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigFragmentGQL]: + payload = await info.context.adapters.app_config_fragment.search( + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + input=SearchAppConfigFragmentsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ), + ) + return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] diff --git a/src/ai/backend/manager/api/gql/app_config/types/__init__.py b/src/ai/backend/manager/api/gql/app_config/types/__init__.py new file mode 100644 index 00000000000..c38511359aa --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/__init__.py @@ -0,0 +1,13 @@ +from .filters import ( + AppConfigFilterGQL, + AppConfigOrderByGQL, + AppConfigOrderFieldGQL, +) +from .node import AppConfigGQL + +__all__ = [ + "AppConfigFilterGQL", + "AppConfigGQL", + "AppConfigOrderByGQL", + "AppConfigOrderFieldGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py new file mode 100644 index 00000000000..f5e738bb818 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/bulk_payloads.py @@ -0,0 +1,55 @@ +"""AppConfig (merged-view) GQL payloads for self-service bulk mutations.""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config.response import ( + MyBulkCreateAppConfigFragmentsPayload as MyBulkCreatePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config.response import ( + MyBulkUpdateAppConfigFragmentsPayload as MyBulkUpdatePayloadDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types.node import AppConfigGQL +from ai.backend.manager.api.gql.app_config_fragment.types.bulk_payloads import ( + AppConfigFragmentBulkErrorGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).", + ), + model=MyBulkCreatePayloadDTO, + name="MyBulkCreateAppConfigFragmentsPayload", +) +class MyBulkCreateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[MyBulkCreatePayloadDTO]): + created: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each created USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field( + description="Per-item failures.", + ) + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).", + ), + model=MyBulkUpdatePayloadDTO, + name="MyBulkUpdateAppConfigFragmentsPayload", +) +class MyBulkUpdateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[MyBulkUpdatePayloadDTO]): + updated: list[AppConfigGQL] = gql_field( + description="Recomputed merged AppConfig views for each updated USER fragment.", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field( + description="Per-item failures.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/filters.py b/src/ai/backend/manager/api/gql/app_config/types/filters.py new file mode 100644 index 00000000000..00616ee382c --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/filters.py @@ -0,0 +1,78 @@ +"""AppConfig (merged view) GQL filter / order types.""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigFilter as AppConfigFilterDTO, +) +from ai.backend.common.dto.manager.v2.app_config.request import ( + AppConfigOrder as AppConfigOrderDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.base import ( + DateTimeFilter, + OrderDirection, + StringFilter, + UUIDFilter, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_enum, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Filter input for querying merged AppConfigs.", + ), + name="AppConfigFilter", +) +class AppConfigFilterGQL(PydanticInputMixin[AppConfigFilterDTO]): + name: StringFilter | None = gql_field(description="Filter by policy name.", default=None) + user_id: UUIDFilter | None = gql_field( + description="Filter by target user id (admin cross-user search only).", + default=None, + ) + created_at: DateTimeFilter | None = gql_field( + description="Filter by the oldest contributing fragment's creation timestamp.", + default=None, + ) + updated_at: DateTimeFilter | None = gql_field( + description="Filter by the latest contributing fragment's update timestamp.", + default=None, + ) + + +@gql_enum( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Fields available for ordering merged AppConfigs.", + ), + name="AppConfigOrderField", +) +class AppConfigOrderFieldGQL(StrEnum): + USER_ID = "user_id" + NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Specifies ordering for merged AppConfig results.", + ), + name="AppConfigOrderBy", +) +class AppConfigOrderByGQL(PydanticInputMixin[AppConfigOrderDTO]): + field: AppConfigOrderFieldGQL = gql_field(description="The field to order by.") + direction: OrderDirection = gql_field( + description="Sort direction.", + default=OrderDirection.ASC, + ) diff --git a/src/ai/backend/manager/api/gql/app_config/types/node.py b/src/ai/backend/manager/api/gql/app_config/types/node.py new file mode 100644 index 00000000000..8a1ecbf5bcf --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config/types/node.py @@ -0,0 +1,53 @@ +"""AppConfig (merged view) GQL output types.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated +from uuid import UUID + +import strawberry +from strawberry.scalars import JSON + +from ai.backend.common.dto.manager.v2.app_config.response import AppConfigNode +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + +if TYPE_CHECKING: + from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Merged per-user AppConfig view. `fragments` are ordered " + "low → high merge priority; `config` is the deep-merge result and is " + "null when every contributing fragment is empty." + ), + ), + model=AppConfigNode, + name="AppConfig", +) +class AppConfigGQL(PydanticOutputMixin[AppConfigNode]): + user_id: UUID = gql_field(description="Target user's UUID.") + name: str = gql_field(description="Policy / config name.") + # Use `strawberry.lazy()` to break the import cycle between + # `app_config.types.node` and `app_config_fragment.types.node`: + # the fragment package's `__init__.py` eagerly loads its resolver, + # which imports `MyBulkCreate*` payloads back from `app_config.types`. + fragments: list[ + Annotated[ + AppConfigFragmentGQL, + strawberry.lazy("ai.backend.manager.api.gql.app_config_fragment.types.node"), + ] + ] = gql_field( + description="Contributing fragments in merge order (low → high).", + ) + config: JSON | None = gql_field( + description="Deep-merged configuration, or null when every fragment is empty.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py new file mode 100644 index 00000000000..3ca89260fe6 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/__init__.py @@ -0,0 +1,9 @@ +"""AppConfigFragment GraphQL API package. + +Resolver and type names are re-exported by ``schema.py`` directly via +their submodules to keep this package's ``__init__`` import-light: a +top-level ``from app_config_fragment import ...`` would otherwise drag +in the mutation resolvers, which back-import from +``app_config.types.bulk_payloads`` and form an import cycle when +``AppConfigGQL`` is loading. +""" diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py new file mode 100644 index 00000000000..68fabfd3943 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/__init__.py @@ -0,0 +1,21 @@ +from .mutation import ( + admin_bulk_create_app_config_fragments, + admin_bulk_purge_app_config_fragments, + admin_bulk_update_app_config_fragments, + my_bulk_create_app_config_fragments, + my_bulk_update_app_config_fragments, +) +from .query import ( + admin_app_config_fragments, + app_config_fragment, +) + +__all__ = [ + "admin_app_config_fragments", + "admin_bulk_create_app_config_fragments", + "admin_bulk_purge_app_config_fragments", + "admin_bulk_update_app_config_fragments", + "app_config_fragment", + "my_bulk_create_app_config_fragments", + "my_bulk_update_app_config_fragments", +] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py new file mode 100644 index 00000000000..2f21360be1f --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/mutation.py @@ -0,0 +1,112 @@ +"""AppConfigFragment GQL mutation resolvers (bulk-only).""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config.types.bulk_payloads import ( + MyBulkCreateAppConfigFragmentsPayloadGQL, + MyBulkUpdateAppConfigFragmentsPayloadGQL, +) +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AdminBulkCreateAppConfigFragmentInputGQL, + AdminBulkCreateAppConfigFragmentsPayloadGQL, + AdminBulkPurgeAppConfigFragmentInputGQL, + AdminBulkPurgeAppConfigFragmentsPayloadGQL, + AdminBulkUpdateAppConfigFragmentInputGQL, + AdminBulkUpdateAppConfigFragmentsPayloadGQL, + MyBulkCreateAppConfigFragmentInputGQL, + MyBulkUpdateAppConfigFragmentInputGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_mutation, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Strict insert across any scope; each item runs in its own transaction " + "and failures are collected per-item (admin only)." + ), + ) +) +async def admin_bulk_create_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkCreateAppConfigFragmentInputGQL, +) -> AdminBulkCreateAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_create(input.to_pydantic()) + return AdminBulkCreateAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Wholesale JSON replacement; items with no existing row are returned as failures " + "(admin only)." + ), + ) +) +async def admin_bulk_update_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkUpdateAppConfigFragmentInputGQL, +) -> AdminBulkUpdateAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_update(input.to_pydantic()) + return AdminBulkUpdateAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Cleanup-only deletion; absent keys are no-oped (admin only).", + ) +) +async def admin_bulk_purge_app_config_fragments( + info: Info[StrawberryGQLContext], + input: AdminBulkPurgeAppConfigFragmentInputGQL, +) -> AdminBulkPurgeAppConfigFragmentsPayloadGQL: + check_admin_only() + result = await info.context.adapters.app_config_fragment.admin_bulk_purge(input.to_pydantic()) + return AdminBulkPurgeAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Strict insert on the caller's USER row; duplicates fail per-item. " + "Returns recomputed merged `AppConfig` views." + ), + ) +) +async def my_bulk_create_app_config_fragments( + info: Info[StrawberryGQLContext], + input: MyBulkCreateAppConfigFragmentInputGQL, +) -> MyBulkCreateAppConfigFragmentsPayloadGQL: + result = await info.context.adapters.app_config.my_bulk_create(input.to_pydantic()) + return MyBulkCreateAppConfigFragmentsPayloadGQL.from_pydantic(result) + + +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Wholesale replacement on the caller's USER row; missing rows are returned as " + "failures. Returns recomputed merged `AppConfig` views." + ), + ) +) +async def my_bulk_update_app_config_fragments( + info: Info[StrawberryGQLContext], + input: MyBulkUpdateAppConfigFragmentInputGQL, +) -> MyBulkUpdateAppConfigFragmentsPayloadGQL: + result = await info.context.adapters.app_config.my_bulk_update(input.to_pydantic()) + return MyBulkUpdateAppConfigFragmentsPayloadGQL.from_pydantic(result) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py new file mode 100644 index 00000000000..8648ccf0a69 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/resolver/query.py @@ -0,0 +1,88 @@ +"""AppConfigFragment GQL query resolvers. + +Per the scope-bound list is exposed via child fields on +`DomainV2.appConfigFragments` / `UserV2.appConfigFragments`, not as a +root resolver. Only the single-row read and the cross-scope admin +search live here. The scope-bound REST endpoint +`POST /v2/app-config-fragments/{scope_type}/{scope_id}/search` +continues to use the adapter's `search()` method directly. +""" + +from __future__ import annotations + +from strawberry import Info + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentKeyInput, + SearchAppConfigFragmentsInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentGQL, + AppConfigFragmentOrderByGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_root_field, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Get a single app-config fragment by natural key " + "`(scope_type, scope_id, name)`. Available to any authenticated user " + "— service-layer authorization gates cross-scope reads." + ), + ) +) # type: ignore[misc] +async def app_config_fragment( + info: Info[StrawberryGQLContext], + scope_type: AppConfigScopeType, + scope_id: str, + name: str, +) -> AppConfigFragmentGQL | None: + payload = await info.context.adapters.app_config_fragment.get( + AppConfigFragmentKeyInput(scope_type=scope_type, scope_id=scope_id, name=name) + ) + if payload.item is None: + return None + return AppConfigFragmentGQL.from_pydantic(payload.item) + + +@gql_root_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Cross-scope admin search across all app-config fragments (admin only).", + ) +) # type: ignore[misc] +async def admin_app_config_fragments( + info: Info[StrawberryGQLContext], + filter: AppConfigFragmentFilterGQL | None = None, + order_by: list[AppConfigFragmentOrderByGQL] | None = None, + first: int | None = None, + after: str | None = None, + last: int | None = None, + before: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[AppConfigFragmentGQL]: + check_admin_only() + payload = await info.context.adapters.app_config_fragment.admin_search( + SearchAppConfigFragmentsInput( + filter=filter.to_pydantic() if filter else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ) + ) + return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py new file mode 100644 index 00000000000..657b6b6bb3e --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/__init__.py @@ -0,0 +1,44 @@ +from .bulk_inputs import ( + AdminAppConfigFragmentItemInputGQL, + AdminBulkCreateAppConfigFragmentInputGQL, + AdminBulkPurgeAppConfigFragmentInputGQL, + AdminBulkUpdateAppConfigFragmentInputGQL, + MyAppConfigFragmentItemInputGQL, + MyBulkCreateAppConfigFragmentInputGQL, + MyBulkUpdateAppConfigFragmentInputGQL, +) +from .bulk_payloads import ( + AdminBulkCreateAppConfigFragmentsPayloadGQL, + AdminBulkPurgeAppConfigFragmentsPayloadGQL, + AdminBulkUpdateAppConfigFragmentsPayloadGQL, + AppConfigFragmentBulkErrorGQL, + PurgeAppConfigFragmentKeyGQL, +) +from .filters import ( + AppConfigFragmentFilterGQL, + AppConfigFragmentOrderByGQL, + AppConfigFragmentOrderFieldGQL, +) +from .inputs import AppConfigFragmentKeyInputGQL +from .node import AppConfigFragmentGQL, AppConfigScopeTypeGQL + +__all__ = [ + "AdminAppConfigFragmentItemInputGQL", + "AdminBulkCreateAppConfigFragmentInputGQL", + "AdminBulkCreateAppConfigFragmentsPayloadGQL", + "AdminBulkPurgeAppConfigFragmentInputGQL", + "AdminBulkPurgeAppConfigFragmentsPayloadGQL", + "AdminBulkUpdateAppConfigFragmentInputGQL", + "AdminBulkUpdateAppConfigFragmentsPayloadGQL", + "AppConfigFragmentBulkErrorGQL", + "AppConfigFragmentFilterGQL", + "AppConfigFragmentGQL", + "AppConfigFragmentKeyInputGQL", + "AppConfigFragmentOrderByGQL", + "AppConfigFragmentOrderFieldGQL", + "AppConfigScopeTypeGQL", + "MyBulkCreateAppConfigFragmentInputGQL", + "MyBulkUpdateAppConfigFragmentInputGQL", + "MyAppConfigFragmentItemInputGQL", + "PurgeAppConfigFragmentKeyGQL", +] diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py new file mode 100644 index 00000000000..aef8bdaf36e --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_inputs.py @@ -0,0 +1,120 @@ +"""AppConfigFragment bulk-mutation GQL input types.""" + +from __future__ import annotations + +from strawberry.scalars import JSON + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminAppConfigFragmentItemInput as AdminItemInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkCreateAppConfigFragmentsInput as AdminBulkCreateInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkPurgeAppConfigFragmentsInput as AdminBulkPurgeInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AdminBulkUpdateAppConfigFragmentsInput as AdminBulkUpdateInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyAppConfigFragmentItemInput as MyItemInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyBulkCreateAppConfigFragmentsInput as MyBulkCreateInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + MyBulkUpdateAppConfigFragmentsInput as MyBulkUpdateInputDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types.inputs import ( + AppConfigFragmentKeyInputGQL, +) +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item input for admin bulk create / update.", + ), + name="AdminAppConfigFragmentItemInput", +) +class AdminAppConfigFragmentItemInputGQL(PydanticInputMixin[AdminItemInputDTO]): + key: AppConfigFragmentKeyInputGQL = gql_field(description="Natural-key identifier.") + config: JSON = gql_field(description="Raw configuration payload.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk create input — items carry any scope.", + ), + name="AdminBulkCreateAppConfigFragmentInput", +) +class AdminBulkCreateAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkCreateInputDTO]): + items: list[AdminAppConfigFragmentItemInputGQL] = gql_field(description="Rows to create.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk update input.", + ), + name="AdminBulkUpdateAppConfigFragmentInput", +) +class AdminBulkUpdateAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkUpdateInputDTO]): + items: list[AdminAppConfigFragmentItemInputGQL] = gql_field(description="Rows to update.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Admin bulk purge input — keyed on `AppConfigFragmentKey`.", + ), + name="AdminBulkPurgeAppConfigFragmentInput", +) +class AdminBulkPurgeAppConfigFragmentInputGQL(PydanticInputMixin[AdminBulkPurgeInputDTO]): + keys: list[AppConfigFragmentKeyInputGQL] = gql_field(description="Natural keys to purge.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item input for self-service (`my`) bulk.", + ), + name="MyAppConfigFragmentItemInput", +) +class MyAppConfigFragmentItemInputGQL(PydanticInputMixin[MyItemInputDTO]): + name: str = gql_field(description="Policy name.") + config: JSON = gql_field(description="Raw configuration payload.") + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Self-service bulk create — scope is `USER` / `current_user`.", + ), + name="MyBulkCreateAppConfigFragmentInput", +) +class MyBulkCreateAppConfigFragmentInputGQL(PydanticInputMixin[MyBulkCreateInputDTO]): + items: list[MyAppConfigFragmentItemInputGQL] = gql_field( + description="USER-scope rows to create.", + ) + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Self-service bulk update — scope is `USER` / `current_user`.", + ), + name="MyBulkUpdateAppConfigFragmentInput", +) +class MyBulkUpdateAppConfigFragmentInputGQL(PydanticInputMixin[MyBulkUpdateInputDTO]): + items: list[MyAppConfigFragmentItemInputGQL] = gql_field( + description="USER-scope rows to update.", + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py new file mode 100644 index 00000000000..25daaf10b4e --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/bulk_payloads.py @@ -0,0 +1,105 @@ +"""AppConfigFragment bulk-mutation GQL payload types (admin only). + +Self-service `my_*` bulk payloads return recomputed merged AppConfig +views, so they live in `app_config/types/bulk_payloads.py` to keep the +import direction `app_config -> app_config_fragment` (one-way) and +avoid a circular import. +""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkCreateAppConfigFragmentsPayload as AdminBulkCreatePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkPurgeAppConfigFragmentsPayload as AdminBulkPurgePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AdminBulkUpdateAppConfigFragmentsPayload as AdminBulkUpdatePayloadDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentBulkError as AppConfigFragmentBulkErrorDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + PurgeAppConfigFragmentKey as PurgeAppConfigFragmentKeyDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.app_config_fragment.types.node import AppConfigFragmentGQL +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Per-item failure info for bulk Fragment mutations.", + ), + model=AppConfigFragmentBulkErrorDTO, + name="AppConfigFragmentBulkError", +) +class AppConfigFragmentBulkErrorGQL(PydanticOutputMixin[AppConfigFragmentBulkErrorDTO]): + index: int = gql_field(description="Original position in the input list.") + scope_type: AppConfigScopeType = gql_field(description="Scope type of the failed row.") + scope_id: str = gql_field(description="Scope id of the failed row.") + name: str = gql_field(description="Policy name of the failed row.") + message: str = gql_field(description="Reason for the failure.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Natural key of a purged fragment row.", + ), + model=PurgeAppConfigFragmentKeyDTO, + name="PurgeAppConfigFragmentKey", +) +class PurgeAppConfigFragmentKeyGQL(PydanticOutputMixin[PurgeAppConfigFragmentKeyDTO]): + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkCreateAppConfigFragments`.", + ), + model=AdminBulkCreatePayloadDTO, + name="AdminBulkCreateAppConfigFragmentsPayload", +) +class AdminBulkCreateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkCreatePayloadDTO]): + created: list[AppConfigFragmentGQL] = gql_field(description="Created fragments.") + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkUpdateAppConfigFragments`.", + ), + model=AdminBulkUpdatePayloadDTO, + name="AdminBulkUpdateAppConfigFragmentsPayload", +) +class AdminBulkUpdateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkUpdatePayloadDTO]): + updated: list[AppConfigFragmentGQL] = gql_field(description="Updated fragments.") + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Payload for `adminBulkPurgeAppConfigFragments`.", + ), + model=AdminBulkPurgePayloadDTO, + name="AdminBulkPurgeAppConfigFragmentsPayload", +) +class AdminBulkPurgeAppConfigFragmentsPayloadGQL(PydanticOutputMixin[AdminBulkPurgePayloadDTO]): + purged: list[PurgeAppConfigFragmentKeyGQL] = gql_field( + description="Keys of rows actually removed (absent keys are no-oped).", + ) + failed: list[AppConfigFragmentBulkErrorGQL] = gql_field(description="Per-item failures.") diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py new file mode 100644 index 00000000000..7c3d89dd699 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/filters.py @@ -0,0 +1,63 @@ +"""AppConfigFragment GQL filter / order types.""" + +from __future__ import annotations + +from enum import StrEnum + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentFilter as AppConfigFragmentFilterDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentOrder as AppConfigFragmentOrderDTO, +) +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.base import OrderDirection, StringFilter +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_enum, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Filter input for querying app-config fragments.", + ), + name="AppConfigFragmentFilter", +) +class AppConfigFragmentFilterGQL(PydanticInputMixin[AppConfigFragmentFilterDTO]): + name: StringFilter | None = gql_field(description="Filter by policy name.", default=None) + scope_id: StringFilter | None = gql_field(description="Filter by scope_id.", default=None) + + +@gql_enum( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Fields available for ordering app-config fragments.", + ), + name="AppConfigFragmentOrderField", +) +class AppConfigFragmentOrderFieldGQL(StrEnum): + SCOPE_TYPE = "scope_type" + SCOPE_ID = "scope_id" + NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Specifies ordering for app-config fragment results.", + ), + name="AppConfigFragmentOrderBy", +) +class AppConfigFragmentOrderByGQL(PydanticInputMixin[AppConfigFragmentOrderDTO]): + field: AppConfigFragmentOrderFieldGQL = gql_field(description="The field to order by.") + direction: OrderDirection = gql_field( + description="Sort direction.", + default=OrderDirection.DESC, + ) diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py new file mode 100644 index 00000000000..d0a417b2c20 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/inputs.py @@ -0,0 +1,28 @@ +"""AppConfigFragment GQL natural-key input shared by bulk types.""" + +from __future__ import annotations + +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentKeyInput as AppConfigFragmentKeyInputDTO, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_input, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticInputMixin + + +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Natural key for an app-config fragment row.", + ), + name="AppConfigFragmentKeyInput", +) +class AppConfigFragmentKeyInputGQL(PydanticInputMixin[AppConfigFragmentKeyInputDTO]): + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name.") diff --git a/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py new file mode 100644 index 00000000000..fa1e1369201 --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config_fragment/types/node.py @@ -0,0 +1,43 @@ +"""AppConfigFragment GQL output type.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from strawberry.scalars import JSON + +from ai.backend.common.dto.manager.v2.app_config_fragment.response import AppConfigFragmentNode +from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType +from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION +from ai.backend.manager.api.gql.decorators import ( + BackendAIGQLMeta, + gql_field, + gql_pydantic_type, +) +from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin + +# The shared DTO enum is auto-registered by Strawberry the first time it +# is referenced as a typed field. Re-export under the ``GQL`` suffix so +# other modules can write `from ... import AppConfigScopeTypeGQL`. Calling +# `strawberry.enum(...)` here would clash with that auto-registration +# under the same `"AppConfigScopeType"` name. +AppConfigScopeTypeGQL = AppConfigScopeType + + +@gql_pydantic_type( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Raw per-scope app-config fragment.", + ), + model=AppConfigFragmentNode, + name="AppConfigFragment", +) +class AppConfigFragmentGQL(PydanticOutputMixin[AppConfigFragmentNode]): + id: UUID = gql_field(description="Fragment row UUID.") + scope_type: AppConfigScopeType = gql_field(description="Scope type.") + scope_id: str = gql_field(description="Scope id.") + name: str = gql_field(description="Policy name (FK to app_config_policies).") + config: JSON | None = gql_field(description="Raw configuration payload, or null.") + created_at: datetime = gql_field(description="Creation timestamp.") + updated_at: datetime | None = gql_field(description="Last update timestamp.") diff --git a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py index d03f365d20d..07c4930e450 100644 --- a/src/ai/backend/manager/api/gql/data_loader/data_loaders.py +++ b/src/ai/backend/manager/api/gql/data_loader/data_loaders.py @@ -14,6 +14,9 @@ from ai.backend.common.dto.manager.v2.rbac.response import EntityNode # pants: no-infer-dep from ai.backend.manager.api.adapters.registry import Adapters # pants: no-infer-dep from ai.backend.manager.api.gql.agent.types import AgentV2GQL # pants: no-infer-dep + from ai.backend.manager.api.gql.app_config_fragment.types import ( # pants: no-infer-dep + AppConfigFragmentGQL, + ) from ai.backend.manager.api.gql.artifact.types import ( # pants: no-infer-dep ArtifactRevision, ) @@ -113,6 +116,35 @@ class DataLoaders: def __init__(self, adapters: Adapters) -> None: self._adapters = adapters + @cached_property + def app_config_fragment_loader( + self, + ) -> DataLoader[uuid.UUID, AppConfigFragmentGQL | None]: + adapter = self._adapters.app_config_fragment + + async def load_fn(ids: list[uuid.UUID]) -> list[AppConfigFragmentGQL | None]: + from ai.backend.common.dto.manager.query import UUIDFilter # pants: no-infer-dep + from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( # pants: no-infer-dep + AppConfigFragmentFilter, + SearchAppConfigFragmentsInput, + ) + from ai.backend.manager.api.gql.app_config_fragment.types import ( # pants: no-infer-dep + AppConfigFragmentGQL as F, + ) + + if not ids: + return [] + payload = await adapter.admin_search( + SearchAppConfigFragmentsInput( + filter=AppConfigFragmentFilter(id=UUIDFilter.model_validate({"in": list(ids)})), + limit=len(ids), + ), + ) + by_id = {dto.id: F.from_pydantic(dto) for dto in payload.items} + return [by_id.get(fragment_id) for fragment_id in ids] + + return DataLoader(load_fn=load_fn) + @cached_property def audit_log_loader( self, diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 55a50492e72..030f3c53a3d 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -15,6 +15,22 @@ agent_stats, agents_v2, ) +from .app_config import ( + admin_app_configs, + my_app_configs, + public_app_config_fragments, +) +from .app_config_fragment.resolver.mutation import ( + admin_bulk_create_app_config_fragments, + admin_bulk_purge_app_config_fragments, + admin_bulk_update_app_config_fragments, + my_bulk_create_app_config_fragments, + my_bulk_update_app_config_fragments, +) +from .app_config_fragment.resolver.query import ( + admin_app_config_fragments, + app_config_fragment, +) from .app_config_policy import ( admin_bulk_create_app_config_policies, admin_bulk_purge_app_config_policies, @@ -515,6 +531,13 @@ class Query: # App Config Policy APIs (read available to any authenticated user) app_config_policy = app_config_policy app_config_policies = app_config_policies + # App Config Fragment APIs + app_config_fragment = app_config_fragment + admin_app_config_fragments = admin_app_config_fragments + public_app_config_fragments = public_app_config_fragments + # App Config merged view APIs + my_app_configs = my_app_configs + admin_app_configs = admin_app_configs # Prometheus Query Preset APIs (read available to any authenticated user) prometheus_query_preset = prometheus_query_preset prometheus_query_presets = prometheus_query_presets @@ -790,10 +813,16 @@ class Mutation: admin_unblock_user = admin_unblock_user # IP allowlist self-service mutation update_my_allowed_client_ip = update_my_allowed_client_ip - # App Config Policy - Bulk admin mutations (bulk-only) + # App Config Policy - Bulk admin mutations admin_bulk_create_app_config_policies = admin_bulk_create_app_config_policies admin_bulk_update_app_config_policies = admin_bulk_update_app_config_policies admin_bulk_purge_app_config_policies = admin_bulk_purge_app_config_policies + # App Config Fragment - Bulk mutations + admin_bulk_create_app_config_fragments = admin_bulk_create_app_config_fragments + admin_bulk_update_app_config_fragments = admin_bulk_update_app_config_fragments + admin_bulk_purge_app_config_fragments = admin_bulk_purge_app_config_fragments + my_bulk_create_app_config_fragments = my_bulk_create_app_config_fragments + my_bulk_update_app_config_fragments = my_bulk_update_app_config_fragments # Prometheus Query Preset - Admin APIs admin_create_prometheus_query_preset = admin_create_prometheus_query_preset admin_modify_prometheus_query_preset = admin_modify_prometheus_query_preset diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py new file mode 100644 index 00000000000..ce84afac2c9 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/admin_search_app_configs.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class AdminSearchAppConfigsAction(AppConfigFragmentAction): + """Cross-user merged-view search(admin only).""" + + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class AdminSearchAppConfigsActionResult(BaseActionResult): + items: list[AppConfigData] + total_count: int + has_next_page: bool + has_previous_page: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py new file mode 100644 index 00000000000..74923db4d3e --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/get_user_app_config.py @@ -0,0 +1,34 @@ +import uuid +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class GetUserAppConfigAction(AppConfigFragmentAction): + """Resolve a single per-user merged AppConfig.""" + + user_id: uuid.UUID + config_name: str + + @override + def entity_id(self) -> str | None: + return f"{self.user_id}:{self.config_name}" + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + +@dataclass +class GetUserAppConfigActionResult(BaseActionResult): + app_config: AppConfigData + + @override + def entity_id(self) -> str | None: + return f"{self.app_config.user_id}:{self.app_config.name}" diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py index d7e3418734c..a1fd08cfd18 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/my_bulk_update.py @@ -15,7 +15,7 @@ @dataclass class MyBulkUpdateAppConfigFragmentsAction(BaseBulkAction[MyAppConfigFragmentBulkItem]): - """Self-service bulk update — see `BulkCreateMyAppConfigFragmentsAction`.""" + """Self-service bulk update — see `MyBulkCreateAppConfigFragmentsAction`.""" items: list[MyAppConfigFragmentBulkItem] = field(default_factory=list) diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py new file mode 100644 index 00000000000..6dc1d28b85e --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/search_user_app_configs.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.repositories.app_config_fragment.types import UserAppConfigSearchScope +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.app_config_fragment.actions.base import AppConfigFragmentAction + + +@dataclass +class SearchUserAppConfigsAction(AppConfigFragmentAction): + """Scope-bound merged-view search(per-user).""" + + scope: UserAppConfigSearchScope + querier: BatchQuerier + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class SearchUserAppConfigsActionResult(BaseActionResult): + items: list[AppConfigData] + total_count: int + has_next_page: bool + has_previous_page: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/app_config_fragment/processors.py b/src/ai/backend/manager/services/app_config_fragment/processors.py index 616a7dd4dac..5c5deb8b844 100644 --- a/src/ai/backend/manager/services/app_config_fragment/processors.py +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -21,10 +21,18 @@ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, + AdminSearchAppConfigsActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.get import ( GetAppConfigFragmentAction, GetAppConfigFragmentActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( + GetUserAppConfigAction, + GetUserAppConfigActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( MyBulkCreateAppConfigFragmentsAction, MyBulkCreateAppConfigFragmentsActionResult, @@ -37,6 +45,10 @@ SearchAppConfigFragmentsAction, SearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, + SearchUserAppConfigsActionResult, +) from ai.backend.manager.services.app_config_fragment.service import AppConfigFragmentService @@ -46,10 +58,18 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage): admin_search: ActionProcessor[ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult ] - # Bulk mutations — wrapped by BulkActionProcessor so validators - # (RBAC, etc.) can filter entity_ids per-item before the service - # runs. No bulk validators are wired today; the processor simply - # forwards to the service. + # Merged-view (AppConfig) + get_user_app_config: ActionProcessor[GetUserAppConfigAction, GetUserAppConfigActionResult] + search_user_app_configs: ActionProcessor[ + SearchUserAppConfigsAction, SearchUserAppConfigsActionResult + ] + admin_search_app_configs: ActionProcessor[ + AdminSearchAppConfigsAction, AdminSearchAppConfigsActionResult + ] + # Bulk mutations — wrapped by BulkActionProcessor so + # validators (RBAC, etc.) can filter entity_ids per-item before the + # service runs. No bulk validators are wired today; the processor + # simply forwards to the service. admin_bulk_create: BulkActionProcessor[ AdminBulkCreateAppConfigFragmentsAction, AdminBulkCreateAppConfigFragmentsActionResult ] @@ -75,6 +95,13 @@ def __init__( self.get = ActionProcessor(service.get, action_monitors) self.search = ActionProcessor(service.search, action_monitors) self.admin_search = ActionProcessor(service.admin_search, action_monitors) + self.get_user_app_config = ActionProcessor(service.get_user_app_config, action_monitors) + self.search_user_app_configs = ActionProcessor( + service.search_user_app_configs, action_monitors + ) + self.admin_search_app_configs = ActionProcessor( + service.admin_search_app_configs, action_monitors + ) self.admin_bulk_create = BulkActionProcessor(service.admin_bulk_create, action_monitors) self.admin_bulk_update = BulkActionProcessor(service.admin_bulk_update, action_monitors) self.admin_bulk_purge = BulkActionProcessor(service.admin_bulk_purge, action_monitors) @@ -87,6 +114,9 @@ def supported_actions(self) -> list[ActionSpec]: GetAppConfigFragmentAction.spec(), SearchAppConfigFragmentsAction.spec(), AdminSearchAppConfigFragmentsAction.spec(), + GetUserAppConfigAction.spec(), + SearchUserAppConfigsAction.spec(), + AdminSearchAppConfigsAction.spec(), AdminBulkCreateAppConfigFragmentsAction.spec(), AdminBulkUpdateAppConfigFragmentsAction.spec(), AdminBulkPurgeAppConfigFragmentsAction.spec(), diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py index 91bc46be13f..cbe0c9d6c28 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -32,10 +32,18 @@ AdminSearchAppConfigFragmentsAction, AdminSearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.admin_search_app_configs import ( + AdminSearchAppConfigsAction, + AdminSearchAppConfigsActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.get import ( GetAppConfigFragmentAction, GetAppConfigFragmentActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.get_user_app_config import ( + GetUserAppConfigAction, + GetUserAppConfigActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.my_bulk_create import ( MyBulkCreateAppConfigFragmentsAction, MyBulkCreateAppConfigFragmentsActionResult, @@ -48,6 +56,10 @@ SearchAppConfigFragmentsAction, SearchAppConfigFragmentsActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.search_user_app_configs import ( + SearchUserAppConfigsAction, + SearchUserAppConfigsActionResult, +) log = BraceStyleAdapter(logging.getLogger(__spec__.name)) @@ -90,7 +102,37 @@ async def admin_search( has_previous_page=result.has_previous_page, ) - # ── Bulk mutations (per-item transaction) ───────────────────── + # ── Merged-view reads (AppConfig) ──────────────── + + async def get_user_app_config( + self, action: GetUserAppConfigAction + ) -> GetUserAppConfigActionResult: + app_config = await self._repository.app_config(action.user_id, action.config_name) + return GetUserAppConfigActionResult(app_config=app_config) + + async def search_user_app_configs( + self, action: SearchUserAppConfigsAction + ) -> SearchUserAppConfigsActionResult: + result = await self._repository.search_app_configs(action.scope, action.querier) + return SearchUserAppConfigsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def admin_search_app_configs( + self, action: AdminSearchAppConfigsAction + ) -> AdminSearchAppConfigsActionResult: + result = await self._admin_repository.admin_search_app_configs(action.querier) + return AdminSearchAppConfigsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + # ── Bulk mutations ──────── async def admin_bulk_create( self, action: AdminBulkCreateAppConfigFragmentsAction