diff --git a/changes/11265.breaking.md b/changes/11265.breaking.md new file mode 100644 index 00000000000..0d7f854cf91 --- /dev/null +++ b/changes/11265.breaking.md @@ -0,0 +1 @@ +Drop the legacy `app_configs` table and its GraphQL / REST surface (`mergedAppConfig`, `domainAppConfig`, `userAppConfig`, `adminDomainAppConfig`, `upsertDomainAppConfig`, `deleteDomainAppConfig`, `upsertUserAppConfig`, `deleteUserAppConfig`, `adminUpsertDomainAppConfig`, `adminDeleteDomainAppConfig`, and the matching REST v2 handlers). Preparation for the scoped app-config redesign — the replacement `AppConfigFragment` / `AppConfigPolicy` layer lands in subsequent PRs. diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 76cc4cb8323..390c36ba601 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -895,14 +895,6 @@ type AllowedResourceGroupsPayload items: [String!]! } -"""Added in 25.16.0. App configuration data.""" -type AppConfig - @join__type(graph: STRAWBERRY) -{ - """Additional configuration data.""" - extraConfig: JSON! -} - """ Added in 24.09.0. Input for approving an artifact revision. @@ -4443,23 +4435,6 @@ type DeleteDomain msg: String } -"""Added in 25.16.0. Input for deleting domain-level app configuration""" -input DeleteDomainConfigInput - @join__type(graph: STRAWBERRY) -{ - domainName: String! -} - -""" -Added in 25.16.0. Payload returned after deleting domain-level app configuration. Indicates whether the deletion was successful. -""" -type DeleteDomainConfigPayload - @join__type(graph: STRAWBERRY) -{ - """Whether the deletion was successful.""" - deleted: Boolean! -} - """Added in 26.4.2. Payload for domain deletion mutation.""" type DeleteDomainPayloadGQL @join__type(graph: STRAWBERRY) @@ -4777,26 +4752,6 @@ type DeleteUser msg: String } -""" -Added in 24.09.0. Input for deleting user-level app configuration. -If user_id is not provided, the current user's configuration will be deleted. -""" -input DeleteUserConfigInput - @join__type(graph: STRAWBERRY) -{ - userId: ID = null -} - -""" -Added in 25.16.0. Payload returned after deleting user-level app configuration. Indicates whether the deletion was successful. -""" -type DeleteUserConfigPayload - @join__type(graph: STRAWBERRY) -{ - """Whether the deletion was successful.""" - deleted: Boolean! -} - type DeleteUserResourcePolicy @join__type(graph: GRAPHENE) { @@ -6354,7 +6309,6 @@ union EntityNode @join__unionMember(graph: STRAWBERRY, member: "SessionV2") @join__unionMember(graph: STRAWBERRY, member: "Artifact") @join__unionMember(graph: STRAWBERRY, member: "ArtifactRegistry") - @join__unionMember(graph: STRAWBERRY, member: "AppConfig") @join__unionMember(graph: STRAWBERRY, member: "NotificationChannel") @join__unionMember(graph: STRAWBERRY, member: "NotificationRule") @join__unionMember(graph: STRAWBERRY, member: "ModelDeployment") @@ -6362,7 +6316,7 @@ union EntityNode @join__unionMember(graph: STRAWBERRY, member: "ContainerRegistryV2") @join__unionMember(graph: STRAWBERRY, member: "ArtifactRevision") @join__unionMember(graph: STRAWBERRY, member: "Role") - = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | SessionV2 | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role + = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | SessionV2 | Artifact | ArtifactRegistry | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role """Added in 26.4.2. Valid entity-operation combination for RBAC actions.""" type EntityOperationCombination @@ -10458,16 +10412,6 @@ type Mutation """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! @join__field(graph: STRAWBERRY) - """ - Added in 25.16.0. Create or update user-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration - """ - upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! @join__field(graph: STRAWBERRY) - - """ - Added in 25.16.0. Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration - """ - deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! @join__field(graph: STRAWBERRY) - """ Added in 25.15.0. Triggers artifact scanning on a remote reservoir registry. This mutation instructs a reservoir-type registry to initiate a scan of artifacts from its associated remote reservoir registry source. The scan process will discover and catalog artifacts available in the remote reservoir, making them accessible through the local reservoir registry. Requirements: - The delegator registry must be of type 'reservoir' - The delegator reservoir registry must have a valid remote registry configuration """ @@ -10563,16 +10507,6 @@ type Mutation """Added in 26.4.2. Validate a notification rule (admin only)""" adminValidateNotificationRule(input: ValidateNotificationRuleInput!): ValidateNotificationRulePayload! @join__field(graph: STRAWBERRY) - """ - Added in 26.2.0. Create or update domain-level app configuration (admin only). The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys - """ - adminUpsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! @join__field(graph: STRAWBERRY) - - """ - Added in 26.2.0. Delete domain-level app configuration (admin only). All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults - """ - adminDeleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! @join__field(graph: STRAWBERRY) - """Added in 26.4.2. Create a new notification channel""" createNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_create_notification_channel instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") @@ -10597,16 +10531,6 @@ type Mutation """Added in 26.4.2. Validate a notification rule""" validateNotificationRule(input: ValidateNotificationRuleInput!): ValidateNotificationRulePayload! @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_validate_notification_rule instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """ - Added in 25.16.0. Create or update domain-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys. Requires admin privileges - """ - upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_upsert_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - - """ - Added in 25.16.0. Delete domain-level app configuration. All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults. Requires admin privileges - """ - deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_delete_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """Added in 25.14.0. Create an object storage.""" createObjectStorage(input: CreateObjectStorageInput!): CreateObjectStoragePayload! @join__field(graph: STRAWBERRY) @@ -13245,16 +13169,6 @@ type Query """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection @join__field(graph: STRAWBERRY) - """ - Added in 25.16.0. Retrieve user-level app configuration. Returns only the configuration set specifically for the user, without merging with domain config. This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration. - """ - userAppConfig(userId: ID = null): AppConfig @join__field(graph: STRAWBERRY) - - """ - Added in 25.16.0. Retrieve merged app configuration for the current user. The result combines domain-level and user-level configurations, where user settings override domain settings for the same keys. This query should be used when working with user app configurations to get the actual configuration values that will be applied. - """ - mergedAppConfig: AppConfig! @join__field(graph: STRAWBERRY) - """Added in 25.16.0. Get a specific deployment by ID.""" deployment(id: ID!): ModelDeployment @join__field(graph: STRAWBERRY) @@ -13377,11 +13291,6 @@ type Query """Added in 26.4.2. List notification rules (admin only)""" adminNotificationRules(filter: NotificationRuleFilter = null, orderBy: [NotificationRuleOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): NotificationRuleConnection @join__field(graph: STRAWBERRY) - """ - Added in 26.2.0. Retrieve domain-level app configuration (admin only). Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. - """ - adminDomainAppConfig(domainName: String!): AppConfig @join__field(graph: STRAWBERRY) - """Added in 26.2.0. Get domain fair share data (admin only).""" adminDomainFairShare(resourceGroupName: String!, domainName: String!): DomainFairShare @join__field(graph: STRAWBERRY) @@ -13633,11 +13542,6 @@ type Query """Added in 26.2.0. List resource groups""" resourceGroups(filter: ResourceGroupFilter = null, orderBy: [ResourceGroupOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ResourceGroupConnection @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_resource_groups instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """ - Added in 25.16.0. Retrieve domain-level app configuration. Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. Requires admin privileges. - """ - domainAppConfig(domainName: String!): AppConfig @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """Added in 26.1.0. Get domain fair share data (superadmin only).""" domainFairShare(resourceGroupName: String!, domainName: String!): DomainFairShare @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_domain_fair_share instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") @@ -18412,29 +18316,6 @@ type UpdateVFSStoragePayload vfsStorage: VFSStorage! } -""" -Added in 24.09.0. Input for creating or updating domain-level app configuration. -The provided extra_config object will completely replace the existing configuration; -existing keys not present in the new extra_config will be removed. -All users in this domain will be affected by these settings when their configurations are merged. -""" -input UpsertDomainConfigInput - @join__type(graph: STRAWBERRY) -{ - domainName: String! - extraConfig: JSON! -} - -""" -Added in 25.16.0. Payload returned after upserting domain-level app configuration. Contains the resulting configuration that was stored. -""" -type UpsertDomainConfigPayload - @join__type(graph: STRAWBERRY) -{ - """The resulting app configuration""" - appConfig: AppConfig! -} - """ Added in 26.1.0. Input for upserting domain fair share weight. The weight parameter affects scheduling priority - higher weight = higher priority. Set weight to null to use resource group's default_weight. """ @@ -18492,30 +18373,6 @@ type UpsertProjectFairShareWeightPayload projectFairShare: ProjectFairShare! } -""" -Added in 24.09.0. Input for creating or updating user-level app configuration. -The provided extra_config object will completely replace the existing configuration; -existing keys not present in the new extra_config will be removed. -These settings will override domain-level settings when configurations are merged for this user. -If user_id is not provided, the current user's configuration will be updated. -""" -input UpsertUserConfigInput - @join__type(graph: STRAWBERRY) -{ - extraConfig: JSON! - userId: ID = null -} - -""" -Added in 25.16.0. Payload returned after upserting user-level app configuration. Contains the resulting configuration that was stored. -""" -type UpsertUserConfigPayload - @join__type(graph: STRAWBERRY) -{ - """The resulting app configuration""" - appConfig: AppConfig! -} - """ Added in 26.1.0. Input for upserting user fair share weight. The weight parameter affects scheduling priority - higher weight = higher priority. Set weight to null to use resource group's default_weight. """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 137bfbfd6eb..55d5aab3936 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -609,12 +609,6 @@ type AllowedResourceGroupsPayload { items: [String!]! } -"""Added in 25.16.0. App configuration data.""" -type AppConfig { - """Additional configuration data.""" - extraConfig: JSON! -} - """ Added in 24.09.0. Input for approving an artifact revision. @@ -2829,19 +2823,6 @@ type DeleteDeploymentRevisionPresetPayloadGQL { id: UUID! } -"""Added in 25.16.0. Input for deleting domain-level app configuration""" -input DeleteDomainConfigInput { - domainName: String! -} - -""" -Added in 25.16.0. Payload returned after deleting domain-level app configuration. Indicates whether the deletion was successful. -""" -type DeleteDomainConfigPayload { - """Whether the deletion was successful.""" - deleted: Boolean! -} - """Added in 26.4.2. Payload for domain deletion mutation.""" type DeleteDomainPayloadGQL { """Whether the deletion was successful.""" @@ -3026,22 +3007,6 @@ type DeleteRuntimeVariantsPayloadGQL { deletedCount: Int! } -""" -Added in 24.09.0. Input for deleting user-level app configuration. -If user_id is not provided, the current user's configuration will be deleted. -""" -input DeleteUserConfigInput { - userId: ID = null -} - -""" -Added in 25.16.0. Payload returned after deleting user-level app configuration. Indicates whether the deletion was successful. -""" -type DeleteUserConfigPayload { - """Whether the deletion was successful.""" - deleted: Boolean! -} - """Added in 26.4.2. Payload for user resource policy deletion.""" type DeleteUserResourcePolicyPayloadGQL { """Name of the deleted user resource policy.""" @@ -4121,7 +4086,7 @@ input EntityFilter { NOT: [EntityFilter!] = null } -union EntityNode = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | SessionV2 | Artifact | ArtifactRegistry | AppConfig | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role +union EntityNode = UserV2 | ProjectV2 | DomainV2 | VirtualFolderNode | ImageV2 | ComputeSessionNode | SessionV2 | Artifact | ArtifactRegistry | NotificationChannel | NotificationRule | ModelDeployment | ResourceGroup | ContainerRegistryV2 | ArtifactRevision | Role """Added in 26.4.2. Valid entity-operation combination for RBAC actions.""" type EntityOperationCombination { @@ -6404,16 +6369,6 @@ type Mutation { """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! - """ - Added in 25.16.0. Create or update user-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration - """ - upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! - - """ - Added in 25.16.0. Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration - """ - deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! - """ Added in 25.15.0. Triggers artifact scanning on a remote reservoir registry. This mutation instructs a reservoir-type registry to initiate a scan of artifacts from its associated remote reservoir registry source. The scan process will discover and catalog artifacts available in the remote reservoir, making them accessible through the local reservoir registry. Requirements: - The delegator registry must be of type 'reservoir' - The delegator reservoir registry must have a valid remote registry configuration """ @@ -6509,16 +6464,6 @@ type Mutation { """Added in 26.4.2. Validate a notification rule (admin only)""" adminValidateNotificationRule(input: ValidateNotificationRuleInput!): ValidateNotificationRulePayload! - """ - Added in 26.2.0. Create or update domain-level app configuration (admin only). The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys - """ - adminUpsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! - - """ - Added in 26.2.0. Delete domain-level app configuration (admin only). All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults - """ - adminDeleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! - """Added in 26.4.2. Create a new notification channel""" createNotificationChannel(input: CreateNotificationChannelInput!): CreateNotificationChannelPayload! @deprecated(reason: "Use admin_create_notification_channel instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") @@ -6543,16 +6488,6 @@ type Mutation { """Added in 26.4.2. Validate a notification rule""" validateNotificationRule(input: ValidateNotificationRuleInput!): ValidateNotificationRulePayload! @deprecated(reason: "Use admin_validate_notification_rule instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """ - Added in 25.16.0. Create or update domain-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys. Requires admin privileges - """ - upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! @deprecated(reason: "Use admin_upsert_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - - """ - Added in 25.16.0. Delete domain-level app configuration. All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults. Requires admin privileges - """ - deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! @deprecated(reason: "Use admin_delete_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """Added in 25.14.0. Create an object storage.""" createObjectStorage(input: CreateObjectStorageInput!): CreateObjectStoragePayload! @@ -8406,16 +8341,6 @@ type Query { """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection - """ - Added in 25.16.0. Retrieve user-level app configuration. Returns only the configuration set specifically for the user, without merging with domain config. This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration. - """ - userAppConfig(userId: ID = null): AppConfig - - """ - Added in 25.16.0. Retrieve merged app configuration for the current user. The result combines domain-level and user-level configurations, where user settings override domain settings for the same keys. This query should be used when working with user app configurations to get the actual configuration values that will be applied. - """ - mergedAppConfig: AppConfig! - """Added in 25.16.0. Get a specific deployment by ID.""" deployment(id: ID!): ModelDeployment @@ -8538,11 +8463,6 @@ type Query { """Added in 26.4.2. List notification rules (admin only)""" adminNotificationRules(filter: NotificationRuleFilter = null, orderBy: [NotificationRuleOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): NotificationRuleConnection - """ - Added in 26.2.0. Retrieve domain-level app configuration (admin only). Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. - """ - adminDomainAppConfig(domainName: String!): AppConfig - """Added in 26.2.0. Get domain fair share data (admin only).""" adminDomainFairShare(resourceGroupName: String!, domainName: String!): DomainFairShare @@ -8794,11 +8714,6 @@ type Query { """Added in 26.2.0. List resource groups""" resourceGroups(filter: ResourceGroupFilter = null, orderBy: [ResourceGroupOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ResourceGroupConnection @deprecated(reason: "Use admin_resource_groups instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """ - Added in 25.16.0. Retrieve domain-level app configuration. Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. Requires admin privileges. - """ - domainAppConfig(domainName: String!): AppConfig @deprecated(reason: "Use admin_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") - """Added in 26.1.0. Get domain fair share data (superadmin only).""" domainFairShare(resourceGroupName: String!, domainName: String!): DomainFairShare @deprecated(reason: "Use admin_domain_fair_share instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") @@ -12629,25 +12544,6 @@ type UpdateVFSStoragePayload { vfsStorage: VFSStorage! } -""" -Added in 24.09.0. Input for creating or updating domain-level app configuration. -The provided extra_config object will completely replace the existing configuration; -existing keys not present in the new extra_config will be removed. -All users in this domain will be affected by these settings when their configurations are merged. -""" -input UpsertDomainConfigInput { - domainName: String! - extraConfig: JSON! -} - -""" -Added in 25.16.0. Payload returned after upserting domain-level app configuration. Contains the resulting configuration that was stored. -""" -type UpsertDomainConfigPayload { - """The resulting app configuration""" - appConfig: AppConfig! -} - """ Added in 26.1.0. Input for upserting domain fair share weight. The weight parameter affects scheduling priority - higher weight = higher priority. Set weight to null to use resource group's default_weight. """ @@ -12697,26 +12593,6 @@ type UpsertProjectFairShareWeightPayload { projectFairShare: ProjectFairShare! } -""" -Added in 24.09.0. Input for creating or updating user-level app configuration. -The provided extra_config object will completely replace the existing configuration; -existing keys not present in the new extra_config will be removed. -These settings will override domain-level settings when configurations are merged for this user. -If user_id is not provided, the current user's configuration will be updated. -""" -input UpsertUserConfigInput { - extraConfig: JSON! - userId: ID = null -} - -""" -Added in 25.16.0. Payload returned after upserting user-level app configuration. Contains the resulting configuration that was stored. -""" -type UpsertUserConfigPayload { - """The resulting app configuration""" - appConfig: AppConfig! -} - """ Added in 26.1.0. Input for upserting user fair share weight. The weight parameter affects scheduling priority - higher weight = higher priority. Set weight to null to use resource group's default_weight. """ diff --git a/proposals/BEP-1052-scoped-app-config-redesign.md b/proposals/BEP-1052-scoped-app-config-redesign.md index 794af41ec8b..7a2f45c42cf 100644 --- a/proposals/BEP-1052-scoped-app-config-redesign.md +++ b/proposals/BEP-1052-scoped-app-config-redesign.md @@ -62,11 +62,10 @@ Summary matrix: > tooling: > - `domain` — values semantically owned by the domain, often > admin-only (e.g. a `theme` policy with `scope_sources=["domain"]` -> and `userWritable=false`; see §7 S4). +> ; see §7 S4). > - `domain_user_defaults` — values positioned as per-user seeds the > user can override (e.g. a `preferences` policy with -> `scope_sources=["domain_user_defaults", "user"]` and -> `userWritable=true`; see §7 S5 and S8). +> `scope_sources=["domain_user_defaults", "user"]`; see §7 S5 and S8). > > Both can participate in any resolved chain when the policy says so. @@ -184,11 +183,11 @@ rows persist: callers "clear" a document by `*Update*`-ing with **A matching `app_config_policies` row is required for every write (required-policy invariant).** The service layer rejects items per-row when: -- no policy exists for `name` (policy-not-found), -- `scope_type ∉ policy.scope_sources`, or -- the caller is on the my-path and `policy.user_writable = False`. - The admin-path ignores `user_writable` — admins may seed USER - rows regardless. +- no policy exists for `name` (policy-not-found), or +- `scope_type ∉ policy.scope_sources`. + +The my-path additionally rejects writes whose `scope_type` is not +USER; the admin-path may write any allowed scope. Because every row is created under a matching policy, the merge chain (§5) always resolves — no "policy-less fallback" path. @@ -217,8 +216,6 @@ class AppConfigPolicyRow(Base): # drives both the merge order (§5) and the # write allow-list. String-typed so that # adding a scope does not require migration. - user_writable: Mapped[bool] # Gate for the `bulk*MyAppConfigFragments` path. - # Admin-path writes are not gated. created_at: Mapped[datetime] updated_at: Mapped[datetime] @@ -232,8 +229,8 @@ class AppConfigPolicyRow(Base): - **Create**: service rejects items with no matching policy (friendly error); FK catches any bypass path. - **Policy rename**: forbidden — `config_name` is immutable - (updates touch `scope_sources` / `user_writable` only). Removes - the "rename orphans configs" failure mode. + (updates touch `scope_sources` only). Removes the "rename + orphans configs" failure mode. - **Policy purge**: only via `adminBulkPurgeAppConfigPolicies` (§3); the service rejects if any AppConfigFragment row still references the policy. Admin purges referencing rows first, then the policy. @@ -275,7 +272,7 @@ repositories/app_config_policy/ | Repository | Methods | |------------------------------|----------------------------------------------------------------------------------------------------------------------| | `AppConfigFragmentRepository` | Scope-parameterized CRUD (`get / get_by_id / create / update / purge`) taking an `AppConfigFragmentKey`. `search(scope, querier)` for a bound scope (via `AppConfigFragmentSearchScope`), `admin_search(querier)` for cross-scope (admin). Plus merge-specific reads that serve the merged view (`AppConfig`): `get_app_config(user_id, config_name)`, `search_app_configs(scope, querier)` (`UserAppConfigSearchScope`), and `admin_search_app_configs(querier)` for cross-user admin search. All three derive the chain in SQL via a policy join — see §5. | -| `AppConfigPolicyRepository` | `get(config_name)`, `get_by_id(id)`, `create(config_name, scope_sources, user_writable)`, `update(config_name, scope_sources, user_writable)`, `purge(config_name)`, `search(querier)`. Updates do not touch `config_name` (immutable — §1). The `purge` call rejects at the service layer if any `AppConfigFragment` row still references the `config_name`. | +| `AppConfigPolicyRepository` | `get(config_name)`, `get_by_id(id)`, `create(config_name, scope_sources)`, `update(config_name, scope_sources)`, `purge(config_name)`, `search(querier)`. Updates do not touch `config_name` (immutable — §1). The `purge` call rejects at the service layer if any `AppConfigFragment` row still references the `config_name`. | `AppConfigFragmentRepository` plays a **dual role** — raw CRUD (served as `AppConfigFragment`) + merged-view reads (served as `AppConfig`, @@ -403,9 +400,9 @@ FK-driven join surface. Exposes the six-operation shape (§1 allows Write orchestration for `app_config_fragments` consults policies in the **service layer**: for each batch the service fetches the distinct `name`s' policies once (batch cache), then applies §1's -three checks (policy-not-found / `scope_type ∉ scope_sources` / -`user_writable = False` on the my-path) before calling the -fragment repository. `AppConfigPolicyRepository.update` refuses to change +two checks (policy-not-found / `scope_type ∉ scope_sources`) +before calling the fragment repository. The my-path additionally +restricts `scope_type` to `USER`. `AppConfigPolicyRepository.update` refuses to change `config_name`; `.purge` rejects when any `app_config_fragments` row still references the policy (required-policy invariant preserved). @@ -711,7 +708,6 @@ enum AppConfigOrderField { input AppConfigPolicyFilter { configName: StringFilter = null - userWritable: Boolean = null createdAt: DateTimeFilter = null updatedAt: DateTimeFilter = null AND: [AppConfigPolicyFilter!] = null @@ -779,7 +775,7 @@ type Mutation { input: AdminBulkCreateAppConfigPolicyInput! ): AdminBulkCreateAppConfigPoliciesPayload! - """Replace `scope_sources` / `user_writable`; `configName` is immutable (§1).""" + """Replace `scope_sources`; `configName` is immutable (§1).""" adminBulkUpdateAppConfigPolicies( input: AdminBulkUpdateAppConfigPolicyInput! ): AdminBulkUpdateAppConfigPoliciesPayload! @@ -936,7 +932,6 @@ input AdminAppConfigPolicyItemInput { scopeSources: [String!]! """Whether the owner may write their own `USER` row (my-path gate).""" - userWritable: Boolean! } input AdminBulkCreateAppConfigPolicyInput { @@ -1041,7 +1036,6 @@ type AppConfigPolicy implements Node { scopeSources: [String!]! """Gate for the `bulk*MyAppConfigFragments` path. Admin-path is ungated.""" - userWritable: Boolean! createdAt: DateTime! updatedAt: DateTime! @@ -1707,11 +1701,10 @@ mutation SaveMyConfig($input: BulkUpdateMyAppConfigFragmentInput!) { - **Replace** semantics: anything the caller wants to keep must be sent in the same payload — there is no partial-merge or per-key patch. -- **Policy**: if an `AppConfigPolicy` exists for `name` and either - `USER ∉ scope_sources` or `user_writable = False`, the item is - appended to `failed` with a policy-violation message. Clients can - discover this ahead of time by reading the policy via - `appConfigPolicy(configName:)`. +- **Policy**: if an `AppConfigPolicy` exists for `name` and + `USER ∉ scope_sources`, the item is appended to `failed` with a + policy-violation message. Clients can discover this ahead of time + by reading the policy via `appConfigPolicy(configName:)`. - **First write vs. subsequent writes**: `bulkUpdateMyAppConfigFragments` places items with no USER row into `failed`. For the very first save of a given `name`, the client calls `bulkCreateMyAppConfigFragments` @@ -1737,7 +1730,7 @@ mutation PublishThemePolicy( $input: AdminBulkCreateAppConfigPolicyInput! ) { adminBulkCreateAppConfigPolicies(input: $input) { - created { id configName scopeSources userWritable } + created { id configName scopeSources } failed { index configName message } } } @@ -1750,7 +1743,6 @@ mutation PublishThemePolicy( { "configName": "theme", "scopeSources": ["domain"], - "userWritable": false } ] } @@ -1761,8 +1753,8 @@ mutation PublishThemePolicy( - Effect: - Writes to `theme` at any scope other than `DOMAIN` are rejected at the service layer. - - `bulk*MyAppConfigFragments` calls targeting `theme` are rejected because - `user_writable = false`. + - `bulk*MyAppConfigFragments` calls targeting `theme` are rejected + because the policy's `scope_sources` does not include `USER`. - `myAppConfigs` entries for `theme` are resolved through the chain `[DOMAIN]` (single-scope — `fragments` has at most one element, and `config` equals that element's `config` @@ -1772,23 +1764,23 @@ mutation PublishThemePolicy( ### S5. Varied policy shapes -Same mechanics as S4 with different `scopeSources` / `userWritable` -combinations. Each shape backs a different product decision: +Same mechanics as S4 with different `scopeSources` combinations. +Each shape backs a different product decision: -- **`[user]`, `userWritable=true`** — purely user-local document. +- **`[user]`** — purely user-local document. Admin seeding and domain defaults play no role; the resolved view is either the user's own row or nothing. Fits "this tab's column order", "editor keybindings", or other state the user alone authors. -- **`[domain]`, `userWritable=false`** — strict admin-owned document +- **`[domain]`** — strict admin-owned document with no per-user override. Fits the default `theme` setup used in S4 / S8. -- **`[domain, user]`, `userWritable=true`** — admin establishes a +- **`[domain, user]`** — admin establishes a baseline at `DOMAIN`, users may override it on their own `USER` row. The per-user merge produces the domain value plus whatever the user set on top. Site operators pick this shape when they want a default everyone starts with but individuals can customize. -- **`[domain, domain_user_defaults, user]`, `userWritable=true`** — +- **`[domain, domain_user_defaults, user]`** — three-layer chain. The admin can publish a domain-wide value (`DOMAIN`) as the strongest admin signal, a softer per-user seed (`DOMAIN_USER_DEFAULTS`) that newcomers inherit at boot, and then @@ -1797,8 +1789,7 @@ combinations. Each shape backs a different product decision: each user. Any of the above may be switched live: an admin editing -`adminBulkUpdateAppConfigPolicies` for `theme` from `[domain]` + -`userWritable=false` to `[domain, user]` + `userWritable=true` +`adminBulkUpdateAppConfigPolicies` for `theme` from `[domain]` to `[domain, user]` immediately loosens the document — existing admin rows remain, and from the next `bulkUpdateMyAppConfigFragments` onward users can layer their own customization on top (§7 S6). @@ -1806,7 +1797,7 @@ own customization on top (§7 S6). ### S6. Promoting a document from admin-only to user-customizable A site operator initially published `theme` under the strict policy -from S4 (`scopeSources=["domain"]`, `userWritable=false`). After +from S4 (`scopeSources=["domain"]`). After user feedback, they decide individual users should be able to tweak accent colors on top of the domain's theme. @@ -1815,7 +1806,7 @@ mutation PromoteThemePolicy( $input: AdminBulkUpdateAppConfigPolicyInput! ) { adminBulkUpdateAppConfigPolicies(input: $input) { - updated { id configName scopeSources userWritable } + updated { id configName scopeSources } failed { index configName message } } } @@ -1828,7 +1819,6 @@ mutation PromoteThemePolicy( { "configName": "theme", "scopeSources": ["domain", "user"], - "userWritable": true } ] } @@ -1845,7 +1835,7 @@ mutation PromoteThemePolicy( `fragments` is `[, ]` and whose `config` is `domain ⊕ user`. - Reversibility: flipping the policy back to - `scopeSources=["domain"]` + `userWritable=false` blocks new user + `scopeSources=["domain"]` blocks new user writes and excludes `USER` rows from the resolved view, but leaves any pre-existing `USER` rows untouched at the DB level (they simply stop being read). Admins who want those rows gone target @@ -1910,7 +1900,7 @@ mutation PurgeBadPolicy($input: AdminBulkPurgeAppConfigPolicyInput!) { The domain admin publishes the `preferences` document's per-user default — every user in the domain inherits it at merge time as the base for their own `USER` row. The policy for `preferences` (S5's -"`[domain_user_defaults, user]` + `userWritable=true`" shape) admits +"`[domain_user_defaults, user]`" shape) admits both admin-written `DOMAIN_USER_DEFAULTS` entries and user overrides; this scenario exercises the admin side. The first publish uses `adminBulkCreateAppConfigFragments` with `key.scopeType = @@ -2001,9 +1991,8 @@ mutation AdminCreateAppConfigsForUser($input: AdminBulkCreateAppConfigFragmentIn - `adminBulkCreateAppConfigFragments` fails the item if a row already exists for the key; use `adminBulkUpdateAppConfigFragments` instead to overwrite. - Policy: if an `AppConfigPolicy` for `preferences` has `USER ∉ - scope_sources`, the admin path still rejects the item - (`scope_sources` applies to both paths — admins just bypass - `user_writable`, not the scope list). With the usual + scope_sources`, the admin path still rejects the item — + `scope_sources` applies to both paths. With the usual `preferences`-style policy (`scope_sources` includes `USER`) this write passes. - The response is a list of raw `AppConfigFragment`; the target user's @@ -2028,7 +2017,7 @@ query AuditConfigs( cursor node { id scopeType scopeId name config updatedAt - policy { configName scopeSources userWritable } + policy { configName scopeSources } } } pageInfo { hasNextPage endCursor } diff --git a/src/ai/backend/client/cli/v2/__init__.py b/src/ai/backend/client/cli/v2/__init__.py index fe6661177ef..6b2c64b2c2e 100644 --- a/src/ai/backend/client/cli/v2/__init__.py +++ b/src/ai/backend/client/cli/v2/__init__.py @@ -251,11 +251,6 @@ def notification() -> None: """Notification commands.""" -@v2.group(cls=LazyGroup, import_name="ai.backend.client.cli.v2.app_config:app_config") -def app_config() -> None: - """App config commands.""" - - @v2.group( cls=LazyGroup, import_name="ai.backend.client.cli.v2.prometheus_query_preset:prometheus_query_preset", diff --git a/src/ai/backend/client/cli/v2/app_config/__init__.py b/src/ai/backend/client/cli/v2/app_config/__init__.py deleted file mode 100644 index ce37633c8c3..00000000000 --- a/src/ai/backend/client/cli/v2/app_config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .commands import app_config as app_config - -__all__ = ("app_config",) diff --git a/src/ai/backend/client/cli/v2/app_config/commands.py b/src/ai/backend/client/cli/v2/app_config/commands.py deleted file mode 100644 index 29658447dfc..00000000000 --- a/src/ai/backend/client/cli/v2/app_config/commands.py +++ /dev/null @@ -1,103 +0,0 @@ -"""CLI commands for app configuration management.""" - -from __future__ import annotations - -import asyncio - -import click - -from ai.backend.client.cli.v2.helpers import create_v2_registry, load_v2_config, print_result - - -@click.group(name="app-config") -def app_config() -> None: - """App configuration commands.""" - - -# ------------------------------------------------------------------ Domain config - - -@app_config.command(name="get-domain") -@click.argument("domain_name", type=str) -def get_domain(domain_name: str) -> None: - """Get domain-level app configuration.""" - - async def _run() -> None: - registry = await create_v2_registry(load_v2_config()) - try: - result = await registry.app_config.get_domain_config(domain_name) - print_result(result) - finally: - await registry.close() - - asyncio.run(_run()) - - -@app_config.command(name="delete-domain") -@click.argument("domain_name", type=str) -def delete_domain(domain_name: str) -> None: - """Delete domain-level app configuration.""" - - async def _run() -> None: - registry = await create_v2_registry(load_v2_config()) - try: - result = await registry.app_config.delete_domain_config(domain_name) - print_result(result) - finally: - await registry.close() - - asyncio.run(_run()) - - -# ------------------------------------------------------------------ User config - - -@app_config.command(name="get-user") -@click.argument("user_id", type=str) -def get_user(user_id: str) -> None: - """Get user-level app configuration.""" - - async def _run() -> None: - registry = await create_v2_registry(load_v2_config()) - try: - result = await registry.app_config.get_user_config(user_id) - print_result(result) - finally: - await registry.close() - - asyncio.run(_run()) - - -@app_config.command(name="delete-user") -@click.argument("user_id", type=str) -def delete_user(user_id: str) -> None: - """Delete user-level app configuration.""" - - async def _run() -> None: - registry = await create_v2_registry(load_v2_config()) - try: - result = await registry.app_config.delete_user_config(user_id) - print_result(result) - finally: - await registry.close() - - asyncio.run(_run()) - - -# ------------------------------------------------------------------ Merged config - - -@app_config.command(name="get-merged") -@click.argument("user_id", type=str) -def get_merged(user_id: str) -> None: - """Get merged app configuration for a user.""" - - async def _run() -> None: - registry = await create_v2_registry(load_v2_config()) - try: - result = await registry.app_config.get_merged_config(user_id) - print_result(result) - finally: - await registry.close() - - asyncio.run(_run()) diff --git a/src/ai/backend/client/v2/domains_v2/app_config.py b/src/ai/backend/client/v2/domains_v2/app_config.py deleted file mode 100644 index dfa3e0942ec..00000000000 --- a/src/ai/backend/client/v2/domains_v2/app_config.py +++ /dev/null @@ -1,90 +0,0 @@ -"""V2 SDK client for the app configuration domain.""" - -from __future__ import annotations - -from ai.backend.client.v2.base_domain import BaseDomainClient -from ai.backend.common.dto.manager.v2.app_config.request import ( - UpsertDomainConfigInput, - UpsertUserConfigInput, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - AppConfigNode, - DeleteDomainConfigPayload, - DeleteUserConfigPayload, - UpsertDomainConfigPayloadDTO, - UpsertUserConfigPayloadDTO, -) - -_PATH = "/v2/app-configs" - - -class V2AppConfigClient(BaseDomainClient): - """SDK client for app configuration operations.""" - - # ------------------------------------------------------------------ Domain config - - async def get_domain_config(self, domain_name: str) -> AppConfigNode: - """Get domain-level app configuration.""" - return await self._client.typed_request( - "GET", - f"{_PATH}/domains/{domain_name}", - response_model=AppConfigNode, - ) - - async def upsert_domain_config( - self, domain_name: str, request: UpsertDomainConfigInput - ) -> UpsertDomainConfigPayloadDTO: - """Create or update domain-level app configuration.""" - return await self._client.typed_request( - "PUT", - f"{_PATH}/domains/{domain_name}", - request=request, - response_model=UpsertDomainConfigPayloadDTO, - ) - - async def delete_domain_config(self, domain_name: str) -> DeleteDomainConfigPayload: - """Delete domain-level app configuration.""" - return await self._client.typed_request( - "DELETE", - f"{_PATH}/domains/{domain_name}", - response_model=DeleteDomainConfigPayload, - ) - - # ------------------------------------------------------------------ User config - - async def get_user_config(self, user_id: str) -> AppConfigNode: - """Get user-level app configuration.""" - return await self._client.typed_request( - "GET", - f"{_PATH}/users/{user_id}", - response_model=AppConfigNode, - ) - - async def upsert_user_config( - self, user_id: str, request: UpsertUserConfigInput - ) -> UpsertUserConfigPayloadDTO: - """Create or update user-level app configuration.""" - return await self._client.typed_request( - "PUT", - f"{_PATH}/users/{user_id}", - request=request, - response_model=UpsertUserConfigPayloadDTO, - ) - - async def delete_user_config(self, user_id: str) -> DeleteUserConfigPayload: - """Delete user-level app configuration.""" - return await self._client.typed_request( - "DELETE", - f"{_PATH}/users/{user_id}", - response_model=DeleteUserConfigPayload, - ) - - # ------------------------------------------------------------------ Merged config - - async def get_merged_config(self, user_id: str) -> AppConfigNode: - """Get merged app configuration for a user.""" - return await self._client.typed_request( - "GET", - f"{_PATH}/users/{user_id}/merged", - response_model=AppConfigNode, - ) diff --git a/src/ai/backend/client/v2/v2_registry.py b/src/ai/backend/client/v2/v2_registry.py index 7f7af4a0bc5..5836da239aa 100644 --- a/src/ai/backend/client/v2/v2_registry.py +++ b/src/ai/backend/client/v2/v2_registry.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from .domains_v2.agent import V2AgentClient - from .domains_v2.app_config import V2AppConfigClient from .domains_v2.artifact import V2ArtifactClient from .domains_v2.artifact_registry import V2ArtifactRegistryClient from .domains_v2.audit_log import V2AuditLogClient @@ -89,12 +88,6 @@ def agent(self) -> V2AgentClient: return V2AgentClient(self._client) - @cached_property - def app_config(self) -> V2AppConfigClient: - from .domains_v2.app_config import V2AppConfigClient - - return V2AppConfigClient(self._client) - @cached_property def artifact(self) -> V2ArtifactClient: from .domains_v2.artifact import V2ArtifactClient 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 deleted file mode 100644 index 97052785044..00000000000 --- a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -App configuration DTOs v2 for Manager API. -""" - -from ai.backend.common.dto.manager.v2.app_config.request import ( - DeleteDomainConfigInput, - DeleteUserConfigInput, - UpsertDomainConfigInput, - UpsertUserConfigInput, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - AppConfigNode, - DeleteDomainConfigPayload, - DeleteUserConfigPayload, - UpsertDomainConfigPayloadDTO, - UpsertUserConfigPayloadDTO, -) - -__all__ = ( - "DeleteDomainConfigInput", - "DeleteUserConfigInput", - "UpsertDomainConfigInput", - "UpsertUserConfigInput", - "AppConfigNode", - "DeleteDomainConfigPayload", - "DeleteUserConfigPayload", - "UpsertDomainConfigPayloadDTO", - "UpsertUserConfigPayloadDTO", -) 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 deleted file mode 100644 index e8e7769c41f..00000000000 --- a/src/ai/backend/common/dto/manager/v2/app_config/request.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Request DTOs for App Configuration v2. -""" - -from __future__ import annotations - -from typing import Any -from uuid import UUID - -from pydantic import Field - -from ai.backend.common.api_handlers import BaseRequestModel - -__all__ = ( - "DeleteDomainConfigInput", - "DeleteUserConfigInput", - "UpsertDomainConfigInput", - "UpsertUserConfigInput", -) - - -class UpsertDomainConfigInput(BaseRequestModel): - """Input for creating or updating domain-level app configuration.""" - - domain_name: str = Field(description="Domain name whose configuration will be upserted.") - extra_config: dict[str, Any] = Field( - description="Configuration data to store. Completely replaces existing configuration." - ) - - -class UpsertUserConfigInput(BaseRequestModel): - """Input for creating or updating user-level app configuration.""" - - extra_config: dict[str, Any] = Field( - description="Configuration data to store. Completely replaces existing configuration." - ) - user_id: UUID | None = Field( - default=None, - description="User ID whose configuration will be upserted. Defaults to current user.", - ) - - -class DeleteDomainConfigInput(BaseRequestModel): - """Input for deleting domain-level app configuration.""" - - domain_name: str = Field(description="Domain name whose configuration will be deleted.") - - -class DeleteUserConfigInput(BaseRequestModel): - """Input for deleting user-level app configuration.""" - - user_id: UUID | None = Field( - default=None, - description="User ID whose configuration will be deleted. Defaults to current 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 deleted file mode 100644 index 6e6ac09415e..00000000000 --- a/src/ai/backend/common/dto/manager/v2/app_config/response.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Response DTOs for app_config DTO v2. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import Field - -from ai.backend.common.api_handlers import BaseResponseModel - -__all__ = ( - "AppConfigNode", - "DeleteDomainConfigPayload", - "DeleteUserConfigPayload", - "UpsertDomainConfigPayloadDTO", - "UpsertUserConfigPayloadDTO", -) - - -class AppConfigNode(BaseResponseModel): - """Node model representing app configuration data.""" - - extra_config: dict[str, Any] = Field(description="Additional configuration data.") - - -class DeleteDomainConfigPayload(BaseResponseModel): - """Payload for domain-level app config deletion mutation result.""" - - deleted: bool = Field(description="Whether the deletion was successful.") - - -class DeleteUserConfigPayload(BaseResponseModel): - """Payload for user-level app config deletion mutation result.""" - - deleted: bool = Field(description="Whether the deletion was successful.") - - -class UpsertDomainConfigPayloadDTO(BaseResponseModel): - """Payload returned after upserting domain-level app configuration.""" - - app_config: AppConfigNode = Field(description="The resulting app configuration") - - -class UpsertUserConfigPayloadDTO(BaseResponseModel): - """Payload returned after upserting user-level app configuration.""" - - app_config: AppConfigNode = Field(description="The resulting app configuration") diff --git a/src/ai/backend/manager/api/adapters/app_config/adapter.py b/src/ai/backend/manager/api/adapters/app_config/adapter.py deleted file mode 100644 index 465af0ade76..00000000000 --- a/src/ai/backend/manager/api/adapters/app_config/adapter.py +++ /dev/null @@ -1,94 +0,0 @@ -"""App configuration domain adapter - Pydantic-in/Pydantic-out transport layer.""" - -from __future__ import annotations - -from typing import Any - -from ai.backend.common.dto.manager.v2.app_config.response import ( - AppConfigNode, - DeleteDomainConfigPayload, - DeleteUserConfigPayload, -) -from ai.backend.manager.api.adapters.base import BaseAdapter -from ai.backend.manager.data.app_config.types import AppConfigData -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.services.app_config.actions import ( - DeleteDomainConfigAction, - DeleteUserConfigAction, - GetDomainConfigAction, - GetMergedAppConfigAction, - GetUserConfigAction, - UpsertDomainConfigAction, - UpsertUserConfigAction, -) -from ai.backend.manager.types import OptionalState - - -class AppConfigAdapter(BaseAdapter): - """Adapter for app configuration domain operations.""" - - async def get_domain_config(self, domain_name: str) -> AppConfigNode | None: - """Get domain-level app configuration.""" - action_result = await self._processors.app_config.get_domain_config.wait_for_complete( - GetDomainConfigAction(domain_name=domain_name) - ) - if not action_result.result: - return None - return self._data_to_dto(action_result.result) - - async def upsert_domain_config( - self, domain_name: str, extra_config: dict[str, Any] - ) -> AppConfigNode: - """Create or update domain-level app configuration.""" - action_result = await self._processors.app_config.upsert_domain_config.wait_for_complete( - UpsertDomainConfigAction( - domain_name=domain_name, - updater_spec=AppConfigUpdaterSpec(extra_config=OptionalState.update(extra_config)), - ) - ) - return self._data_to_dto(action_result.result) - - async def delete_domain_config(self, domain_name: str) -> DeleteDomainConfigPayload: - """Delete domain-level app configuration.""" - action_result = await self._processors.app_config.delete_domain_config.wait_for_complete( - DeleteDomainConfigAction(domain_name=domain_name) - ) - return DeleteDomainConfigPayload(deleted=action_result.deleted) - - async def get_user_config(self, user_id: str) -> AppConfigNode | None: - """Get user-level app configuration.""" - action_result = await self._processors.app_config.get_user_config.wait_for_complete( - GetUserConfigAction(user_id=user_id) - ) - if not action_result.result: - return None - return self._data_to_dto(action_result.result) - - async def upsert_user_config(self, user_id: str, extra_config: dict[str, Any]) -> AppConfigNode: - """Create or update user-level app configuration.""" - action_result = await self._processors.app_config.upsert_user_config.wait_for_complete( - UpsertUserConfigAction( - user_id=user_id, - updater_spec=AppConfigUpdaterSpec(extra_config=OptionalState.update(extra_config)), - ) - ) - return self._data_to_dto(action_result.result) - - async def delete_user_config(self, user_id: str) -> DeleteUserConfigPayload: - """Delete user-level app configuration.""" - action_result = await self._processors.app_config.delete_user_config.wait_for_complete( - DeleteUserConfigAction(user_id=user_id) - ) - return DeleteUserConfigPayload(deleted=action_result.deleted) - - async def get_merged_config(self, user_id: str) -> AppConfigNode: - """Get merged app configuration for a user.""" - action_result = await self._processors.app_config.get_merged_config.wait_for_complete( - GetMergedAppConfigAction(user_id=user_id) - ) - return AppConfigNode(extra_config=dict(action_result.merged_config)) - - @staticmethod - def _data_to_dto(data: AppConfigData) -> AppConfigNode: - """Convert data layer type to Pydantic DTO.""" - return AppConfigNode(extra_config=data.extra_config) diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index ccaf603268c..6905b2c46f8 100644 --- a/src/ai/backend/manager/api/adapters/registry.py +++ b/src/ai/backend/manager/api/adapters/registry.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from ai.backend.manager.api.adapters.agent.adapter import AgentAdapter -from ai.backend.manager.api.adapters.app_config.adapter import AppConfigAdapter from ai.backend.manager.api.adapters.artifact.adapter import ArtifactAdapter from ai.backend.manager.api.adapters.artifact_registry.adapter import ArtifactRegistryAdapter from ai.backend.manager.api.adapters.audit_log.adapter import AuditLogAdapter @@ -72,7 +71,6 @@ class Adapters: def __init__( self, agent: AgentAdapter, - app_config: AppConfigAdapter, artifact: ArtifactAdapter, artifact_registry: ArtifactRegistryAdapter, audit_log: AuditLogAdapter, @@ -113,7 +111,6 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent - self.app_config = app_config self.artifact = artifact self.artifact_registry = artifact_registry self.audit_log = audit_log @@ -173,7 +170,6 @@ def create( """ return cls( agent=AgentAdapter(processors), - app_config=AppConfigAdapter(processors), artifact=ArtifactAdapter(processors), artifact_registry=ArtifactRegistryAdapter(processors), audit_log=AuditLogAdapter(processors), diff --git a/src/ai/backend/manager/api/gql/app_config.py b/src/ai/backend/manager/api/gql/app_config.py deleted file mode 100644 index 553375eb0db..00000000000 --- a/src/ai/backend/manager/api/gql/app_config.py +++ /dev/null @@ -1,393 +0,0 @@ -"""GraphQL types and operations for app configuration.""" - -from __future__ import annotations - -from typing import Any, cast - -import strawberry -from strawberry import ID, Info - -from ai.backend.common.contexts.user import current_user -from ai.backend.common.dto.manager.v2.app_config.request import ( - DeleteDomainConfigInput as DeleteDomainConfigInputDTO, -) -from ai.backend.common.dto.manager.v2.app_config.request import ( - DeleteUserConfigInput as DeleteUserConfigInputDTO, -) -from ai.backend.common.dto.manager.v2.app_config.request import ( - UpsertDomainConfigInput as UpsertDomainConfigInputDTO, -) -from ai.backend.common.dto.manager.v2.app_config.request import ( - UpsertUserConfigInput as UpsertUserConfigInputDTO, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - AppConfigNode, - UpsertDomainConfigPayloadDTO, - UpsertUserConfigPayloadDTO, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - DeleteDomainConfigPayload as DeleteDomainConfigPayloadDTO, -) -from ai.backend.common.dto.manager.v2.app_config.response import ( - DeleteUserConfigPayload as DeleteUserConfigPayloadDTO, -) -from ai.backend.manager.api.gql.decorators import ( - BackendAIGQLMeta, - PydanticInputMixin, - gql_mutation, - gql_pydantic_input, - gql_pydantic_type, - gql_root_field, -) -from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin -from ai.backend.manager.api.gql.utils import check_admin_only, dedent_strip -from ai.backend.manager.errors.auth import InsufficientPrivilege - -from .types import StrawberryGQLContext - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version="25.16.0", - description="App configuration data.", - ), - model=AppConfigNode, -) -class AppConfig(PydanticOutputMixin[AppConfigNode]): - """GraphQL type for app configuration.""" - - extra_config: strawberry.scalars.JSON - - -@gql_pydantic_input( - BackendAIGQLMeta( - description=dedent_strip("""\ - Input for creating or updating domain-level app configuration. - The provided extra_config object will completely replace the existing configuration; - existing keys not present in the new extra_config will be removed. - All users in this domain will be affected by these settings when their configurations are merged. - """), - added_version="24.09.0", - ), -) -class UpsertDomainConfigInput(PydanticInputMixin[UpsertDomainConfigInputDTO]): - """Input type for upserting domain-level app configuration.""" - - domain_name: str - extra_config: strawberry.scalars.JSON - - -@gql_pydantic_input( - BackendAIGQLMeta( - description=dedent_strip("""\ - Input for creating or updating user-level app configuration. - The provided extra_config object will completely replace the existing configuration; - existing keys not present in the new extra_config will be removed. - These settings will override domain-level settings when configurations are merged for this user. - If user_id is not provided, the current user's configuration will be updated. - """), - added_version="24.09.0", - ), -) -class UpsertUserConfigInput(PydanticInputMixin[UpsertUserConfigInputDTO]): - """Input type for upserting user-level app configuration.""" - - extra_config: strawberry.scalars.JSON - user_id: ID | None = None - - -@gql_pydantic_input( - BackendAIGQLMeta( - description="Input for deleting domain-level app configuration", added_version="25.16.0" - ), -) -class DeleteDomainConfigInput(PydanticInputMixin[DeleteDomainConfigInputDTO]): - """Input type for deleting domain-level app configuration.""" - - domain_name: str - - -@gql_pydantic_input( - BackendAIGQLMeta( - description=dedent_strip("""\ - Input for deleting user-level app configuration. - If user_id is not provided, the current user's configuration will be deleted. - """), - added_version="24.09.0", - ), -) -class DeleteUserConfigInput(PydanticInputMixin[DeleteUserConfigInputDTO]): - """Input type for deleting user-level app configuration.""" - - user_id: ID | None = None - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version="25.16.0", - description="Payload returned after upserting domain-level app configuration. Contains the resulting configuration that was stored.", - ), - model=UpsertDomainConfigPayloadDTO, -) -class UpsertDomainConfigPayload(PydanticOutputMixin[UpsertDomainConfigPayloadDTO]): - """Payload returned after upserting domain-level app configuration.""" - - app_config: AppConfig - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version="25.16.0", - description="Payload returned after upserting user-level app configuration. Contains the resulting configuration that was stored.", - ), - model=UpsertUserConfigPayloadDTO, -) -class UpsertUserConfigPayload(PydanticOutputMixin[UpsertUserConfigPayloadDTO]): - """Payload returned after upserting user-level app configuration.""" - - app_config: AppConfig - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version="25.16.0", - description="Payload returned after deleting domain-level app configuration. Indicates whether the deletion was successful.", - ), - model=DeleteDomainConfigPayloadDTO, -) -class DeleteDomainConfigPayload: - """Payload returned after deleting domain-level app configuration.""" - - deleted: strawberry.auto - - -@gql_pydantic_type( - BackendAIGQLMeta( - added_version="25.16.0", - description="Payload returned after deleting user-level app configuration. Indicates whether the deletion was successful.", - ), - model=DeleteUserConfigPayloadDTO, -) -class DeleteUserConfigPayload: - """Payload returned after deleting user-level app configuration.""" - - deleted: strawberry.auto - - -@gql_root_field( - BackendAIGQLMeta( - added_version="26.2.0", - description="Retrieve domain-level app configuration (admin only). Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead.", - ) -) # type: ignore[misc] -async def admin_domain_app_config( - domain_name: str, - info: Info[StrawberryGQLContext], -) -> AppConfig | None: - """Get domain-level app configuration (admin only).""" - check_admin_only() - result = await info.context.adapters.app_config.get_domain_config(domain_name) - if result is None: - return None - return AppConfig.from_pydantic(result) - - -@gql_root_field( - BackendAIGQLMeta( - added_version="25.16.0", - description="Retrieve domain-level app configuration. Returns only the configuration set specifically for the domain, without merging. This query is useful for checking what values are configured at the domain level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. Requires admin privileges.", - ), - deprecation_reason="Use admin_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.", -) # type: ignore[misc] -async def domain_app_config( - domain_name: str, - info: Info[StrawberryGQLContext], -) -> AppConfig | None: - """Get domain-level app configuration.""" - me = current_user() - if me is None or not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Admin privileges required to access domain configuration") - - result = await info.context.adapters.app_config.get_domain_config(domain_name) - if result is None: - return None - return AppConfig.from_pydantic(result) - - -@gql_root_field( - BackendAIGQLMeta( - added_version="25.16.0", - description="Retrieve user-level app configuration. Returns only the configuration set specifically for the user, without merging with domain config. This query is useful for checking what values are configured at the user level when you want to modify domain or user configurations separately. For actual configuration values to be applied, use mergedAppConfig instead. If user_id is not provided, returns the current user's configuration. Users can only access their own configuration, but admins can access any user's configuration.", - ) -) # type: ignore[misc] -async def user_app_config( - info: Info[StrawberryGQLContext], - user_id: ID | None = None, -) -> AppConfig | None: - """Get user-level app configuration.""" - me = current_user() - if me is None: - raise InsufficientPrivilege("Authentication required") - - # Use current user's ID if user_id is not provided - target_user_id = str(user_id) if user_id is not None else str(me.user_id) - - if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Cannot access another user's app configuration") - - result = await info.context.adapters.app_config.get_user_config(target_user_id) - if result is None: - return None - return AppConfig.from_pydantic(result) - - -@gql_root_field( - BackendAIGQLMeta( - added_version="25.16.0", - description="Retrieve merged app configuration for the current user. The result combines domain-level and user-level configurations, where user settings override domain settings for the same keys. This query should be used when working with user app configurations to get the actual configuration values that will be applied.", - ) -) # type: ignore[misc] -async def merged_app_config( - info: Info[StrawberryGQLContext], -) -> AppConfig: - """Get merged app configuration for the current user.""" - me = current_user() - if me is None: - raise InsufficientPrivilege("Authentication required") - - result = await info.context.adapters.app_config.get_merged_config(str(me.user_id)) - return AppConfig.from_pydantic(result) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="26.2.0", - description="Create or update domain-level app configuration (admin only). The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys", - ), - name="adminUpsertDomainAppConfig", -) -async def admin_upsert_domain_app_config( - input: UpsertDomainConfigInput, - info: Info[StrawberryGQLContext], -) -> UpsertDomainConfigPayload: - """Create or update domain-level app configuration (admin only).""" - check_admin_only() - result = await info.context.adapters.app_config.upsert_domain_config( - input.domain_name, cast(dict[str, Any], input.extra_config) - ) - return UpsertDomainConfigPayload(app_config=AppConfig.from_pydantic(result)) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="25.16.0", - description="Create or update domain-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. All users in this domain will be affected by these settings when their configurations are merged, unless they have user-level configurations that override specific keys. Requires admin privileges", - ), - name="upsertDomainAppConfig", - deprecation_reason="Use admin_upsert_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.", -) -async def upsert_domain_app_config( - input: UpsertDomainConfigInput, - info: Info[StrawberryGQLContext], -) -> UpsertDomainConfigPayload: - """Create or update domain-level app configuration.""" - me = current_user() - if me is None or not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Admin privileges required to modify domain configuration") - - result = await info.context.adapters.app_config.upsert_domain_config( - input.domain_name, cast(dict[str, Any], input.extra_config) - ) - return UpsertDomainConfigPayload(app_config=AppConfig.from_pydantic(result)) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="25.16.0", - description="Create or update user-level app configuration. The provided extra_config object will completely replace the existing configuration; existing keys not present in the new extra_config will be removed. These settings will override domain-level settings when configurations are merged for this user. If user_id is not provided, the current user's configuration will be updated. Users can only modify their own configuration, but admins can modify any user's configuration", - ), - name="upsertUserAppConfig", -) -async def upsert_user_app_config( - input: UpsertUserConfigInput, - info: Info[StrawberryGQLContext], -) -> UpsertUserConfigPayload: - """Create or update user-level app configuration.""" - me = current_user() - if me is None: - raise InsufficientPrivilege("Authentication required") - - # Use current user's ID if user_id is not provided - target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) - - if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Cannot modify another user's app configuration") - - result = await info.context.adapters.app_config.upsert_user_config( - target_user_id, cast(dict[str, Any], input.extra_config) - ) - return UpsertUserConfigPayload(app_config=AppConfig.from_pydantic(result)) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="26.2.0", - description="Delete domain-level app configuration (admin only). All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults", - ), - name="adminDeleteDomainAppConfig", -) -async def admin_delete_domain_app_config( - input: DeleteDomainConfigInput, - info: Info[StrawberryGQLContext], -) -> DeleteDomainConfigPayload: - """Delete domain-level app configuration (admin only).""" - check_admin_only() - result = await info.context.adapters.app_config.delete_domain_config(input.domain_name) - return DeleteDomainConfigPayload(deleted=result.deleted) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="25.16.0", - description="Delete domain-level app configuration. All users in this domain may be affected by this deletion. After deletion, users will only receive their user-level configurations when configurations are merged, with no domain-level defaults. Requires admin privileges", - ), - name="deleteDomainAppConfig", - deprecation_reason="Use admin_delete_domain_app_config instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.", -) -async def delete_domain_app_config( - input: DeleteDomainConfigInput, - info: Info[StrawberryGQLContext], -) -> DeleteDomainConfigPayload: - """Delete domain-level app configuration.""" - me = current_user() - if me is None or not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Admin privileges required to delete domain configuration") - - result = await info.context.adapters.app_config.delete_domain_config(input.domain_name) - return DeleteDomainConfigPayload(deleted=result.deleted) - - -@gql_mutation( - BackendAIGQLMeta( - added_version="25.16.0", - description="Delete user-level app configuration. After deletion, the user will still receive domain-level configuration values when configurations are merged, as domain settings remain unaffected. If user_id is not provided, the current user's configuration will be deleted. Users can only delete their own configuration, but admins can delete any user's configuration", - ), - name="deleteUserAppConfig", -) -async def delete_user_app_config( - input: DeleteUserConfigInput, - info: Info[StrawberryGQLContext], -) -> DeleteUserConfigPayload: - """Delete user-level app configuration.""" - me = current_user() - if me is None: - raise InsufficientPrivilege("Authentication required") - - # Use current user's ID if user_id is not provided - target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) - - if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): - raise InsufficientPrivilege("Cannot delete another user's app configuration") - - result = await info.context.adapters.app_config.delete_user_config(target_user_id) - return DeleteUserConfigPayload(deleted=result.deleted) diff --git a/src/ai/backend/manager/api/gql/rbac/types/entity_node.py b/src/ai/backend/manager/api/gql/rbac/types/entity_node.py index 11682471513..91cb5b205df 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/entity_node.py +++ b/src/ai/backend/manager/api/gql/rbac/types/entity_node.py @@ -10,7 +10,6 @@ import strawberry -from ai.backend.manager.api.gql.app_config import AppConfig from ai.backend.manager.api.gql.artifact.types import Artifact, ArtifactRevision from ai.backend.manager.api.gql.artifact_registry import ArtifactRegistry from ai.backend.manager.api.gql.container_registry.types import ContainerRegistryGQL @@ -43,7 +42,6 @@ | SessionV2GQL | Artifact | ArtifactRegistry - | AppConfig | NotificationChannel | NotificationRule | ModelDeployment diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 030a5a3edf5..ae998ff6675 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -15,18 +15,6 @@ agent_stats, agents_v2, ) -from .app_config import ( - admin_delete_domain_app_config, - admin_domain_app_config, - admin_upsert_domain_app_config, - delete_domain_app_config, - delete_user_app_config, - domain_app_config, - merged_app_config, - upsert_domain_app_config, - upsert_user_app_config, - user_app_config, -) from .artifact import ( approve_artifact_revision, artifact, @@ -459,8 +447,6 @@ class Query: artifacts = artifacts artifact_revision = artifact_revision artifact_revisions = artifact_revisions - user_app_config = user_app_config - merged_app_config = merged_app_config deployment = deployment revisions = revisions revision = revision @@ -496,7 +482,6 @@ class Query: admin_notification_channels = admin_notification_channels admin_notification_rule = admin_notification_rule admin_notification_rules = admin_notification_rules - admin_domain_app_config = admin_domain_app_config admin_domain_fair_share = admin_domain_fair_share admin_domain_fair_shares = admin_domain_fair_shares admin_project_fair_share = admin_project_fair_share @@ -573,7 +558,6 @@ class Query: route_scoped_scheduling_histories = route_scoped_scheduling_histories # Legacy APIs (deprecated) resource_groups = resource_groups - domain_app_config = domain_app_config domain_fair_share = domain_fair_share domain_fair_shares = domain_fair_shares project_fair_share = project_fair_share @@ -661,8 +645,6 @@ class Mutation: scan_artifacts = scan_artifacts scan_artifact_models = scan_artifact_models import_artifacts = import_artifacts - upsert_user_app_config = upsert_user_app_config - delete_user_app_config = delete_user_app_config delegate_scan_artifacts = delegate_scan_artifacts delegate_import_artifacts = delegate_import_artifacts update_artifact = update_artifact @@ -690,9 +672,6 @@ class Mutation: admin_update_notification_rule = admin_update_notification_rule admin_delete_notification_rule = admin_delete_notification_rule admin_validate_notification_rule = admin_validate_notification_rule - # App Config - Admin APIs - admin_upsert_domain_app_config = admin_upsert_domain_app_config - admin_delete_domain_app_config = admin_delete_domain_app_config # Notification - Legacy (deprecated) create_notification_channel = create_notification_channel update_notification_channel = update_notification_channel @@ -702,9 +681,6 @@ class Mutation: update_notification_rule = update_notification_rule delete_notification_rule = delete_notification_rule validate_notification_rule = validate_notification_rule - # App Config - Legacy (deprecated) - upsert_domain_app_config = upsert_domain_app_config - delete_domain_app_config = delete_domain_app_config create_object_storage = create_object_storage update_object_storage = update_object_storage create_auto_scaling_rule = create_auto_scaling_rule diff --git a/src/ai/backend/manager/api/rest/v2/app_config/__init__.py b/src/ai/backend/manager/api/rest/v2/app_config/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/ai/backend/manager/api/rest/v2/app_config/handler.py b/src/ai/backend/manager/api/rest/v2/app_config/handler.py deleted file mode 100644 index 0eed7fc60ad..00000000000 --- a/src/ai/backend/manager/api/rest/v2/app_config/handler.py +++ /dev/null @@ -1,101 +0,0 @@ -"""REST v2 handler for the app configuration domain.""" - -from __future__ import annotations - -import logging -from http import HTTPStatus -from typing import TYPE_CHECKING, Final - -from aiohttp import web - -from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam -from ai.backend.common.dto.manager.v2.app_config.request import ( - UpsertDomainConfigInput, - UpsertUserConfigInput, -) -from ai.backend.logging import BraceStyleAdapter -from ai.backend.manager.api.rest.v2.path_params import DomainNamePathParam, UserIdPathParam - -if TYPE_CHECKING: - from ai.backend.manager.api.adapters.app_config.adapter import AppConfigAdapter - -log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) - - -class V2AppConfigHandler: - """REST v2 handler for app configuration operations.""" - - def __init__(self, *, adapter: AppConfigAdapter) -> None: - self._adapter = adapter - - # ------------------------------------------------------------------ domain config - - async def get_domain_config( - self, - path: PathParam[DomainNamePathParam], - ) -> APIResponse: - """Get domain-level app configuration.""" - result = await self._adapter.get_domain_config(path.parsed.domain_name) - if result is None: - raise web.HTTPNotFound(reason="Domain config not found") - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - async def upsert_domain_config( - self, - path: PathParam[DomainNamePathParam], - body: BodyParam[UpsertDomainConfigInput], - ) -> APIResponse: - """Create or update domain-level app configuration.""" - result = await self._adapter.upsert_domain_config( - path.parsed.domain_name, body.parsed.extra_config - ) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - async def delete_domain_config( - self, - path: PathParam[DomainNamePathParam], - ) -> APIResponse: - """Delete domain-level app configuration.""" - result = await self._adapter.delete_domain_config(path.parsed.domain_name) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - # ------------------------------------------------------------------ user config - - async def get_user_config( - self, - path: PathParam[UserIdPathParam], - ) -> APIResponse: - """Get user-level app configuration.""" - result = await self._adapter.get_user_config(str(path.parsed.user_id)) - if result is None: - raise web.HTTPNotFound(reason="User config not found") - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - async def upsert_user_config( - self, - path: PathParam[UserIdPathParam], - body: BodyParam[UpsertUserConfigInput], - ) -> APIResponse: - """Create or update user-level app configuration.""" - result = await self._adapter.upsert_user_config( - str(path.parsed.user_id), body.parsed.extra_config - ) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - async def delete_user_config( - self, - path: PathParam[UserIdPathParam], - ) -> APIResponse: - """Delete user-level app configuration.""" - result = await self._adapter.delete_user_config(str(path.parsed.user_id)) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) - - # ------------------------------------------------------------------ merged config - - async def get_merged_config( - self, - path: PathParam[UserIdPathParam], - ) -> APIResponse: - """Get merged app configuration for a user.""" - result = await self._adapter.get_merged_config(str(path.parsed.user_id)) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) diff --git a/src/ai/backend/manager/api/rest/v2/app_config/registry.py b/src/ai/backend/manager/api/rest/v2/app_config/registry.py deleted file mode 100644 index 0062432c417..00000000000 --- a/src/ai/backend/manager/api/rest/v2/app_config/registry.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Route registration for v2 app configuration endpoints.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from ai.backend.manager.api.rest.middleware.auth import superadmin_required -from ai.backend.manager.api.rest.routing import RouteRegistry - -from .handler import V2AppConfigHandler - -if TYPE_CHECKING: - from ai.backend.manager.api.rest.types import RouteDeps - - -def register_v2_app_config_routes( - handler: V2AppConfigHandler, - route_deps: RouteDeps, -) -> RouteRegistry: - """Register all v2 app configuration routes.""" - reg = RouteRegistry.create("app-configs", route_deps.cors_options) - - # Domain config endpoints - reg.add( - "GET", - "/domains/{domain_name}", - handler.get_domain_config, - middlewares=[superadmin_required], - ) - reg.add( - "PUT", - "/domains/{domain_name}", - handler.upsert_domain_config, - middlewares=[superadmin_required], - ) - reg.add( - "DELETE", - "/domains/{domain_name}", - handler.delete_domain_config, - middlewares=[superadmin_required], - ) - - # User config endpoints - reg.add("GET", "/users/{user_id}", handler.get_user_config, middlewares=[superadmin_required]) - reg.add( - "PUT", "/users/{user_id}", handler.upsert_user_config, middlewares=[superadmin_required] - ) - reg.add( - "DELETE", "/users/{user_id}", handler.delete_user_config, middlewares=[superadmin_required] - ) - - # Merged config endpoint - reg.add( - "GET", - "/users/{user_id}/merged", - handler.get_merged_config, - middlewares=[superadmin_required], - ) - - return reg diff --git a/src/ai/backend/manager/api/rest/v2/tree.py b/src/ai/backend/manager/api/rest/v2/tree.py index 479744bb5e9..34bc31b8744 100644 --- a/src/ai/backend/manager/api/rest/v2/tree.py +++ b/src/ai/backend/manager/api/rest/v2/tree.py @@ -28,8 +28,6 @@ def build_v2_routes( # Lazy imports to avoid circular dependencies at module level from .agent.handler import V2AgentHandler from .agent.registry import register_v2_agent_routes - from .app_config.handler import V2AppConfigHandler - from .app_config.registry import register_v2_app_config_routes from .artifact.handler import V2ArtifactHandler from .artifact.registry import register_v2_artifact_routes from .artifact_registry.handler import V2ArtifactRegistryHandler @@ -117,7 +115,6 @@ def build_v2_routes( # Build all handlers (each takes its individual adapter) agent_handler = V2AgentHandler(adapter=adapters.agent) - app_config_handler = V2AppConfigHandler(adapter=adapters.app_config) artifact_handler = V2ArtifactHandler(adapter=adapters.artifact) artifact_registry_handler = V2ArtifactRegistryHandler(adapter=adapters.artifact_registry) audit_log_handler = V2AuditLogHandler(adapter=adapters.audit_log) @@ -174,7 +171,6 @@ def build_v2_routes( # Add all domain sub-registries v2_reg.add_subregistry(register_v2_agent_routes(agent_handler, route_deps)) - v2_reg.add_subregistry(register_v2_app_config_routes(app_config_handler, route_deps)) v2_reg.add_subregistry(register_v2_artifact_routes(artifact_handler, route_deps)) v2_reg.add_subregistry( register_v2_artifact_registry_routes(artifact_registry_handler, route_deps) diff --git a/src/ai/backend/manager/data/app_config/__init__.py b/src/ai/backend/manager/data/app_config/__init__.py deleted file mode 100644 index 1bf3c499178..00000000000 --- a/src/ai/backend/manager/data/app_config/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .types import ( - AppConfigData, - AppConfigScopeType, - MergedAppConfig, -) - -__all__ = ( - "AppConfigData", - "AppConfigScopeType", - "MergedAppConfig", -) diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py deleted file mode 100644 index fc61f69c06c..00000000000 --- a/src/ai/backend/manager/data/app_config/types.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import enum -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any -from uuid import UUID - - -class AppConfigScopeType(enum.StrEnum): - DOMAIN = "domain" - PROJECT = "project" - USER = "user" - - -@dataclass -class MergedAppConfig: - domain_name: str - user_id: str - merged_config: Mapping[str, Any] - - -@dataclass -class AppConfigData: - id: UUID - scope_type: AppConfigScopeType - scope_id: str - extra_config: dict[str, Any] - created_at: datetime = field(compare=False) - modified_at: datetime = field(compare=False) diff --git a/src/ai/backend/manager/models/alembic/versions/84d5c6daf8cc_drop_legacy_app_configs_table.py b/src/ai/backend/manager/models/alembic/versions/84d5c6daf8cc_drop_legacy_app_configs_table.py new file mode 100644 index 00000000000..abaf59466da --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/84d5c6daf8cc_drop_legacy_app_configs_table.py @@ -0,0 +1,70 @@ +"""drop legacy app_configs table + +Removes the predecessor `app_configs` table and its enum type as +preparation for BEP-1052 (Scoped App Config Redesign). The +replacement tables (`app_config_fragments`, `app_config_policies`) +are introduced in a follow-up migration on top of this one — the +new shape is incompatible with the old (different scope enum, +different unique key) so no in-place data migration is attempted. + +Revision ID: 84d5c6daf8cc +Revises: ce69b746304e +Create Date: 2026-04-24 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql as pgsql + +from ai.backend.manager.models.base import IDColumn + +# revision identifiers, used by Alembic. +revision = "84d5c6daf8cc" +down_revision = "ce69b746304e" +# Part of: 26.5.0 +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_table("app_configs") + op.execute("DROP TYPE IF EXISTS app_config_scope_type") + + +def downgrade() -> None: + # Recreate the predecessor table to allow `alembic downgrade` to + # complete cleanly. Existing-row restoration is not attempted. + app_config_scope_type = sa.Enum("DOMAIN", "PROJECT", "USER", name="app_config_scope_type") + app_config_scope_type.create(op.get_bind(), checkfirst=True) + op.create_table( + "app_configs", + IDColumn(), + sa.Column( + "scope_type", + app_config_scope_type, + nullable=False, + index=True, + ), + sa.Column("scope_id", sa.String(length=256), nullable=False, index=True), + sa.Column( + "extra_config", + pgsql.JSONB, + nullable=False, + server_default="{}", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "modified_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + onupdate=sa.func.current_timestamp(), + nullable=False, + ), + sa.UniqueConstraint("scope_type", "scope_id", name="uq_app_configs_scope"), + ) diff --git a/src/ai/backend/manager/models/app_config/__init__.py b/src/ai/backend/manager/models/app_config/__init__.py deleted file mode 100644 index 2173b082d16..00000000000 --- a/src/ai/backend/manager/models/app_config/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from ai.backend.manager.data.app_config.types import AppConfigScopeType - -from .row import AppConfigRow - -__all__ = ( - "AppConfigRow", - "AppConfigScopeType", -) diff --git a/src/ai/backend/manager/models/app_config/row.py b/src/ai/backend/manager/models/app_config/row.py deleted file mode 100644 index c72089e8e2b..00000000000 --- a/src/ai/backend/manager/models/app_config/row.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -import logging -import uuid -from collections.abc import Mapping, Sequence -from datetime import datetime -from typing import Any - -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql as pgsql -from sqlalchemy.orm import Mapped, mapped_column - -from ai.backend.logging import BraceStyleAdapter -from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigScopeType -from ai.backend.manager.models.base import GUID, Base - -log = BraceStyleAdapter(logging.getLogger(__spec__.name)) - - -__all__: Sequence[str] = ( - "AppConfigRow", - "AppConfigScopeType", -) - - -class AppConfigRow(Base): # type: ignore[misc] - __tablename__ = "app_configs" - - id: Mapped[uuid.UUID] = mapped_column( - "id", GUID, primary_key=True, server_default=sa.text("uuid_generate_v4()") - ) - scope_type: Mapped[AppConfigScopeType] = mapped_column( - "scope_type", - sa.Enum(AppConfigScopeType, name="app_config_scope_type"), - nullable=False, - index=True, - ) - scope_id: Mapped[str] = mapped_column( - "scope_id", sa.String(length=256), nullable=False, index=True - ) - extra_config: Mapped[Mapping[str, Any]] = mapped_column( - "extra_config", pgsql.JSONB, nullable=False, default=dict - ) - created_at: Mapped[datetime] = mapped_column( - "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False - ) - modified_at: Mapped[datetime] = mapped_column( - "modified_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - onupdate=sa.func.current_timestamp(), - nullable=False, - ) - - __table_args__ = (sa.UniqueConstraint("scope_type", "scope_id", name="uq_app_configs_scope"),) - - def to_data(self) -> AppConfigData: - return AppConfigData( - id=self.id, - scope_type=self.scope_type, - scope_id=self.scope_id, - extra_config=dict(self.extra_config), - created_at=self.created_at, - modified_at=self.modified_at, - ) diff --git a/src/ai/backend/manager/repositories/app_config/__init__.py b/src/ai/backend/manager/repositories/app_config/__init__.py deleted file mode 100644 index fed46582d1c..00000000000 --- a/src/ai/backend/manager/repositories/app_config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .repository import AppConfigRepository - -__all__ = ("AppConfigRepository",) diff --git a/src/ai/backend/manager/repositories/app_config/cache_source/__init__.py b/src/ai/backend/manager/repositories/app_config/cache_source/__init__.py deleted file mode 100644 index 069bd36cb39..00000000000 --- a/src/ai/backend/manager/repositories/app_config/cache_source/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .cache_source import AppConfigCacheSource - -__all__ = ("AppConfigCacheSource",) diff --git a/src/ai/backend/manager/repositories/app_config/cache_source/cache_source.py b/src/ai/backend/manager/repositories/app_config/cache_source/cache_source.py deleted file mode 100644 index 8c0fc42ea3a..00000000000 --- a/src/ai/backend/manager/repositories/app_config/cache_source/cache_source.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Cache source for app_config repository operations.""" - -from __future__ import annotations - -import logging -from collections.abc import Mapping -from typing import Any, cast - -from glide import Batch, ExpirySet, ExpiryType - -from ai.backend.common.json import dump_json, load_json -from ai.backend.logging.utils import BraceStyleAdapter -from ai.backend.manager.clients.valkey_client.valkey_cache import ValkeyCache -from ai.backend.manager.data.app_config.types import MergedAppConfig -from ai.backend.manager.models.app_config import AppConfigScopeType -from ai.backend.manager.repositories.utils import suppress_with_log - -log = BraceStyleAdapter(logging.getLogger(__spec__.name)) - -count = 0 - - -class AppConfigCacheSource: - """ - Cache source for app config operations. - Handles all Redis/Valkey cache operations for app configurations. - """ - - _valkey_cache: ValkeyCache - _cache_ttl: int - - def __init__(self, valkey_cache: ValkeyCache, cache_ttl: int = 300) -> None: - """ - Initialize cache source. - - Args: - valkey_cache: Valkey cache client for caching - cache_ttl: Cache TTL in seconds (default: 5 minutes) - """ - self._valkey_cache = valkey_cache - self._cache_ttl = cache_ttl - - def _get_merged_config_cache_key(self, user_id: str) -> str: - """Generate cache key for merged config.""" - return f"app_config:merged:{user_id}" - - def _get_domain_users_set_key(self, domain_name: str) -> str: - """Generate Redis Set key for tracking users in a domain.""" - return f"app_config:domain:{domain_name}:users" - - async def get_merged_config(self, user_id: str) -> Mapping[str, Any] | None: - """ - Get merged configuration from cache. - - Returns: - Cached config if exists, None if not in cache - """ - with suppress_with_log([Exception], "Failed to get merged config from cache"): - cache_key = self._get_merged_config_cache_key(user_id) - async with self._valkey_cache.client() as conn: - cached_value = await conn.get(cache_key) - global count - count += 1 - if cached_value: - log.debug("Cache hit for merged config: {}, hit count: {}", user_id, count) - return cast(Mapping[str, Any] | None, load_json(cached_value)) - log.debug("Cache miss for merged config: {}", user_id) - return None - - async def set_merged_config( - self, - merged_config: MergedAppConfig, - ) -> None: - """ - Set merged configuration in cache. - - Also tracks this user_id in the domain's users Set for efficient invalidation. - - Args: - merged_config: MergedAppConfig containing user_id, domain_name, and config - """ - with suppress_with_log([Exception], "Failed to set merged config in cache"): - # Use batch to set both the config and add user to domain set - batch = Batch(is_atomic=False) - - # Cache the merged config - cache_key = self._get_merged_config_cache_key(merged_config.user_id) - batch.set( - cache_key, - dump_json(merged_config.merged_config), - expiry=ExpirySet(ExpiryType.SEC, self._cache_ttl), - ) - - # Add user_id to domain's users Set - domain_users_key = self._get_domain_users_set_key(merged_config.domain_name) - batch.sadd(domain_users_key, [merged_config.user_id]) - batch.expire(domain_users_key, self._cache_ttl) - - async with self._valkey_cache.client() as conn: - await conn.exec(batch, raise_on_error=True) - - log.trace( - "Cached merged config for user {} in domain {}", - merged_config.user_id, - merged_config.domain_name, - ) - - async def invalidate_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - ) -> None: - """ - Invalidate cache for a specific config. - - When a config is updated: - - Domain config change: Delete the domain users Set and use pattern matching to find all user configs - - User config change: Invalidate only that user - """ - with suppress_with_log([Exception], "Failed to invalidate config cache"): - match scope_type: - case AppConfigScopeType.DOMAIN: - domain_users_key = self._get_domain_users_set_key(scope_id) - async with self._valkey_cache.client() as conn: - user_ids = await conn.smembers(domain_users_key) - if not user_ids: - log.debug( - "No users found for domain: {}, skipping cache invalidation", scope_id - ) - return - keys_to_delete: list[str | bytes] = [ - self._get_merged_config_cache_key(user_id.decode()) for user_id in user_ids - ] - keys_to_delete.append(domain_users_key) - async with self._valkey_cache.client() as conn: - remove_count = await conn.delete(keys_to_delete) - log.debug( - "Invalidated {} merged config caches for domain: {}", - remove_count, - scope_id, - ) - case AppConfigScopeType.USER: - # For user-level config, only invalidate that user's merged config - cache_key = self._get_merged_config_cache_key(scope_id) - async with self._valkey_cache.client() as conn: - await conn.delete([cache_key]) - log.debug("Invalidated merged config for user: {}", scope_id) - - case _: - # PROJECT or other future scope types - log.debug("No cache invalidation needed for scope type: {}", scope_type) - - log.trace("Invalidated config cache: {}:{}", scope_type.value, scope_id) diff --git a/src/ai/backend/manager/repositories/app_config/creators.py b/src/ai/backend/manager/repositories/app_config/creators.py deleted file mode 100644 index 28349be2fff..00000000000 --- a/src/ai/backend/manager/repositories/app_config/creators.py +++ /dev/null @@ -1,27 +0,0 @@ -"""CreatorSpec implementations for app config entities.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, override - -from ai.backend.manager.data.app_config.types import AppConfigScopeType -from ai.backend.manager.models.app_config import AppConfigRow -from ai.backend.manager.repositories.base.creator import CreatorSpec - - -@dataclass -class AppConfigCreatorSpec(CreatorSpec[AppConfigRow]): - """CreatorSpec for app configurations.""" - - scope_type: AppConfigScopeType - scope_id: str - extra_config: dict[str, Any] - - @override - def build_row(self) -> AppConfigRow: - return AppConfigRow( - scope_type=self.scope_type, - scope_id=self.scope_id, - extra_config=self.extra_config, - ) diff --git a/src/ai/backend/manager/repositories/app_config/db_source/__init__.py b/src/ai/backend/manager/repositories/app_config/db_source/__init__.py deleted file mode 100644 index 472739bf9c4..00000000000 --- a/src/ai/backend/manager/repositories/app_config/db_source/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .db_source import AppConfigDBSource - -__all__ = ("AppConfigDBSource",) diff --git a/src/ai/backend/manager/repositories/app_config/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config/db_source/db_source.py deleted file mode 100644 index 66b21bcb9f8..00000000000 --- a/src/ai/backend/manager/repositories/app_config/db_source/db_source.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Database source for app_config repository operations.""" - -from __future__ import annotations - -from typing import Any, cast - -import sqlalchemy as sa -from sqlalchemy.engine import CursorResult - -from ai.backend.manager.data.app_config.types import ( - AppConfigData, - MergedAppConfig, -) -from ai.backend.manager.errors.user import UserNotFound -from ai.backend.manager.models.app_config import AppConfigRow, AppConfigScopeType -from ai.backend.manager.models.user import UserRow -from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.repositories.base.rbac.entity_creator import ( - RBACEntityCreator, - execute_rbac_entity_creator, -) - - -class AppConfigDBSource: - """ - Database source for app config operations. - Handles all database operations for app configurations. - """ - - _db: ExtendedAsyncSAEngine - - def __init__(self, db: ExtendedAsyncSAEngine) -> None: - self._db = db - - async def get_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - ) -> AppConfigData | None: - """Get app configuration for a specific scope.""" - async with self._db.begin_readonly_session_read_committed() as db_sess: - result = await db_sess.execute( - sa.select(AppConfigRow).where( - sa.and_( - AppConfigRow.scope_type == scope_type, - AppConfigRow.scope_id == scope_id, - ) - ) - ) - row = result.scalar_one_or_none() - return row.to_data() if row else None - - async def get_merged_config( - self, - user_id: str, - ) -> MergedAppConfig: - """ - Get merged configuration with override logic. - Priority: user > domain - - Fetches user's domain information internally to query domain-level config. - - Returns: - MergedAppConfig containing domain_name, user_id, and merged_config - """ - async with self._db.begin_readonly_session() as db_sess: - # Fetch user's domain name - user_result = await db_sess.execute( - sa.select(UserRow.domain_name).where(UserRow.uuid == user_id) - ) - user_row = user_result.one_or_none() - if not user_row: - raise UserNotFound(f"User {user_id} not found") - - domain_name = user_row.domain_name - result = await db_sess.execute( - sa.select(AppConfigRow) - .where( - sa.or_( - sa.and_( - AppConfigRow.scope_type == AppConfigScopeType.DOMAIN, - AppConfigRow.scope_id == domain_name, - ), - sa.and_( - AppConfigRow.scope_type == AppConfigScopeType.USER, - AppConfigRow.scope_id == user_id, - ), - ) - ) - .order_by( - sa.case( - (AppConfigRow.scope_type == AppConfigScopeType.DOMAIN, 1), - (AppConfigRow.scope_type == AppConfigScopeType.USER, 2), - else_=0, - ) - ) - ) - rows = result.scalars().all() - - # Merge configurations with override logic (domain first, then user) - merged_config: dict[str, Any] = {} - for row in rows: - merged_config.update(row.extra_config) - - return MergedAppConfig( - domain_name=domain_name, - user_id=user_id, - merged_config=merged_config, - ) - - async def create_config(self, creator: RBACEntityCreator[AppConfigRow]) -> AppConfigData: - """Create a new app configuration.""" - async with self._db.begin_session() as db_sess: - result = await execute_rbac_entity_creator(db_sess, creator) - return result.row.to_data() - - async def upsert_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - spec: AppConfigUpdaterSpec, - ) -> AppConfigData: - """ - Create or update app configuration. - If exists, update; otherwise, create new. - """ - async with self._db.begin_session() as db_sess: - fields_to_update = spec.build_values() - if not fields_to_update: - # No fields to update, just fetch existing - result = await db_sess.execute( - sa.select(AppConfigRow).where( - sa.and_( - AppConfigRow.scope_type == scope_type, - AppConfigRow.scope_id == scope_id, - ) - ) - ) - row = result.scalar_one_or_none() - if row: - return row.to_data() - - # Create new with empty config - config_row = AppConfigRow( - scope_type=scope_type, - scope_id=scope_id, - extra_config={}, - ) - db_sess.add(config_row) - await db_sess.flush() - await db_sess.refresh(config_row) - return config_row.to_data() - - # Try to update first - result = await db_sess.execute( - sa.update(AppConfigRow) - .where( - sa.and_( - AppConfigRow.scope_type == scope_type, - AppConfigRow.scope_id == scope_id, - ) - ) - .values(**fields_to_update) - ) - - if cast(CursorResult[Any], result).rowcount > 0: - # Fetch updated row - fetch_result = await db_sess.execute( - sa.select(AppConfigRow).where( - sa.and_( - AppConfigRow.scope_type == scope_type, - AppConfigRow.scope_id == scope_id, - ) - ) - ) - row = fetch_result.scalar_one() - return row.to_data() - - # If not exists, create new with the spec's values - extra_config = fields_to_update.get("extra_config", {}) - config_row = AppConfigRow( - scope_type=scope_type, - scope_id=scope_id, - extra_config=extra_config, - ) - db_sess.add(config_row) - await db_sess.flush() - await db_sess.refresh(config_row) - return config_row.to_data() - - async def delete_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - ) -> bool: - """Delete an app configuration. Returns True if deleted, False if not found.""" - async with self._db.begin_session() as db_sess: - result = await db_sess.execute( - sa.delete(AppConfigRow).where( - sa.and_( - AppConfigRow.scope_type == scope_type, - AppConfigRow.scope_id == scope_id, - ) - ) - ) - return cast(CursorResult[Any], result).rowcount > 0 diff --git a/src/ai/backend/manager/repositories/app_config/repositories.py b/src/ai/backend/manager/repositories/app_config/repositories.py deleted file mode 100644 index f140d547ad7..00000000000 --- a/src/ai/backend/manager/repositories/app_config/repositories.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from typing import Self - -from ai.backend.manager.repositories.app_config.repository import AppConfigRepository -from ai.backend.manager.repositories.types import RepositoryArgs - - -@dataclass -class AppConfigRepositories: - repository: AppConfigRepository - - @classmethod - def create(cls, args: RepositoryArgs) -> Self: - repository = AppConfigRepository(args.db, args.valkey_stat_client) - - return cls( - repository=repository, - ) diff --git a/src/ai/backend/manager/repositories/app_config/repository.py b/src/ai/backend/manager/repositories/app_config/repository.py deleted file mode 100644 index b85c9cf4ae9..00000000000 --- a/src/ai/backend/manager/repositories/app_config/repository.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Main repository for app_config operations.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from ai.backend.common.clients.valkey_client.valkey_stat.client import ValkeyStatClient -from ai.backend.manager.clients.valkey_client.valkey_cache import ValkeyCache -from ai.backend.manager.data.app_config.types import AppConfigData -from ai.backend.manager.models.app_config import AppConfigRow, AppConfigScopeType -from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.repositories.base.rbac.entity_creator import RBACEntityCreator - -from .cache_source import AppConfigCacheSource -from .db_source import AppConfigDBSource - - -class AppConfigRepository: - """ - Main repository for app config operations. - Combines DB and cache sources for efficient configuration management. - """ - - _db_source: AppConfigDBSource - _cache_source: AppConfigCacheSource - - def __init__( - self, - db: ExtendedAsyncSAEngine, - valkey_stat: ValkeyStatClient, - cache_ttl: int = 600, - ) -> None: - """ - Initialize repository. - - Args: - db: Database engine - valkey_stat: Valkey client for caching - cache_ttl: Cache TTL in seconds (default: 10 minutes) - """ - self._db_source = AppConfigDBSource(db) - valkey_cache = ValkeyCache(valkey_stat._client) - self._cache_source = AppConfigCacheSource(valkey_cache, cache_ttl) - - async def get_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - ) -> AppConfigData | None: - """Get app configuration for a specific scope.""" - return await self._db_source.get_config(scope_type, scope_id) - - async def get_merged_config( - self, - user_id: str, - ) -> Mapping[str, Any]: - """ - Get merged configuration for a user. - - Tries cache first, falls back to DB on cache miss. - - Returns: - Merged configuration dictionary - """ - # Try cache first - cached_config = await self._cache_source.get_merged_config(user_id) - if cached_config is not None: - return cached_config - - # Cache miss - fetch from DB - merged_config = await self._db_source.get_merged_config(user_id) - - # Cache the result - await self._cache_source.set_merged_config(merged_config) - - return merged_config.merged_config - - async def create_config(self, creator: RBACEntityCreator[AppConfigRow]) -> AppConfigData: - """Create a new app configuration.""" - return await self._db_source.create_config(creator) - - async def upsert_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - spec: AppConfigUpdaterSpec, - ) -> AppConfigData: - """ - Create or update app configuration. - - Invalidates cache after update. - """ - result = await self._db_source.upsert_config(scope_type, scope_id, spec) - await self._cache_source.invalidate_config(scope_type, scope_id) - - return result - - async def delete_config( - self, - scope_type: AppConfigScopeType, - scope_id: str, - ) -> bool: - """ - Delete an app configuration. - - Invalidates cache after deletion. - """ - result = await self._db_source.delete_config(scope_type, scope_id) - await self._cache_source.invalidate_config(scope_type, scope_id) - return result diff --git a/src/ai/backend/manager/repositories/app_config/updaters.py b/src/ai/backend/manager/repositories/app_config/updaters.py deleted file mode 100644 index 7affc11f30c..00000000000 --- a/src/ai/backend/manager/repositories/app_config/updaters.py +++ /dev/null @@ -1,34 +0,0 @@ -"""UpdaterSpec implementations for app_config repository.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, override - -from ai.backend.manager.models.app_config import AppConfigRow -from ai.backend.manager.repositories.base.updater import UpdaterSpec -from ai.backend.manager.types import OptionalState - - -@dataclass -class AppConfigUpdaterSpec(UpdaterSpec[AppConfigRow]): - """UpdaterSpec for app config updates. - - Note: App config uses upsert pattern with composite key (scope_type + scope_id), - so this spec is used with custom db_source logic. - """ - - extra_config: OptionalState[dict[str, Any]] = field( - default_factory=OptionalState[dict[str, Any]].nop - ) - - @property - @override - def row_class(self) -> type[AppConfigRow]: - return AppConfigRow - - @override - def build_values(self) -> dict[str, Any]: - to_update: dict[str, Any] = {} - self.extra_config.update_dict(to_update, "extra_config") - return to_update diff --git a/src/ai/backend/manager/repositories/repositories.py b/src/ai/backend/manager/repositories/repositories.py index a88ab43debf..0089e821a94 100644 --- a/src/ai/backend/manager/repositories/repositories.py +++ b/src/ai/backend/manager/repositories/repositories.py @@ -2,7 +2,6 @@ from typing import Self from ai.backend.manager.repositories.agent.repositories import AgentRepositories -from ai.backend.manager.repositories.app_config.repositories import AppConfigRepositories from ai.backend.manager.repositories.artifact.repositories import ArtifactRepositories from ai.backend.manager.repositories.artifact_registry.repositories import ( ArtifactRegistryRepositories, @@ -84,7 +83,6 @@ @dataclass class Repositories: agent: AgentRepositories - app_config: AppConfigRepositories auth: AuthRepositories container_registry: ContainerRegistryRepositories deployment: DeploymentRepositories @@ -134,7 +132,6 @@ class Repositories: @classmethod def create(cls, args: RepositoryArgs) -> Self: agent_repositories = AgentRepositories.create(args) - app_config_repositories = AppConfigRepositories.create(args) auth_repositories = AuthRepositories.create(args) container_registry_repositories = ContainerRegistryRepositories.create(args) deployment_repositories = DeploymentRepositories.create(args) @@ -185,7 +182,6 @@ def create(cls, args: RepositoryArgs) -> Self: return cls( agent=agent_repositories, - app_config=app_config_repositories, auth=auth_repositories, container_registry=container_registry_repositories, deployment=deployment_repositories, diff --git a/src/ai/backend/manager/services/app_config/__init__.py b/src/ai/backend/manager/services/app_config/__init__.py deleted file mode 100644 index 291a241d3f0..00000000000 --- a/src/ai/backend/manager/services/app_config/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""App configuration service.""" - -from .processors import AppConfigProcessors -from .service import AppConfigService - -__all__ = [ - "AppConfigProcessors", - "AppConfigService", -] diff --git a/src/ai/backend/manager/services/app_config/actions/__init__.py b/src/ai/backend/manager/services/app_config/actions/__init__.py deleted file mode 100644 index 68f15e68dd9..00000000000 --- a/src/ai/backend/manager/services/app_config/actions/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Actions for app configuration service.""" - -from .base import AppConfigScopeAction, AppConfigScopeActionResult -from .domain import ( - DeleteDomainConfigAction, - DeleteDomainConfigActionResult, - GetDomainConfigAction, - GetDomainConfigActionResult, - UpsertDomainConfigAction, - UpsertDomainConfigActionResult, -) -from .get_merged import GetMergedAppConfigAction, GetMergedAppConfigActionResult -from .user import ( - DeleteUserConfigAction, - DeleteUserConfigActionResult, - GetUserConfigAction, - GetUserConfigActionResult, - UpsertUserConfigAction, - UpsertUserConfigActionResult, -) - -__all__ = [ - "AppConfigScopeAction", - "AppConfigScopeActionResult", - # Domain config actions - "GetDomainConfigAction", - "GetDomainConfigActionResult", - "UpsertDomainConfigAction", - "UpsertDomainConfigActionResult", - "DeleteDomainConfigAction", - "DeleteDomainConfigActionResult", - # User config actions - "GetUserConfigAction", - "GetUserConfigActionResult", - "UpsertUserConfigAction", - "UpsertUserConfigActionResult", - "DeleteUserConfigAction", - "DeleteUserConfigActionResult", - # Merged config action - "GetMergedAppConfigAction", - "GetMergedAppConfigActionResult", -] diff --git a/src/ai/backend/manager/services/app_config/actions/base.py b/src/ai/backend/manager/services/app_config/actions/base.py deleted file mode 100644 index 78c29e711ae..00000000000 --- a/src/ai/backend/manager/services/app_config/actions/base.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import override - -from ai.backend.common.data.permission.types import EntityType -from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult - - -class AppConfigScopeAction(BaseScopeAction): - @override - @classmethod - def entity_type(cls) -> EntityType: - return EntityType.APP_CONFIG - - -class AppConfigScopeActionResult(BaseScopeActionResult): - pass diff --git a/src/ai/backend/manager/services/app_config/actions/domain.py b/src/ai/backend/manager/services/app_config/actions/domain.py deleted file mode 100644 index 9b436e33e93..00000000000 --- a/src/ai/backend/manager/services/app_config/actions/domain.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Domain-level app configuration actions.""" - -from dataclasses import dataclass -from typing import override - -from ai.backend.common.data.permission.types import RBACElementType, ScopeType -from ai.backend.manager.actions.types import ActionOperationType -from ai.backend.manager.data.app_config.types import AppConfigData -from ai.backend.manager.data.permission.types import RBACElementRef -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.services.app_config.actions.base import ( - AppConfigScopeAction, - AppConfigScopeActionResult, -) - - -@dataclass -class GetDomainConfigAction(AppConfigScopeAction): - """Action to get domain-level app configuration.""" - - domain_name: str - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.GET - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.domain_name - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.DOMAIN, self.domain_name) - - -@dataclass -class GetDomainConfigActionResult(AppConfigScopeActionResult): - """Result of get domain config action.""" - - result: AppConfigData | None - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.result.scope_id if self.result else "" - - -@dataclass -class UpsertDomainConfigAction(AppConfigScopeAction): - """Action to create or update domain-level app configuration.""" - - domain_name: str - updater_spec: AppConfigUpdaterSpec - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.UPDATE - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.domain_name - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.DOMAIN, self.domain_name) - - -@dataclass -class UpsertDomainConfigActionResult(AppConfigScopeActionResult): - """Result of upsert domain config action.""" - - result: AppConfigData - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.result.scope_id - - -@dataclass -class DeleteDomainConfigAction(AppConfigScopeAction): - """Action to delete domain-level app configuration.""" - - domain_name: str - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.DELETE - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.domain_name - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.DOMAIN, self.domain_name) - - -@dataclass -class DeleteDomainConfigActionResult(AppConfigScopeActionResult): - """Result of delete domain config action.""" - - deleted: bool - domain_name: str - - @override - def scope_type(self) -> ScopeType: - return ScopeType.DOMAIN - - @override - def scope_id(self) -> str: - return self.domain_name diff --git a/src/ai/backend/manager/services/app_config/actions/get_merged.py b/src/ai/backend/manager/services/app_config/actions/get_merged.py deleted file mode 100644 index fc49f9d4af6..00000000000 --- a/src/ai/backend/manager/services/app_config/actions/get_merged.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Get merged configuration action.""" - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, override - -from ai.backend.common.data.permission.types import RBACElementType, ScopeType -from ai.backend.manager.actions.types import ActionOperationType -from ai.backend.manager.data.permission.types import RBACElementRef -from ai.backend.manager.services.app_config.actions.base import ( - AppConfigScopeAction, - AppConfigScopeActionResult, -) - - -@dataclass -class GetMergedAppConfigAction(AppConfigScopeAction): - """Action to get merged app configuration for a user.""" - - user_id: str - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.GET - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.USER, self.user_id) - - -@dataclass -class GetMergedAppConfigActionResult(AppConfigScopeActionResult): - """Result of get merged app configuration action.""" - - user_id: str - merged_config: Mapping[str, Any] - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id diff --git a/src/ai/backend/manager/services/app_config/actions/user.py b/src/ai/backend/manager/services/app_config/actions/user.py deleted file mode 100644 index c7961b47e7c..00000000000 --- a/src/ai/backend/manager/services/app_config/actions/user.py +++ /dev/null @@ -1,133 +0,0 @@ -"""User-level app configuration actions.""" - -from dataclasses import dataclass -from typing import override - -from ai.backend.common.data.permission.types import RBACElementType, ScopeType -from ai.backend.manager.actions.types import ActionOperationType -from ai.backend.manager.data.app_config.types import AppConfigData -from ai.backend.manager.data.permission.types import RBACElementRef -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.services.app_config.actions.base import ( - AppConfigScopeAction, - AppConfigScopeActionResult, -) - - -@dataclass -class GetUserConfigAction(AppConfigScopeAction): - """Action to get user-level app configuration.""" - - user_id: str - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.GET - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.USER, self.user_id) - - -@dataclass -class GetUserConfigActionResult(AppConfigScopeActionResult): - """Result of get user config action.""" - - result: AppConfigData | None - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.result.scope_id if self.result else "" - - -@dataclass -class UpsertUserConfigAction(AppConfigScopeAction): - """Action to create or update user-level app configuration.""" - - user_id: str - updater_spec: AppConfigUpdaterSpec - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.UPDATE - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.USER, self.user_id) - - -@dataclass -class UpsertUserConfigActionResult(AppConfigScopeActionResult): - """Result of upsert user config action.""" - - result: AppConfigData - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.result.scope_id - - -@dataclass -class DeleteUserConfigAction(AppConfigScopeAction): - """Action to delete user-level app configuration.""" - - user_id: str - - @override - @classmethod - def operation_type(cls) -> ActionOperationType: - return ActionOperationType.DELETE - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id - - @override - def target_element(self) -> RBACElementRef: - return RBACElementRef(RBACElementType.USER, self.user_id) - - -@dataclass -class DeleteUserConfigActionResult(AppConfigScopeActionResult): - """Result of delete user config action.""" - - deleted: bool - user_id: str - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER - - @override - def scope_id(self) -> str: - return self.user_id diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py deleted file mode 100644 index e935605103d..00000000000 --- a/src/ai/backend/manager/services/app_config/processors.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Processors for app configuration service.""" - -from typing import override - -from ai.backend.manager.actions.monitors.monitor import ActionMonitor -from ai.backend.manager.actions.processor.scope import ScopeActionProcessor -from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec -from ai.backend.manager.actions.validators import ActionValidators - -from .actions import ( - DeleteDomainConfigAction, - DeleteDomainConfigActionResult, - DeleteUserConfigAction, - DeleteUserConfigActionResult, - GetDomainConfigAction, - GetDomainConfigActionResult, - GetMergedAppConfigAction, - GetMergedAppConfigActionResult, - GetUserConfigAction, - GetUserConfigActionResult, - UpsertDomainConfigAction, - UpsertDomainConfigActionResult, - UpsertUserConfigAction, - UpsertUserConfigActionResult, -) -from .service import AppConfigService - - -class AppConfigProcessors(AbstractProcessorPackage): - """Processors for app configuration operations.""" - - # Domain config processors - get_domain_config: ScopeActionProcessor[GetDomainConfigAction, GetDomainConfigActionResult] - upsert_domain_config: ScopeActionProcessor[ - UpsertDomainConfigAction, UpsertDomainConfigActionResult - ] - delete_domain_config: ScopeActionProcessor[ - DeleteDomainConfigAction, DeleteDomainConfigActionResult - ] - - # User config processors - get_user_config: ScopeActionProcessor[GetUserConfigAction, GetUserConfigActionResult] - upsert_user_config: ScopeActionProcessor[UpsertUserConfigAction, UpsertUserConfigActionResult] - delete_user_config: ScopeActionProcessor[DeleteUserConfigAction, DeleteUserConfigActionResult] - - # Merged config processor - get_merged_config: ScopeActionProcessor[ - GetMergedAppConfigAction, GetMergedAppConfigActionResult - ] - - def __init__( - self, - service: AppConfigService, - action_monitors: list[ActionMonitor], - validators: ActionValidators, - ) -> None: - scope_rbac_validators = [validators.rbac.scope] - # Domain config processors - self.get_domain_config = ScopeActionProcessor( - service.get_domain_config, action_monitors, validators=scope_rbac_validators - ) - self.upsert_domain_config = ScopeActionProcessor( - service.upsert_domain_config, action_monitors, validators=scope_rbac_validators - ) - self.delete_domain_config = ScopeActionProcessor( - service.delete_domain_config, action_monitors, validators=scope_rbac_validators - ) - - # User config processors - self.get_user_config = ScopeActionProcessor( - service.get_user_config, action_monitors, validators=scope_rbac_validators - ) - self.upsert_user_config = ScopeActionProcessor( - service.upsert_user_config, action_monitors, validators=scope_rbac_validators - ) - self.delete_user_config = ScopeActionProcessor( - service.delete_user_config, action_monitors, validators=scope_rbac_validators - ) - - # Merged config processor - self.get_merged_config = ScopeActionProcessor( - service.get_merged_config, action_monitors, validators=scope_rbac_validators - ) - - @override - def supported_actions(self) -> list[ActionSpec]: - return [ - # Domain config actions - GetDomainConfigAction.spec(), - UpsertDomainConfigAction.spec(), - DeleteDomainConfigAction.spec(), - # User config actions - GetUserConfigAction.spec(), - UpsertUserConfigAction.spec(), - DeleteUserConfigAction.spec(), - # Merged config action - GetMergedAppConfigAction.spec(), - ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py deleted file mode 100644 index 7abc15d88c2..00000000000 --- a/src/ai/backend/manager/services/app_config/service.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Service layer for app configuration operations.""" - -from __future__ import annotations - -import logging - -from ai.backend.logging.utils import BraceStyleAdapter -from ai.backend.manager.models.app_config import AppConfigScopeType -from ai.backend.manager.repositories.app_config import AppConfigRepository - -from .actions import ( - DeleteDomainConfigAction, - DeleteDomainConfigActionResult, - DeleteUserConfigAction, - DeleteUserConfigActionResult, - GetDomainConfigAction, - GetDomainConfigActionResult, - GetMergedAppConfigAction, - GetMergedAppConfigActionResult, - GetUserConfigAction, - GetUserConfigActionResult, - UpsertDomainConfigAction, - UpsertDomainConfigActionResult, - UpsertUserConfigAction, - UpsertUserConfigActionResult, -) - -log = BraceStyleAdapter(logging.getLogger(__spec__.name)) - - -class AppConfigService: - """Service for app configuration operations.""" - - _app_config_repository: AppConfigRepository - - def __init__( - self, - app_config_repository: AppConfigRepository, - ) -> None: - self._app_config_repository = app_config_repository - - # Domain config operations - - async def get_domain_config(self, action: GetDomainConfigAction) -> GetDomainConfigActionResult: - """Get domain-level app configuration.""" - log.debug("Getting domain config for: {}", action.domain_name) - config_data = await self._app_config_repository.get_config( - AppConfigScopeType.DOMAIN, - action.domain_name, - ) - return GetDomainConfigActionResult(result=config_data) - - async def upsert_domain_config( - self, action: UpsertDomainConfigAction - ) -> UpsertDomainConfigActionResult: - """Create or update domain-level app configuration.""" - log.debug("Upserting domain config for: {}", action.domain_name) - config_data = await self._app_config_repository.upsert_config( - AppConfigScopeType.DOMAIN, - action.domain_name, - action.updater_spec, - ) - return UpsertDomainConfigActionResult(result=config_data) - - async def delete_domain_config( - self, action: DeleteDomainConfigAction - ) -> DeleteDomainConfigActionResult: - """Delete domain-level app configuration.""" - log.debug("Deleting domain config for: {}", action.domain_name) - deleted = await self._app_config_repository.delete_config( - AppConfigScopeType.DOMAIN, - action.domain_name, - ) - return DeleteDomainConfigActionResult( - deleted=deleted, - domain_name=action.domain_name, - ) - - # User config operations - - async def get_user_config(self, action: GetUserConfigAction) -> GetUserConfigActionResult: - """Get user-level app configuration.""" - log.debug("Getting user config for: {}", action.user_id) - config_data = await self._app_config_repository.get_config( - AppConfigScopeType.USER, - action.user_id, - ) - return GetUserConfigActionResult(result=config_data) - - async def upsert_user_config( - self, action: UpsertUserConfigAction - ) -> UpsertUserConfigActionResult: - """Create or update user-level app configuration.""" - log.debug("Upserting user config for: {}", action.user_id) - config_data = await self._app_config_repository.upsert_config( - AppConfigScopeType.USER, - action.user_id, - action.updater_spec, - ) - return UpsertUserConfigActionResult(result=config_data) - - async def delete_user_config( - self, action: DeleteUserConfigAction - ) -> DeleteUserConfigActionResult: - """Delete user-level app configuration.""" - log.debug("Deleting user config for: {}", action.user_id) - deleted = await self._app_config_repository.delete_config( - AppConfigScopeType.USER, - action.user_id, - ) - return DeleteUserConfigActionResult( - deleted=deleted, - user_id=action.user_id, - ) - - # Merged config operation - - async def get_merged_config( - self, action: GetMergedAppConfigAction - ) -> GetMergedAppConfigActionResult: - """ - Get merged app configuration for a user. - Domain config is merged with user config (user overrides domain). - """ - log.debug("Getting merged app config for user: {}", action.user_id) - merged_config = await self._app_config_repository.get_merged_config(action.user_id) - return GetMergedAppConfigActionResult( - user_id=action.user_id, - merged_config=merged_config, - ) diff --git a/src/ai/backend/manager/services/factory.py b/src/ai/backend/manager/services/factory.py index ddc09973190..db84237ba9b 100644 --- a/src/ai/backend/manager/services/factory.py +++ b/src/ai/backend/manager/services/factory.py @@ -6,8 +6,6 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors from ai.backend.manager.services.agent.service import AgentService -from ai.backend.manager.services.app_config.processors import AppConfigProcessors -from ai.backend.manager.services.app_config.service import AppConfigService from ai.backend.manager.services.artifact.processors import ArtifactProcessors from ai.backend.manager.services.artifact.service import ArtifactService from ai.backend.manager.services.artifact_registry.processors import ArtifactRegistryProcessors @@ -162,9 +160,6 @@ def create_services(args: ServiceArgs) -> Services: args.event_producer, args.agent_cache, ), - app_config=AppConfigService( - app_config_repository=repositories.app_config.repository, - ), domain=DomainService(repositories.domain.repository), dotfile=DotfileService( repository=repositories.dotfile.repository, @@ -419,7 +414,6 @@ def create_processors( services = create_services(args.service_args) return Processors( agent=AgentProcessors(services.agent, action_monitors, validators), - app_config=AppConfigProcessors(services.app_config, action_monitors, validators), domain=DomainProcessors(services.domain, action_monitors, validators), dotfile=DotfileProcessors(services.dotfile, action_monitors, validators), error_log=ErrorLogProcessors(services.error_log, action_monitors, validators), diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index c7d800458a1..ab628d19f8b 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -44,12 +44,6 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors # pants: no-infer-dep from ai.backend.manager.services.agent.service import AgentService # pants: no-infer-dep - from ai.backend.manager.services.app_config.processors import ( - AppConfigProcessors, # pants: no-infer-dep - ) - from ai.backend.manager.services.app_config.service import ( - AppConfigService, # pants: no-infer-dep - ) from ai.backend.manager.services.artifact.processors import ( ArtifactProcessors, # pants: no-infer-dep ) @@ -357,7 +351,6 @@ class ServiceArgs: @dataclass class Services: agent: AgentService - app_config: AppConfigService domain: DomainService dotfile: DotfileService error_log: ErrorLogService @@ -422,7 +415,6 @@ class ProcessorArgs: @dataclass class Processors(AbstractProcessorPackage): agent: AgentProcessors - app_config: AppConfigProcessors domain: DomainProcessors dotfile: DotfileProcessors error_log: ErrorLogProcessors @@ -480,7 +472,6 @@ class Processors(AbstractProcessorPackage): def supported_actions(self) -> list[ActionSpec]: return [ *self.agent.supported_actions(), - *self.app_config.supported_actions(), *self.domain.supported_actions(), *self.dotfile.supported_actions(), *self.error_log.supported_actions(), diff --git a/tests/unit/manager/repositories/app_config/BUILD b/tests/unit/manager/repositories/app_config/BUILD deleted file mode 100644 index 75b8f46de9b..00000000000 --- a/tests/unit/manager/repositories/app_config/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests(name="tests") diff --git a/tests/unit/manager/repositories/app_config/__init__.py b/tests/unit/manager/repositories/app_config/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/unit/manager/repositories/app_config/test_app_config.py b/tests/unit/manager/repositories/app_config/test_app_config.py deleted file mode 100644 index 43e1173fb7a..00000000000 --- a/tests/unit/manager/repositories/app_config/test_app_config.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -Tests for AppConfigRepository functionality. -Tests the repository layer with real database and cache operations. -""" - -import uuid -from collections.abc import AsyncGenerator - -import pytest - -from ai.backend.common.clients.valkey_client.valkey_stat.client import ValkeyStatClient -from ai.backend.common.data.permission.types import RBACElementType -from ai.backend.common.types import BinarySize, ResourceSlot, ValkeyTarget -from ai.backend.manager.data.auth.hash import PasswordHashAlgorithm -from ai.backend.manager.data.permission.types import RBACElementRef -from ai.backend.manager.models.agent import AgentRow -from ai.backend.manager.models.app_config import AppConfigRow, AppConfigScopeType -from ai.backend.manager.models.deployment_auto_scaling_policy import DeploymentAutoScalingPolicyRow -from ai.backend.manager.models.deployment_policy import DeploymentPolicyRow -from ai.backend.manager.models.deployment_revision import DeploymentRevisionRow -from ai.backend.manager.models.domain import DomainRow -from ai.backend.manager.models.endpoint import EndpointRow -from ai.backend.manager.models.group import GroupRow -from ai.backend.manager.models.hasher.types import PasswordInfo -from ai.backend.manager.models.image import ImageRow -from ai.backend.manager.models.kernel import KernelRow -from ai.backend.manager.models.keypair import KeyPairRow -from ai.backend.manager.models.rbac_models import RoleRow, UserRoleRow -from ai.backend.manager.models.rbac_models.association_scopes_entities import ( - AssociationScopesEntitiesRow, -) -from ai.backend.manager.models.resource_policy import ( - KeyPairResourcePolicyRow, - ProjectResourcePolicyRow, - UserResourcePolicyRow, -) -from ai.backend.manager.models.resource_preset import ResourcePresetRow -from ai.backend.manager.models.routing import RoutingRow -from ai.backend.manager.models.runtime_variant import RuntimeVariantRow -from ai.backend.manager.models.scaling_group import ScalingGroupRow -from ai.backend.manager.models.session import SessionRow -from ai.backend.manager.models.user import UserRole, UserRow, UserStatus -from ai.backend.manager.models.utils import ExtendedAsyncSAEngine -from ai.backend.manager.models.vfolder import VFolderRow -from ai.backend.manager.repositories.app_config import AppConfigRepository -from ai.backend.manager.repositories.app_config.creators import AppConfigCreatorSpec -from ai.backend.manager.repositories.app_config.updaters import AppConfigUpdaterSpec -from ai.backend.manager.repositories.base.rbac.entity_creator import RBACEntityCreator -from ai.backend.manager.types import OptionalState -from ai.backend.testutils.db import with_tables - - -def create_test_password_info(password: str) -> PasswordInfo: - """Create a PasswordInfo object for testing with default PBKDF2 algorithm.""" - return PasswordInfo( - password=password, - algorithm=PasswordHashAlgorithm.PBKDF2_SHA256, - rounds=100_000, - salt_size=32, - ) - - -class TestAppConfigRepository: - """Test cases for AppConfigRepository""" - - @pytest.fixture - async def db_with_cleanup( - self, - database_connection: ExtendedAsyncSAEngine, - ) -> AsyncGenerator[ExtendedAsyncSAEngine, None]: - """Database connection with tables created. TRUNCATE CASCADE handles cleanup.""" - async with with_tables( - database_connection, - [ - # FK dependency order: parents before children - DomainRow, - ScalingGroupRow, - UserResourcePolicyRow, - ProjectResourcePolicyRow, - KeyPairResourcePolicyRow, - RoleRow, - UserRoleRow, - UserRow, - KeyPairRow, - GroupRow, - ImageRow, - VFolderRow, - EndpointRow, - DeploymentPolicyRow, - DeploymentAutoScalingPolicyRow, - RuntimeVariantRow, - DeploymentRevisionRow, - SessionRow, - AgentRow, - KernelRow, - RoutingRow, - ResourcePresetRow, - AppConfigRow, - AssociationScopesEntitiesRow, - ], - ): - yield database_connection - - @pytest.fixture - async def test_domain_name( - self, - db_with_cleanup: ExtendedAsyncSAEngine, - ) -> AsyncGenerator[str, None]: - """Create test domain and return domain name""" - domain_name = f"test-domain-{uuid.uuid4().hex[:8]}" - - async with db_with_cleanup.begin_session() as db_sess: - domain = DomainRow( - name=domain_name, - description="Test domain for app config", - is_active=True, - total_resource_slots=ResourceSlot(), - allowed_vfolder_hosts={}, - allowed_docker_registries=[], - ) - db_sess.add(domain) - await db_sess.flush() - - yield domain_name - - @pytest.fixture - async def test_resource_policy_name( - self, - db_with_cleanup: ExtendedAsyncSAEngine, - ) -> AsyncGenerator[str, None]: - """Create test resource policy and return policy name""" - policy_name = f"test-policy-{uuid.uuid4().hex[:8]}" - - async with db_with_cleanup.begin_session() as db_sess: - policy = UserResourcePolicyRow( - name=policy_name, - max_vfolder_count=10, - max_quota_scope_size=BinarySize.finite_from_str("10GiB"), - max_session_count_per_model_session=5, - max_customized_image_count=3, - ) - db_sess.add(policy) - await db_sess.flush() - - yield policy_name - - @pytest.fixture - async def test_user_id( - self, - db_with_cleanup: ExtendedAsyncSAEngine, - test_domain_name: str, - test_resource_policy_name: str, - ) -> AsyncGenerator[str, None]: - """Create test user and return user UUID string""" - user_uuid = uuid.uuid4() - user_id = str(user_uuid) - - async with db_with_cleanup.begin_session() as db_sess: - user = UserRow( - uuid=user_uuid, - username=f"testuser-{user_uuid.hex[:8]}", - email=f"test-{user_uuid.hex[:8]}@example.com", - password=create_test_password_info("test_password"), - need_password_change=False, - status=UserStatus.ACTIVE, - status_info="active", - domain_name=test_domain_name, - role=UserRole.USER, - resource_policy=test_resource_policy_name, - ) - db_sess.add(user) - await db_sess.flush() - - yield user_id - - @pytest.fixture - async def valkey_stat_client( - self, - redis_container: tuple[str, tuple[str, int]], - ) -> AsyncGenerator[ValkeyStatClient, None]: - """Create ValkeyStatClient with real Redis container""" - _, redis_addr = redis_container - - valkey_target = ValkeyTarget( - addr=f"{redis_addr[0]}:{redis_addr[1]}", - ) - - client = await ValkeyStatClient.create( - valkey_target=valkey_target, - db_id=0, - human_readable_name="test-valkey-stat", - ) - - try: - yield client - finally: - await client.close() - - @pytest.fixture - async def app_config_repository( - self, - db_with_cleanup: ExtendedAsyncSAEngine, - valkey_stat_client: ValkeyStatClient, - ) -> AsyncGenerator[AppConfigRepository, None]: - """Create AppConfigRepository instance with real database and cache""" - repo = AppConfigRepository(db=db_with_cleanup, valkey_stat=valkey_stat_client) - yield repo - - async def test_create_domain_config( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - ) -> None: - """Test creating domain-level configuration""" - creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark", "language": "en"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - - config = await app_config_repository.create_config(creator) - - assert config.scope_type == AppConfigScopeType.DOMAIN - assert config.scope_id == test_domain_name - assert config.extra_config == {"theme": "dark", "language": "en"} - - async def test_create_user_config( - self, - app_config_repository: AppConfigRepository, - test_user_id: str, - ) -> None: - """Test creating user-level configuration""" - creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.USER, - scope_id=test_user_id, - extra_config={"theme": "light", "notifications": True}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.USER, test_user_id), - ) - - config = await app_config_repository.create_config(creator) - - assert config.scope_type == AppConfigScopeType.USER - assert config.scope_id == test_user_id - assert config.extra_config == {"theme": "light", "notifications": True} - - async def test_get_merged_config_domain_only( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - test_user_id: str, - ) -> None: - """Test getting merged config with only domain-level config""" - # Create domain config - domain_creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark", "language": "en"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - await app_config_repository.create_config(domain_creator) - - # Get merged config - merged_config = await app_config_repository.get_merged_config(test_user_id) - - assert merged_config == {"theme": "dark", "language": "en"} - - async def test_get_merged_config_with_user_override( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - test_user_id: str, - ) -> None: - """Test getting merged config with user config overriding domain config""" - # Create domain config - domain_creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark", "language": "en", "sidebar": "expanded"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - await app_config_repository.create_config(domain_creator) - - # Create user config that overrides theme - user_creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.USER, - scope_id=test_user_id, - extra_config={"theme": "light", "notifications": True}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.USER, test_user_id), - ) - await app_config_repository.create_config(user_creator) - - # Get merged config - merged_config = await app_config_repository.get_merged_config(test_user_id) - - # User config should override domain config for 'theme' - assert merged_config["theme"] == "light" # Overridden by user - assert merged_config["language"] == "en" # From domain - assert merged_config["sidebar"] == "expanded" # From domain - assert merged_config["notifications"] is True # From user only - - async def test_upsert_config_create( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - ) -> None: - """Test upserting config when it doesn't exist (create)""" - spec = AppConfigUpdaterSpec( - extra_config=OptionalState.update({"theme": "dark", "language": "en"}) - ) - - config = await app_config_repository.upsert_config( - AppConfigScopeType.DOMAIN, test_domain_name, spec - ) - - assert config.scope_type == AppConfigScopeType.DOMAIN - assert config.scope_id == test_domain_name - assert config.extra_config == {"theme": "dark", "language": "en"} - - async def test_upsert_config_update( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - ) -> None: - """Test upserting config when it exists (update)""" - # Create initial config - creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark", "language": "en"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - initial_config = await app_config_repository.create_config(creator) - - # Update config - spec = AppConfigUpdaterSpec( - extra_config=OptionalState.update({"theme": "light", "language": "ko"}) - ) - updated_config = await app_config_repository.upsert_config( - AppConfigScopeType.DOMAIN, test_domain_name, spec - ) - - assert updated_config.id == initial_config.id - assert updated_config.extra_config == {"theme": "light", "language": "ko"} - - async def test_delete_config( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - ) -> None: - """Test deleting configuration""" - # Create config - creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - await app_config_repository.create_config(creator) - - # Delete config - deleted = await app_config_repository.delete_config( - AppConfigScopeType.DOMAIN, test_domain_name - ) - - assert deleted is True - - # Verify deletion - config = await app_config_repository.get_config(AppConfigScopeType.DOMAIN, test_domain_name) - assert config is None - - async def test_delete_nonexistent_config( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - ) -> None: - """Test deleting non-existent configuration""" - deleted = await app_config_repository.delete_config( - AppConfigScopeType.DOMAIN, test_domain_name - ) - - assert deleted is False - - async def test_get_merged_config_empty( - self, - app_config_repository: AppConfigRepository, - test_user_id: str, - ) -> None: - """Test getting merged config when no config exists""" - merged_config = await app_config_repository.get_merged_config(test_user_id) - - assert merged_config == {} - - async def test_cache_invalidation_domain_config( - self, - app_config_repository: AppConfigRepository, - test_domain_name: str, - test_user_id: str, - ) -> None: - """Test cache invalidation when domain config is updated""" - # Create domain config - domain_creator = RBACEntityCreator( - spec=AppConfigCreatorSpec( - scope_type=AppConfigScopeType.DOMAIN, - scope_id=test_domain_name, - extra_config={"theme": "dark"}, - ), - element_type=RBACElementType.APP_CONFIG, - scope_ref=RBACElementRef(RBACElementType.DOMAIN, test_domain_name), - ) - await app_config_repository.create_config(domain_creator) - - # First call - cache miss, fetch from DB - merged_config1 = await app_config_repository.get_merged_config(test_user_id) - assert merged_config1 == {"theme": "dark"} - - # Second call - cache hit - merged_config2 = await app_config_repository.get_merged_config(test_user_id) - assert merged_config2 == {"theme": "dark"} - - # Update domain config - should invalidate cache - spec = AppConfigUpdaterSpec(extra_config=OptionalState.update({"theme": "light"})) - await app_config_repository.upsert_config(AppConfigScopeType.DOMAIN, test_domain_name, spec) - - # Cache should be invalidated, get updated config - merged_config3 = await app_config_repository.get_merged_config(test_user_id) - assert merged_config3 == {"theme": "light"}