diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 000000000..b126e05db --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,33 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: multi-agent-custom-automation-engine-solution-accelerator +metadata: + template: multi-agent-custom-automation-engine-solution-accelerator@1.0 +requiredVersions: + azd: ">=1.15.0 !=1.17.1" + +services: + backend: + project: ./src/backend + language: py + host: containerapp + docker: + image: backend + remoteBuild: true + + frontend: + project: ./src/frontend + language: py + host: appservice + dist: ./dist + hooks: + prepackage: + windows: + shell: pwsh + run: ../../infra/scripts/package_frontend.ps1 + interactive: true + continueOnError: false + posix: + shell: sh + run: bash ../../infra/scripts/package_frontend.sh + interactive: true + continueOnError: false \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep index f6ea978ee..317315053 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1739,3 +1739,5 @@ output AZURE_AI_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeploymen output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint output APP_ENV string = 'Prod' +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAiServices.outputs.resourceId +output COSMOSDB_ACCOUNT_NAME string = cosmosDbResourceName diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 000000000..102bc73ec --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1798 @@ +metadata name = 'Multi-Agent Custom Automation Engine' +metadata description = 'This module contains the resources required to deploy the Multi-Agent Custom Automation Engine solution accelerator for both Sandbox environments and WAF aligned environments.' + +@description('Set to true if you want to deploy WAF-aligned infrastructure.') +param useWafAlignedArchitecture bool + +@description('Use this parameter to use an existing AI project resource ID') +param existingFoundryProjectResourceId string = '' + +@description('Required. Name of the environment to deploy the solution into.') +param environmentName string + +@description('Required. Location for all Resources except AI Foundry.') +param solutionLocation string = resourceGroup().location + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +param existingLogAnalyticsWorkspaceId string = '' + +param azureopenaiVersion string = '2025-01-01-preview' + +// Restricting deployment to only supported Azure OpenAI regions validated with GPT-4o model +@metadata({ + azd : { + type: 'location' + usageName : [ + 'OpenAI.GlobalStandard.gpt-4o, 150' + ] + } +}) +@allowed(['australiaeast', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'swedencentral', 'uksouth', 'westus']) +@description('Azure OpenAI Location') +param aiDeploymentsLocation string + +@minLength(1) +@description('Name of the GPT model to deploy:') +param gptModelName string = 'gpt-4o' + +param gptModelVersion string = '2024-08-06' + +@minLength(1) +@description('GPT model deployment type:') +param modelDeploymentType string = 'GlobalStandard' + +@description('Optional. AI model deployment token capacity.') +param gptModelCapacity int = 150 + +@description('Set the image tag for the container images used in the solution. Default is "latest".') +param imageTag string = 'latest' + +param solutionPrefix string = 'macae-${padLeft(take(toLower(uniqueString(subscription().id, environmentName, resourceGroup().location, resourceGroup().name)), 12), 12, '0')}' + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags object = { + app: solutionPrefix + location: solutionLocation +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Log Analytics Workspace resource.') +param logAnalyticsWorkspaceConfiguration logAnalyticsWorkspaceConfigurationType = { + enabled: true + name: 'log-${solutionPrefix}' + location: solutionLocation + sku: 'PerGB2018' + tags: tags + dataRetentionInDays: useWafAlignedArchitecture ? 365 : 30 + existingWorkspaceResourceId: existingLogAnalyticsWorkspaceId +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Application Insights resource.') +param applicationInsightsConfiguration applicationInsightsConfigurationType = { + enabled: true + name: 'appi-${solutionPrefix}' + location: solutionLocation + tags: tags + retentionInDays: useWafAlignedArchitecture ? 365 : 30 +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Managed Identity resource.') +param userAssignedManagedIdentityConfiguration userAssignedManagedIdentityType = { + enabled: true + name: 'id-${solutionPrefix}' + location: solutionLocation + tags: tags +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the backend subnet.') +param networkSecurityGroupBackendConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-backend-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the containers subnet.') +param networkSecurityGroupContainersConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-containers-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the Bastion subnet.') +param networkSecurityGroupBastionConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-bastion-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the administration subnet.') +param networkSecurityGroupAdministrationConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-administration-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine virtual network resource.') +param virtualNetworkConfiguration virtualNetworkConfigurationType = { + enabled: useWafAlignedArchitecture ? true : false + name: 'vnet-${solutionPrefix}' + location: solutionLocation + tags: tags + addressPrefixes: null //Default value set on module configuration + subnets: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine bastion resource.') +param bastionConfiguration bastionConfigurationType = { + enabled: true + name: 'bas-${solutionPrefix}' + location: solutionLocation + tags: tags + sku: 'Standard' + virtualNetworkResourceId: null //Default value set on module configuration + publicIpResourceName: 'pip-bas${solutionPrefix}' +} + +@description('Optional. Configuration for the Windows virtual machine.') +param virtualMachineConfiguration virtualMachineConfigurationType = { + enabled: true + name: 'vm${solutionPrefix}' + location: solutionLocation + tags: tags + adminUsername: 'adminuser' + adminPassword: useWafAlignedArchitecture? 'P@ssw0rd1234' : guid(solutionPrefix, subscription().subscriptionId) + vmSize: 'Standard_D2s_v3' + subnetResourceId: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the AI Foundry AI Services resource.') +param aiFoundryAiServicesConfiguration aiServicesConfigurationType = { + enabled: true + name: 'aisa-${solutionPrefix}' + location: aiDeploymentsLocation + sku: 'S0' + deployments: null //Default value set on module configuration + subnetResourceId: null //Default value set on module configuration + modelCapacity: gptModelCapacity +} + +@description('Optional. The configuration to apply for the AI Foundry AI Project resource.') +param aiFoundryAiProjectConfiguration aiProjectConfigurationType = { + enabled: true + name: 'aifp-${solutionPrefix}' + location: aiDeploymentsLocation + sku: 'Basic' + tags: tags +} + +@description('Optional. The configuration to apply for the Cosmos DB Account resource.') +param cosmosDbAccountConfiguration cosmosDbAccountConfigurationType = { + enabled: true + name: 'cosmos-${solutionPrefix}' + location: solutionLocation + tags: tags + subnetResourceId: null //Default value set on module configuration + sqlDatabases: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Container App Environment resource.') +param containerAppEnvironmentConfiguration containerAppEnvironmentConfigurationType = { + enabled: true + name: 'cae-${solutionPrefix}' + location: solutionLocation + tags: tags + subnetResourceId: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Container App resource.') +param containerAppConfiguration containerAppConfigurationType = { + enabled: true + name: 'ca-${solutionPrefix}' + location: solutionLocation + tags: union(tags, { 'azd-service-name': 'backend' }) + environmentResourceId: null //Default value set on module configuration + concurrentRequests: '100' + containerCpu: '2.0' + containerMemory: '4.0Gi' + containerImageRegistryDomain: '' + containerImageName: 'macaebackend' + containerImageTag: imageTag + containerName: 'backend' + ingressTargetPort: 8000 + maxReplicas: 1 + minReplicas: 1 +} + +@description('Optional. The configuration to apply for the Web Server Farm resource.') +param webServerFarmConfiguration webServerFarmConfigurationType = { + enabled: true + name: 'asp-${solutionPrefix}' + location: solutionLocation + skuName: useWafAlignedArchitecture? 'P1v3' : 'B2' + skuCapacity: useWafAlignedArchitecture ? 3 : 1 + tags: tags +} + +@description('Optional. The configuration to apply for the Web Server Farm resource.') +param webSiteConfiguration webSiteConfigurationType = { + enabled: true + name: 'app-${solutionPrefix}' + location: solutionLocation + containerImageRegistryDomain: 'biabcontainerreg.azurecr.io' + containerImageName: 'macaefrontend' + containerImageTag: imageTag + containerName: 'backend' + tags: union(tags, { 'azd-service-name': 'frontend' }) + environmentResourceId: null //Default value set on module configuration +} + +// ========== Resource Group Tag ========== // +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: { + ...tags + TemplateName: 'Macae' + } + } +} + +// ========== Log Analytics Workspace ========== // +// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics +// Log Analytics configuration defaults +var logAnalyticsWorkspaceEnabled = logAnalyticsWorkspaceConfiguration.?enabled ?? true +var logAnalyticsWorkspaceResourceName = logAnalyticsWorkspaceConfiguration.?name ?? 'log-${solutionPrefix}' +var existingWorkspaceResourceId = logAnalyticsWorkspaceConfiguration.?existingWorkspaceResourceId ?? '' +var useExistingWorkspace = existingWorkspaceResourceId != '' + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (logAnalyticsWorkspaceEnabled && !useExistingWorkspace) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + tags: logAnalyticsWorkspaceConfiguration.?tags ?? tags + location: logAnalyticsWorkspaceConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + skuName: logAnalyticsWorkspaceConfiguration.?sku ?? 'PerGB2018' + dataRetention: logAnalyticsWorkspaceConfiguration.?dataRetentionInDays ?? 365 + diagnosticSettings: [{ useThisWorkspace: true }] + } +} + +var logAnalyticsWorkspaceId = useExistingWorkspace ? existingWorkspaceResourceId : logAnalyticsWorkspace.outputs.resourceId + +// ========== Application Insights ========== // +// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights +// Application Insights configuration defaults +var applicationInsightsEnabled = applicationInsightsConfiguration.?enabled ?? true +var applicationInsightsResourceName = applicationInsightsConfiguration.?name ?? 'appi-${solutionPrefix}' +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (applicationInsightsEnabled) { + name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) + params: { + name: applicationInsightsResourceName + workspaceResourceId: logAnalyticsWorkspaceId + location: applicationInsightsConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + tags: applicationInsightsConfiguration.?tags ?? tags + retentionInDays: applicationInsightsConfiguration.?retentionInDays ?? 365 + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + } +} + +// ========== User assigned identity Web Site ========== // +// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access +var userAssignedManagedIdentityEnabled = userAssignedManagedIdentityConfiguration.?enabled ?? true +var userAssignedManagedIdentityResourceName = userAssignedManagedIdentityConfiguration.?name ?? 'id-${solutionPrefix}' +module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = if (userAssignedManagedIdentityEnabled) { + name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedManagedIdentityResourceName}', 64) + params: { + name: userAssignedManagedIdentityResourceName + tags: userAssignedManagedIdentityConfiguration.?tags ?? tags + location: userAssignedManagedIdentityConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + } +} + +// ========== Network Security Groups ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +var networkSecurityGroupBackendEnabled = networkSecurityGroupBackendConfiguration.?enabled ?? true +var networkSecurityGroupBackendResourceName = networkSecurityGroupBackendConfiguration.?name ?? 'nsg-backend-${solutionPrefix}' +module networkSecurityGroupBackend 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupBackendEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupBackendResourceName}', 64) + params: { + name: networkSecurityGroupBackendResourceName + location: networkSecurityGroupBackendConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupBackendConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupBackendConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +var networkSecurityGroupContainersEnabled = networkSecurityGroupContainersConfiguration.?enabled ?? true +var networkSecurityGroupContainersResourceName = networkSecurityGroupContainersConfiguration.?name ?? 'nsg-containers-${solutionPrefix}' +module networkSecurityGroupContainers 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupContainersEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupContainersResourceName}', 64) + params: { + name: networkSecurityGroupContainersResourceName + location: networkSecurityGroupContainersConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupContainersConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupContainersConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +var networkSecurityGroupBastionEnabled = networkSecurityGroupBastionConfiguration.?enabled ?? true +var networkSecurityGroupBastionResourceName = networkSecurityGroupBastionConfiguration.?name ?? 'nsg-bastion-${solutionPrefix}' +module networkSecurityGroupBastion 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupBastionEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupBastionResourceName}', 64) + params: { + name: networkSecurityGroupBastionResourceName + location: networkSecurityGroupBastionConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupBastionConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupBastionConfiguration.?securityRules ?? [ + { + name: 'AllowHttpsInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'Internet' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 100 + direction: 'Inbound' + } + } + { + name: 'AllowGatewayManagerInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'GatewayManager' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 110 + direction: 'Inbound' + } + } + { + name: 'AllowLoadBalancerInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'AzureLoadBalancer' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 120 + direction: 'Inbound' + } + } + { + name: 'AllowBastionHostCommunicationInBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationPortRanges: [ + '8080' + '5701' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 130 + direction: 'Inbound' + } + } + { + name: 'DenyAllInBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRange: '*' + destinationAddressPrefix: '*' + access: 'Deny' + priority: 1000 + direction: 'Inbound' + } + } + { + name: 'AllowSshRdpOutBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 100 + direction: 'Outbound' + } + } + { + name: 'AllowAzureCloudCommunicationOutBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRange: '443' + destinationAddressPrefix: 'AzureCloud' + access: 'Allow' + priority: 110 + direction: 'Outbound' + } + } + { + name: 'AllowBastionHostCommunicationOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationPortRanges: [ + '8080' + '5701' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 120 + direction: 'Outbound' + } + } + { + name: 'AllowGetSessionInformationOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'Internet' + destinationPortRanges: [ + '80' + '443' + ] + access: 'Allow' + priority: 130 + direction: 'Outbound' + } + } + { + name: 'DenyAllOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + access: 'Deny' + priority: 1000 + direction: 'Outbound' + } + } + ] + } +} + +var networkSecurityGroupAdministrationEnabled = networkSecurityGroupAdministrationConfiguration.?enabled ?? true +var networkSecurityGroupAdministrationResourceName = networkSecurityGroupAdministrationConfiguration.?name ?? 'nsg-administration-${solutionPrefix}' +module networkSecurityGroupAdministration 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupAdministrationEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupAdministrationResourceName}', 64) + params: { + name: networkSecurityGroupAdministrationResourceName + location: networkSecurityGroupAdministrationConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupAdministrationConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupAdministrationConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +// ========== Virtual Network ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +var virtualNetworkEnabled = virtualNetworkConfiguration.?enabled ?? true +var virtualNetworkResourceName = virtualNetworkConfiguration.?name ?? 'vnet-${solutionPrefix}' +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = if (virtualNetworkEnabled) { + name: take('avm.res.network.virtual-network.${virtualNetworkResourceName}', 64) + params: { + name: virtualNetworkResourceName + location: virtualNetworkConfiguration.?location ?? solutionLocation + tags: virtualNetworkConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + addressPrefixes: virtualNetworkConfiguration.?addressPrefixes ?? ['10.0.0.0/8'] + subnets: virtualNetworkConfiguration.?subnets ?? [ + { + name: 'backend' + addressPrefix: '10.0.0.0/27' + //defaultOutboundAccess: false TODO: check this configuration for a more restricted outbound access + networkSecurityGroupResourceId: networkSecurityGroupBackend.outputs.resourceId + } + { + name: 'administration' + addressPrefix: '10.0.0.32/27' + networkSecurityGroupResourceId: networkSecurityGroupAdministration.outputs.resourceId + } + { + // For Azure Bastion resources deployed on or after November 2, 2021, the minimum AzureBastionSubnet size is /26 or larger (/25, /24, etc.). + // https://learn.microsoft.com/en-us/azure/bastion/configuration-settings#subnet + name: 'AzureBastionSubnet' //This exact name is required for Azure Bastion + addressPrefix: '10.0.0.64/26' + networkSecurityGroupResourceId: networkSecurityGroupBastion.outputs.resourceId + } + { + // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the Container App environment you deploy. This subnet isn't available to other services + // https://learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli#custom-vnw-configuration + name: 'containers' + addressPrefix: '10.0.2.0/23' //subnet of size /23 is required for container app + delegation: 'Microsoft.App/environments' + networkSecurityGroupResourceId: networkSecurityGroupContainers.outputs.resourceId + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + ] + } +} +var bastionEnabled = bastionConfiguration.?enabled ?? true +var bastionResourceName = bastionConfiguration.?name ?? 'bas-${solutionPrefix}' + +// ========== Bastion host ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (virtualNetworkEnabled && bastionEnabled) { + name: take('avm.res.network.bastion-host.${bastionResourceName}', 64) + params: { + name: bastionResourceName + location: bastionConfiguration.?location ?? solutionLocation + skuName: bastionConfiguration.?sku ?? 'Standard' + enableTelemetry: enableTelemetry + tags: bastionConfiguration.?tags ?? tags + virtualNetworkResourceId: bastionConfiguration.?virtualNetworkResourceId ?? virtualNetwork.?outputs.?resourceId + publicIPAddressObject: { + name: bastionConfiguration.?publicIpResourceName ?? 'pip-bas${solutionPrefix}' + zones: [] + } + disableCopyPaste: false + enableFileCopy: false + enableIpConnect: true + enableShareableLink: true + } +} + +// ========== Virtual machine ========== // +// WAF best practices for virtual machines: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-machines +var virtualMachineEnabled = virtualMachineConfiguration.?enabled ?? true +var virtualMachineResourceName = virtualMachineConfiguration.?name ?? 'vm${solutionPrefix}' +module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.0' = if (virtualNetworkEnabled && virtualMachineEnabled) { + name: take('avm.res.compute.virtual-machine.${virtualMachineResourceName}', 64) + params: { + name: virtualMachineResourceName + computerName: take(virtualMachineResourceName, 15) + location: virtualMachineConfiguration.?location ?? solutionLocation + tags: virtualMachineConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + vmSize: virtualMachineConfiguration.?vmSize ?? 'Standard_D2s_v3' + adminUsername: virtualMachineConfiguration.?adminUsername ?? 'adminuser' + adminPassword: virtualMachineConfiguration.?adminPassword ?? guid(solutionPrefix, subscription().subscriptionId) + nicConfigurations: [ + { + name: 'nic-${virtualMachineResourceName}' + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + ipConfigurations: [ + { + name: '${virtualMachineResourceName}-nic01-ipconfig01' + subnetResourceId: virtualMachineConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[1] + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + } + ] + } + ] + imageReference: { + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' + version: 'latest' + } + osDisk: { + name: 'osdisk-${virtualMachineResourceName}' + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + diskSizeGB: 128 + caching: 'ReadWrite' + } + osType: 'Windows' + encryptionAtHost: false //The property 'securityProfile.encryptionAtHost' is not valid because the 'Microsoft.Compute/EncryptionAtHost' feature is not enabled for this subscription. + zone: 0 + extensionAadJoinConfig: { + enabled: true + typeHandlerVersion: '1.0' + } + } +} + +// ========== AI Foundry: AI Services ========== // +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai +var openAiSubResource = 'account' +var openAiPrivateDnsZones = { + 'privatelink.cognitiveservices.azure.com': openAiSubResource + 'privatelink.openai.azure.com': openAiSubResource + 'privatelink.services.ai.azure.com': openAiSubResource +} + +module privateDnsZonesAiServices 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ + for zone in objectKeys(openAiPrivateDnsZones): if (virtualNetworkEnabled && aiFoundryAIservicesEnabled) { + name: take( + 'avm.res.network.private-dns-zone.ai-services.${uniqueString(aiFoundryAiServicesResourceName,zone)}.${solutionPrefix}', + 64 + ) + params: { + name: zone + tags: tags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: 'vnetlink-${split(zone, '.')[1]}' + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + } + ] + } + } +] + +// NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM +var useExistingFoundryProject = !empty(existingFoundryProjectResourceId) +var existingAiFoundryName = useExistingFoundryProject?split( existingFoundryProjectResourceId,'/')[8]:'' +var aiFoundryAiServicesResourceName = useExistingFoundryProject? existingAiFoundryName : aiFoundryAiServicesConfiguration.?name ?? 'aisa-${solutionPrefix}' +var aiFoundryAIservicesEnabled = aiFoundryAiServicesConfiguration.?enabled ?? true +var aiFoundryAiServicesModelDeployment = { + format: 'OpenAI' + name: gptModelName + version: gptModelVersion + sku: { + name: modelDeploymentType + //Curently the capacity is set to 140 for opinanal performance. + capacity: aiFoundryAiServicesConfiguration.?modelCapacity ?? gptModelCapacity + } + raiPolicyName: 'Microsoft.Default' +} + +module aiFoundryAiServices 'modules/account/main.bicep' = if (aiFoundryAIservicesEnabled) { + name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) + params: { + name: aiFoundryAiServicesResourceName + tags: aiFoundryAiServicesConfiguration.?tags ?? tags + location: aiFoundryAiServicesConfiguration.?location ?? aiDeploymentsLocation + enableTelemetry: enableTelemetry + projectName: 'aifp-${solutionPrefix}' + projectDescription: 'aifp-${solutionPrefix}' + existingFoundryProjectResourceId: existingFoundryProjectResourceId + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + sku: aiFoundryAiServicesConfiguration.?sku ?? 'S0' + kind: 'AIServices' + disableLocalAuth: true //Should be set to true for WAF aligned configuration + customSubDomainName: aiFoundryAiServicesResourceName + apiProperties: { + //staticsEnabled: false + } + allowProjectManagement: true + managedIdentities: { + systemAssigned: true + } + publicNetworkAccess: virtualNetworkEnabled ? 'Disabled' : 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: (virtualNetworkEnabled) ? 'Deny' : 'Allow' + } + privateEndpoints: virtualNetworkEnabled && !useExistingFoundryProject + ? ([ + { + name: 'pep-${aiFoundryAiServicesResourceName}' + customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' + subnetResourceId: aiFoundryAiServicesConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[0] + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: map(objectKeys(openAiPrivateDnsZones), zone => { + name: replace(zone, '.', '-') + privateDnsZoneResourceId: resourceId('Microsoft.Network/privateDnsZones', zone) + }) + } + } + ]) + : [] + deployments: aiFoundryAiServicesConfiguration.?deployments ?? [ + { + name: aiFoundryAiServicesModelDeployment.name + model: { + format: aiFoundryAiServicesModelDeployment.format + name: aiFoundryAiServicesModelDeployment.name + version: aiFoundryAiServicesModelDeployment.version + } + raiPolicyName: aiFoundryAiServicesModelDeployment.raiPolicyName + sku: { + name: aiFoundryAiServicesModelDeployment.sku.name + capacity: aiFoundryAiServicesModelDeployment.sku.capacity + } + } + ] + } +} + +// AI Foundry: AI Project +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai +var existingAiFounryProjectName = useExistingFoundryProject ? last(split( existingFoundryProjectResourceId,'/')) : '' +var aiFoundryAiProjectName = useExistingFoundryProject ? existingAiFounryProjectName : aiFoundryAiProjectConfiguration.?name ?? 'aifp-${solutionPrefix}' + +var useExistingResourceId = !empty(existingFoundryProjectResourceId) + +module cogServiceRoleAssignmentsNew './modules/role.bicep' = if(!useExistingResourceId) { + params: { + name: 'new-${guid(containerApp.name, aiFoundryAiServices.outputs.resourceId)}' + principalId: containerApp.outputs.?systemAssignedMIPrincipalId! + aiServiceName: aiFoundryAiServices.outputs.name + } + scope: resourceGroup(subscription().subscriptionId, resourceGroup().name) +} + +module cogServiceRoleAssignmentsExisting './modules/role.bicep' = if(useExistingResourceId) { + params: { + name: 'reuse-${guid(containerApp.name, aiFoundryAiServices.outputs.aiProjectInfo.resourceId)}' + principalId: containerApp.outputs.?systemAssignedMIPrincipalId! + aiServiceName: aiFoundryAiServices.outputs.name + } + scope: resourceGroup( split(existingFoundryProjectResourceId, '/')[2], split(existingFoundryProjectResourceId, '/')[4]) +} + +// ========== Cosmos DB ========== // +// WAF best practices for Cosmos DB: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/cosmos-db +module privateDnsZonesCosmosDb 'br/public:avm/res/network/private-dns-zone:0.7.0' = if (virtualNetworkEnabled) { + name: take('avm.res.network.private-dns-zone.cosmos-db.${solutionPrefix}', 64) + params: { + name: 'privatelink.documents.azure.com' + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: 'vnetlink-cosmosdb' + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + } + ] + tags: tags + } +} + +var cosmosDbAccountEnabled = cosmosDbAccountConfiguration.?enabled ?? true +var cosmosDbResourceName = cosmosDbAccountConfiguration.?name ?? 'cosmos-${solutionPrefix}' +var cosmosDbDatabaseName = 'macae' +var cosmosDbDatabaseMemoryContainerName = 'memory' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.12.0' = if (cosmosDbAccountEnabled) { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + // Required parameters + name: cosmosDbAccountConfiguration.?name ?? 'cosmos-${solutionPrefix}' + location: cosmosDbAccountConfiguration.?location ?? solutionLocation + tags: cosmosDbAccountConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + databaseAccountOfferType: 'Standard' + enableFreeTier: false + networkRestrictions: { + networkAclBypass: 'None' + publicNetworkAccess: virtualNetworkEnabled ? 'Disabled' : 'Enabled' + } + privateEndpoints: virtualNetworkEnabled + ? [ + { + name: 'pep-${cosmosDbResourceName}' + customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [{ privateDnsZoneResourceId: privateDnsZonesCosmosDb.outputs.resourceId }] + } + service: 'Sql' + subnetResourceId: cosmosDbAccountConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[0] + } + ] + : [] + sqlDatabases: concat(cosmosDbAccountConfiguration.?sqlDatabases ?? [], [ + { + name: cosmosDbDatabaseName + containers: [ + { + name: cosmosDbDatabaseMemoryContainerName + paths: [ + '/session_id' + ] + kind: 'Hash' + version: 2 + } + ] + } + ]) + locations: [ + { + locationName: cosmosDbAccountConfiguration.?location ?? solutionLocation + failoverPriority: 0 + isZoneRedundant: false + } + ] + capabilitiesToAdd: [ + 'EnableServerless' + ] + sqlRoleAssignmentsPrincipalIds: [ + containerApp.outputs.?systemAssignedMIPrincipalId + ] + sqlRoleDefinitions: [ + { + // Replace this with built-in role definition Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor + roleType: 'CustomRole' + roleName: 'Cosmos DB SQL Data Contributor' + name: 'cosmos-db-sql-data-contributor' + dataAction: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + ] + } + ] + } +} + +// ========== Container Registry ========== // +module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { + name: 'registryDeployment' + params: { + name: 'cr${replace(solutionPrefix,'-','')}' + acrAdminUserEnabled: false + acrSku: 'Basic' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + location: solutionLocation + softDeletePolicyDays: 7 + softDeletePolicyStatus: 'disabled' + tags: tags + networkRuleBypassOptions: 'AzureServices' + roleAssignments: [ + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: userAssignedIdentity.outputs.principalId + } + ] + } +} + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +// ========== Backend Container App Environment ========== // +// WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps +var containerAppEnvironmentEnabled = containerAppEnvironmentConfiguration.?enabled ?? true +var containerAppEnvironmentResourceName = containerAppEnvironmentConfiguration.?name ?? 'cae-${solutionPrefix}' +module containerAppEnvironment 'modules/container-app-environment.bicep' = if (containerAppEnvironmentEnabled) { + name: take('module.container-app-environment.${containerAppEnvironmentResourceName}', 64) + params: { + name: containerAppEnvironmentResourceName + tags: containerAppEnvironmentConfiguration.?tags ?? tags + location: containerAppEnvironmentConfiguration.?location ?? solutionLocation + logAnalyticsResourceId: logAnalyticsWorkspaceId + publicNetworkAccess: 'Enabled' + zoneRedundant: false + applicationInsightsConnectionString: applicationInsights.outputs.connectionString + enableTelemetry: enableTelemetry + subnetResourceId: virtualNetworkEnabled + ? containerAppEnvironmentConfiguration.?subnetResourceId ?? virtualNetwork.?outputs.?subnetResourceIds[3] ?? '' + : '' + } +} + +// ========== Backend Container App Service ========== // +// WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps +var containerAppEnabled = containerAppConfiguration.?enabled ?? true +var containerAppResourceName = containerAppConfiguration.?name ?? 'ca-${solutionPrefix}' +module containerApp 'br/public:avm/res/app/container-app:0.14.2' = if (containerAppEnabled) { + name: take('avm.res.app.container-app.${containerAppResourceName}', 64) + params: { + name: containerAppResourceName + tags: containerAppConfiguration.?tags ?? tags + location: containerAppConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + environmentResourceId: containerAppConfiguration.?environmentResourceId ?? containerAppEnvironment.outputs.resourceId + managedIdentities: { + systemAssigned: true //Replace with user assigned identity + userAssignedResourceIds: [userAssignedIdentity.outputs.resourceId] + } + ingressTargetPort: containerAppConfiguration.?ingressTargetPort ?? 8000 + ingressExternal: true + activeRevisionsMode: 'Single' + corsPolicy: { + allowedOrigins: [ + 'https://${webSiteName}.azurewebsites.net' + 'http://${webSiteName}.azurewebsites.net' + ] + } + scaleSettings: { + //TODO: Make maxReplicas and minReplicas parameterized + maxReplicas: containerAppConfiguration.?maxReplicas ?? 1 + minReplicas: containerAppConfiguration.?minReplicas ?? 1 + rules: [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: containerAppConfiguration.?concurrentRequests ?? '100' + } + } + } + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: userAssignedIdentity.outputs.resourceId + } + ] + containers: [ + { + name: containerAppConfiguration.?containerName ?? 'backend' + image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + //image: '${containerAppConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}/${containerAppConfiguration.?containerImageName ?? 'macaebackend'}:${containerAppConfiguration.?containerImageTag ?? 'latest'}' + resources: { + //TODO: Make cpu and memory parameterized + cpu: containerAppConfiguration.?containerCpu ?? '2.0' + memory: containerAppConfiguration.?containerMemory ?? '4.0Gi' + } + env: [ + { + name: 'COSMOSDB_ENDPOINT' + value: 'https://${cosmosDbResourceName}.documents.azure.com:443/' + } + { + name: 'COSMOSDB_DATABASE' + value: cosmosDbDatabaseName + } + { + name: 'COSMOSDB_CONTAINER' + value: cosmosDbDatabaseMemoryContainerName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${aiFoundryAiServicesResourceName}.openai.azure.com/' + } + { + name: 'AZURE_OPENAI_MODEL_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'AZURE_OPENAI_DEPLOYMENT_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'AZURE_OPENAI_API_VERSION' + value: azureopenaiVersion + } + { + name: 'APPLICATIONINSIGHTS_INSTRUMENTATION_KEY' + value: applicationInsights.outputs.instrumentationKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.outputs.connectionString + } + { + name: 'AZURE_AI_SUBSCRIPTION_ID' + value: subscription().subscriptionId + } + { + name: 'AZURE_AI_RESOURCE_GROUP' + value: resourceGroup().name + } + { + name: 'AZURE_AI_PROJECT_NAME' + value: aiFoundryAiProjectName + } + { + name: 'FRONTEND_SITE_NAME' + value: 'https://${webSiteName}.azurewebsites.net' + } + { + name: 'AZURE_AI_AGENT_ENDPOINT' + value: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'APP_ENV' + value: 'Prod' + } + ] + } + ] + } +} + +var webServerFarmEnabled = webServerFarmConfiguration.?enabled ?? true +var webServerFarmResourceName = webServerFarmConfiguration.?name ?? 'asp-${solutionPrefix}' + +// ========== Frontend server farm ========== // +// WAF best practices for web app service: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +module webServerFarm 'br/public:avm/res/web/serverfarm:0.4.1' = if (webServerFarmEnabled) { + name: take('avm.res.web.serverfarm.${webServerFarmResourceName}', 64) + params: { + name: webServerFarmResourceName + tags: tags + location: webServerFarmConfiguration.?location ?? solutionLocation + skuName: webServerFarmConfiguration.?skuName ?? 'P1v3' + skuCapacity: webServerFarmConfiguration.?skuCapacity ?? 3 + reserved: true + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + kind: 'linux' + zoneRedundant: false //TODO: make it zone redundant for waf aligned + } +} + +// ========== Frontend web site ========== // +// WAF best practices for web app service: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +var webSiteEnabled = webSiteConfiguration.?enabled ?? true + +var webSiteName = 'app-${solutionPrefix}' +module webSite 'br/public:avm/res/web/site:0.15.1' = if (webSiteEnabled) { + name: take('avm.res.web.site.${webSiteName}', 64) + params: { + name: webSiteName + tags: webSiteConfiguration.?tags ?? tags + location: webSiteConfiguration.?location ?? solutionLocation + kind: 'app,linux' + //kind: 'app,linux,container' + enableTelemetry: enableTelemetry + serverFarmResourceId: webSiteConfiguration.?environmentResourceId ?? webServerFarm.?outputs.resourceId + appInsightResourceId: applicationInsights.outputs.resourceId + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + publicNetworkAccess: 'Enabled' //TODO: use Azure Front Door WAF or Application Gateway WAF instead + siteConfig: { + //linuxFxVersion: 'DOCKER|${webSiteConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}/${webSiteConfiguration.?containerImageName ?? 'macaefrontend'}:${webSiteConfiguration.?containerImageTag ?? 'latest'}', + linuxFxVersion: 'python|3.11' + appCommandLine: 'python3 -m uvicorn frontend_server:app --host 0.0.0.0 --port 8000' + } + appSettingsKeyValuePairs: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + //DOCKER_REGISTRY_SERVER_URL: 'https://${webSiteConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}' + WEBSITES_PORT: '8000' + // WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed + BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' + AUTH_ENABLED: 'false' + APP_ENV: 'Prod' + ENABLE_ORYX_BUILD: 'True' + } + } +} + +// ============ // +// Outputs // +// ============ // + +// Add your outputs here + +@description('The default url of the website to connect to the Multi-Agent Custom Automation Engine solution.') +output webSiteDefaultHostname string = webSite.outputs.defaultHostname + + + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +// @description('The name of the resource.') +// output name string = .name + +// @description('The location the resource was deployed into.') +// output location string = .location + +// ================ // +// Definitions // +// ================ // +// +// Add your User-defined-types here, if any +// + + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Log Analytics Workspace resource configuration.') +type logAnalyticsWorkspaceConfigurationType = { + @description('Optional. If the Log Analytics Workspace resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Log Analytics Workspace resource.') + @maxLength(63) + name: string? + + @description('Optional. Location for the Log Analytics Workspace resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to for the Log Analytics Workspace resource.') + tags: object? + + @description('Optional. The SKU for the Log Analytics Workspace resource.') + sku: ('CapacityReservation' | 'Free' | 'LACluster' | 'PerGB2018' | 'PerNode' | 'Premium' | 'Standalone' | 'Standard')? + + @description('Optional. The number of days to retain the data in the Log Analytics Workspace. If empty, it will be set to 365 days.') + @maxValue(730) + dataRetentionInDays: int? + + @description('Optional: Existing Log Analytics Workspace Resource ID') + existingWorkspaceResourceId: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Application Insights resource configuration.') +type applicationInsightsConfigurationType = { + @description('Optional. If the Application Insights resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Application Insights resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Application Insights resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Application Insights resource.') + tags: object? + + @description('Optional. The retention of Application Insights data in days. If empty, Standard will be used.') + retentionInDays: (120 | 180 | 270 | 30 | 365 | 550 | 60 | 730 | 90)? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Application User Assigned Managed Identity resource configuration.') +type userAssignedManagedIdentityType = { + @description('Optional. If the User Assigned Managed Identity resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the User Assigned Managed Identity resource.') + @maxLength(128) + name: string? + + @description('Optional. Location for the User Assigned Managed Identity resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the User Assigned Managed Identity resource.') + tags: object? +} + +@export() +import { securityRuleType } from 'br/public:avm/res/network/network-security-group:0.5.1' +@description('The type for the Multi-Agent Custom Automation Engine Network Security Group resource configuration.') +type networkSecurityGroupConfigurationType = { + @description('Optional. If the Network Security Group resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Network Security Group resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Network Security Group resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Network Security Group resource.') + tags: object? + + @description('Optional. The security rules to set for the Network Security Group resource.') + securityRules: securityRuleType[]? +} + +@export() +@description('The type for the Multi-Agent Custom Automation virtual network resource configuration.') +type virtualNetworkConfigurationType = { + @description('Optional. If the Virtual Network resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Virtual Network resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Virtual Network resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Virtual Network resource.') + tags: object? + + @description('Optional. An array of 1 or more IP Addresses prefixes for the Virtual Network resource.') + addressPrefixes: string[]? + + @description('Optional. An array of 1 or more subnets for the Virtual Network resource.') + subnets: subnetType[]? +} + +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +type subnetType = { + @description('Optional. The Name of the subnet resource.') + name: string + + @description('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') + addressPrefix: string? + + @description('Conditional. List of address prefixes for the subnet. Required if `addressPrefix` is empty.') + addressPrefixes: string[]? + + @description('Optional. Application gateway IP configurations of virtual network resource.') + applicationGatewayIPConfigurations: object[]? + + @description('Optional. The delegation to enable on the subnet.') + delegation: string? + + @description('Optional. The resource ID of the NAT Gateway to use for the subnet.') + natGatewayResourceId: string? + + @description('Optional. The resource ID of the network security group to assign to the subnet.') + networkSecurityGroupResourceId: string? + + @description('Optional. enable or disable apply network policies on private endpoint in the subnet.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + + @description('Optional. enable or disable apply network policies on private link service in the subnet.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + + @description('Optional. Array of role assignments to create.') + roleAssignments: roleAssignmentType[]? + + @description('Optional. The resource ID of the route table to assign to the subnet.') + routeTableResourceId: string? + + @description('Optional. An array of service endpoint policies.') + serviceEndpointPolicies: object[]? + + @description('Optional. The service endpoints to enable on the subnet.') + serviceEndpoints: string[]? + + @description('Optional. Set this property to false to disable default outbound connectivity for all VMs in the subnet. This property can only be set at the time of subnet creation and cannot be updated for an existing subnet.') + defaultOutboundAccess: bool? + + @description('Optional. Set this property to Tenant to allow sharing subnet with other subscriptions in your AAD tenant. This property can only be set if defaultOutboundAccess is set to false, both properties can only be set if subnet is empty.') + sharingScope: ('DelegatedServices' | 'Tenant')? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Bastion resource configuration.') +type bastionConfigurationType = { + @description('Optional. If the Bastion resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Bastion resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Bastion resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Bastion resource.') + tags: object? + + @description('Optional. The SKU for the Bastion resource.') + sku: ('Basic' | 'Developer' | 'Premium' | 'Standard')? + + @description('Optional. The Virtual Network resource id where the Bastion resource should be deployed.') + virtualNetworkResourceId: string? + + @description('Optional. The name of the Public Ip resource created to connect to Bastion.') + publicIpResourceName: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine virtual machine resource configuration.') +type virtualMachineConfigurationType = { + @description('Optional. If the Virtual Machine resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Virtual Machine resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Virtual Machine resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Virtual Machine resource.') + tags: object? + + @description('Optional. Specifies the size for the Virtual Machine resource.') + vmSize: ( + | 'Basic_A0' + | 'Basic_A1' + | 'Basic_A2' + | 'Basic_A3' + | 'Basic_A4' + | 'Standard_A0' + | 'Standard_A1' + | 'Standard_A2' + | 'Standard_A3' + | 'Standard_A4' + | 'Standard_A5' + | 'Standard_A6' + | 'Standard_A7' + | 'Standard_A8' + | 'Standard_A9' + | 'Standard_A10' + | 'Standard_A11' + | 'Standard_A1_v2' + | 'Standard_A2_v2' + | 'Standard_A4_v2' + | 'Standard_A8_v2' + | 'Standard_A2m_v2' + | 'Standard_A4m_v2' + | 'Standard_A8m_v2' + | 'Standard_B1s' + | 'Standard_B1ms' + | 'Standard_B2s' + | 'Standard_B2ms' + | 'Standard_B4ms' + | 'Standard_B8ms' + | 'Standard_D1' + | 'Standard_D2' + | 'Standard_D3' + | 'Standard_D4' + | 'Standard_D11' + | 'Standard_D12' + | 'Standard_D13' + | 'Standard_D14' + | 'Standard_D1_v2' + | 'Standard_D2_v2' + | 'Standard_D3_v2' + | 'Standard_D4_v2' + | 'Standard_D5_v2' + | 'Standard_D2_v3' + | 'Standard_D4_v3' + | 'Standard_D8_v3' + | 'Standard_D16_v3' + | 'Standard_D32_v3' + | 'Standard_D64_v3' + | 'Standard_D2s_v3' + | 'Standard_D4s_v3' + | 'Standard_D8s_v3' + | 'Standard_D16s_v3' + | 'Standard_D32s_v3' + | 'Standard_D64s_v3' + | 'Standard_D11_v2' + | 'Standard_D12_v2' + | 'Standard_D13_v2' + | 'Standard_D14_v2' + | 'Standard_D15_v2' + | 'Standard_DS1' + | 'Standard_DS2' + | 'Standard_DS3' + | 'Standard_DS4' + | 'Standard_DS11' + | 'Standard_DS12' + | 'Standard_DS13' + | 'Standard_DS14' + | 'Standard_DS1_v2' + | 'Standard_DS2_v2' + | 'Standard_DS3_v2' + | 'Standard_DS4_v2' + | 'Standard_DS5_v2' + | 'Standard_DS11_v2' + | 'Standard_DS12_v2' + | 'Standard_DS13_v2' + | 'Standard_DS14_v2' + | 'Standard_DS15_v2' + | 'Standard_DS13-4_v2' + | 'Standard_DS13-2_v2' + | 'Standard_DS14-8_v2' + | 'Standard_DS14-4_v2' + | 'Standard_E2_v3' + | 'Standard_E4_v3' + | 'Standard_E8_v3' + | 'Standard_E16_v3' + | 'Standard_E32_v3' + | 'Standard_E64_v3' + | 'Standard_E2s_v3' + | 'Standard_E4s_v3' + | 'Standard_E8s_v3' + | 'Standard_E16s_v3' + | 'Standard_E32s_v3' + | 'Standard_E64s_v3' + | 'Standard_E32-16_v3' + | 'Standard_E32-8s_v3' + | 'Standard_E64-32s_v3' + | 'Standard_E64-16s_v3' + | 'Standard_F1' + | 'Standard_F2' + | 'Standard_F4' + | 'Standard_F8' + | 'Standard_F16' + | 'Standard_F1s' + | 'Standard_F2s' + | 'Standard_F4s' + | 'Standard_F8s' + | 'Standard_F16s' + | 'Standard_F2s_v2' + | 'Standard_F4s_v2' + | 'Standard_F8s_v2' + | 'Standard_F16s_v2' + | 'Standard_F32s_v2' + | 'Standard_F64s_v2' + | 'Standard_F72s_v2' + | 'Standard_G1' + | 'Standard_G2' + | 'Standard_G3' + | 'Standard_G4' + | 'Standard_G5' + | 'Standard_GS1' + | 'Standard_GS2' + | 'Standard_GS3' + | 'Standard_GS4' + | 'Standard_GS5' + | 'Standard_GS4-8' + | 'Standard_GS4-4' + | 'Standard_GS5-16' + | 'Standard_GS5-8' + | 'Standard_H8' + | 'Standard_H16' + | 'Standard_H8m' + | 'Standard_H16m' + | 'Standard_H16r' + | 'Standard_H16mr' + | 'Standard_L4s' + | 'Standard_L8s' + | 'Standard_L16s' + | 'Standard_L32s' + | 'Standard_M64s' + | 'Standard_M64ms' + | 'Standard_M128s' + | 'Standard_M128ms' + | 'Standard_M64-32ms' + | 'Standard_M64-16ms' + | 'Standard_M128-64ms' + | 'Standard_M128-32ms' + | 'Standard_NC6' + | 'Standard_NC12' + | 'Standard_NC24' + | 'Standard_NC24r' + | 'Standard_NC6s_v2' + | 'Standard_NC12s_v2' + | 'Standard_NC24s_v2' + | 'Standard_NC24rs_v2' + | 'Standard_NC6s_v3' + | 'Standard_NC12s_v3' + | 'Standard_NC24s_v3' + | 'Standard_NC24rs_v3' + | 'Standard_ND6s' + | 'Standard_ND12s' + | 'Standard_ND24s' + | 'Standard_ND24rs' + | 'Standard_NV6' + | 'Standard_NV12' + | 'Standard_NV24')? + + @description('Optional. The username for the administrator account on the virtual machine. Required if a virtual machine is created as part of the module.') + adminUsername: string? + + @description('Optional. The password for the administrator account on the virtual machine. Required if a virtual machine is created as part of the module.') + @secure() + adminPassword: string? + + @description('Optional. The resource ID of the subnet where the Virtual Machine resource should be deployed.') + subnetResourceId: string? +} + +@export() +import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' +@description('The type for the Multi-Agent Custom Automation Engine AI Services resource configuration.') +type aiServicesConfigurationType = { + @description('Optional. If the AI Services resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the AI Services resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the AI Services resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the AI Services resource.') + tags: object? + + @description('Optional. The SKU of the AI Services resource. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') + sku: ( + | 'C2' + | 'C3' + | 'C4' + | 'F0' + | 'F1' + | 'S' + | 'S0' + | 'S1' + | 'S10' + | 'S2' + | 'S3' + | 'S4' + | 'S5' + | 'S6' + | 'S7' + | 'S8' + | 'S9')? + + @description('Optional. The resource Id of the subnet where the AI Services private endpoint should be created.') + subnetResourceId: string? + + @description('Optional. The model deployments to set for the AI Services resource.') + deployments: deploymentType[]? + + @description('Optional. The capacity to set for AI Services GTP model.') + modelCapacity: int? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine AI Foundry AI Project resource configuration.') +type aiProjectConfigurationType = { + @description('Optional. If the AI Project resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the AI Project resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the AI Project resource deployment.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The SKU of the AI Project resource.') + sku: ('Basic' | 'Free' | 'Standard' | 'Premium')? + + @description('Optional. The tags to set for the AI Project resource.') + tags: object? +} + +import { sqlDatabaseType } from 'br/public:avm/res/document-db/database-account:0.13.0' +@export() +@description('The type for the Multi-Agent Custom Automation Engine Cosmos DB Account resource configuration.') +type cosmosDbAccountConfigurationType = { + @description('Optional. If the Cosmos DB Account resource should be deployed or not.') + enabled: bool? + @description('Optional. The name of the Cosmos DB Account resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Cosmos DB Account resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Cosmos DB Account resource.') + tags: object? + + @description('Optional. The resource Id of the subnet where the Cosmos DB Account private endpoint should be created.') + subnetResourceId: string? + + @description('Optional. The SQL databases configuration for the Cosmos DB Account resource.') + sqlDatabases: sqlDatabaseType[]? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Container App Environment resource configuration.') +type containerAppEnvironmentConfigurationType = { + @description('Optional. If the Container App Environment resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Container App Environment resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Container App Environment resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Container App Environment resource.') + tags: object? + + @description('Optional. The resource Id of the subnet where the Container App Environment private endpoint should be created.') + subnetResourceId: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Container App resource configuration.') +type containerAppConfigurationType = { + @description('Optional. If the Container App resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Container App resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Container App resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Container App resource.') + tags: object? + + @description('Optional. The resource Id of the Container App Environment where the Container App should be created.') + environmentResourceId: string? + + @description('Optional. The maximum number of replicas of the Container App.') + maxReplicas: int? + + @description('Optional. The minimum number of replicas of the Container App.') + minReplicas: int? + + @description('Optional. The ingress target port of the Container App.') + ingressTargetPort: int? + + @description('Optional. The concurrent requests allowed for the Container App.') + concurrentRequests: string? + + @description('Optional. The name given to the Container App.') + containerName: string? + + @description('Optional. The container registry domain of the container image to be used by the Container App. Default to `biabcontainerreg.azurecr.io`') + containerImageRegistryDomain: string? + + @description('Optional. The name of the container image to be used by the Container App.') + containerImageName: string? + + @description('Optional. The tag of the container image to be used by the Container App.') + containerImageTag: string? + + @description('Optional. The CPU reserved for the Container App. Defaults to 2.0') + containerCpu: string? + + @description('Optional. The Memory reserved for the Container App. Defaults to 4.0Gi') + containerMemory: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Entra ID Application resource configuration.') +type entraIdApplicationConfigurationType = { + @description('Optional. If the Entra ID Application for website authentication should be deployed or not.') + enabled: bool? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Web Server Farm resource configuration.') +type webServerFarmConfigurationType = { + @description('Optional. If the Web Server Farm resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Web Server Farm resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Web Server Farm resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Web Server Farm resource.') + tags: object? + + @description('Optional. The name of th SKU that will determine the tier, size and family for the Web Server Farm resource. This defaults to P1v3 to leverage availability zones.') + skuName: string? + + @description('Optional. Number of workers associated with the App Service Plan. This defaults to 3, to leverage availability zones.') + skuCapacity: int? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Web Site resource configuration.') +type webSiteConfigurationType = { + @description('Optional. If the Web Site resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Web Site resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Web Site resource deployment.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Web Site resource.') + tags: object? + + @description('Optional. The resource Id of the Web Site Environment where the Web Site should be created.') + environmentResourceId: string? + + @description('Optional. The name given to the Container App.') + containerName: string? + + @description('Optional. The container registry domain of the container image to be used by the Web Site. Default to `biabcontainerreg.azurecr.io`') + containerImageRegistryDomain: string? + + @description('Optional. The name of the container image to be used by the Web Site.') + containerImageName: string? + + @description('Optional. The tag of the container image to be used by the Web Site.') + containerImageTag: string? +} + + +output COSMOSDB_ENDPOINT string = 'https://${cosmosDbResourceName}.documents.azure.com:443/' +output COSMOSDB_DATABASE string = cosmosDbDatabaseName +output COSMOSDB_CONTAINER string = cosmosDbDatabaseMemoryContainerName +output AZURE_OPENAI_ENDPOINT string = 'https://${aiFoundryAiServicesResourceName}.openai.azure.com/' +output AZURE_OPENAI_MODEL_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_OPENAI_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_OPENAI_API_VERSION string = azureopenaiVersion +// output APPLICATIONINSIGHTS_INSTRUMENTATION_KEY string = applicationInsights.outputs.instrumentationKey +// output AZURE_AI_PROJECT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output AZURE_AI_SUBSCRIPTION_ID string = subscription().subscriptionId +output AZURE_AI_RESOURCE_GROUP string = resourceGroup().name +output AZURE_AI_PROJECT_NAME string = aiFoundryAiProjectName +output AZURE_AI_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +// output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsights.outputs.connectionString +output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output APP_ENV string = 'Prod' +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAiServices.outputs.resourceId +output COSMOSDB_ACCOUNT_NAME string = cosmosDbResourceName diff --git a/infra/scripts/add_cosmosdb_access.sh b/infra/scripts/add_cosmosdb_access.sh new file mode 100644 index 000000000..86a848b36 --- /dev/null +++ b/infra/scripts/add_cosmosdb_access.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Variables +resource_group="$1" +account_name="$2" +principal_ids="$3" + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + + +IFS=',' read -r -a principal_ids_array <<< $principal_ids + +echo "Assigning Cosmos DB Built-in Data Contributor role to users" +for principal_id in "${principal_ids_array[@]}"; do + + # Check if the user has the Cosmos DB Built-in Data Contributor role + echo "Checking if user - ${principal_id} has the Cosmos DB Built-in Data Contributor role" + roleExists=$(az cosmosdb sql role assignment list \ + --resource-group $resource_group \ + --account-name $account_name \ + --query "[?roleDefinitionId.ends_with(@, '00000000-0000-0000-0000-000000000002') && principalId == '$principal_id']" -o tsv) + + # Check if the role exists + if [ -n "$roleExists" ]; then + echo "User - ${principal_id} already has the Cosmos DB Built-in Data Contributer role." + else + echo "User - ${principal_id} does not have the Cosmos DB Built-in Data Contributer role. Assigning the role." + MSYS_NO_PATHCONV=1 az cosmosdb sql role assignment create \ + --resource-group $resource_group \ + --account-name $account_name \ + --role-definition-id 00000000-0000-0000-0000-000000000002 \ + --principal-id $principal_id \ + --scope "/" \ + --output none + if [ $? -eq 0 ]; then + echo "Cosmos DB Built-in Data Contributer role assigned successfully." + else + echo "Failed to assign Cosmos DB Built-in Data Contributer role." + fi + fi +done \ No newline at end of file diff --git a/infra/scripts/assign_azure_ai_user_role.sh b/infra/scripts/assign_azure_ai_user_role.sh new file mode 100644 index 000000000..e44dad6cb --- /dev/null +++ b/infra/scripts/assign_azure_ai_user_role.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Variables +resource_group="$1" +aif_resource_id="$2" +principal_ids="$3" + + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + + +IFS=',' read -r -a principal_ids_array <<< $principal_ids + +echo "Assigning Azure AI User role role to users" + +echo "Using provided Azure AI resource id: $aif_resource_id" + +for principal_id in "${principal_ids_array[@]}"; do + + # Check if the user has the Azure AI User role + echo "Checking if user - ${principal_id} has the Azure AI User role" + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --assignee $principal_id --query "[].roleDefinitionId" -o tsv) + if [ -z "$role_assignment" ]; then + echo "User - ${principal_id} does not have the Azure AI User role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $principal_id --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --output none + if [ $? -eq 0 ]; then + echo "Azure AI User role assigned successfully." + else + echo "Failed to assign Azure AI User role." + exit 1 + fi + else + echo "User - ${principal_id} already has the Azure AI User role." + fi +done \ No newline at end of file diff --git a/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh b/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh new file mode 100644 index 000000000..f8c14522a --- /dev/null +++ b/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Variables + +principal_ids="$1" +cosmosDbAccountName="$2" +resourceGroupName="$3" +managedIdentityClientId="$4" +aif_resource_id="${5}" + +# Function to merge and deduplicate principal IDs +merge_principal_ids() { + local param_ids="$1" + local env_ids="$2" + local all_ids="" + + # Add parameter IDs if provided + if [ -n "$param_ids" ]; then + all_ids="$param_ids" + fi + + signed_user_id=$(az ad signed-in-user show --query id -o tsv) + + # Add environment variable IDs if provided + if [ -n "$env_ids" ]; then + if [ -n "$all_ids" ]; then + all_ids="$all_ids,$env_ids" + else + all_ids="$env_ids" + fi + fi + + all_ids="$all_ids,$signed_user_id" + # Remove duplicates and return + if [ -n "$all_ids" ]; then + # Convert to array, remove duplicates, and join back + IFS=',' read -r -a ids_array <<< "$all_ids" + declare -A unique_ids + for id in "${ids_array[@]}"; do + # Trim whitespace + id=$(echo "$id" | xargs) + if [ -n "$id" ]; then + unique_ids["$id"]=1 + fi + done + + # Join unique IDs back with commas + local result="" + for id in "${!unique_ids[@]}"; do + if [ -n "$result" ]; then + result="$result,$id" + else + result="$id" + fi + done + echo "$result" + fi +} + + +# get parameters from azd env, if not provided +if [ -z "$resourceGroupName" ]; then + resourceGroupName=$(azd env get-value AZURE_RESOURCE_GROUP) +fi + +if [ -z "$cosmosDbAccountName" ]; then + cosmosDbAccountName=$(azd env get-value COSMOSDB_ACCOUNT_NAME) +fi + +if [ -z "$aif_resource_id" ]; then + aif_resource_id=$(azd env get-value AI_FOUNDRY_RESOURCE_ID) +fi + +azSubscriptionId=$(azd env get-value AZURE_SUBSCRIPTION_ID) +env_principal_ids=$(azd env get-value PRINCIPAL_IDS) + +# Merge principal IDs from parameter and environment variable +principal_ids=$(merge_principal_ids "$principal_ids_param" "$env_principal_ids") + +# Check if all required arguments are provided +if [ -z "$principal_ids" ] || [ -z "$cosmosDbAccountName" ] || [ -z "$resourceGroupName" ] || [ -z "$aif_resource_id" ] ; then + echo "Usage: $0 " + exit 1 +fi + +echo "Using principal IDs: $principal_ids" + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + +#check if user has selected the correct subscription +currentSubscriptionId=$(az account show --query id -o tsv) +currentSubscriptionName=$(az account show --query name -o tsv) +if [ "$currentSubscriptionId" != "$azSubscriptionId" ]; then + echo "Current selected subscription is $currentSubscriptionName ( $currentSubscriptionId )." + read -rp "Do you want to continue with this subscription?(y/n): " confirmation + if [[ "$confirmation" != "y" && "$confirmation" != "Y" ]]; then + echo "Fetching available subscriptions..." + availableSubscriptions=$(az account list --query "[?state=='Enabled'].[name,id]" --output tsv) + while true; do + echo "" + echo "Available Subscriptions:" + echo "========================" + echo "$availableSubscriptions" | awk '{printf "%d. %s ( %s )\n", NR, $1, $2}' + echo "========================" + echo "" + read -rp "Enter the number of the subscription (1-$(echo "$availableSubscriptions" | wc -l)) to use: " subscriptionIndex + if [[ "$subscriptionIndex" =~ ^[0-9]+$ ]] && [ "$subscriptionIndex" -ge 1 ] && [ "$subscriptionIndex" -le $(echo "$availableSubscriptions" | wc -l) ]; then + selectedSubscription=$(echo "$availableSubscriptions" | sed -n "${subscriptionIndex}p") + selectedSubscriptionName=$(echo "$selectedSubscription" | cut -f1) + selectedSubscriptionId=$(echo "$selectedSubscription" | cut -f2) + + # Set the selected subscription + if az account set --subscription "$selectedSubscriptionId"; then + echo "Switched to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )" + break + else + echo "Failed to switch to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )." + fi + else + echo "Invalid selection. Please try again." + fi + done + else + echo "Proceeding with the current subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" + fi +else + echo "Proceeding with the subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" +fi + +# Call add_cosmosdb_access.sh +echo "Running add_cosmosdb_access.sh" +bash infra/scripts/add_cosmosdb_access.sh "$resourceGroupName" "$cosmosDbAccountName" "$principal_ids" "$managedIdentityClientId" +if [ $? -ne 0 ]; then + echo "Error: add_cosmosdb_access.sh failed." + exit 1 +fi +echo "add_cosmosdb_access.sh completed successfully." + + +# Call add_cosmosdb_access.sh +echo "Running assign_azure_ai_user_role.sh" +bash infra/scripts/assign_azure_ai_user_role.sh "$resourceGroupName" "$aif_resource_id" "$principal_ids" "$managedIdentityClientId" +if [ $? -ne 0 ]; then + echo "Error: assign_azure_ai_user_role.sh failed." + exit 1 +fi +echo "assign_azure_ai_user_role.sh completed successfully." \ No newline at end of file diff --git a/infra/scripts/package_frontend.ps1 b/infra/scripts/package_frontend.ps1 new file mode 100644 index 000000000..71364c296 --- /dev/null +++ b/infra/scripts/package_frontend.ps1 @@ -0,0 +1,11 @@ +mkdir dist -Force +rm dist/* -r -Force + +# Python +cp requirements.txt dist -Force +cp *.py dist -Force + +# Node +npm install +npm run build +cp -r build dist -Force \ No newline at end of file diff --git a/infra/scripts/package_frontend.sh b/infra/scripts/package_frontend.sh new file mode 100644 index 000000000..e334d6b2a --- /dev/null +++ b/infra/scripts/package_frontend.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -eou pipefail + +mkdir -p dist +rm -rf dist/* + +#python +cp -f requirements.txt dist +cp -f *.py dist + +#node +npm install +npm run build +cp -rf build dist \ No newline at end of file diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 23ecf1ba7..35d969796 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -10,14 +10,17 @@ WORKDIR /app COPY uv.lock pyproject.toml /app/ # Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev +# RUN --mount=type=cache,target=/root/.cache/uv \ +# --mount=type=bind,source=uv.lock,target=uv.lock \ +# --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ +# uv sync --frozen --no-install-project --no-dev +RUN uv sync --frozen --no-install-project --no-dev # Backend app setup COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev +#RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev +RUN uv sync --frozen --no-dev + FROM base diff --git a/src/frontend/src/components/common/TeamSelector.tsx b/src/frontend/src/components/common/TeamSelector.tsx deleted file mode 100644 index 04d8f8b0c..000000000 --- a/src/frontend/src/components/common/TeamSelector.tsx +++ /dev/null @@ -1,930 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { - Button, - Dialog, - DialogTrigger, - DialogSurface, - DialogTitle, - DialogContent, - DialogBody, - DialogActions, - Text, - Spinner, - Card, - Body1, - Body2, - Caption1, - Badge, - Input, - Radio, - RadioGroup, - Tab, - TabList -} from '@fluentui/react-components'; -import { - ChevronDown16Regular, - ChevronUpDown16Regular, - CloudAdd20Regular, - Delete20Regular, - Search20Regular, - Desktop20Regular, - BookmarkMultiple20Regular, - Person20Regular, - Building20Regular, - Document20Regular, - Database20Regular, - Play20Regular, - Shield20Regular, - Globe20Regular, - Clipboard20Regular, - WindowConsole20Regular, - Code20Regular, - Wrench20Regular, -} from '@fluentui/react-icons'; -import { TeamConfig } from '../../models/Team'; -import { TeamService } from '../../services/TeamService'; - -// Icon mapping function to convert string icons to FluentUI icons -const getIconFromString = (iconString: string): React.ReactNode => { - const iconMap: Record = { - 'Terminal': , - 'MonitorCog': , - 'BookMarked': , - 'Search': , - 'Robot': , - 'Code': , - 'Play': , - 'Shield': , - 'Globe': , - 'Person': , - 'Database': , - 'Document': , - 'Wrench': , - 'TestTube': , - 'Building': , - 'Desktop': , - 'default': , - }; - - return iconMap[iconString] || iconMap['default'] || ; -}; - -interface TeamSelectorProps { - onTeamSelect?: (team: TeamConfig | null) => void; - onTeamUpload?: () => Promise; - selectedTeam?: TeamConfig | null; -} - -const TeamSelector: React.FC = ({ - onTeamSelect, - onTeamUpload, - selectedTeam, -}) => { - const [isOpen, setIsOpen] = useState(false); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(false); - const [uploadLoading, setUploadLoading] = useState(false); - const [error, setError] = useState(null); - const [uploadMessage, setUploadMessage] = useState(null); - const [tempSelectedTeam, setTempSelectedTeam] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [teamToDelete, setTeamToDelete] = useState(null); - const [deleteLoading, setDeleteLoading] = useState(false); - const [activeTab, setActiveTab] = useState('teams'); - - const loadTeams = async () => { - setLoading(true); - setError(null); - try { - const teamsData = await TeamService.getUserTeams(); - setTeams(teamsData); - } catch (err: any) { - setError(err.message || 'Failed to load teams'); - } finally { - setLoading(false); - } - }; - - const handleOpenChange = async (open: boolean) => { - setIsOpen(open); - if (open) { - await loadTeams(); - setTempSelectedTeam(selectedTeam || null); - setError(null); - setUploadMessage(null); - setSearchQuery(''); - setActiveTab('teams'); - } else { - setTempSelectedTeam(null); - setError(null); - setUploadMessage(null); - setSearchQuery(''); - } - }; - - const handleContinue = () => { - if (tempSelectedTeam) { - onTeamSelect?.(tempSelectedTeam); - } - setIsOpen(false); - }; - - const handleCancel = () => { - setTempSelectedTeam(null); - setIsOpen(false); - }; - - // Filter teams based on search query - const filteredTeams = teams.filter(team => - team.name.toLowerCase().includes(searchQuery.toLowerCase()) || - team.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { - event.stopPropagation(); - setTeamToDelete(team); - setDeleteConfirmOpen(true); - }; - - const confirmDeleteTeam = async () => { - if (!teamToDelete || deleteLoading) return; - - if (teamToDelete.protected) { - setError('This team is protected and cannot be deleted.'); - setDeleteConfirmOpen(false); - setTeamToDelete(null); - return; - } - - setDeleteLoading(true); - - try { - const success = await TeamService.deleteTeam(teamToDelete.id); - - if (success) { - setDeleteConfirmOpen(false); - setTeamToDelete(null); - setDeleteLoading(false); - - if (tempSelectedTeam?.team_id === teamToDelete.team_id) { - setTempSelectedTeam(null); - if (selectedTeam?.team_id === teamToDelete.team_id) { - onTeamSelect?.(null); - } - } - - setTeams(currentTeams => currentTeams.filter(team => team.id !== teamToDelete.id)); - await loadTeams(); - } else { - setError('Failed to delete team configuration.'); - setDeleteConfirmOpen(false); - setTeamToDelete(null); - } - } catch (err: any) { - let errorMessage = 'Failed to delete team configuration. Please try again.'; - - if (err.response?.status === 404) { - errorMessage = 'Team not found. It may have already been deleted.'; - } else if (err.response?.status === 403) { - errorMessage = 'You do not have permission to delete this team.'; - } else if (err.response?.status === 409) { - errorMessage = 'Cannot delete team because it is currently in use.'; - } else if (err.response?.data?.detail) { - errorMessage = err.response.data.detail; - } else if (err.message) { - errorMessage = `Delete failed: ${err.message}`; - } - - setError(errorMessage); - setDeleteConfirmOpen(false); - setTeamToDelete(null); - } finally { - setDeleteLoading(false); - } - }; - - const handleFileUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - setUploadLoading(true); - setError(null); - setUploadMessage('Reading and validating team configuration...'); - - try { - if (!file.name.toLowerCase().endsWith('.json')) { - throw new Error('Please upload a valid JSON file'); - } - - // Read and parse the JSON file to check agent count - const fileText = await file.text(); - let teamData; - try { - teamData = JSON.parse(fileText); - } catch (parseError) { - throw new Error('Invalid JSON file format'); - } - - // Check if the team has more than 6 agents - if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { - throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); - } - - setUploadMessage('Uploading team configuration...'); - const result = await TeamService.uploadCustomTeam(file); - - if (result.success) { - setUploadMessage('Team uploaded successfully!'); - - // Immediately add the team to local state for instant visibility - if (result.team) { - setTeams(currentTeams => [...currentTeams, result.team!]); - } - - // Also reload teams from server in the background to ensure consistency - setTimeout(() => { - loadTeams().catch(console.error); - }, 1000); - - setUploadMessage(null); - if (onTeamUpload) { - await onTeamUpload(); - } - } else if (result.raiError) { - setError('❌ Content Safety Check Failed\n\nYour team configuration contains content that doesn\'t meet our safety guidelines.'); - setUploadMessage(null); - } else if (result.modelError) { - setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); - setUploadMessage(null); - } else if (result.searchError) { - setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); - setUploadMessage(null); - } else { - setError(result.error || 'Failed to upload team configuration'); - setUploadMessage(null); - } - } catch (err: any) { - setError(err.message || 'Failed to upload team configuration'); - setUploadMessage(null); - } finally { - setUploadLoading(false); - event.target.value = ''; - } - }; - - const handleDragOver = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.style.borderColor = '#6264a7'; - event.currentTarget.style.backgroundColor = '#f0f0ff'; - }; - - const handleDragLeave = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.style.borderColor = '#d1d5db'; - event.currentTarget.style.backgroundColor = '#f9fafb'; - }; - - const handleDrop = async (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - - // Reset visual state - event.currentTarget.style.borderColor = '#d1d5db'; - event.currentTarget.style.backgroundColor = '#f9fafb'; - - const files = event.dataTransfer.files; - if (files.length === 0) return; - - const file = files[0]; - if (!file.name.toLowerCase().endsWith('.json')) { - setError('Please upload a valid JSON file'); - return; - } - - setUploadLoading(true); - setError(null); - setUploadMessage('Reading and validating team configuration...'); - - try { - // Read and parse the JSON file to check agent count - const fileText = await file.text(); - let teamData; - try { - teamData = JSON.parse(fileText); - } catch (parseError) { - throw new Error('Invalid JSON file format'); - } - - // Check if the team has more than 6 agents - if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { - throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); - } - - setUploadMessage('Uploading team configuration...'); - const result = await TeamService.uploadCustomTeam(file); - - if (result.success) { - setUploadMessage('Team uploaded successfully!'); - - // Immediately add the team to local state for instant visibility - if (result.team) { - setTeams(currentTeams => [...currentTeams, result.team!]); - } - - // Also reload teams from server in the background to ensure consistency - setTimeout(() => { - loadTeams().catch(console.error); - }, 1000); - - setUploadMessage(null); - if (onTeamUpload) { - await onTeamUpload(); - } - } else if (result.raiError) { - setError('❌ Content Safety Check Failed\n\nYour team configuration contains content that doesn\'t meet our safety guidelines.'); - setUploadMessage(null); - } else if (result.modelError) { - setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); - setUploadMessage(null); - } else if (result.searchError) { - setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); - setUploadMessage(null); - } else { - setError(result.error || 'Failed to upload team configuration'); - setUploadMessage(null); - } - } catch (err: any) { - setError(err.message || 'Failed to upload team configuration'); - setUploadMessage(null); - } finally { - setUploadLoading(false); - } - }; - - const renderTeamCard = (team: TeamConfig) => { - const isSelected = tempSelectedTeam?.team_id === team.team_id; - - return ( -
setTempSelectedTeam(team)} - > - {/* Radio Button */} - - - {/* Team Info */} -
-
- {team.name} -
-
- {team.description} -
-
- - {/* Tags */} -
- {team.agents.slice(0, 3).map((agent) => ( - - {agent.icon && ( - - {getIconFromString(agent.icon)} - - )} - {agent.type} - - ))} - {team.agents.length > 3 && ( - - +{team.agents.length - 3} - - )} -
- - {/* Delete Button */} -
- ); - }; - - return ( - <> - handleOpenChange(data.open)}> - - - - - - Select a Team - - - - {/* Tab Navigation - Integrated with content */} -
- setActiveTab(data.value as string)} - style={{ - width: '100%', - backgroundColor: '#ffffff' - }} - > - - Teams - - - Upload Team - - -
- - {/* Tab Content - Directly below tabs without separation */} -
- {activeTab === 'teams' && ( -
- {error && ( -
- {error} -
- )} - - {/* Search Input */} -
- setSearchQuery(e.target.value)} - contentBefore={} - style={{ - width: '100%', - backgroundColor: '#ffffff', - border: '1px solid #e1e1e1', - color: '#323130' - }} - /> -
- - {/* Teams List */} - {loading ? ( -
- -
- ) : filteredTeams.length > 0 ? ( -
- -
- {filteredTeams.map((team) => renderTeamCard(team))} -
-
-
- ) : searchQuery ? ( -
- - No teams found matching "{searchQuery}" - -
- ) : ( -
- - No teams available - - - Upload a JSON team configuration to get started - -
- )} -
- )} - - {activeTab === 'upload' && ( -
- {uploadMessage && ( -
- - {uploadMessage} -
- )} - - {error && ( -
- {error} -
- )} - - {/* Drag and Drop Zone */} -
document.getElementById('team-upload-input')?.click()} - > -
-
- ↑ -
- - Drag & drop your team JSON here - - - or click to browse - -
- - -
- - {/* Upload Requirements */} -
- - Upload Requirements - -
    -
  • - - JSON must include name, description, and status - -
  • -
  • - - At least one agent with name, type, input_key, and deployment_name - -
  • -
  • - - Maximum of 6 agents per team configuration - -
  • -
  • - - RAG agents additionally require index_name - -
  • -
  • - - Starting tasks are optional, but if provided must include name and prompt - -
  • - {/*
  • - - Text fields cannot be empty - -
  • */} -
-
-
- )} -
-
-
- - - - -
-
- - {/* Delete Confirmation Dialog */} - setDeleteConfirmOpen(data.open)}> - - - - ⚠️ Delete Team Configuration -
- - Are you sure you want to delete "{teamToDelete?.name}"? - -
- - This action cannot be undone and will remove the team for all users. - -
-
-
- - - - -
-
-
- - ); -}; - -export default TeamSelector; diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx deleted file mode 100644 index 2b81a827f..000000000 --- a/src/frontend/src/pages/PlanPage.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import React, { useCallback, useEffect, useState, useRef } from "react"; -import { useParams, useNavigate } from "react-router-dom"; -import { - Text, - ToggleButton, - Toast, - ToastTitle, - ToastBody, - useToastController, -} from "@fluentui/react-components"; -import "../styles/PlanPage.css"; -import CoralShellColumn from "../coral/components/Layout/CoralShellColumn"; -import CoralShellRow from "../coral/components/Layout/CoralShellRow"; -import Content from "../coral/components/Content/Content"; -import { NewTaskService } from "../services/NewTaskService"; -import { PlanDataService } from "../services/PlanDataService"; -import { Step, ProcessedPlanData } from "@/models"; -import PlanPanelLeft from "@/components/content/PlanPanelLeft"; -import ContentToolbar from "@/coral/components/Content/ContentToolbar"; -import PlanChat from "@/components/content/PlanChat"; -import PlanPanelRight from "@/components/content/PlanPanelRight"; -import InlineToaster, { - useInlineToaster, -} from "../components/toast/InlineToaster"; -import Octo from "../coral/imports/Octopus.png"; // 🐙 Animated PNG loader -import PanelRightToggles from "@/coral/components/Header/PanelRightToggles"; -import { TaskListSquareLtr } from "@/coral/imports/bundleicons"; -import LoadingMessage, { loadingMessages } from "@/coral/components/LoadingMessage"; -import { RAIErrorCard, RAIErrorData } from "../components/errors"; -import { TeamConfig } from "../models/Team"; -import { TeamService } from "../services/TeamService"; -import { webSocketService, StreamMessage, StreamingPlanUpdate } from "../services/WebSocketService"; - -/** - * Page component for displaying a specific plan - * Accessible via the route /plan/{plan_id} - */ -const PlanPage: React.FC = () => { - const { planId } = useParams<{ planId: string }>(); - const navigate = useNavigate(); - const { showToast, dismissToast } = useInlineToaster(); - const { dispatchToast } = useToastController("toast"); - - const [input, setInput] = useState(""); - const [planData, setPlanData] = useState(null); - const [allPlans, setAllPlans] = useState([]); - const [loading, setLoading] = useState(true); - const [submittingChatDisableInput, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [processingSubtaskId, setProcessingSubtaskId] = useState( - null - ); - const [reloadLeftList, setReloadLeftList] = useState(true); - const [raiError, setRAIError] = useState(null); - const [selectedTeam, setSelectedTeam] = useState(null); - const [streamingMessages, setStreamingMessages] = useState([]); - const [wsConnected, setWsConnected] = useState(false); - - const loadPlanDataRef = useRef<((navigate?: boolean) => Promise) | null>(null); - - const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); - - // WebSocket connection and streaming setup - useEffect(() => { - const initializeWebSocket = async () => { - try { - await webSocketService.connect(); - setWsConnected(true); - } catch (error) { - console.error('Failed to connect to WebSocket:', error); - setWsConnected(false); - } - }; - - initializeWebSocket(); - - // Set up WebSocket event listeners - const unsubscribeConnectionStatus = webSocketService.on('connection_status', (message: StreamMessage) => { - setWsConnected(message.data?.connected || false); - }); - - const unsubscribePlanUpdate = webSocketService.on('plan_update', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Plan update received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - - // Refresh plan data for major updates - if (message.data.status === 'completed' && loadPlanDataRef.current) { - loadPlanDataRef.current(false); - } - } - }); - - const unsubscribeStepUpdate = webSocketService.on('step_update', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Step update received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - } - }); - - const unsubscribeAgentMessage = webSocketService.on('agent_message', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Agent message received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - } - }); - - const unsubscribeError = webSocketService.on('error', (message: StreamMessage) => { - console.error('WebSocket error:', message.data); - showToast('Connection error: ' + (message.data?.error || 'Unknown error'), 'error'); - }); - - // Cleanup function - return () => { - unsubscribeConnectionStatus(); - unsubscribePlanUpdate(); - unsubscribeStepUpdate(); - unsubscribeAgentMessage(); - unsubscribeError(); - webSocketService.disconnect(); - }; - }, [planId, showToast]); - - // Subscribe to plan updates when planId changes - useEffect(() => { - if (planId && wsConnected) { - console.log('Subscribing to plan updates for:', planId); - webSocketService.subscribeToPlan(planId); - - return () => { - webSocketService.unsubscribeFromPlan(planId); - }; - } - }, [planId, wsConnected]); - - // 🌀 Cycle loading messages while loading - useEffect(() => { - if (!loading) return; - let index = 0; - const interval = setInterval(() => { - index = (index + 1) % loadingMessages.length; - setLoadingMessage(loadingMessages[index]); - }, 2000); - return () => clearInterval(interval); - }, [loading]); - - // Load default team on component mount - useEffect(() => { - const loadDefaultTeam = async () => { - let defaultTeam = TeamService.getStoredTeam(); - if (defaultTeam) { - setSelectedTeam(defaultTeam); - console.log('Default team loaded from storage:', defaultTeam.name); - return; - } - - try { - const teams = await TeamService.getUserTeams(); - console.log('All teams loaded:', teams); - if (teams.length > 0) { - // Always prioritize "Business Operations Team" as default - const businessOpsTeam = teams.find(team => team.name === "Business Operations Team"); - defaultTeam = businessOpsTeam || teams[0]; - TeamService.storageTeam(defaultTeam); - setSelectedTeam(defaultTeam); - console.log('Default team loaded:', defaultTeam.name); - } - } catch (error) { - console.error('Error loading default team:', error); - } - }; - - loadDefaultTeam(); - }, []); - - - useEffect(() => { - const currentPlan = allPlans.find( - (plan) => plan.plan.id === planId - ); - setPlanData(currentPlan || null); - }, [allPlans,planId]); - - const loadPlanData = useCallback( - async (navigate: boolean = true) => { - if (!planId) return; - - try { - setInput(""); // Clear input on new load - if (navigate) { - setPlanData(null); - setLoading(true); - setError(null); - setProcessingSubtaskId(null); - } - - setError(null); - const data = await PlanDataService.fetchPlanData(planId,navigate); - let plans = [...allPlans]; - const existingIndex = plans.findIndex(p => p.plan.id === data.plan.id); - if (existingIndex !== -1) { - plans[existingIndex] = data; - } else { - plans.push(data); - } - setAllPlans(plans); - //setPlanData(data); - } catch (err) { - console.log("Failed to load plan data:", err); - setError( - err instanceof Error ? err : new Error("Failed to load plan data") - ); - } finally { - setLoading(false); - } - }, - [planId] - ); - - // Update the ref whenever loadPlanData changes - useEffect(() => { - loadPlanDataRef.current = loadPlanData; - }, [loadPlanData]); - - const handleOnchatSubmit = useCallback( - async (chatInput: string) => { - - if (!chatInput.trim()) { - showToast("Please enter a clarification", "error"); - return; - } - setInput(""); - setRAIError(null); // Clear any previous RAI errors - if (!planData?.plan) return; - setSubmitting(true); - let id = showToast("Submitting clarification", "progress"); - try { - await PlanDataService.submitClarification( - planData.plan.id, - planData.plan.session_id, - chatInput - ); - setInput(""); - dismissToast(id); - showToast("Clarification submitted successfully", "success"); - await loadPlanData(false); - } catch (error: any) { - dismissToast(id); - - // Check if this is an RAI validation error - let errorDetail = null; - try { - // Try to parse the error detail if it's a string - if (typeof error?.response?.data?.detail === 'string') { - errorDetail = JSON.parse(error.response.data.detail); - } else { - errorDetail = error?.response?.data?.detail; - } - } catch (parseError) { - // If parsing fails, use the original error - errorDetail = error?.response?.data?.detail; - } - - // Handle RAI validation errors with better UX - if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { - setRAIError(errorDetail); - } else { - // Handle other errors with toast messages - showToast("Failed to submit clarification", "error"); - } - } finally { - setInput(""); - setSubmitting(false); - } - }, - [planData, loadPlanData] - ); - - const handleApproveStep = useCallback( - async (step: Step, total: number, completed: number, approve: boolean) => { - setProcessingSubtaskId(step.id); - const toastMessage = approve ? "Approving step" : "Rejecting step"; - let id = showToast(toastMessage, "progress"); - setSubmitting(true); - try { - let approveRejectDetails = await PlanDataService.stepStatus(step, approve); - dismissToast(id); - showToast(`Step ${approve ? "approved" : "rejected"} successfully`, "success"); - if (approveRejectDetails && Object.keys(approveRejectDetails).length > 0) { - await loadPlanData(false); - } - setReloadLeftList(true); - } catch (error) { - dismissToast(id); - showToast(`Failed to ${approve ? "approve" : "reject"} step`, "error"); - } finally { - setProcessingSubtaskId(null); - setSubmitting(false); - } - }, - [loadPlanData] - ); - - - useEffect(() => { - loadPlanData(true); - }, [loadPlanData]); - - const handleNewTaskButton = () => { - NewTaskService.handleNewTaskFromPlan(navigate); - }; - - /** - * Handle team selection from the TeamSelector - */ - const handleTeamSelect = useCallback((team: TeamConfig | null) => { - setSelectedTeam(team); - if (team) { - dispatchToast( - - Team Selected - - {team.name} team has been selected with {team.agents.length} agents - - , - { intent: "success" } - ); - } else { - dispatchToast( - - Team Deselected - - No team is currently selected - - , - { intent: "info" } - ); - } - }, [dispatchToast]); - - /** - * Handle team upload completion - refresh team list - */ - const handleTeamUpload = useCallback(async () => { - try { - const teams = await TeamService.getUserTeams(); - console.log('Teams refreshed after upload:', teams.length); - - if (teams.length > 0) { - // Always keep "Business Operations Team" as default, even after new uploads - const businessOpsTeam = teams.find(team => team.name === "Business Operations Team"); - const defaultTeam = businessOpsTeam || teams[0]; - setSelectedTeam(defaultTeam); - console.log('Default team after upload:', defaultTeam.name); - - dispatchToast( - - Team Uploaded Successfully! - - Team uploaded. {defaultTeam.name} remains your default team. - - , - { intent: "success" } - ); - } - } catch (error) { - console.error('Error refreshing teams after upload:', error); - } - }, [dispatchToast]); - - if (!planId) { - return ( -
- Error: No plan ID provided -
- ); - } - - return ( - - - setReloadLeftList(false)} - onTeamSelect={handleTeamSelect} - onTeamUpload={handleTeamUpload} - selectedTeam={selectedTeam} - /> - - - {/* 🐙 Only replaces content body, not page shell */} - {loading ? ( - <> - - - ) : ( - <> - } - > - - } - /> - - - - {/* Show RAI error if present */} - {raiError && ( -
- { - setRAIError(null); - }} - onDismiss={() => setRAIError(null)} - /> -
- )} - - - - )} -
- - -
-
- ); -}; - -export default PlanPage; diff --git a/src/frontend/src/services/TeamService.tsx b/src/frontend/src/services/TeamService.tsx deleted file mode 100644 index 839055afc..000000000 --- a/src/frontend/src/services/TeamService.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { TeamConfig } from '../models/Team'; -import { apiClient } from '../api/apiClient'; - -export class TeamService { - /** - * Upload a custom team configuration - */ - private static readonly STORAGE_KEY = 'macae.v3.customTeam'; - - static storageTeam(team: TeamConfig): boolean { - // Persist a TeamConfig to localStorage (browser-only). - if (typeof window === 'undefined' || !window.localStorage) return false; - try { - const serialized = JSON.stringify(team); - window.localStorage.setItem(TeamService.STORAGE_KEY, serialized); - return true; - } catch { - return false; - } - } - - static getStoredTeam(): TeamConfig | null { - if (typeof window === 'undefined' || !window.localStorage) return null; - try { - const raw = window.localStorage.getItem(TeamService.STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - return parsed as TeamConfig; - } catch { - return null; - } - } - - static async uploadCustomTeam(teamFile: File): Promise<{ - modelError?: any; success: boolean; team?: TeamConfig; error?: string; raiError?: any; searchError?: any - }> { - try { - const formData = new FormData(); - formData.append('file', teamFile); - console.log(formData); - const response = await apiClient.upload('/v3/upload_team_config', formData); - - return { - success: true, - team: response.data - }; - } catch (error: any) { - - // Check if this is an RAI validation error - const errorDetail = error.response?.data?.detail || error.response?.data; - - // If the error message contains "inappropriate content", treat it as RAI error - if (typeof errorDetail === 'string' && errorDetail.includes('inappropriate content')) { - return { - success: false, - raiError: { - error_type: 'RAI_VALIDATION_FAILED', - message: errorDetail, - description: errorDetail - } - }; - } - - // If the error message contains "Search index validation failed", treat it as search error - if (typeof errorDetail === 'string' && errorDetail.includes('Search index validation failed')) { - return { - success: false, - searchError: { - error_type: 'SEARCH_VALIDATION_FAILED', - message: errorDetail, - description: errorDetail - } - }; - } - - // Get error message from the response - let errorMessage = error.message || 'Failed to upload team configuration'; - if (error.response?.data?.detail) { - errorMessage = error.response.data.detail; - } - - return { - success: false, - error: errorMessage - }; - } - } - - /** - * Get user's custom teams - */ - static async getUserTeams(): Promise { - try { - const response = await apiClient.get('/v3/team_configs'); - - // The apiClient returns the response data directly, not wrapped in a data property - const teams = Array.isArray(response) ? response : []; - - return teams; - } catch (error: any) { - return []; - } - } - - /** - * Get a specific team by ID - */ - static async getTeamById(teamId: string): Promise { - try { - const teams = await this.getUserTeams(); - const team = teams.find(t => t.team_id === teamId); - return team || null; - } catch (error: any) { - return null; - } - } - - /** - * Delete a custom team - */ - static async deleteTeam(teamId: string): Promise { - try { - const response = await apiClient.delete(`/v3/team_configs/${teamId}`); - return true; - } catch (error: any) { - return false; - } - } - - /** - * Validate a team configuration JSON structure - */ - static validateTeamConfig(config: any): { isValid: boolean; errors: string[]; warnings: string[] } { - const errors: string[] = []; - const warnings: string[] = []; - - // Required fields validation - const requiredFields = ['id', 'team_id', 'name', 'description', 'status', 'created', 'created_by', 'agents']; - for (const field of requiredFields) { - if (!config[field]) { - errors.push(`Missing required field: ${field}`); - } - } - - // Status validation - if (config.status && !['visible', 'hidden'].includes(config.status)) { - errors.push('Status must be either "visible" or "hidden"'); - } - - // Agents validation - if (config.agents && Array.isArray(config.agents)) { - config.agents.forEach((agent: any, index: number) => { - const agentRequiredFields = ['input_key', 'type', 'name']; - for (const field of agentRequiredFields) { - if (!agent[field]) { - errors.push(`Agent ${index + 1}: Missing required field: ${field}`); - } - } - - // RAG agent validation - if (agent.use_rag === true && !agent.index_name) { - errors.push(`Agent ${index + 1} (${agent.name}): RAG agents must have an index_name`); - } - - // New field warnings for completeness - if (agent.type === 'RAG' && !agent.use_rag) { - warnings.push(`Agent ${index + 1} (${agent.name}): RAG type agent should have use_rag: true`); - } - - if (agent.use_rag && !agent.index_endpoint) { - warnings.push(`Agent ${index + 1} (${agent.name}): RAG agent missing index_endpoint (will use default)`); - } - }); - } else if (config.agents) { - errors.push('Agents must be an array'); - } - - // Starting tasks validation - if (config.starting_tasks && Array.isArray(config.starting_tasks)) { - config.starting_tasks.forEach((task: any, index: number) => { - const taskRequiredFields = ['id', 'name', 'prompt']; - for (const field of taskRequiredFields) { - if (!task[field]) { - warnings.push(`Starting task ${index + 1}: Missing recommended field: ${field}`); - } - } - }); - } - - // Optional field checks - const optionalFields = ['logo', 'plan', 'protected']; - for (const field of optionalFields) { - if (!config[field]) { - warnings.push(`Optional field missing: ${field} (recommended for better user experience)`); - } - } - - return { isValid: errors.length === 0, errors, warnings }; - } -} - -export default TeamService;