diff --git a/azure.yaml b/azure.yaml index e7802aa..4de7305 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,9 +2,25 @@ name: chatgpt-openai-py-ai-func metadata: - template: chatgpt-openai-py-ai-func@1.0.0-beta + template: chatgpt-openai-py-ai-func@1.0.1 services: api: project: ./ language: python host: function +hooks: + postprovision: + posix: + shell: sh + run: ./infra/scripts/setuplocalenvironment.sh + interactive: true + continueOnError: false + windows: + shell: pwsh + run: ./infra/scripts/setuplocalenvironment.ps1 + interactive: true + continueOnError: false + postdeploy: + shell: sh + run: echo "Deployment completed successfully" + continueOnError: false diff --git a/function_app.py b/function_app.py index bf78b4b..128a2ac 100644 --- a/function_app.py +++ b/function_app.py @@ -1,90 +1,197 @@ import json import logging +import os +from datetime import datetime + import azure.functions as func +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from openai import OpenAI app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) +# Configuration for Azure OpenAI +endpoint = os.environ["AZURE_OPENAI_ENDPOINT"] +model_name = os.environ["MODEL_DEPLOYMENT_NAME"] +token_provider = get_bearer_token_provider(DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default") + +# Azure OpenAI with standard OpenAI client +base_url = f"{endpoint.rstrip('/')}/openai/v1" + +client = OpenAI( + base_url=base_url, + api_key=token_provider +) + +# Table name for chat sessions +table_name = "ChatSessions" + # Simple ask http POST function that returns the completion based on prompt # This OpenAI completion input requires a {prompt} value in json POST body @app.function_name("ask") @app.route(route="ask", methods=["POST"]) -@app.text_completion_input(arg_name="response", prompt="{prompt}", - model="%CHAT_MODEL_DEPLOYMENT_NAME%") -def ask(req: func.HttpRequest, response: str) -> func.HttpResponse: - response_json = json.loads(response) - return func.HttpResponse(response_json["content"], status_code=200) +def ask(req: func.HttpRequest) -> func.HttpResponse: + try: + req_body = req.get_json() + prompt = req_body.get('prompt') + + if not prompt: + return func.HttpResponse("Please provide 'prompt' in the request body.", status_code=400) + + logging.info(f"Processing POST request. Prompt: {prompt}") + + response = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": prompt}] + ) + return func.HttpResponse(response.choices[0].message.content, status_code=200) + + except (ValueError, TypeError): + return func.HttpResponse("Invalid JSON in request body", status_code=400) + except Exception as e: + logging.error(f"Error processing request: {e}") + return func.HttpResponse("Internal server error", status_code=500) # Simple WhoIs http GET function that returns the completion based on name -# This OpenAI completion input requires a {name} binding value. @app.function_name("whois") @app.route(route="whois/{name}", methods=["GET"]) -@app.text_completion_input(arg_name="response", prompt="Who is {name}?", - max_tokens="100", - model="%CHAT_MODEL_DEPLOYMENT_NAME%") -def whois(req: func.HttpRequest, response: str) -> func.HttpResponse: - response_json = json.loads(response) - return func.HttpResponse(response_json["content"], status_code=200) +def whois(req: func.HttpRequest) -> func.HttpResponse: + try: + name = req.route_params.get('name') + + if not name: + return func.HttpResponse("Please provide a name in the URL path.", status_code=400) + + logging.info(f"Processing GET request for name: {name}") + response = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": f"Who is {name}?"}], + max_tokens=100 + ) + return func.HttpResponse(response.choices[0].message.content, status_code=200) -CHAT_STORAGE_CONNECTION = "AzureWebJobsStorage" -COLLECTION_NAME = "ChatState" + except Exception as e: + logging.error(f"Error processing whois request: {e}") + return func.HttpResponse("Internal server error", status_code=500) -# http PUT function to start ChatBot conversation based on a chatID +# Create or get existing chat session @app.function_name("CreateChatBot") @app.route(route="chats/{chatId}", methods=["PUT"]) -@app.assistant_create_output(arg_name="requests") -def create_chat_bot(req: func.HttpRequest, - requests: func.Out[str]) -> func.HttpResponse: - chatId = req.route_params.get("chatId") - input_json = req.get_json() - logging.info( - f"Creating chat ${chatId} from input parameters " + - "${json.dumps(input_json)}") - create_request = { - "id": chatId, - "instructions": input_json.get("instructions"), - "chatStorageConnectionSetting": CHAT_STORAGE_CONNECTION, - "collectionName": COLLECTION_NAME - } - requests.set(json.dumps(create_request)) - response_json = {"chatId": chatId} - return func.HttpResponse(json.dumps(response_json), status_code=202, - mimetype="application/json") - - -# http GET function to get ChatBot conversation with chatID & timestamp +@app.table_output(arg_name="chat_table", table_name=table_name, connection="AzureWebJobsStorage") +def create_chat_bot(req: func.HttpRequest, chat_table: func.Out[str]) -> func.HttpResponse: + try: + chat_id = req.route_params.get("chatId") + input_json = req.get_json() + instructions = input_json.get("instructions", "You are a helpful assistant.") if input_json else "You are a helpful assistant." + + # Create/update chat entity (upsert) + entity = { + "PartitionKey": "chat", + "RowKey": chat_id, + "instructions": instructions, + "created_at": datetime.utcnow().isoformat(), + "ETag": "*" + } + + chat_table.set(json.dumps(entity)) + return func.HttpResponse(json.dumps({"chatId": chat_id}), status_code=201, + mimetype="application/json") + + except Exception as e: + logging.error(f"Error creating chat: {e}") + return func.HttpResponse("Internal server error", status_code=500) + + +# Get chat session info @app.function_name("GetChatState") @app.route(route="chats/{chatId}", methods=["GET"]) -@app.assistant_query_input( - arg_name="state", - id="{chatId}", - timestamp_utc="{Query.timestampUTC}", - chat_storage_connection_setting=CHAT_STORAGE_CONNECTION, - collection_name=COLLECTION_NAME -) -def get_chat_state(req: func.HttpRequest, state: str) -> func.HttpResponse: - return func.HttpResponse(state, status_code=200, - mimetype="application/json") +@app.table_input(arg_name="chat_entity", table_name=table_name, + partition_key="chat", row_key="{chatId}", connection="AzureWebJobsStorage") +def get_chat_state(req: func.HttpRequest, chat_entity: str) -> func.HttpResponse: + try: + chat_id = req.route_params.get("chatId") + + if not chat_entity: + return func.HttpResponse("Chat not found", status_code=404) + + # Find the specific entity in the response + entities = json.loads(chat_entity) if isinstance(chat_entity, str) else chat_entity + if isinstance(entities, list): + entity = next((e for e in entities if e.get("RowKey") == chat_id), None) + else: + entity = entities + + if not entity: + return func.HttpResponse("Chat not found", status_code=404) + + return func.HttpResponse(json.dumps({ + "instructions": entity.get("instructions", ""), + "created_at": entity.get("created_at", "") + }), status_code=200, mimetype="application/json") + + except Exception as e: + logging.error(f"Error getting chat state: {e}") + return func.HttpResponse("Internal server error", status_code=500) -# http POST function for user to send a message to ChatBot with chatID +# Send message to chat and get AI response @app.function_name("PostUserResponse") @app.route(route="chats/{chatId}", methods=["POST"]) -@app.assistant_post_input( - arg_name="state", id="{chatId}", - user_message="{message}", - model="%CHAT_MODEL_DEPLOYMENT_NAME%", - chat_storage_connection_setting=CHAT_STORAGE_CONNECTION, - collection_name=COLLECTION_NAME - ) -def post_user_response(req: func.HttpRequest, state: str) -> func.HttpResponse: - # Parse the JSON string into a dictionary - data = json.loads(state) - - # Extract the content of the recentMessage - recent_message_content = data['recentMessages'][0]['content'] - return func.HttpResponse(recent_message_content, status_code=200, - mimetype="text/plain") +@app.table_input(arg_name="chat_entity", table_name=table_name, + partition_key="chat", row_key="{chatId}", connection="AzureWebJobsStorage") +@app.table_output(arg_name="chat_table", table_name=table_name, connection="AzureWebJobsStorage") +def post_user_response(req: func.HttpRequest, chat_entity: str, chat_table: func.Out[str]) -> func.HttpResponse: + try: + chat_id = req.route_params.get("chatId") + req_body = req.get_json() + message = req_body.get('message') if req_body else None + + if not message: + return func.HttpResponse("Message is required", status_code=400) + if not chat_entity: + return func.HttpResponse("Chat not found", status_code=404) + + # Find the specific entity in the response + entities = json.loads(chat_entity) if isinstance(chat_entity, str) else chat_entity + if isinstance(entities, list): + entity = next((e for e in entities if e.get("RowKey") == chat_id), None) + else: + entity = entities + + if not entity: + return func.HttpResponse("Chat not found", status_code=404) + + # Prepare messages for chat completion + messages = [] + + # Add system message if instructions exist + if entity.get("instructions"): + messages.append({"role": "system", "content": entity["instructions"]}) + + # Add user message + messages.append({"role": "user", "content": message}) + + # Get AI response using chat completions + response = client.chat.completions.create( + model=model_name, + messages=messages + ) + + # Update chat state (removing previous_response_id as it's not needed with standard OpenAI) + updated_entity = { + "PartitionKey": "chat", + "RowKey": chat_id, + "instructions": entity.get("instructions", "You are a helpful assistant."), + "created_at": entity.get("created_at", datetime.utcnow().isoformat()), + "ETag": "*" + } + + chat_table.set(json.dumps(updated_entity)) + return func.HttpResponse(response.choices[0].message.content, status_code=200, mimetype="text/plain") + + except Exception as e: + logging.error(f"Error processing chat message: {e}") + return func.HttpResponse("Internal server error", status_code=500) diff --git a/host.json b/host.json index 2708957..9df9136 100644 --- a/host.json +++ b/host.json @@ -9,7 +9,7 @@ } }, "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" } } \ No newline at end of file diff --git a/infra/agent/post-capability-host-role-assignments.bicep b/infra/agent/post-capability-host-role-assignments.bicep new file mode 100644 index 0000000..f202255 --- /dev/null +++ b/infra/agent/post-capability-host-role-assignments.bicep @@ -0,0 +1,106 @@ +// These must be created post-capability host addition because otherwise +// the containers will not yet exist. + +param aiProjectPrincipalId string +param aiProjectPrincipalType string = 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal +param aiProjectWorkspaceId string + +param aiStorageAccountName string +param cosmosDbAccountName string + +// Assignments for Storage Account containers +// ------------------------------------------------------------------ + +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' existing = { + name: aiStorageAccountName +} + +// Assign AI Project Storage Blob Data Owner Role for the dependent resource storage account. +// Limits ownership to containers specific to the Project Workspace. + +var storageBlobDataOwnerRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' +var conditionStr = '((!(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write\'}) ) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase \'${aiProjectWorkspaceId}\' AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase \'*-azureml-agent\'))' + +resource storageBlobDataOwnerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: storage + name: guid(storage.id, aiProjectPrincipalId, storageBlobDataOwnerRoleDefinitionId, aiProjectWorkspaceId) + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataOwnerRoleDefinitionId) + principalId: aiProjectPrincipalId + principalType: aiProjectPrincipalType + conditionVersion: '2.0' + condition: conditionStr + } +} + +// Assignments for CosmosDB containers +// ------------------------------------------------------------------ + +var userThreadName = '${aiProjectWorkspaceId}-thread-message-store' +var systemThreadName = '${aiProjectWorkspaceId}-system-thread-message-store' +var entityStoreName = '${aiProjectWorkspaceId}-agent-entity-store' + +resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: cosmosDbAccountName +} + +// Reference existing database +resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-12-01-preview' existing = { + parent: cosmosAccount + name: 'enterprise_memory' +} + +resource containerUserMessageStore 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-12-01-preview' existing = { + parent: database + name: userThreadName +} + +resource containerSystemMessageStore 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-12-01-preview' existing = { + parent: database + name: systemThreadName +} + +resource containerEntityStore 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-12-01-preview' existing = { + parent: database + name: entityStoreName +} + +var roleDefinitionId = resourceId( + 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', + cosmosDbAccountName, + '00000000-0000-0000-0000-000000000002' +) + +var scopeSystemContainer = '${cosmosAccount.id}/dbs/enterprise_memory/colls/${systemThreadName}' +var scopeUserContainer = '${cosmosAccount.id}/dbs/enterprise_memory/colls/${userThreadName}' +var scopeEntityContainer = '${cosmosAccount.id}/dbs/enterprise_memory/colls/${entityStoreName}' + +resource containerRoleAssignmentUserContainer 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmosAccount + name: guid(aiProjectWorkspaceId, containerUserMessageStore.id, roleDefinitionId, aiProjectPrincipalId) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: roleDefinitionId + scope: scopeUserContainer + } +} + +resource containerRoleAssignmentSystemContainer 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmosAccount + name: guid(aiProjectWorkspaceId, containerSystemMessageStore.id, roleDefinitionId, aiProjectPrincipalId) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: roleDefinitionId + scope: scopeSystemContainer + } +} + +resource containerRoleAssignmentEntityContainer 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmosAccount + name: guid(aiProjectWorkspaceId, containerEntityStore.id, roleDefinitionId, aiProjectPrincipalId) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: roleDefinitionId + scope: scopeEntityContainer + } +} diff --git a/infra/agent/standard-ai-project-capability-host.bicep b/infra/agent/standard-ai-project-capability-host.bicep new file mode 100644 index 0000000..0bb95b8 --- /dev/null +++ b/infra/agent/standard-ai-project-capability-host.bicep @@ -0,0 +1,43 @@ +param cosmosDbConnection string +param azureStorageConnection string +param aiSearchConnection string +param projectName string +param aiServicesAccountName string +param projectCapHost string +param accountCapHost string + +var threadConnections = ['${cosmosDbConnection}'] +var storageConnections = ['${azureStorageConnection}'] +var vectorStoreConnections = ['${aiSearchConnection}'] + + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesAccountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + name: projectName + parent: account +} + +resource accountCapabilityHost 'Microsoft.CognitiveServices/accounts/capabilityHosts@2025-04-01-preview' = { + name: accountCapHost + parent: account + properties: { + capabilityHostKind: 'Agents' + } +} + +resource projectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = { + name: projectCapHost + parent: project + properties: { + capabilityHostKind: 'Agents' + vectorStoreConnections: vectorStoreConnections + storageConnections: storageConnections + threadStorageConnections: threadConnections + } + dependsOn: [ + accountCapabilityHost + ] +} diff --git a/infra/agent/standard-ai-project-role-assignments.bicep b/infra/agent/standard-ai-project-role-assignments.bicep new file mode 100644 index 0000000..2f06b72 --- /dev/null +++ b/infra/agent/standard-ai-project-role-assignments.bicep @@ -0,0 +1,247 @@ +param aiProjectPrincipalId string +param aiProjectPrincipalType string = 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal +param userPrincipalId string = '' +param allowUserIdentityPrincipal bool = false // Flag to enable user identity role assignments + +param aiServicesName string +param aiSearchName string +param aiCosmosDbName string +param aiStorageAccountName string + +param integrationStorageAccountName string + +// Parameters for function app managed identity +param functionAppManagedIdentityPrincipalId string = '' +param allowFunctionAppIdentityPrincipal bool = true // Flag to enable function app identity role assignments + +// Assignments for AI Services +// ------------------------------------------------------------------ + +resource aiServices 'Microsoft.CognitiveServices/accounts@2024-06-01-preview' existing = { + name: aiServicesName +} + +// Assign AI Project the Cognitive Services Contributor Role on the AI Services resource + +var cognitiveServicesContributorRoleDefinitionId = '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68' + +resource cognitiveServicesContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01'= { + scope: aiServices + name: guid(aiServices.id, cognitiveServicesContributorRoleDefinitionId, aiProjectPrincipalId) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesContributorRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assign AI Project the Cognitive Services OpenAI User Role on the AI Services resource + +var cognitiveServicesOpenAIUserRoleDefinitionId = '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + +resource cognitiveServicesOpenAIUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiServices + name: guid(aiProjectPrincipalId, cognitiveServicesOpenAIUserRoleDefinitionId, aiServices.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesOpenAIUserRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assign AI Project the Cognitive Services User Role on the AI Services resource + +var cognitiveServicesUserRoleDefinitionId = 'a97b65f3-24c7-4388-baec-2e87135dc908' + +resource cognitiveServicesUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiServices + name: guid(aiProjectPrincipalId, cognitiveServicesUserRoleDefinitionId, aiServices.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesUserRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assign AI Project the Cognitive Services Contributor Role on the User Identity Principal on the AI Services resource + +resource cognitiveServicesContributorAssignmentUser 'Microsoft.Authorization/roleAssignments@2022-04-01'= if (allowUserIdentityPrincipal && !empty(userPrincipalId)) { + scope: aiServices + name: guid(aiServices.id, cognitiveServicesContributorRoleDefinitionId, userPrincipalId) + properties: { + principalId: userPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesContributorRoleDefinitionId) + principalType: 'User' + } +} + +// Assign AI Project the Cognitive Services OpenAI User Role on the User Identity Principal on the AI Services resource + +resource cognitiveServicesOpenAIUserRoleAssignmentUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userPrincipalId)){ + scope: aiServices + name: guid(userPrincipalId, cognitiveServicesOpenAIUserRoleDefinitionId, aiServices.id) + properties: { + principalId: userPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesOpenAIUserRoleDefinitionId) + principalType: 'User' + } +} + +// Assign AI Project the Cognitive Services User Role on the User Identity Principal on the AI Services resource + +resource cognitiveServicesUserRoleAssignmentUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userPrincipalId)){ + scope: aiServices + name: guid(userPrincipalId, cognitiveServicesUserRoleDefinitionId, aiServices.id) + properties: { + principalId: userPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesUserRoleDefinitionId) + principalType: 'User' + } +} + +// Assignments for AI Search Service +// ------------------------------------------------------------------ + +resource aiSearchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { + name: aiSearchName +} + +// Assign AI Project the Search Index Data Contributor Role on the AI Search Service resource + +var searchIndexDataContributorRoleDefinitionId = '8ebe5a00-799e-43f5-93ac-243d3dce84a7' + +resource searchIndexDataContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiSearchService + name: guid(aiProjectPrincipalId, searchIndexDataContributorRoleDefinitionId, aiSearchService.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', searchIndexDataContributorRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assign AI Project the Search Index Data Contributor Role on the AI Search Service resource + +var searchServiceContributorRoleDefinitionId = '7ca78c08-252a-4471-8644-bb5ff32d4ba0' + +resource searchServiceContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiSearchService + name: guid(aiProjectPrincipalId, searchServiceContributorRoleDefinitionId, aiSearchService.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', searchServiceContributorRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assignments for Storage Account +// ------------------------------------------------------------------ + +resource aiStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: aiStorageAccountName +} + +// Assign AI Project the Storage Blob Data Contributor Role on the Storage Account resource + +var storageBlobDataContributorRoleDefinitionId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + +resource storageBlobDataContributorRoleAssignmentProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiStorageAccount + name: guid(aiProjectPrincipalId, storageBlobDataContributorRoleDefinitionId, aiStorageAccount.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assignments for Cosmos DB +// ------------------------------------------------------------------ + +resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: aiCosmosDbName +} + +// Assign AI Project the Cosmos DB Operator Role on the Cosmos DB Account resource + +var cosmosDbOperatorRoleDefinitionId = '230815da-be43-4aae-9cb4-875f7bd000aa' + +resource cosmosDbOperatorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: cosmosDbAccount + name: guid(aiProjectPrincipalId, cosmosDbOperatorRoleDefinitionId, cosmosDbAccount.id) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cosmosDbOperatorRoleDefinitionId) + principalType: aiProjectPrincipalType + } +} + +// Assignments for Storage Account +// ------------------------------------------------------------------ + +resource integrationStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: integrationStorageAccountName +} + +// Assign AI Project Storage Queue Data Contributor Role on the integration Storage Account resource +// between the agent and azure function + +var storageQueueDataContributorRoleDefinitionId = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' + +resource storageQueueDataContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(integrationStorageAccount.id, aiProjectPrincipalId, storageQueueDataContributorRoleDefinitionId) + scope: integrationStorageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageQueueDataContributorRoleDefinitionId) + principalId: aiProjectPrincipalId + principalType: aiProjectPrincipalType + } +} + +// assignments for User Identity Principal + +resource storageQueueDataContributorRoleAssignmentUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userPrincipalId)) { + name: guid(integrationStorageAccount.id, userPrincipalId, storageQueueDataContributorRoleDefinitionId) + scope: integrationStorageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageQueueDataContributorRoleDefinitionId) + principalId: userPrincipalId + principalType: 'User' + } +} + +// Assign Function App Managed Identity the Cognitive Services Contributor Role on the AI Services resource + +resource cognitiveServicesContributorAssignmentFunctionApp 'Microsoft.Authorization/roleAssignments@2022-04-01'= if (allowFunctionAppIdentityPrincipal && !empty(functionAppManagedIdentityPrincipalId)) { + scope: aiServices + name: guid(aiServices.id, cognitiveServicesContributorRoleDefinitionId, functionAppManagedIdentityPrincipalId) + properties: { + principalId: functionAppManagedIdentityPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesContributorRoleDefinitionId) + principalType: 'ServicePrincipal' + } +} + +// Assign Function App Managed Identity the Cognitive Services OpenAI User Role on the AI Services resource + +resource cognitiveServicesOpenAIUserRoleAssignmentFunctionApp 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowFunctionAppIdentityPrincipal && !empty(functionAppManagedIdentityPrincipalId)) { + scope: aiServices + name: guid(functionAppManagedIdentityPrincipalId, cognitiveServicesOpenAIUserRoleDefinitionId, aiServices.id) + properties: { + principalId: functionAppManagedIdentityPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesOpenAIUserRoleDefinitionId) + principalType: 'ServicePrincipal' + } +} + +// Assign Function App Managed Identity the Cognitive Services User Role on the AI Services resource + +resource cognitiveServicesUserRoleAssignmentFunctionApp 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowFunctionAppIdentityPrincipal && !empty(functionAppManagedIdentityPrincipalId)) { + scope: aiServices + name: guid(functionAppManagedIdentityPrincipalId, cognitiveServicesUserRoleDefinitionId, aiServices.id) + properties: { + principalId: functionAppManagedIdentityPrincipalId + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesUserRoleDefinitionId) + principalType: 'ServicePrincipal' + } +} diff --git a/infra/agent/standard-ai-project.bicep b/infra/agent/standard-ai-project.bicep new file mode 100644 index 0000000..c17a3bf --- /dev/null +++ b/infra/agent/standard-ai-project.bicep @@ -0,0 +1,132 @@ +// Creates an Azure AI resource with proxied endpoints for the Azure AI services provider + +@description('Azure region of the deployment') +param location string + +@description('Tags to add to the resources') +param tags object + +@description('AI Services Foundry account under which the project will be created') +param aiServicesAccountName string + +@description('AI Project name') +param aiProjectName string + +@description('AI Project display name') +param aiProjectFriendlyName string = aiProjectName + +@description('AI Project description') +param aiProjectDescription string + +@description('Cosmos DB Account for agent thread storage') +param cosmosDbAccountName string +param cosmosDbAccountSubscriptionId string +param cosmosDbAccountResourceGroupName string + +@description('Storage Account for agent artifacts') +param storageAccountName string +param storageAccountSubscriptionId string +param storageAccountResourceGroupName string + +@description('AI Search Service for vector store and search') +param aiSearchName string +param aiSearchSubscriptionId string +param aiSearchResourceGroupName string + +resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' existing = { + name: cosmosDbAccountName + scope: resourceGroup(cosmosDbAccountSubscriptionId, cosmosDbAccountResourceGroupName) +} + +resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: storageAccountName + scope: resourceGroup(storageAccountSubscriptionId, storageAccountResourceGroupName) +} + +resource aiSearchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { + name: aiSearchName + scope: resourceGroup(aiSearchSubscriptionId, aiSearchResourceGroupName) +} + +resource aiServicesAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesAccountName +} + +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: aiServicesAccount + name: aiProjectName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + description: aiProjectDescription + displayName: aiProjectFriendlyName + } + + resource project_connection_cosmosdb_account 'connections@2025-04-01-preview' = { + name: cosmosDbAccountName + properties: { + category: 'CosmosDB' + target: cosmosDbAccount.properties.documentEndpoint + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: cosmosDbAccount.id + location: cosmosDbAccount.location + } + } + } + + resource project_connection_azure_storage 'connections@2025-04-01-preview' = { + name: storageAccountName + properties: { + category: 'AzureStorageAccount' + target: storageAccount.properties.primaryEndpoints.blob + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: storageAccount.id + location: storageAccount.location + } + } + } + + resource project_connection_azureai_search 'connections@2025-04-01-preview' = { + name: aiSearchName + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearchName}.search.windows.net' + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: aiSearchService.id + location: aiSearchService.location + } + } + } +} + +// Outputs + +output aiProjectName string = aiProject.name +output aiProjectResourceId string = aiProject.id +output aiProjectPrincipalId string = aiProject.identity.principalId + +output aiSearchConnection string = aiSearchName +output azureStorageConnection string = storageAccountName +output cosmosDbConnection string = cosmosDbAccountName + +// This is used for storage naming conventions and is needed to help +// create the right fine-grained role assignments. The naming +// convention also uses dashes injected into the value, so we're +// handling that here. +// This will likely change or be made available via a different property. +#disable-next-line BCP053 +var internalId = aiProject.properties.internalId +output projectWorkspaceId string = '${substring(internalId, 0, 8)}-${substring(internalId, 8, 4)}-${substring(internalId, 12, 4)}-${substring(internalId, 16, 4)}-${substring(internalId, 20, 12)}' + +// This endpoint is also built by convention at this time but will +// hopefully be available as a different property at some point. +output projectEndpoint string = 'https://${aiServicesAccountName}.services.ai.azure.com/api/projects/${aiProjectName}' diff --git a/infra/agent/standard-dependent-resources.bicep b/infra/agent/standard-dependent-resources.bicep new file mode 100644 index 0000000..ec530a4 --- /dev/null +++ b/infra/agent/standard-dependent-resources.bicep @@ -0,0 +1,194 @@ +// Creates Azure dependent resources for Azure AI studio + +@description('Azure region of the deployment') +param location string = resourceGroup().location + +@description('Tags to add to the resources') +param tags object = {} + +@description('AI services name') +param aiServicesName string + +@description('The name of the AI Search resource') +param aiSearchName string + +@description('The name of the Cosmos DB account') +param cosmosDbName string + +@description('Name of the storage account') +param storageName string + +@description('Model name for deployment') +param modelName string + +@description('Model format for deployment') +param modelFormat string + +@description('Model version for deployment') +param modelVersion string + +@description('Model deployment SKU name') +param modelSkuName string + +@description('Model deployment capacity') +param modelCapacity int + +@description('Model/AI Resource deployment location') +param modelLocation string + +@description('The AI Service Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiServiceAccountResourceId string + +@description('The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiSearchServiceResourceId string + +@description('The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiStorageAccountResourceId string + +@description('The AI Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiCosmosDbAccountResourceId string + +var aiServiceExists = aiServiceAccountResourceId != '' +var acsExists = aiSearchServiceResourceId != '' +var aiStorageExists = aiStorageAccountResourceId != '' +var cosmosExists = aiCosmosDbAccountResourceId != '' + +// Create an AI Service account and model deployment if it doesn't already exist + + + +resource aiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = if(!aiServiceExists) { + name: aiServicesName + location: modelLocation + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: toLower('${(aiServicesName)}') + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + // API-key based auth is not supported for the Agent service and policy requires it to be disabled + disableLocalAuth: true + } +} +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview'= if(!aiServiceExists){ + parent: aiServices + name: modelName + sku : { + capacity: modelCapacity + name: modelSkuName + } + properties: { + model:{ + name: modelName + format: modelFormat + version: modelVersion + } + } +} + +// Create an AI Search Service if it doesn't already exist +resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' = if(!acsExists) { + name: aiSearchName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + disableLocalAuth: true + encryptionWithCmk: { + enforcement: 'Unspecified' + } + hostingMode: 'default' + partitionCount: 1 + publicNetworkAccess: 'enabled' + replicaCount: 1 + semanticSearch: 'disabled' + } + sku: { + name: 'standard' + } +} + +// Create a Storage account if it doesn't already exist + +param sku string = 'Standard_LRS' + +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' = if(!aiStorageExists) { + name: storageName + location: location + kind: 'StorageV2' + sku: { + name: sku + } + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + virtualNetworkRules: [] + } + allowSharedKeyAccess: false + } +} + +// Create a Cosmos DB Account if it doesn't already exist + +var canaryRegions = ['eastus2euap', 'centraluseuap'] +var cosmosDbRegion = contains(canaryRegions, location) ? 'eastus2' : location +resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = if(!cosmosExists) { + name: cosmosDbName + location: cosmosDbRegion + kind: 'GlobalDocumentDB' + properties: { + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + disableLocalAuth: true + enableAutomaticFailover: false + enableMultipleWriteLocations: false + enableFreeTier: false + locations: [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + databaseAccountOfferType: 'Standard' + } +} + +// Outputs + +output aiServicesName string = aiServicesName +output aiservicesID string = aiServices.id +output aiServiceAccountResourceGroupName string = resourceGroup().name +output aiServiceAccountSubscriptionId string = subscription().subscriptionId + +output aiSearchName string = aiSearchName +output aisearchID string = aiSearch.id +output aiSearchServiceResourceGroupName string = resourceGroup().name +output aiSearchServiceSubscriptionId string = subscription().subscriptionId + +output storageAccountName string = storageName +output storageId string = storage.id +output storageAccountResourceGroupName string = resourceGroup().name +output storageAccountSubscriptionId string = subscription().subscriptionId + +output cosmosDbAccountName string = cosmosDbName +output cosmosDbAccountId string = cosmosDbAccount.id +output cosmosDbAccountResourceGroupName string = resourceGroup().name +output cosmosDbAccountSubscriptionId string = subscription().subscriptionId diff --git a/infra/app/ai-Cog-Service-Access.bicep b/infra/app/ai-Cog-Service-Access.bicep deleted file mode 100644 index 72ec472..0000000 --- a/infra/app/ai-Cog-Service-Access.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param principalID string -param principalType string = 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal -param roleDefinitionID string -param aiResourceName string - -resource cognitiveService 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { - name: aiResourceName -} - -// Allow access from API to this resource using a managed identity and least priv role grants -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(cognitiveService.id, principalID, roleDefinitionID) - scope: cognitiveService - properties: { - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) - principalId: principalID - principalType: principalType - } -} - -output ROLE_ASSIGNMENT_NAME string = roleAssignment.name diff --git a/infra/app/api.bicep b/infra/app/api.bicep index 7b85562..ea1d1b2 100644 --- a/infra/app/api.bicep +++ b/infra/app/api.bicep @@ -1,4 +1,5 @@ param name string +@description('Primary location for all resources & Flex Consumption Function App') param location string = resourceGroup().location param tags object = {} param applicationInsightsName string = '' @@ -14,38 +15,95 @@ param instanceMemoryMB int = 2048 param maximumInstanceCount int = 100 param identityId string = '' param identityClientId string = '' -param aiServiceUrl string = '' +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false +param enableFile bool = false + +@allowed(['SystemAssigned', 'UserAssigned']) +param identityType string = 'UserAssigned' var applicationInsightsIdentity = 'ClientId=${identityClientId};Authorization=AAD' +var kind = 'functionapp' + +// Create base application settings +var baseAppSettings = { + // Only include required credential settings unconditionally + AzureWebJobsStorage__credential: 'managedidentity' + AzureWebJobsStorage__clientId: identityClientId + + // Application Insights settings are always included + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString +} + +// Dynamically build storage endpoint settings based on feature flags +var blobSettings = enableBlob ? { AzureWebJobsStorage__blobServiceUri: stg.properties.primaryEndpoints.blob } : {} +var queueSettings = enableQueue ? { AzureWebJobsStorage__queueServiceUri: stg.properties.primaryEndpoints.queue } : {} +var tableSettings = enableTable ? { AzureWebJobsStorage__tableServiceUri: stg.properties.primaryEndpoints.table } : {} +var fileSettings = enableFile ? { AzureWebJobsStorage__fileServiceUri: stg.properties.primaryEndpoints.file } : {} + +// Merge all app settings +var allAppSettings = union( + appSettings, + blobSettings, + queueSettings, + tableSettings, + fileSettings, + baseAppSettings +) + +resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} -module api '../core/host/functions-flexconsumption.bicep' = { - name: '${serviceName}-functions-module' +// Create a Flex Consumption Function App to host the API +module api 'br/public:avm/res/web/site:0.15.1' = { + name: '${serviceName}-flex-consumption' params: { + kind: kind name: name location: location tags: union(tags, { 'azd-service-name': serviceName }) - identityType: 'UserAssigned' - identityId: identityId - identityClientId: identityClientId - appSettings: union(appSettings, - { - AzureWebJobsStorage__clientId : identityClientId - APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity - AZURE_OPENAI_ENDPOINT: aiServiceUrl - AZURE_CLIENT_ID: identityClientId - }) - applicationInsightsName: applicationInsightsName - appServicePlanId: appServicePlanId - runtimeName: runtimeName - runtimeVersion: runtimeVersion - storageAccountName: storageAccountName - deploymentStorageContainerName: deploymentStorageContainerName - virtualNetworkSubnetId: virtualNetworkSubnetId - instanceMemoryMB: instanceMemoryMB - maximumInstanceCount: maximumInstanceCount + serverFarmResourceId: appServicePlanId + managedIdentities: { + systemAssigned: identityType == 'SystemAssigned' + userAssignedResourceIds: [ + '${identityId}' + ] + } + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${stg.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' + authentication: { + type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' + userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' + } + } + } + scaleAndConcurrency: { + instanceMemoryMB: instanceMemoryMB + maximumInstanceCount: maximumInstanceCount + } + runtime: { + name: runtimeName + version: runtimeVersion + } + } + siteConfig: { + alwaysOn: false + } + virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null + appSettingsKeyValuePairs: allAppSettings } } output SERVICE_API_NAME string = api.outputs.name -output SERVICE_API_URI string = api.outputs.uri -output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.identityPrincipalId +// Ensure output is always string, handle potential null from module output if SystemAssigned is not used +output SERVICE_API_IDENTITY_PRINCIPAL_ID string = identityType == 'SystemAssigned' ? api.outputs.?systemAssignedMIPrincipalId ?? '' : '' diff --git a/infra/app/eventgrid.bicep b/infra/app/eventgrid.bicep deleted file mode 100644 index efcbbdd..0000000 --- a/infra/app/eventgrid.bicep +++ /dev/null @@ -1,38 +0,0 @@ -param location string = resourceGroup().location -param tags object = {} -param storageAccountId string - -resource unprocessedPdfSystemTopic 'Microsoft.EventGrid/systemTopics@2024-06-01-preview' = { - name: 'unprocessed-pdf-topic' - location: location - tags: tags - properties: { - source: storageAccountId - topicType: 'Microsoft.Storage.StorageAccounts' - } -} - -// The actual event grid subscription will be created in the post deployment script as it needs the function to be deployed first - -// resource unprocessedPdfSystemTopicSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2024-06-01-preview' = { -// parent: unprocessedPdfSystemTopic -// name: 'unprocessed-pdf-topic-subscription' -// properties: { -// destination: { -// endpointType: 'WebHook' -// properties: { -// //Will be set on post-deployment script once the function is created and the blobs extension code is available -// //endpointUrl: 'https://${function_app_blob_event_grid_name}.azurewebsites.net/runtime/webhooks/blobs?functionName=Host.Functions.Trigger_BlobEventGrid&code=${blobs_extension}' -// } -// } -// filter: { -// includedEventTypes: [ -// 'Microsoft.Storage.BlobCreated' -// ] -// subjectBeginsWith: '/blobServices/default/containers/${unprocessedPdfContainerName}/' -// } -// } -// } - -output unprocessedPdfSystemTopicId string = unprocessedPdfSystemTopic.id -output unprocessedPdfSystemTopicName string = unprocessedPdfSystemTopic.name diff --git a/infra/app/rbac.bicep b/infra/app/rbac.bicep new file mode 100644 index 0000000..6a2dde7 --- /dev/null +++ b/infra/app/rbac.bicep @@ -0,0 +1,110 @@ +param storageAccountName string +param appInsightsName string +param managedIdentityPrincipalId string // Principal ID for the Managed Identity +param userIdentityPrincipalId string = '' // Principal ID for the User Identity +param allowUserIdentityPrincipal bool = false // Flag to enable user identity role assignments +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false + +// Define Role Definition IDs internally +var storageRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' //Storage Blob Data Owner role +var queueRoleDefinitionId = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' // Storage Queue Data Contributor role +var tableRoleDefinitionId = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' // Storage Table Data Contributor role +var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID + +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: appInsightsName +} + +// Role assignment for Storage Account (Blob) - Managed Identity +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob) { + name: guid(storageAccount.id, managedIdentityPrincipalId, storageRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Blob) - User Identity +resource storageRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, storageRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Storage Account (Queue) - Managed Identity +resource queueRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue) { + name: guid(storageAccount.id, managedIdentityPrincipalId, queueRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Queue) - User Identity +resource queueRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, queueRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Storage Account (Table) - Managed Identity +resource tableRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable) { + name: guid(storageAccount.id, managedIdentityPrincipalId, tableRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Table) - User Identity +resource tableRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, tableRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Application Insights - Managed Identity +resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(applicationInsights.id, managedIdentityPrincipalId, monitoringRoleDefinitionId) // Use managed identity ID + scope: applicationInsights + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Application Insights - User Identity +resource appInsightsRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(applicationInsights.id, userIdentityPrincipalId, monitoringRoleDefinitionId) + scope: applicationInsights + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} diff --git a/infra/app/storage-Access.bicep b/infra/app/storage-Access.bicep deleted file mode 100644 index 78c37ae..0000000 --- a/infra/app/storage-Access.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param principalID string -param principalType string = 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal -param roleDefinitionID string -param storageAccountName string - -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { - name: storageAccountName -} - -// Allow access from API to storage account using a managed identity and least priv Storage roles -resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, principalID, roleDefinitionID) - scope: storageAccount - properties: { - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) - principalId: principalID - principalType: principalType - } -} - -output ROLE_ASSIGNMENT_NAME string = storageRoleAssignment.name diff --git a/infra/app/storage-PrivateEndpoint.bicep b/infra/app/storage-PrivateEndpoint.bicep index b2ae5e2..8ded984 100644 --- a/infra/app/storage-PrivateEndpoint.bicep +++ b/infra/app/storage-PrivateEndpoint.bicep @@ -1,19 +1,13 @@ -// Parameters -@description('Specifies the name of the virtual network.') param virtualNetworkName string - -@description('Specifies the name of the subnet which contains the virtual machine.') param subnetName string - -@description('Specifies the resource name of the Storage resource with an endpoint.') +@description('Specifies the storage account resource name') param resourceName string - -@description('Specifies the location.') param location string = resourceGroup().location - param tags object = {} +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false -// Virtual Network resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' existing = { name: virtualNetworkName } @@ -22,67 +16,165 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing name: resourceName } -var blobPrivateDNSZoneName = format('privatelink.blob.{0}', environment().suffixes.storage) -var blobPrivateDnsZoneVirtualNetworkLinkName = format('{0}-link-{1}', resourceName, take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)) +// Storage DNS zone names +var blobPrivateDNSZoneName = 'privatelink.blob.${environment().suffixes.storage}' +var queuePrivateDNSZoneName = 'privatelink.queue.${environment().suffixes.storage}' +var tablePrivateDNSZoneName = 'privatelink.table.${environment().suffixes.storage}' -// Private DNS Zones -resource blobPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: blobPrivateDNSZoneName - location: 'global' - tags: tags - properties: {} +// AVM module for Blob Private Endpoint with private DNS zone +module blobPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableBlob) { + name: 'blob-private-endpoint-deployment' + params: { + name: 'blob-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' + privateLinkServiceConnections: [ + { + name: 'blobPrivateLinkConnection' + properties: { + privateLinkServiceId: storageAccount.id + groupIds: [ + 'blob' + ] + } + } + ] + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'blobPrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageBlobARecord' + privateDnsZoneResourceId: privateDnsZoneBlobDeployment.outputs.resourceId + } + ] + } + } dependsOn: [ - vnet + privateDnsZoneBlobDeployment ] } -// Virtual Network Links -resource blobPrivateDnsZoneVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: blobPrivateDnsZone - name: blobPrivateDnsZoneVirtualNetworkLinkName - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id +// AVM module for Queue Private Endpoint with private DNS zone +module queuePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableQueue) { + name: 'queue-private-endpoint-deployment' + params: { + name: 'queue-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' + privateLinkServiceConnections: [ + { + name: 'queuePrivateLinkConnection' + properties: { + privateLinkServiceId: storageAccount.id + groupIds: [ + 'queue' + ] + } + } + ] + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'queuePrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageQueueARecord' + privateDnsZoneResourceId: enableQueue ? privateDnsZoneQueueDeployment.outputs.resourceId : '' + } + ] } } } -// Private Endpoints -resource blobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-08-01' = { - name: 'blob-private-endpoint' - location: location - tags: tags - properties: { +// AVM module for Table Private Endpoint with private DNS zone +module tablePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableTable) { + name: 'table-private-endpoint-deployment' + params: { + name: 'table-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' privateLinkServiceConnections: [ { - name: 'blobPrivateLinkConnection' + name: 'tablePrivateLinkConnection' properties: { privateLinkServiceId: storageAccount.id groupIds: [ - 'blob' + 'table' ] } } ] - subnet: { - id: '${vnet.id}/subnets/${subnetName}' + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'tablePrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageTableARecord' + privateDnsZoneResourceId: enableTable ? privateDnsZoneTableDeployment.outputs.resourceId : '' + } + ] } } } -resource blobPrivateDnsZoneGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2022-01-01' = { - parent: blobPrivateEndpoint - name: 'blobPrivateDnsZoneGroup' - properties: { - privateDnsZoneConfigs: [ +// AVM module for Blob Private DNS Zone +module privateDnsZoneBlobDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableBlob) { + name: 'blob-private-dns-zone-deployment' + params: { + name: blobPrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ { - name: 'storageBlobARecord' - properties: { - privateDnsZoneId: blobPrivateDnsZone.id - } + name: '${resourceName}-blob-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags + } + ] + } +} + +// AVM module for Queue Private DNS Zone +module privateDnsZoneQueueDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableQueue) { + name: 'queue-private-dns-zone-deployment' + params: { + name: queuePrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ + { + name: '${resourceName}-queue-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags + } + ] + } +} + +// AVM module for Table Private DNS Zone +module privateDnsZoneTableDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableTable) { + name: 'table-private-dns-zone-deployment' + params: { + name: tablePrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ + { + name: '${resourceName}-table-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags } ] } diff --git a/infra/app/vnet.bicep b/infra/app/vnet.bicep index 1a24c11..6b75848 100644 --- a/infra/app/vnet.bicep +++ b/infra/app/vnet.bicep @@ -12,64 +12,37 @@ param appSubnetName string = 'app' param tags object = {} -resource virtualNetwork 'Microsoft.Network/virtualNetworks@2023-05-01' = { - name: vNetName - location: location - tags: tags - properties: { - addressSpace: { - addressPrefixes: [ - '10.0.0.0/16' - ] - } - encryption: { - enabled: false - enforcement: 'AllowUnencrypted' - } +// Migrated to use AVM module instead of direct resource declaration +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { + name: 'vnet-deployment' + params: { + // Required parameters + name: vNetName + addressPrefixes: [ + '10.0.0.0/16' + ] + // Non-required parameters + location: location + tags: tags subnets: [ { name: peSubnetName - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'private-endpoints-subnet') - properties: { - addressPrefixes: [ - '10.0.1.0/24' - ] - delegations: [] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - type: 'Microsoft.Network/virtualNetworks/subnets' + addressPrefix: '10.0.1.0/24' + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' } { name: appSubnetName - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'app') - properties: { - addressPrefixes: [ - '10.0.2.0/24' - ] - delegations: [ - { - name: 'delegation' - id: resourceId('Microsoft.Network/virtualNetworks/subnets/delegations', vNetName, 'app', 'delegation') - properties: { - //Microsoft.App/environments is the correct delegation for Flex Consumption VNet integration - serviceName: 'Microsoft.App/environments' - } - type: 'Microsoft.Network/virtualNetworks/subnets/delegations' - } - ] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - type: 'Microsoft.Network/virtualNetworks/subnets' + addressPrefix: '10.0.2.0/24' + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + delegation: 'Microsoft.App/environments' } ] - virtualNetworkPeerings: [] - enableDdosProtection: false } } -output peSubnetName string = virtualNetwork.properties.subnets[0].name -output peSubnetID string = virtualNetwork.properties.subnets[0].id -output appSubnetName string = virtualNetwork.properties.subnets[1].name -output appSubnetID string = virtualNetwork.properties.subnets[1].id +output peSubnetName string = peSubnetName +output peSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${peSubnetName}' +output appSubnetName string = appSubnetName +output appSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${appSubnetName}' diff --git a/infra/core/ai/openai.bicep b/infra/core/ai/openai.bicep deleted file mode 100644 index b3d12ee..0000000 --- a/infra/core/ai/openai.bicep +++ /dev/null @@ -1,48 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param customSubDomainName string = name -param deployments array = [] -param kind string = 'OpenAI' -param publicNetworkAccess string = 'Enabled' -param sku object = { - name: 'S0' -} - -resource account 'Microsoft.CognitiveServices/accounts@2023-10-01-preview' = { - name: name - location: location - tags: tags - kind: kind - properties: { - customSubDomainName: name - networkAcls : { - defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny' - virtualNetworkRules: [] - ipRules: [] - } - publicNetworkAccess: publicNetworkAccess - } - sku: sku -} - -@batchSize(1) -resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { - parent: account - name: deployment.name - sku: { - name: 'Standard' - capacity: deployment.capacity - } - properties: { - model: deployment.model - raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null - } -}] - -output endpoint string = account.properties.endpoint -output id string = account.id -output name string = account.name -output location string = account.location - \ No newline at end of file diff --git a/infra/core/host/appserviceplan.bicep b/infra/core/host/appserviceplan.bicep deleted file mode 100644 index 9ab72a8..0000000 --- a/infra/core/host/appserviceplan.bicep +++ /dev/null @@ -1,20 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param kind string = '' -param reserved bool = true -param sku object - -resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { - name: name - location: location - tags: tags - sku: sku - kind: kind - properties: { - reserved: reserved - } -} - -output id string = appServicePlan.id diff --git a/infra/core/host/functions-flexconsumption.bicep b/infra/core/host/functions-flexconsumption.bicep deleted file mode 100644 index 954a196..0000000 --- a/infra/core/host/functions-flexconsumption.bicep +++ /dev/null @@ -1,92 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -// Reference Properties -param applicationInsightsName string = '' -param appServicePlanId string -param storageAccountName string -param virtualNetworkSubnetId string = '' -@allowed(['SystemAssigned', 'UserAssigned']) -param identityType string -@description('User assigned identity name') -param identityId string -@description('User assigned identity client id') -param identityClientId string - -// Runtime Properties -@allowed([ - 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' -]) -param runtimeName string -@allowed(['3.10', '3.11', '7.4', '8.0', '10', '11', '17', '20']) -param runtimeVersion string -param kind string = 'functionapp,linux' - -// Microsoft.Web/sites/config -param appSettings object = {} -param instanceMemoryMB int = 2048 -param maximumInstanceCount int = 100 -param deploymentStorageContainerName string - -resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { - name: storageAccountName -} - -resource functions 'Microsoft.Web/sites@2023-12-01' = { - name: name - location: location - tags: tags - kind: kind - identity: { - type: identityType - userAssignedIdentities: { - '${identityId}': {} - } - } - properties: { - serverFarmId: appServicePlanId - functionAppConfig: { - deployment: { - storage: { - type: 'blobContainer' - value: '${stg.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' - authentication: { - type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' - userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' - } - } - } - scaleAndConcurrency: { - instanceMemoryMB: instanceMemoryMB - maximumInstanceCount: maximumInstanceCount - } - runtime: { - name: runtimeName - version: runtimeVersion - } - } - virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null - } - - resource configAppSettings 'config' = { - name: 'appsettings' - properties: union(appSettings, - { - AzureWebJobsStorage__blobServiceUri: stg.properties.primaryEndpoints.blob - AzureWebJobsStorage__tableServiceUri: stg.properties.primaryEndpoints.table - AzureWebJobsStorage__queueServiceUri: stg.properties.primaryEndpoints.queue - AzureWebJobsStorage__credential : 'managedidentity' - AzureWebJobsStorage__clientId : identityClientId - APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString - }) - } -} - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { - name: applicationInsightsName -} - -output name string = functions.name -output uri string = 'https://${functions.properties.defaultHostName}' -output identityPrincipalId string = identityType == 'SystemAssigned' ? functions.identity.principalId : '' diff --git a/infra/core/identity/userAssignedIdentity.bicep b/infra/core/identity/userAssignedIdentity.bicep deleted file mode 100644 index 0d4e02e..0000000 --- a/infra/core/identity/userAssignedIdentity.bicep +++ /dev/null @@ -1,14 +0,0 @@ -param identityName string -param location string -param tags object = {} - -resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = { - name: identityName - location: location - tags: tags -} - -output identityId string = userAssignedIdentity.id -output identityName string = userAssignedIdentity.name -output identityPrincipalId string = userAssignedIdentity.properties.principalId -output identityClientId string = userAssignedIdentity.properties.clientId diff --git a/infra/core/monitor/appinsights-access.bicep b/infra/core/monitor/appinsights-access.bicep deleted file mode 100644 index f151b10..0000000 --- a/infra/core/monitor/appinsights-access.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param principalID string -param roleDefinitionID string -param appInsightsName string - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { - name: appInsightsName -} - -// Allow access from API to app insights using a managed identity and least priv role -resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(applicationInsights.id, principalID, roleDefinitionID) - scope: applicationInsights - properties: { - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) - principalId: principalID - principalType: 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal - } -} - -output ROLE_ASSIGNMENT_NAME string = appInsightsRoleAssignment.name - diff --git a/infra/core/monitor/applicationinsights.bicep b/infra/core/monitor/applicationinsights.bicep deleted file mode 100644 index f6d9ee5..0000000 --- a/infra/core/monitor/applicationinsights.bicep +++ /dev/null @@ -1,22 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param logAnalyticsWorkspaceId string -param disableLocalAuth bool = false - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { - name: name - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: logAnalyticsWorkspaceId - DisableLocalAuth: disableLocalAuth - } -} - -output connectionString string = applicationInsights.properties.ConnectionString -output instrumentationKey string = applicationInsights.properties.InstrumentationKey -output name string = applicationInsights.name diff --git a/infra/core/monitor/loganalytics.bicep b/infra/core/monitor/loganalytics.bicep deleted file mode 100644 index 770544c..0000000 --- a/infra/core/monitor/loganalytics.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { - name: name - location: location - tags: tags - properties: any({ - retentionInDays: 30 - features: { - searchVersion: 1 - } - sku: { - name: 'PerGB2018' - } - }) -} - -output id string = logAnalytics.id -output name string = logAnalytics.name diff --git a/infra/core/monitor/monitoring.bicep b/infra/core/monitor/monitoring.bicep deleted file mode 100644 index 791c5eb..0000000 --- a/infra/core/monitor/monitoring.bicep +++ /dev/null @@ -1,31 +0,0 @@ -param logAnalyticsName string -param applicationInsightsName string -param location string = resourceGroup().location -param tags object = {} -param disableLocalAuth bool = false - -module logAnalytics 'loganalytics.bicep' = { - name: 'loganalytics' - params: { - name: logAnalyticsName - location: location - tags: tags - } -} - -module applicationInsights 'applicationinsights.bicep' = { - name: 'applicationinsights' - params: { - name: applicationInsightsName - location: location - tags: tags - logAnalyticsWorkspaceId: logAnalytics.outputs.id - disableLocalAuth: disableLocalAuth - } -} - -output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString -output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey -output applicationInsightsName string = applicationInsights.outputs.name -output logAnalyticsWorkspaceId string = logAnalytics.outputs.id -output logAnalyticsWorkspaceName string = logAnalytics.outputs.name diff --git a/infra/core/storage/storage-account.bicep b/infra/core/storage/storage-account.bicep deleted file mode 100644 index 7dcb9af..0000000 --- a/infra/core/storage/storage-account.bicep +++ /dev/null @@ -1,41 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param allowBlobPublicAccess bool = false -param containers array = [] -param kind string = 'StorageV2' -param minimumTlsVersion string = 'TLS1_2' -param sku object = { name: 'Standard_LRS' } -param networkAcls object = { - bypass: 'AzureServices' - defaultAction: 'Allow' -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: name - location: location - tags: tags - kind: kind - sku: sku - properties: { - minimumTlsVersion: minimumTlsVersion - allowBlobPublicAccess: allowBlobPublicAccess - allowSharedKeyAccess: false - networkAcls: networkAcls - } - - resource blobServices 'blobServices' = if (!empty(containers)) { - name: 'default' - resource container 'containers' = [for container in containers: { - name: container.name - properties: { - publicAccess: container.?publicAccess ?? 'None' - } - }] - } -} - -output name string = storage.name -output primaryEndpoints object = storage.properties.primaryEndpoints -output id string = storage.id diff --git a/infra/main.bicep b/infra/main.bicep index f8e66e5..4db10b9 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -6,15 +6,37 @@ targetScope = 'subscription' param environmentName string @minLength(1) -@description('Primary location for all resources') -@allowed(['australiaeast', 'eastasia', 'eastus', 'northeurope', 'southcentralus', 'southeastasia', 'uksouth', 'westus2']) +@description('Primary location for all resources & Flex Consumption Function App') +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'norwayeast' + 'southafricanorth' + 'southcentralus' + 'southindia' + 'swedencentral' + 'uaenorth' + 'uksouth' + 'westeurope' + 'westus' + 'westus3' +]) @metadata({ azd: { type: 'location' } }) param location string -param skipVnet bool = true + +param vnetEnabled bool = false param apiServiceName string = '' param apiUserAssignedIdentityName string = '' param applicationInsightsName string = '' @@ -22,38 +44,69 @@ param appServicePlanName string = '' param logAnalyticsName string = '' param resourceGroupName string = '' param storageAccountName string = '' -param vNetName string = '' -param disableLocalAuth bool = true +@description('Id of the user identity to be used for testing and debugging. This is not required in production. Leave empty if not needed.') +param principalId string = '' -@allowed([ 'consumption', 'flexconsumption' ]) -param azFunctionHostingPlanType string = 'flexconsumption' +@description('Name for the AI project resources.') +param aiProjectName string = 'simple-agent' -param openAiServiceName string = '' - -param openAiSkuName string -@allowed([ 'azure', 'openai', 'azure_custom' ]) -param openAiHost string // Set in main.parameters.json - -param chatModelName string = '' -param chatDeploymentName string = '' -param chatDeploymentVersion string = '' -param chatDeploymentCapacity int = 0 - -var chatModel = { - modelName: !empty(chatModelName) ? chatModelName : startsWith(openAiHost, 'azure') ? 'gpt-4o' : 'gpt-4o' - deploymentName: !empty(chatDeploymentName) ? chatDeploymentName : 'chat' - deploymentVersion: !empty(chatDeploymentVersion) ? chatDeploymentVersion : '2024-08-06' - deploymentCapacity: chatDeploymentCapacity != 0 ? chatDeploymentCapacity : 40 -} +@description('Friendly name for your Azure AI resource') +param aiProjectFriendlyName string = 'Simple AI Agent Project' -@description('Id of the user or app to assign application roles') -param principalId string = '' +@description('Description of your Azure AI resource displayed in AI studio') +param aiProjectDescription string = 'This is a simple AI agent project for Azure Functions.' + +@description('Name of the Azure AI Search account') +param aiSearchName string = 'agent-ai-search' + +@description('Name for capabilityHost.') +param accountCapabilityHostName string = 'caphostacc' + +@description('Name for capabilityHost.') +param projectCapabilityHostName string = 'caphostproj' + +@description('Name of the Azure AI Services account') +param aiServicesName string = 'agent-ai-services' + +@description('Model name for deployment') +param modelName string = 'gpt-4.1-mini' + +@description('Model format for deployment') +param modelFormat string = 'OpenAI' + +@description('Model version for deployment') +param modelVersion string = '2025-04-14' + +@description('Model deployment SKU name') +param modelSkuName string = 'GlobalStandard' + +@description('Model deployment capacity') +param modelCapacity int = 50 + +@description('Name of the Cosmos DB account for agent thread storage') +param cosmosDbName string = 'agent-ai-cosmos' + +@description('The AI Service Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiServiceAccountResourceId string = '' + +@description('The Ai Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiSearchServiceResourceId string = '' + +@description('The Ai Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiStorageAccountResourceId string = '' + +@description('The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiCosmosDbAccountResourceId string = '' var abbrs = loadJsonContent('./abbreviations.json') var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) var tags = { 'azd-env-name': environmentName } var functionAppName = !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesFunctions}api-${resourceToken}' var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, resourceToken)), 7)}' +var projectName = toLower('${aiProjectName}') + +// Create a short, unique suffix, that will be unique to each resource group +var uniqueSuffix = toLower(uniqueString(subscription().id, environmentName, location)) // Organize resources in a resource group resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { @@ -62,253 +115,286 @@ resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { tags: tags } -// User assigned managed identity to be used by the function app to reach storage and service bus -module apiUserAssignedIdentity './core/identity/userAssignedIdentity.bicep' = { +// User assigned managed identity to be used by the function app to reach storage and other dependencies +module apiUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { name: 'apiUserAssignedIdentity' scope: rg params: { location: location tags: tags - identityName: !empty(apiUserAssignedIdentityName) ? apiUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' + name: !empty(apiUserAssignedIdentityName) ? apiUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' } } -// The application backend is a function app -module appServicePlan './core/host/appserviceplan.bicep' = { +// Create an App Service Plan to group applications under the same payment plan and SKU +module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.1' = { name: 'appserviceplan' scope: rg params: { name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' - location: location - tags: tags sku: { name: 'FC1' tier: 'FlexConsumption' } + reserved: true + location: location + tags: tags } } -module api './app/api.bicep' = { - name: 'api' +// Monitor application with Azure Monitor - Log Analytics and Application Insights +module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.11.1' = { + name: '${uniqueString(deployment().name, location)}-loganalytics' scope: rg params: { - name: functionAppName + name: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' location: location tags: tags - applicationInsightsName: monitoring.outputs.applicationInsightsName - appServicePlanId: appServicePlan.outputs.id - runtimeName: 'python' - runtimeVersion: '3.11' - storageAccountName: storage.outputs.name - deploymentStorageContainerName: deploymentStorageContainerName - identityId: apiUserAssignedIdentity.outputs.identityId - identityClientId: apiUserAssignedIdentity.outputs.identityClientId - appSettings: { - CHAT_MODEL_DEPLOYMENT_NAME: chatModel.deploymentName - } - virtualNetworkSubnetId: skipVnet ? '' : serviceVirtualNetwork.outputs.appSubnetID - aiServiceUrl: ai.outputs.endpoint + dataRetention: 30 } } - -module ai 'core/ai/openai.bicep' = { - name: 'openai' + +module monitoring 'br/public:avm/res/insights/component:0.6.0' = { + name: '${uniqueString(deployment().name, location)}-appinsights' scope: rg params: { - name: !empty(openAiServiceName) ? openAiServiceName : '${abbrs.cognitiveServicesAccounts}${resourceToken}' + name: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' location: location tags: tags - publicNetworkAccess: skipVnet == 'false' ? 'Disabled' : 'Enabled' - sku: { - name: openAiSkuName - } - deployments: [ - { - name: chatModel.deploymentName - capacity: chatModel.deploymentCapacity - model: { - format: 'OpenAI' - name: chatModel.modelName - version: chatModel.deploymentVersion - } - scaleSettings: { - scaleType: 'Standard' - } - } - ] + workspaceResourceId: logAnalytics.outputs.resourceId + disableLocalAuth: true } } -// Backing storage for Azure functions backend processor -module storage 'core/storage/storage-account.bicep' = { +// Backing storage for Azure functions backend API +module storage 'br/public:avm/res/storage/storage-account:0.8.3' = { name: 'storage' scope: rg params: { name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' + allowBlobPublicAccess: false + allowSharedKeyAccess: false // Disable local authentication methods as per policy + dnsEndpointType: 'Standard' + publicNetworkAccess: vnetEnabled ? 'Disabled' : 'Enabled' + // When vNet is enabled, restrict access but allow Azure services + networkAcls: vnetEnabled ? { + defaultAction: 'Deny' + bypass: 'AzureServices' // Allow Azure services including AI Agent service + } : { + defaultAction: 'Allow' + bypass: 'AzureServices' + } + blobServices: { + containers: [{name: deploymentStorageContainerName}] + } + queueServices: { + queues: [ + { name: 'input' } + { name: 'output' } + ] + } + minimumTlsVersion: 'TLS1_2' // Enforcing TLS 1.2 for better security location: location tags: tags - containers: [ - {name: deploymentStorageContainerName} - ] - networkAcls: skipVnet ? {} : { - defaultAction: 'Deny' - } - } -} - -var storageRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' // Storage Blob Data Owner role - -// Allow access from api to storage account using a managed identity -module storageRoleAssignmentApi 'app/storage-Access.bicep' = { - name: 'storageRoleAssignmentapi' - scope: rg - params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageRoleDefinitionId - principalID: apiUserAssignedIdentity.outputs.identityPrincipalId - principalType: 'ServicePrincipal' } } -module storageRoleAssignmentUserIdentityApi 'app/storage-Access.bicep' = { - name: 'storageRoleAssignmentUserIdentityApi' +// Dependent resources for the Azure AI workspace +module aiDependencies './agent/standard-dependent-resources.bicep' = { + name: 'dependencies${projectName}${uniqueSuffix}deployment' scope: rg params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageRoleDefinitionId - principalID: principalId - principalType: 'User' - } -} - -var storageQueueDataContributorRoleDefinitionId = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' // Storage Queue Data Contributor - -module storageQueueDataContributorRoleAssignmentprocessor 'app/storage-Access.bicep' = { - name: 'storageQueueDataContributorRoleAssignmentprocessor' - scope: rg - params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageQueueDataContributorRoleDefinitionId - principalID: apiUserAssignedIdentity.outputs.identityPrincipalId - principalType: 'ServicePrincipal' - } -} + location: location + storageName: 'stai${uniqueSuffix}' + aiServicesName: '${aiServicesName}${uniqueSuffix}' + aiSearchName: '${aiSearchName}${uniqueSuffix}' + cosmosDbName: '${cosmosDbName}${uniqueSuffix}' + tags: tags -module storageQueueDataContributorRoleAssignmentUserIdentityprocessor 'app/storage-Access.bicep' = { - name: 'storageQueueDataContributorRoleAssignmentUserIdentityprocessor' - scope: rg - params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageQueueDataContributorRoleDefinitionId - principalID: principalId - principalType: 'User' - } + // Model deployment parameters + modelName: modelName + modelFormat: modelFormat + modelVersion: modelVersion + modelSkuName: modelSkuName + modelCapacity: modelCapacity + modelLocation: location + + aiServiceAccountResourceId: aiServiceAccountResourceId + aiSearchServiceResourceId: aiSearchServiceResourceId + aiStorageAccountResourceId: aiStorageAccountResourceId + aiCosmosDbAccountResourceId: aiCosmosDbAccountResourceId + } } -var storageTableDataContributorRoleDefinitionId = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' // Storage Table Data Contributor - -module storageTableDataContributorRoleAssignmentprocessor 'app/storage-Access.bicep' = { - name: 'storageTableDataContributorRoleAssignmentprocessor' +module aiProject './agent/standard-ai-project.bicep' = { + name: '${projectName}${uniqueSuffix}deployment' scope: rg params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageTableDataContributorRoleDefinitionId - principalID: apiUserAssignedIdentity.outputs.identityPrincipalId - principalType: 'ServicePrincipal' + // workspace organization + aiServicesAccountName: aiDependencies.outputs.aiServicesName + aiProjectName: '${projectName}${uniqueSuffix}' + aiProjectFriendlyName: aiProjectFriendlyName + aiProjectDescription: aiProjectDescription + location: location + tags: tags + + // dependent resources + aiSearchName: aiDependencies.outputs.aiSearchName + aiSearchSubscriptionId: aiDependencies.outputs.aiSearchServiceSubscriptionId + aiSearchResourceGroupName: aiDependencies.outputs.aiSearchServiceResourceGroupName + storageAccountName: aiDependencies.outputs.storageAccountName + storageAccountSubscriptionId: aiDependencies.outputs.storageAccountSubscriptionId + storageAccountResourceGroupName: aiDependencies.outputs.storageAccountResourceGroupName + cosmosDbAccountName: aiDependencies.outputs.cosmosDbAccountName + cosmosDbAccountSubscriptionId: aiDependencies.outputs.cosmosDbAccountSubscriptionId + cosmosDbAccountResourceGroupName: aiDependencies.outputs.cosmosDbAccountResourceGroupName } } -module storageTableDataContributorRoleAssignmentUserIdentityprocessor 'app/storage-Access.bicep' = { - name: 'storageTableDataContributorRoleAssignmentUserIdentityprocessor' +module api './app/api.bicep' = { + name: 'api' scope: rg params: { + name: functionAppName + location: location + tags: tags + applicationInsightsName: monitoring.outputs.name + appServicePlanId: appServicePlan.outputs.resourceId + runtimeName: 'python' + runtimeVersion: '3.12' storageAccountName: storage.outputs.name - roleDefinitionID: storageTableDataContributorRoleDefinitionId - principalID: principalId - principalType: 'User' + enableBlob: storageEndpointConfig.enableBlob + enableQueue: storageEndpointConfig.enableQueue + enableTable: storageEndpointConfig.enableTable + deploymentStorageContainerName: deploymentStorageContainerName + identityId: apiUserAssignedIdentity.outputs.resourceId + identityClientId: apiUserAssignedIdentity.outputs.clientId + appSettings: { + PROJECT_ENDPOINT: aiProject.outputs.projectEndpoint + MODEL_DEPLOYMENT_NAME: modelName + AZURE_OPENAI_ENDPOINT: 'https://${aiDependencies.outputs.aiServicesName}.openai.azure.com/' + AZURE_OPENAI_DEPLOYMENT_NAME: modelName + AZURE_CLIENT_ID: apiUserAssignedIdentity.outputs.clientId + STORAGE_CONNECTION__queueServiceUri: 'https://${storage.outputs.name}.queue.${environment().suffixes.storage}' + STORAGE_CONNECTION__clientId: apiUserAssignedIdentity.outputs.clientId + STORAGE_CONNECTION__credential: 'managedidentity' + PROJECT_ENDPOINT__clientId: apiUserAssignedIdentity.outputs.clientId + } + virtualNetworkSubnetId: '' } } -var cogRoleDefinitionId = 'a97b65f3-24c7-4388-baec-2e87135dc908' // Cognitive Services User - -// Allow access from api to storage account using a managed identity -module cogRoleAssignmentApi 'app/ai-Cog-Service-Access.bicep' = { - name: 'cogRoleAssignmentapi' +module projectRoleAssignments './agent/standard-ai-project-role-assignments.bicep' = { + name: 'aiprojectroleassignments${projectName}${uniqueSuffix}deployment' scope: rg params: { - aiResourceName: ai.outputs.name - roleDefinitionID: cogRoleDefinitionId - principalID: apiUserAssignedIdentity.outputs.identityPrincipalId - principalType: 'ServicePrincipal' + aiProjectPrincipalId: aiProject.outputs.aiProjectPrincipalId + userPrincipalId: principalId + allowUserIdentityPrincipal: !empty(principalId) // Enable user identity role assignments + aiServicesName: aiDependencies.outputs.aiServicesName + aiSearchName: aiDependencies.outputs.aiSearchName + aiCosmosDbName: aiDependencies.outputs.cosmosDbAccountName + aiStorageAccountName: aiDependencies.outputs.storageAccountName + integrationStorageAccountName: storage.outputs.name + functionAppManagedIdentityPrincipalId: apiUserAssignedIdentity.outputs.principalId + allowFunctionAppIdentityPrincipal: true // Enable function app identity role assignments } } -module cogRoleAssignmentUserIdentityApi 'app/ai-Cog-Service-Access.bicep' = { - name: 'cogRoleAssignmentUserIdentityApi' +module aiProjectCapabilityHost './agent/standard-ai-project-capability-host.bicep' = { + name: 'capabilityhost${projectName}${uniqueSuffix}deployment' scope: rg params: { - aiResourceName: ai.outputs.name - roleDefinitionID: cogRoleDefinitionId - principalID: principalId - principalType: 'User' + aiServicesAccountName: aiDependencies.outputs.aiServicesName + projectName: aiProject.outputs.aiProjectName + aiSearchConnection: aiProject.outputs.aiSearchConnection + azureStorageConnection: aiProject.outputs.azureStorageConnection + cosmosDbConnection: aiProject.outputs.cosmosDbConnection + + accountCapHost: '${accountCapabilityHostName}${uniqueSuffix}' + projectCapHost: '${projectCapabilityHostName}${uniqueSuffix}' } + dependsOn: [ projectRoleAssignments ] } -// Virtual Network & private endpoint to blob storage -module serviceVirtualNetwork 'app/vnet.bicep' = if (!skipVnet) { - name: 'serviceVirtualNetwork' +module postCapabilityHostCreationRoleAssignments './agent/post-capability-host-role-assignments.bicep' = { + name: 'postcaphostra${projectName}${uniqueSuffix}deployment' scope: rg params: { - location: location - tags: tags - vNetName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' + aiProjectPrincipalId: aiProject.outputs.aiProjectPrincipalId + aiProjectWorkspaceId: aiProject.outputs.projectWorkspaceId + aiStorageAccountName: aiDependencies.outputs.storageAccountName + cosmosDbAccountName: aiDependencies.outputs.cosmosDbAccountName } + dependsOn: [ aiProjectCapabilityHost ] } -module storagePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = if (!skipVnet) { - name: 'servicePrivateEndpoint' - scope: rg - params: { - location: location - tags: tags - virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' - subnetName: skipVnet ? '' : serviceVirtualNetwork.outputs.peSubnetName - resourceName: storage.outputs.name - } +// Define the configuration object locally to pass to the modules +var storageEndpointConfig = { + enableBlob: true // Required for AzureWebJobsStorage, .zip deployment, Event Hubs trigger and Timer trigger checkpointing + enableQueue: true // Required for Durable Functions and MCP trigger + enableTable: false // Required for Durable Functions and OpenAI triggers and bindings + enableFiles: false // Not required, used in legacy scenarios + allowUserIdentityPrincipal: !empty(principalId) // Allow interactive user identity to access for testing and debugging } -// Monitor application with Azure Monitor -module monitoring './core/monitor/monitoring.bicep' = { - name: 'monitoring' +// Consolidated Role Assignments +module rbac 'app/rbac.bicep' = { + name: 'rbacAssignments' scope: rg params: { - location: location - tags: tags - logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' - applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' - disableLocalAuth: disableLocalAuth + storageAccountName: storage.outputs.name + appInsightsName: monitoring.outputs.name + managedIdentityPrincipalId: apiUserAssignedIdentity.outputs.principalId + userIdentityPrincipalId: principalId + enableBlob: storageEndpointConfig.enableBlob + enableQueue: storageEndpointConfig.enableQueue + enableTable: storageEndpointConfig.enableTable + allowUserIdentityPrincipal: storageEndpointConfig.allowUserIdentityPrincipal } } -var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID - -// Allow access from api to application insights using a managed identity -module appInsightsRoleAssignmentApi './core/monitor/appinsights-access.bicep' = { - name: 'appInsightsRoleAssignmentapi' - scope: rg - params: { - appInsightsName: monitoring.outputs.applicationInsightsName - roleDefinitionID: monitoringRoleDefinitionId - principalID: apiUserAssignedIdentity.outputs.identityPrincipalId - } -} +// Virtual Network & private endpoint to blob storage (disabled for now) +// module serviceVirtualNetwork 'app/vnet.bicep' = if (vnetEnabled) { +// name: 'serviceVirtualNetwork' +// scope: rg +// params: { +// location: location +// tags: tags +// vNetName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' +// } +// } + +// module storagePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = if (vnetEnabled) { +// name: 'servicePrivateEndpoint' +// scope: rg +// params: { +// location: location +// tags: tags +// virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' +// subnetName: serviceVirtualNetwork.outputs.peSubnetName +// resourceName: storage.outputs.name +// enableBlob: storageEndpointConfig.enableBlob +// enableQueue: storageEndpointConfig.enableQueue +// enableTable: storageEndpointConfig.enableTable +// } +// } // App outputs -output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString +output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.connectionString output AZURE_LOCATION string = location output AZURE_TENANT_ID string = tenant().tenantId output SERVICE_API_NAME string = api.outputs.SERVICE_API_NAME -output SERVICE_API_URI string = api.outputs.SERVICE_API_URI +output SERVICE_API_URI string = 'https://${api.outputs.SERVICE_API_NAME}.azurewebsites.net' output AZURE_FUNCTION_APP_NAME string = api.outputs.SERVICE_API_NAME output RESOURCE_GROUP string = rg.name -output AZURE_OPENAI_ENDPOINT string = ai.outputs.endpoint +output STORAGE_ACCOUNT_NAME string = storage.outputs.name +output AI_SERVICES_NAME string = aiDependencies.outputs.aiServicesName + +// AI Foundry outputs +output PROJECT_ENDPOINT string = aiProject.outputs.projectEndpoint +output MODEL_DEPLOYMENT_NAME string = modelName +output AZURE_OPENAI_ENDPOINT string = 'https://${aiDependencies.outputs.aiServicesName}.openai.azure.com/' +output AZURE_OPENAI_DEPLOYMENT_NAME string = modelName +output AZURE_CLIENT_ID string = apiUserAssignedIdentity.outputs.clientId +output STORAGE_CONNECTION__queueServiceUri string = 'https://${storage.outputs.name}.queue.${environment().suffixes.storage}' diff --git a/infra/main.parameters.json b/infra/main.parameters.json index b6b505b..8f7787b 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -2,77 +2,11 @@ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { - "skipVnet": { - "value": "${SKIP_VNET}=true" - }, "environmentName": { "value": "${AZURE_ENV_NAME}" }, "location": { "value": "${AZURE_LOCATION}" - }, - "principalId": { - "value": "${AZURE_PRINCIPAL_ID}" - }, - "openAiSkuName": { - "value": "S0" - }, - "openAiServiceName": { - "value": "${AZURE_OPENAI_SERVICE}" - }, - "openAiHost": { - "value": "${OPENAI_HOST=azure}" - }, - "openAiResourceGroupName": { - "value": "${AZURE_OPENAI_RESOURCE_GROUP}" - }, - "chatGptDeploymentName": { - "value": "${AZURE_OPENAI_CHATGPT_DEPLOYMENT=chat}" - }, - "chatGptDeploymentCapacity":{ - "value": "${AZURE_OPENAI_CHATGPT_DEPLOYMENT_CAPACITY=40}" - }, - "chatGptDeploymentVersion":{ - "value": "${AZURE_OPENAI_CHATGPT_DEPLOYMENT_VERSION=2024-08-06}" - }, - "chatGptModelName":{ - "value": "${AZURE_OPENAI_CHATGPT_MODEL=gpt-4o}" - }, - "embeddingDeploymentName": { - "value": "${AZURE_OPENAI_EMB_DEPLOYMENT=embedding}" - }, - "embeddingModelName":{ - "value": "${AZURE_OPENAI_EMB_MODEL_NAME=text-embedding-3-small}" - }, - "embeddingDeploymentVersion":{ - "value": "${AZURE_OPENAI_EMB_DEPLOYMENT_VERSION}" - }, - "embeddingDeploymentCapacity":{ - "value": "${AZURE_OPENAI_EMB_DEPLOYMENT_CAPACITY}" - }, - "searchServiceName": { - "value": "${AZURE_SEARCH_SERVICE}" - }, - "searchServiceResourceGroupName": { - "value": "${AZURE_SEARCH_SERVICE_RESOURCE_GROUP}" - }, - "searchServiceIndexName": { - "value": "${AZURE_SEARCH_INDEX=openai-index}" - }, - "searchServiceSkuName": { - "value": "${AZURE_SEARCH_SERVICE_SKU=standard}" - }, - "storageAccountName": { - "value": "${AZURE_STORAGE_ACCOUNT}" - }, - "storageResourceGroupName": { - "value": "${AZURE_STORAGE_RESOURCE_GROUP}" - }, - "azFunctionHostingPlanType": { - "value": "flexconsumption" - }, - "systemPrompt": { - "value": "${SYSTEM_PROMPT}=You are a helpful assistant. You are responding to requests from a user about internal emails and documents. You can and should refer to the internal documents to help respond to requests. If a user makes a request thats not covered by the documents provided in the query, you must say that you do not have access to the information and not try and get information from other places besides the documents provided. The following is a list of documents that you can refer to when answering questions. The documents are in the format [filename]: [text] and are separated by newlines. If you answer a question by referencing any of the documents, please cite the document in your answer. For example, if you answer a question by referencing info.txt, you should add \"Reference: info.txt\" to the end of your answer on a separate line." } } } \ No newline at end of file diff --git a/infra/scripts/addclientip.ps1 b/infra/scripts/addclientip.ps1 new file mode 100644 index 0000000..fc74142 --- /dev/null +++ b/infra/scripts/addclientip.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = "Stop" + +$output = azd env get-values + +# Parse the output to get the resource names and the resource group +foreach ($line in $output) { + if ($line -match "STORAGE_ACCOUNT_NAME"){ + $StorageAccount = ($line -split "=")[1] -replace '"','' + } + if ($line -match "RESOURCE_GROUP"){ + $ResourceGroup = ($line -split "=")[1] -replace '"','' + } +} + +# Read the config.json file to see if vnet is enabled +$ConfigFolder = ($ResourceGroup -split '-' | Select-Object -Skip 1) -join '-' +$jsonContent = Get-Content -Path ".azure\$ConfigFolder\config.json" -Raw | ConvertFrom-Json + +# Check for either skipVnet or vnetEnabled parameters +$vnetDisabled = $false +if ($jsonContent.infra.parameters.PSObject.Properties.Name -contains "skipVnet") { + $vnetDisabled = $jsonContent.infra.parameters.skipVnet -eq $true +} elseif ($jsonContent.infra.parameters.PSObject.Properties.Name -contains "vnetEnabled") { + $vnetDisabled = $jsonContent.infra.parameters.vnetEnabled -eq $false +} + +if ($vnetDisabled) { + Write-Output "VNet is not enabled. Skipping adding the client IP to the network rule of the storage account" +} +else { + Write-Output "VNet is enabled. Adding the client IP to the network rule of the Azure Functions storage account" + # Get the client IP + $ClientIP = Invoke-RestMethod -Uri 'https://api.ipify.org' + + # First, ensure the storage account allows access from selected networks (not completely disabled) + Write-Output "Configuring storage account to allow access from selected networks..." + az storage account update --name $StorageAccount --resource-group $ResourceGroup --public-network-access Enabled | Out-Null + + # Add the client IP to the network rules + az storage account network-rule add --resource-group $ResourceGroup --account-name $StorageAccount --ip-address $ClientIP | Out-Null + Write-Output "Client IP $ClientIP added to the network rule of the Azure Functions storage account" +} \ No newline at end of file diff --git a/infra/scripts/addclientip.sh b/infra/scripts/addclientip.sh new file mode 100755 index 0000000..f1847ed --- /dev/null +++ b/infra/scripts/addclientip.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +output=$(azd env get-values) + +# Parse the output to get the resource names and the resource group +while IFS= read -r line; do + if [[ $line == STORAGE_ACCOUNT_NAME* ]]; then + StorageAccount=$(echo "$line" | cut -d'=' -f2 | tr -d '"') + elif [[ $line == RESOURCE_GROUP* ]]; then + ResourceGroup=$(echo "$line" | cut -d'=' -f2 | tr -d '"') + fi +done <<< "$output" + +# Read the config.json file to see if vnet is enabled +ConfigFolder=$(echo "$ResourceGroup" | cut -d'-' -f2-) +configFile=".azure/$ConfigFolder/config.json" + +vnetDisabled=false +if [[ -f "$configFile" ]]; then + jsonContent=$(cat "$configFile") + + # Check for skipVnet parameter first + if echo "$jsonContent" | grep -q '"skipVnet"'; then + skipVnet=$(echo "$jsonContent" | grep '"skipVnet"' | sed 's/.*"skipVnet":\s*\([^,}]*\).*/\1/' | tr -d ' ') + if echo "$skipVnet" | grep -iq "true"; then + vnetDisabled=true + fi + # Check for vnetEnabled parameter + elif echo "$jsonContent" | grep -q '"vnetEnabled"'; then + vnetEnabled=$(echo "$jsonContent" | grep '"vnetEnabled"' | sed 's/.*"vnetEnabled":\s*\([^,}]*\).*/\1/' | tr -d ' ') + if echo "$vnetEnabled" | grep -iq "false"; then + vnetDisabled=true + fi + fi +else + echo "Config file $configFile not found. Assuming VNet is enabled." + vnetDisabled=false +fi + +if [ "$vnetDisabled" = true ]; then + echo "VNet is not enabled. Skipping adding the client IP to the network rule of the Azure Functions storage account" +else + echo "VNet is enabled. Adding the client IP to the network rule of the Azure Functions storage account" + + # Get the client IP + ClientIP=$(curl -s https://api.ipify.org) + + # First, ensure the storage account allows access from selected networks (not completely disabled) + echo "Configuring storage account to allow access from selected networks..." + az storage account update --name "$StorageAccount" --resource-group "$ResourceGroup" --public-network-access Enabled > /dev/null + + # Add the client IP to the network rules + az storage account network-rule add --resource-group "$ResourceGroup" --account-name "$StorageAccount" --ip-address "$ClientIP" > /dev/null + echo "Client IP $ClientIP added to the network rule of the Azure Functions storage account" +fi \ No newline at end of file diff --git a/infra/scripts/createlocalsettings.ps1 b/infra/scripts/createlocalsettings.ps1 new file mode 100644 index 0000000..3152bfe --- /dev/null +++ b/infra/scripts/createlocalsettings.ps1 @@ -0,0 +1,30 @@ +$ErrorActionPreference = "Stop" + +if (-not (Test-Path ".\local.settings.json")) { + + $output = azd env get-values + + # Parse the output to get the endpoint values + foreach ($line in $output) { + if ($line -match "PROJECT_ENDPOINT"){ + $AIProjectEndpoint = ($line -split "=")[1] -replace '"','' + } + if ($line -match "STORAGE_CONNECTION__queueServiceUri"){ + $StorageConnectionQueue = ($line -split "=")[1] -replace '"','' + } + if ($line -match "MODEL_DEPLOYMENT_NAME"){ + $ModelDeploymentName = ($line -split "=")[1] -replace '"','' + } + } + + @{ + "IsEncrypted" = "false"; + "Values" = @{ + "AzureWebJobsStorage" = "UseDevelopmentStorage=true"; + "FUNCTIONS_WORKER_RUNTIME" = "dotnet-isolated"; + "PROJECT_ENDPOINT" = "$AIProjectEndpoint"; + "MODEL_DEPLOYMENT_NAME" = "$ModelDeploymentName"; + "STORAGE_CONNECTION__queueServiceUri" = "$StorageConnectionQueue"; + } + } | ConvertTo-Json | Out-File -FilePath ".\local.settings.json" -Encoding ascii +} \ No newline at end of file diff --git a/infra/scripts/createlocalsettings.sh b/infra/scripts/createlocalsettings.sh new file mode 100755 index 0000000..862942a --- /dev/null +++ b/infra/scripts/createlocalsettings.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -e + +if [ ! -f "./local.settings.json" ]; then + + output=$(azd env get-values) + + # Initialize variables + AIProjectEndpoint="" + StorageConnectionQueue="" + ModelDeploymentName="" + AzureOpenAIDeploymentName="" + AzureOpenAIEndpoint="" + + # Parse the output to get the endpoint URLs + while IFS= read -r line; do + if [[ $line == *"PROJECT_ENDPOINT"* ]]; then + AIProjectEndpoint=$(echo "$line" | cut -d '=' -f 2 | tr -d '"') + fi + if [[ $line == *"STORAGE_CONNECTION__queueServiceUri"* ]]; then + StorageConnectionQueue=$(echo "$line" | cut -d '=' -f 2 | tr -d '"') + fi + if [[ $line == *"MODEL_DEPLOYMENT_NAME"* ]]; then + ModelDeploymentName=$(echo "$line" | cut -d '=' -f 2 | tr -d '"') + fi + if [[ $line == *"AZURE_OPENAI_DEPLOYMENT_NAME"* ]]; then + AzureOpenAIDeploymentName=$(echo "$line" | cut -d '=' -f 2 | tr -d '"') + fi + if [[ $line == *"AZURE_OPENAI_ENDPOINT"* ]]; then + AzureOpenAIEndpoint=$(echo "$line" | cut -d '=' -f 2 | tr -d '"') + fi + done <<< "$output" + + cat < ./local.settings.json +{ + "IsEncrypted": "false", + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "PROJECT_ENDPOINT": "$AIProjectEndpoint", + "MODEL_DEPLOYMENT_NAME": "$ModelDeploymentName", + "AZURE_OPENAI_DEPLOYMENT_NAME": "$AzureOpenAIDeploymentName", + "AZURE_OPENAI_ENDPOINT": "$AzureOpenAIEndpoint", + "STORAGE_CONNECTION__queueServiceUri": "$StorageConnectionQueue" + } +} +EOF + +fi \ No newline at end of file diff --git a/infra/scripts/setuplocalenvironment.ps1 b/infra/scripts/setuplocalenvironment.ps1 new file mode 100644 index 0000000..0103257 --- /dev/null +++ b/infra/scripts/setuplocalenvironment.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" +.\infra\scripts\createlocalsettings.ps1 +.\infra\scripts\addclientip.ps1 \ No newline at end of file diff --git a/infra/scripts/setuplocalenvironment.sh b/infra/scripts/setuplocalenvironment.sh new file mode 100755 index 0000000..96bffee --- /dev/null +++ b/infra/scripts/setuplocalenvironment.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +./infra/scripts/createlocalsettings.sh +./infra/scripts/addclientip.sh \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 36c2014..d1c3dae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ # The Python Worker is managed by the Azure Functions platform # Manually managing azure-functions-worker may cause unexpected issues -azure-functions>=1.22.0b2 +azure-functions>=1.24.0 +openai>=1.108.1 +azure-identity diff --git a/test.http b/test.http index 92078f8..f0bc4f5 100644 --- a/test.http +++ b/test.http @@ -22,16 +22,15 @@ x-functions-key: ### Stateful Chatbot ### CreateChatBot -PUT http://localhost:7071/api/chats/abc123 +PUT http://localhost:7071/api/chats/abc124 Content-Type: application/json { - "name": "Sample ChatBot", - "description": "This is a sample chatbot." + "instructions": "You are a helpful assistant that provides concise and accurate answers." } ### PostChat -POST http://localhost:7071/api/chats/abc123 +POST http://localhost:7071/api/chats/abc124 Content-Type: application/json { @@ -39,7 +38,7 @@ Content-Type: application/json } ### PostChat -POST http://localhost:7071/api/chats/abc123 +POST http://localhost:7071/api/chats/abc124 Content-Type: application/json { @@ -47,5 +46,5 @@ Content-Type: application/json } ### GetChatState -GET http://localhost:7071/api/chats/abc123?timestampUTC=2024-01-15T22:00:00 +GET http://localhost:7071/api/chats/abc124 Content-Type: application/json