diff --git a/COBO-INTEGRATION-TEST.md b/COBO-INTEGRATION-TEST.md new file mode 100644 index 0000000..507507d --- /dev/null +++ b/COBO-INTEGRATION-TEST.md @@ -0,0 +1,129 @@ +## COBO Agent E2E Test + + +1. Get the bicep +```powershell +azd init --template https://github.com/Azure-Samples/azd-ai-starter-basic +``` + +2. Install the required azd version and extension + +Install azd daily build +```powershell +powershell -ex AllSigned -c "Invoke-RestMethod 'https://aka.ms/install-azd.ps1' -OutFile 'install-azd.ps1'; ./install-azd.ps1 -Version 'daily'" +``` +Open a new powershell then +``` +azd version +``` +It should give you the expected commit number, most recently, you should see +``` +azd version 1.20.2 (commit 2063759a9d972b4b4b8d9a5052bc4b5fa664d7e7) +``` + +Install extension +```powershell +azd extension install azure.foundry.ai.agents +``` +Then +``` +azd ext list +``` +You should see +``` +Id Name Version Installed Version Source +azure.coding-agent Coding agent configuration extension 0.5.1 azd +azure.foundry.ai.agents AI Foundry Agents 0.0.2 0.0.2 azd +microsoft.azd.demo Demo Extension 0.3.0 azd +microsoft.azd.extensions AZD Extensions Developer Kit 0.6.0 azd +``` + +3. Init Agent sample +``` +azd ai agent init -m https://github.com/coreai-microsoft/foundry-golden-path/tree/main/idea-to-proto/01-build-agent-in-code/agent-catalog-code-samples/cobo-calculator-agent +``` + +Your folder structure should look like this now: +``` +cobo-calculator-agent/ +├── .azure/ +├── infra/ +├── agent.yaml +├── azure.yaml +├── Dockerfile +├── langraph_agent_calculator.py +└── requirements.txt +``` + + +4. Test +``` +azd up +``` +Use the following parameters if you don't have a test subscription: +``` +? Select an Azure Subscription to use: 87. azure-openai-agents-exp-nonprod-01 (921496dc-987f-410f-bd57-426eb2611356) +? Enter a value for the 'aiDeploymentsLocation' infrastructure parameter: 24. (US) West US 2 (westus2) +? Enter a value for the 'enableCoboAgent' infrastructure parameter: True +``` +Please contact migu@microsoft to get permission to the subscription + + +When it finishes, you should see console output like: +``` +--- Testing Agent with Data Plane Call --- +Data Plane POST URL: https://ai-account-kply6uaglbh5u.services.ai.azure.com/api/projects/migu-cobo-int-1602/openai/responses?api-version=2025-05-15-preview +Data Plane Payload: { + "stream": false, + "agent": { + "version": "2", + "name": "Cobo Calculator Agent", + "type": "agent_reference" + }, + "input": "Tell me a joke." +} +Data Plane POST completed. Response: +{ + "metadata": {}, + "temperature": null, + "top_p": null, + "user": null, + "model": "", + "background": false, + "tools": [], + "id": "caresp_33443885792eaac0004iX4VWbkLbS4rTYxSKosWlyE6h1DZeFF", + "object": "response", + "status": "completed", + "created_at": 1761349040, + "error": null, + "incomplete_details": null, + "output": [ + { + "type": "message", + "id": "msg_33443885792eaac0009lEaLBx6qSEMnII3pakiMz10q6ZnlBVI", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Why don't skeletons fight each other?\n\nBecause they don't have the guts!", + "annotations": [] + } + ] + } + ], + "instructions": null, + "parallel_tool_calls": false, + "conversation": null, + "agent": { + "type": "agent_id", + "name": "Cobo Calculator Agent", + "version": "2" + } +} + +====================================== +Azure Portal Links +====================================== +Container App: https://portal.azure.com/#@/resource/subscriptions/921496dc-987f-410f-bd57-426eb2611356/resourceGroups/rg-migu-cobo-int-1602/providers/Microsoft.App/containerApps/ca-migu-cobo-int-1602-kply6uaglb +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50eeaf1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/devcontainers/python:3.11 + +WORKDIR /app + +# Copy package sources (new_app project) +COPY . . + +# Install the package with the langgraph extras +RUN pip install -r requirements.txt + +# Expose the port that the agent server uses +EXPOSE 8088 + +CMD ["python", "langgraph_agent_calculator.py"] diff --git a/agent.yaml b/agent.yaml new file mode 100644 index 0000000..7714473 --- /dev/null +++ b/agent.yaml @@ -0,0 +1,27 @@ +agent: + kind: container + name: Cobo Calculator Agent + description: This Agent can perform basic arithmetic calculations such as addition, subtraction, multiplication, and division. + metadata: + example: + - role: user + content: |- + What's 3 / 1.5 + 2? + tags: + - example + - learning + authors: + - migu + + models: + - id: gpt-4o-mini + provider: azure + deployment: {{model_deployment_name}} + +parameters: + model_deployment_name: + schema: + type: string + default: gpt-4o-mini + description: the name of your model deployment + required: true diff --git a/azure.yaml b/azure.yaml index 954967d..981b9fc 100644 --- a/azure.yaml +++ b/azure.yaml @@ -9,3 +9,24 @@ requiredVersions: extensions: # the azd ai agent extension is required for this template "azure.foundry.ai.agents": ">=0.0.1" + +services: + cobo-agent: + project: . + language: py + host: containerapp + docker: + remoteBuild: true + +hooks: + postdeploy: + windows: + shell: pwsh + run: ./scripts/postdeploy.ps1 + continueOnError: true + interactive: true + posix: + shell: sh + run: ./scripts/postdeploy.sh + continueOnError: true + interactive: true \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep index 7cc3c4d..9ffd14f 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -149,3 +149,5 @@ output AI_FOUNDRY_PROJECT_PRINCIPAL_ID string = enableCoboAgent ? coboAgent!.out output AI_FOUNDRY_PROJECT_TENANT_ID string = enableCoboAgent ? coboAgent!.outputs.AI_FOUNDRY_PROJECT_TENANT_ID : '' output AI_FOUNDRY_RESOURCE_ID string = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/${aiProject.outputs.aiServicesAccountName}' output AI_FOUNDRY_PROJECT_RESOURCE_ID string = aiProject.outputs.projectId +// Mock output of AGENT_NAME which should be populated by azd extension by reading from agent.yaml +output AGENT_NAME string = 'Cobo Calculator Agent' diff --git a/infra/main.parameters.json b/infra/main.parameters.json index b45b089..16edf0c 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -24,7 +24,7 @@ "value": "${AZURE_PRINCIPAL_TYPE}" }, "aiProjectDeploymentsJson": { - "value": "${AI_PROJECT_DEPLOYMENTS:=[]}" + "value": "[{\"name\":\"gpt-4o-mini\",\"model\":{\"name\":\"gpt-4o-mini\",\"format\":\"OpenAI\",\"version\":\"2024-07-18\"},\"sku\":{\"name\":\"GlobalStandard\",\"capacity\":50}}]" }, "aiProjectConnectionsJson": { "value": "${AI_PROJECT_CONNECTIONS:=[]}" diff --git a/langgraph_agent_calculator.py b/langgraph_agent_calculator.py new file mode 100644 index 0000000..c526433 --- /dev/null +++ b/langgraph_agent_calculator.py @@ -0,0 +1,142 @@ +import os + +from dotenv import load_dotenv +from langchain.chat_models import init_chat_model +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools import tool +from langgraph.graph import ( + END, + START, + MessagesState, + StateGraph, +) +from typing_extensions import Literal +from azure.identity import DefaultAzureCredential, get_bearer_token_provider + +from azure.ai.agentshosting import from_langgraph + +load_dotenv() + +deployment_name = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "gpt-4o-mini") +api_key = os.getenv("AZURE_OPENAI_API_KEY", "") + +if api_key: + llm = init_chat_model(f"azure_openai:{deployment_name}") +else: + credential = DefaultAzureCredential() + token_provider = get_bearer_token_provider( + credential, "https://cognitiveservices.azure.com/.default" + ) + llm = init_chat_model( + f"azure_openai:{deployment_name}", + azure_ad_token_provider=token_provider, + ) + + +# Define tools +@tool +def multiply(a: int, b: int) -> int: + """Multiply a and b. + + Args: + a: first int + b: second int + """ + return a * b + + +@tool +def add(a: int, b: int) -> int: + """Adds a and b. + + Args: + a: first int + b: second int + """ + return a + b + + +@tool +def divide(a: int, b: int) -> float: + """Divide a and b. + + Args: + a: first int + b: second int + """ + return a / b + + +# Augment the LLM with tools +tools = [add, multiply, divide] +tools_by_name = {tool.name: tool for tool in tools} +llm_with_tools = llm.bind_tools(tools) + + +# Nodes +def llm_call(state: MessagesState): + """LLM decides whether to call a tool or not""" + + return { + "messages": [ + llm_with_tools.invoke( + [ + SystemMessage( + content="You are a helpful assistant tasked with performing arithmetic on a set of inputs." + ) + ] + + state["messages"] + ) + ] + } + + +def tool_node(state: dict): + """Performs the tool call""" + + result = [] + for tool_call in state["messages"][-1].tool_calls: + tool = tools_by_name[tool_call["name"]] + observation = tool.invoke(tool_call["args"]) + result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"])) + return {"messages": result} + + +# Conditional edge function to route to the tool node or end based upon whether the LLM made a tool call +def should_continue(state: MessagesState) -> Literal["environment", END]: + """Decide if we should continue the loop or stop based upon whether the LLM made a tool call""" + + messages = state["messages"] + last_message = messages[-1] + # If the LLM makes a tool call, then perform an action + if last_message.tool_calls: + return "Action" + # Otherwise, we stop (reply to the user) + return END + + +# Build workflow +agent_builder = StateGraph(MessagesState) + +# Add nodes +agent_builder.add_node("llm_call", llm_call) +agent_builder.add_node("environment", tool_node) + +# Add edges to connect nodes +agent_builder.add_edge(START, "llm_call") +agent_builder.add_conditional_edges( + "llm_call", + should_continue, + { + "Action": "environment", + END: END, + }, +) +agent_builder.add_edge("environment", "llm_call") + +# Compile the agent +agent = agent_builder.compile() + +if __name__ == "__main__": + adapter = from_langgraph(agent) + adapter.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5b54c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv +my-agents-adapter[langgraph]==0.0.7 +azure-identity \ No newline at end of file diff --git a/scripts/postdeploy.ps1 b/scripts/postdeploy.ps1 new file mode 100644 index 0000000..da1afe4 --- /dev/null +++ b/scripts/postdeploy.ps1 @@ -0,0 +1,650 @@ +#!/usr/bin/env pwsh +# postdeploy.ps1 +# Post-deployment script for AI Foundry integration: +# 1. Configures Container App authentication with AI Foundry Project Application ID +# 2. Registers the agent with AI Foundry using the Agents API +# 3. Tests the agent with a data plane call +# 4. Verifies authentication is enforced (expects 401 for unauthenticated requests) + +Write-Host "======================================" +Write-Host "POSTDEPLOY SCRIPT STARTED" +Write-Host "======================================" + +# Get required values from azd environment +$containerAppPrincipalId = (azd env get-values | Select-String -Pattern '^COBO_ACA_IDENTITY_PRINCIPAL_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' +$aiFoundryResourceId = (azd env get-values | Select-String -Pattern '^AI_FOUNDRY_RESOURCE_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' +$aiFoundryProjectResourceId = (azd env get-values | Select-String -Pattern '^AI_FOUNDRY_PROJECT_RESOURCE_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' +$projectPrincipalId = (azd env get-values | Select-String -Pattern '^AI_FOUNDRY_PROJECT_PRINCIPAL_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' +$projectTenantId = (azd env get-values | Select-String -Pattern '^AI_FOUNDRY_PROJECT_TENANT_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' +$resourceId = (azd env get-values | Select-String -Pattern '^SERVICE_API_RESOURCE_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' + +if (-not $aiFoundryResourceId) { + Write-Error "AI_FOUNDRY_RESOURCE_ID must be set in azd environment" + exit 1 +} + +if (-not $aiFoundryProjectResourceId) { + Write-Error "AI_FOUNDRY_PROJECT_RESOURCE_ID must be set in azd environment" + exit 1 +} + +if (-not $containerAppPrincipalId) { + Write-Error "Could not find container app principal ID in azd environment (COBO_ACA_IDENTITY_PRINCIPAL_ID)" + exit 1 +} + +if (-not $projectPrincipalId) { + Write-Error "AI_FOUNDRY_PROJECT_PRINCIPAL_ID must be set in azd environment" + exit 1 +} + +if (-not $projectTenantId) { + Write-Error "AI_FOUNDRY_PROJECT_TENANT_ID must be set in azd environment" + exit 1 +} + +if (-not $resourceId) { + Write-Error "SERVICE_API_RESOURCE_ID must be set in azd environment" + exit 1 +} + +# Step 1: Configure Container App Authentication +Write-Host "`n======================================" +Write-Host "Configuring Container App Authentication" +Write-Host "======================================" + +Write-Host "Retrieving Application ID (Client ID) for AI Foundry Project..." +Write-Host "Principal ID (Object ID): $projectPrincipalId" + +# Query Azure AD to get the Application ID from the Service Principal +$spJson = az ad sp show --id $projectPrincipalId --query appId -o tsv 2>$null + +if ($LASTEXITCODE -ne 0 -or -not $spJson) { + Write-Error "Failed to retrieve Application ID from Azure AD for Principal ID: $projectPrincipalId" + exit 1 +} + +$projectClientId = $spJson.Trim() +Write-Host "✓ Retrieved Application ID (Client ID): $projectClientId" + +# Configure Container App authentication +Write-Host "`nConfiguring authentication for Container App..." +Write-Host "Container App Resource ID: $resourceId" + +# Build auth configuration JSON +$authConfigObj = @{ + properties = @{ + platform = @{ + enabled = $true + } + globalValidation = @{ + unauthenticatedClientAction = "Return401" + } + identityProviders = @{ + azureActiveDirectory = @{ + enabled = $true + registration = @{ + clientId = $projectClientId + openIdIssuer = "https://sts.windows.net/$projectTenantId/" + } + validation = @{ + allowedAudiences = @( + "https://management.azure.com" + "api://$projectClientId" + "https://ai.azure.com" + "https://containeragents.ai.azure.com" + ) + defaultAuthorizationPolicy = @{ + allowedApplications = @($projectClientId) + } + } + } + } + } +} + +# Convert to JSON and save to temp file to avoid shell escaping issues +$tempFile = [System.IO.Path]::GetTempFileName() +$authConfigObj | ConvertTo-Json -Depth 10 | Out-File -FilePath $tempFile -Encoding UTF8 + +# Configure authentication using Azure REST API +$authResult = az rest --method PUT ` + --uri "https://management.azure.com$resourceId/authConfigs/current?api-version=2024-03-01" ` + --body "@$tempFile" 2>&1 + +# Clean up temp file +Remove-Item $tempFile -ErrorAction SilentlyContinue + +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to configure Container App authentication. Error: $authResult" + exit 1 +} + +Write-Host "✓ Container App authentication configured successfully" + +# Verify authentication configuration +Write-Host "`nVerifying authentication configuration..." +try { + $authConfigJson = az rest --method GET --uri "https://management.azure.com$resourceId/authConfigs/current?api-version=2024-03-01" + if ($authConfigJson) { + $authConfig = $authConfigJson | ConvertFrom-Json + Write-Host "✓ Authentication Platform Enabled: $($authConfig.properties.platform.enabled)" + Write-Host "✓ Unauthenticated Client Action: $($authConfig.properties.globalValidation.unauthenticatedClientAction)" + + if ($authConfig.properties.identityProviders.azureActiveDirectory) { + $aadConfig = $authConfig.properties.identityProviders.azureActiveDirectory + Write-Host "✓ Azure AD Enabled: $($aadConfig.enabled)" + Write-Host "✓ Client ID: $($aadConfig.registration.clientId)" + Write-Host "✓ Issuer: $($aadConfig.registration.openIdIssuer)" + Write-Host "✓ Allowed Audiences: $($aadConfig.validation.allowedAudiences -join ', ')" + } + } +} catch { + Write-Warning "Failed to verify authentication configuration: $($_.Exception.Message)" +} + +Write-Host "`n======================================" +Write-Host "Container App Authentication Setup Complete" +Write-Host "======================================" + +Write-Host "Proceeding to Agent Registration with AI Foundry..." + +# Extract account name and resource group from resource ID +# Format: /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.CognitiveServices/accounts/{account-name} +$resourceIdParts = $aiFoundryResourceId.Split('/') +$aiFoundryResourceGroup = $resourceIdParts[4] +$aiFoundryName = $resourceIdParts[8] + +# Extract project information from project resource ID +# Format: /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.CognitiveServices/accounts/{account-name}/projects/{project-name} +$projectResourceIdParts = $aiFoundryProjectResourceId.Split('/') +$projectSubscriptionId = $projectResourceIdParts[2] +$projectResourceGroup = $projectResourceIdParts[4] +$projectAiFoundryName = $projectResourceIdParts[8] +$projectName = $projectResourceIdParts[10] + +# Set the Azure CLI to use the correct subscription for AI Foundry operations +Write-Host "Setting Azure CLI subscription to: $projectSubscriptionId" +az account set --subscription $projectSubscriptionId + +# Get the region/location of the AI Foundry account +Write-Host "Retrieving AI Foundry account location..." +$aiFoundryAccount = az cognitiveservices account show --name $projectAiFoundryName --resource-group $projectResourceGroup --query location -o tsv +if ($aiFoundryAccount) { $aiFoundryRegion = $aiFoundryAccount.Trim() } else { $aiFoundryRegion = "" } +Write-Host "AI Foundry region: $aiFoundryRegion" + +Write-Host "AI Foundry Resource ID: $aiFoundryResourceId" +Write-Host "AI Foundry Project Resource ID: $aiFoundryProjectResourceId" +Write-Host "AI Foundry: $aiFoundryName in resource group: $aiFoundryResourceGroup" +Write-Host "Project: $projectName in AI Foundry: $projectAiFoundryName" +Write-Host "Container App Principal ID: $containerAppPrincipalId" + +# Get the container app resource ID for agent registration +$resourceId = (azd env get-values | Select-String -Pattern '^SERVICE_API_RESOURCE_ID=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' + +if ($resourceId) { + # Deactivate hello-world revision first + Write-Host "`n======================================" + Write-Host "Deactivating Hello-World Revision" + Write-Host "======================================" + Write-Host "ℹ️ Azure Container Apps requires an image during provision, but with remote Docker" + Write-Host " build, the app image doesn't exist yet. A hello-world placeholder image is used" + Write-Host " during 'azd provision', then replaced with your app image during 'azd deploy'." + Write-Host " Now that your app is deployed, we'll deactivate the placeholder revision.`n" + + # Extract subscription, resource group and app name from resource ID + # Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} + if ($resourceId -match '/subscriptions/([^/]+)/resourceGroups/([^/]+)/.*/containerApps/([^/]+)$') { + $subscription = $matches[1] + $resourceGroup = $matches[2] + $appName = $matches[3] + + try { + # Get all revisions with their images + $revisionsJson = az containerapp revision list --name $appName --resource-group $resourceGroup --subscription $subscription --query "[].{name:name, image:properties.template.containers[0].image, active:properties.active}" -o json + + if (-not $revisionsJson) { + Write-Warning "Could not retrieve revisions" + } else { + $revisions = $revisionsJson | ConvertFrom-Json + + # Find hello-world revision by checking BOTH: + # 1. Image contains 'containerapps-helloworld' + # 2. Revision name does NOT contain '--azd-' (azd-generated revisions have this pattern) + $helloWorldRevision = $revisions | Where-Object { + $_.image -like "*containerapps-helloworld*" -and $_.name -notlike "*--azd-*" + } | Select-Object -First 1 + + if (-not $helloWorldRevision) { + Write-Host "No hello-world revision found (already removed or using custom image)" + } else { + Write-Host "Found hello-world revision: $($helloWorldRevision.name)" + Write-Host "Image: $($helloWorldRevision.image)" + + # Double-check before deactivating + if ($helloWorldRevision.image -notlike "*containerapps-helloworld*") { + Write-Warning "Revision does not have hello-world image, skipping for safety" + } elseif ($helloWorldRevision.name -like "*--azd-*") { + Write-Warning "Revision name contains '--azd-' pattern, skipping for safety" + } else { + # Check if it's already inactive + if ($helloWorldRevision.active -eq $false) { + Write-Host "Revision is already inactive" + } else { + Write-Host "Deactivating revision..." + az containerapp revision deactivate --name $appName --resource-group $resourceGroup --subscription $subscription --revision $helloWorldRevision.name + + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Hello-world revision deactivated successfully" + } else { + Write-Warning "Failed to deactivate hello-world revision" + } + } + } + } + } + } catch { + Write-Warning "Error while deactivating hello-world revision: $($_.Exception.Message)" + } + + # Restart the latest Container App revision to apply authentication changes + Write-Host "`n======================================" + Write-Host "Restarting Container App" + Write-Host "======================================" + Write-Host "ℹ️ Restarting the Container App to apply authentication changes..." + + # Get all revisions (we already have them from the hello-world deactivation) + $activeRevisions = $revisions | Where-Object { $_.active -eq $true } + + if ($activeRevisions) { + # If multiple active revisions, get the latest one (last in the list) + if ($activeRevisions -is [array]) { + $latestRevision = $activeRevisions[-1] + } else { + $latestRevision = $activeRevisions + } + + $revisionName = $latestRevision.name + Write-Host "Latest active revision: $revisionName" + + # Restart the revision + az containerapp revision restart ` + --name $appName ` + --resource-group $resourceGroup ` + --subscription $subscription ` + --revision $revisionName + + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Container App revision restarted successfully" + Write-Host "`nℹ️ Waiting 60 seconds for restart to complete..." + Start-Sleep -Seconds 60 + Write-Host "✓ Wait complete." + } else { + Write-Error "Failed to restart Container App revision" + exit 1 + } + } else { + Write-Error "No active revisions found to restart" + exit 1 + } + } else { + Write-Warning "Could not parse subscription, resource group or app name from resource ID" + } + + # Get the Container App endpoint (FQDN) for testing + Write-Host "Retrieving Container App endpoint..." + $containerAppJson = az resource show --ids $resourceId --query properties.configuration.ingress.fqdn -o json + if ($containerAppJson) { + $containerAppFqdn = ($containerAppJson | ConvertFrom-Json) + $acaEndpoint = "https://$containerAppFqdn" + Write-Host "Container App endpoint: $acaEndpoint" + } else { + Write-Warning "Failed to retrieve Container App endpoint." + $acaEndpoint = $null + } + + # Get AI Foundry Project endpoint from resource properties + Write-Host "Retrieving AI Foundry Project API endpoint..." + $projectJson = az resource show --ids $aiFoundryProjectResourceId + $project = $projectJson | ConvertFrom-Json + $aiFoundryProjectEndpoint = $project.properties.endpoints.'AI Foundry API' + + if ($aiFoundryProjectEndpoint) { + Write-Host "AI Foundry Project API endpoint: $aiFoundryProjectEndpoint" + } else { + Write-Warning "Failed to retrieve AI Foundry Project API endpoint." + } + + # Acquire AAD token for audience https://ai.azure.com + $token = & az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv + if ($token) { $token = $token.Trim() } + + if (-not $token) { + try { + $azToken = Get-AzAccessToken -ResourceUrl "https://ai.azure.com" + $token = $azToken.Token + } catch { + Write-Warning "Failed to obtain AAD access token. Skipping localhost API call." + $token = $null + } + } + + if ($token) { + # Determine the revision name that was just deployed (latest revision) + $latestRevisionName = '' + try { + $latestRevisionName = az containerapp show --ids $resourceId --query properties.latestRevisionName -o tsv + if ($latestRevisionName) { $latestRevisionName = $latestRevisionName.Trim() } + } catch { + Write-Warning "Unable to determine latest revision name: $($_.Exception.Message)" + } + + # Get agent name from azd environment variables + $agentName = (azd env get-values | Select-String -Pattern '^AGENT_NAME=' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) -replace '^"|"$', '' + if (-not $agentName) { + Write-Error "AGENT_NAME must be set in azd environment" + exit 1 + } + Write-Host "Using agent name from environment: $agentName" + + # Construct API endpoint + $workspaceName = "$projectAiFoundryName@$projectName@AML" + $apiPath = "/agents/v2.0/subscriptions/$projectSubscriptionId/resourceGroups/$projectResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/$workspaceName/agents/$agentName/versions?api-version=2025-05-15-preview" + + if (-not $aiFoundryRegion) { + Write-Error "Could not determine AI Foundry region and AI_PROJECT_ENDPOINT is not set" + exit 1 + } + $uri = "https://$aiFoundryRegion.api.azureml.ms$apiPath" + Write-Host "Using regional API endpoint for region: $aiFoundryRegion" + + # Build payload + $ingressSuffix = '' + if ($latestRevisionName) { + # Extract the suffix starting from the last "--", including the dashes. + $lastDoubleDash = $latestRevisionName.LastIndexOf('--') + if ($lastDoubleDash -ge 0) { + $ingressSuffix = $latestRevisionName.Substring($lastDoubleDash) + } else { + # Fallback: prefix with '--' when no double-dash separator exists + $ingressSuffix = "--$latestRevisionName" + } + } + $bodyObject = @{ + description = "Test agent version description" + definition = @{ + kind = "container_app" + container_protocol_versions = @( + @{ + protocol = "responses" + version = "v1" + } + ) + container_app_resource_id = $resourceId + ingress_subdomain_suffix = $ingressSuffix + } + } + $payload = $bodyObject | ConvertTo-Json -Depth 10 + + # Compute Content-Length (UTF8 bytes) + $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $contentLength = $contentBytes.Length + + # Prepare headers + $headers = @{ + "accept-encoding" = "gzip, deflate, br" + "accept" = "application/json" + "authorization" = "Bearer $token" + "content-type" = "application/json" + "content-length" = $contentLength.ToString() + } + + # Send request with retry logic for 500 errors + $maxRetries = 10 + $retryCount = 0 + $retryDelaySeconds = 60 + $response = $null + $agentVersion = $null + + while ($retryCount -lt $maxRetries) { + try { + if ($retryCount -gt 0) { + Write-Host "`n--- Retry attempt $retryCount of $($maxRetries - 1) ---" + } + + # Print request information + Write-Host "POST URL: $uri" + Write-Host "Payload: $payload" + # Headers are not printed to avoid leaking sensitive information + + $response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $payload + Write-Host "POST completed. Response:" + Write-Host (ConvertTo-Json $response -Depth 5) + + # Extract the version from the response for the data plane call + $agentVersion = $response.version + Write-Host "`nAgent version created: $agentVersion" + + # Success - break out of retry loop + break + + } catch { + $statusCode = 0 + $errorMessage = $_.Exception.Message + + # Try to extract status code + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + + if ($statusCode -eq 500) { + $retryCount++ + + if ($retryCount -lt $maxRetries) { + Write-Host "`n======================================" + Write-Host "Agent Registration Failed with 500 Error" + Write-Host "======================================" + Write-Warning "POST failed: $errorMessage" + + if ($_.Exception.Response) { + try { + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host "Response body:" + Write-Host $_.ErrorDetails.Message + } + } catch {} + } + + Write-Host "`nℹ️ Note: The agent service might need some time to recognize that the AI Foundry Project" + Write-Host " identity has read permission to the Container App. This is expected on first deployment." + Write-Host " We are working on avoiding this delay in future updates." + Write-Host "`n⏳ Waiting $retryDelaySeconds seconds before retry attempt $retryCount..." + Start-Sleep -Seconds $retryDelaySeconds + } else { + Write-Host "`n======================================" + Write-Host "Agent Registration Failed After All Retries" + Write-Host "======================================" + Write-Warning "POST failed after $($maxRetries) attempts: $errorMessage" + + if ($_.Exception.Response) { + try { + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host "Response body:" + Write-Host $_.ErrorDetails.Message + } elseif ($_.Exception.Response.StatusCode) { + Write-Host "Response Status: $($_.Exception.Response.StatusCode)" + } + } catch {} + } + + Write-Host "`nℹ️ The agent service may still need more time for permission propagation." + Write-Host " You can try running the postdeploy script again later: .\scripts\postdeploy.ps1" + # Don't exit - continue with other tests if possible + } + } else { + # Non-500 error - don't retry + Write-Warning "POST failed: $errorMessage" + if ($_.Exception.Response) { + try { + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host "Response body:" + Write-Host $_.ErrorDetails.Message + } elseif ($_.Exception.Response.StatusCode) { + Write-Host "Response Status: $($_.Exception.Response.StatusCode)" + } + } catch {} + } + break + } + } + } + + # Only proceed with tests if agent registration succeeded + if ($agentVersion) { + # Test unauthenticated access - should return 401 + if ($acaEndpoint) { + Write-Host "`n======================================" + Write-Host "Testing unauthenticated access (expecting 401)..." + Write-Host "======================================" + + $unauthUri = "$acaEndpoint/responses" + $unauthPayload = '{"input": "test"}' + + Write-Host "Unauthenticated Request URL: $unauthUri" + Write-Host "Unauthenticated Request Payload: $unauthPayload" + + try { + $unauthResponseCode = 0 + $unauthError = $null + $unauthResponseBody = $null + + try { + # Try using Invoke-WebRequest with error handling + $unauthResponse = Invoke-WebRequest -Uri $unauthUri ` + -Method POST ` + -ContentType "application/json" ` + -Body $unauthPayload ` + -UseBasicParsing ` + -ErrorAction Stop + $unauthResponseCode = $unauthResponse.StatusCode + $unauthResponseBody = $unauthResponse.Content + } catch { + $unauthError = $_ + # Extract status code from the exception + if ($_.Exception.Response) { + $unauthResponseCode = [int]$_.Exception.Response.StatusCode + # Try to read the response body + try { + $stream = $_.Exception.Response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + $unauthResponseBody = $reader.ReadToEnd() + $reader.Close() + $stream.Close() + } catch { + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + $unauthResponseBody = $_.ErrorDetails.Message + } + } + } elseif ($_.Exception.Message -match 'The remote server returned an error: \((\d+)\)') { + $unauthResponseCode = [int]$matches[1] + } elseif ($_.Exception.Message -match '(\d+)') { + # Try to extract any number that looks like a status code + $possibleCode = [int]$matches[1] + if ($possibleCode -ge 100 -and $possibleCode -lt 600) { + $unauthResponseCode = $possibleCode + } + } + } + + Write-Host "Response Status Code: $unauthResponseCode" + if ($unauthResponseBody) { + Write-Host "Response Body: $unauthResponseBody" + } + + if ($unauthResponseCode -eq 401) { + Write-Host "✓ Authentication verification successful: Unauthenticated request returned 401 Unauthorized" + } elseif ($unauthResponseCode -eq 0) { + Write-Warning "Could not determine response code for unauthenticated request" + if ($unauthError) { + Write-Host "Error details: $($unauthError.Exception.Message)" + } + } else { + Write-Warning "Unexpected response code for unauthenticated request: $unauthResponseCode (expected 401)" + } + } catch { + Write-Warning "Error testing unauthenticated access: $($_.Exception.Message)" + } + } else { + Write-Warning "Container App endpoint not available. Skipping unauthenticated access test." + } + + # Make a data plane call to test the agent + Write-Host "`n--- Testing Agent with Data Plane Call ---" + + # Use the AI Foundry Project API endpoint + if ($aiFoundryProjectEndpoint) { + $dataPlaneUri = "$aiFoundryProjectEndpoint/openai/responses?api-version=2025-05-15-preview" + } else { + Write-Warning "AI Foundry Project API endpoint not available. Skipping data plane call." + $dataPlaneUri = $null + } + + if ($dataPlaneUri) { + $dataPlaneBody = @{ + agent = @{ + type = "agent_reference" + name = $agentName + version = $agentVersion + } + input = "Tell me a joke." + stream = $false + } + $dataPlanePayload = $dataPlaneBody | ConvertTo-Json -Depth 10 + $dataPlaneContentBytes = [System.Text.Encoding]::UTF8.GetBytes($dataPlanePayload) + $dataPlaneContentLength = $dataPlaneContentBytes.Length + + $dataPlaneHeaders = @{ + "accept-encoding" = "gzip, deflate, br" + "accept" = "application/json" + "authorization" = "Bearer $token" + "content-type" = "application/json" + "content-length" = $dataPlaneContentLength.ToString() + } + + try { + Write-Host "Data Plane POST URL: $dataPlaneUri" + Write-Host "Data Plane Payload: $dataPlanePayload" + + $dataPlaneResponse = Invoke-RestMethod -Uri $dataPlaneUri -Method Post -Headers $dataPlaneHeaders -Body $dataPlanePayload + Write-Host "Data Plane POST completed. Response:" + Write-Host (ConvertTo-Json $dataPlaneResponse -Depth 5) + } catch { + Write-Warning "Data Plane POST failed: $($_.Exception.Message)" + if ($_.Exception.Response) { + try { + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host "Response body:" + Write-Host $_.ErrorDetails.Message + } elseif ($_.Exception.Response.StatusCode) { + Write-Host "Response Status: $($_.Exception.Response.StatusCode)" + } + } catch {} + } + } + } + + } + } + + # Print Azure Portal link for the Container App + Write-Host "`n======================================" + Write-Host "Azure Portal Links" + Write-Host "======================================" + $portalUrl = "https://portal.azure.com/#@/resource$resourceId" + Write-Host "Container App: $portalUrl" + +} else { + Write-Warning "Could not find container app ARM resource ID in azd environment (SERVICE_API_RESOURCE_ID). Skipping localhost API call." +} + +Write-Host "`nPost-deployment configuration completed successfully." diff --git a/scripts/postdeploy.sh b/scripts/postdeploy.sh new file mode 100644 index 0000000..5ccf563 --- /dev/null +++ b/scripts/postdeploy.sh @@ -0,0 +1,475 @@ +#!/bin/bash +# postdeploy.sh +# Post-deployment script for AI Foundry integration: +# 1. Registers the agent with AI Foundry using the Agents API +# 2. Tests the agent with a data plane call +# 3. Verifies authentication is enforced (expects 401 for unauthenticated requests) + +set -e # Exit on error + +# Helper function to get environment variable +get_env_var() { + azd env get-values | grep "^$1=" | cut -d'=' -f2 | tr -d '"' | tr -d "'" | tr -d '\r\n' +} + +# Helper function to parse JSON from string +parse_json_value() { + local json_string=$1 + local json_path=$2 + + if ! command -v python3 &> /dev/null; then + echo "Error: python3 is required but not available. Please install python3." >&2 + exit 1 + fi + + echo "$json_string" | python3 -c "import sys, json; data=json.load(sys.stdin); print($json_path)" +} + +# Get required values from azd environment +CONTAINER_APP_PRINCIPAL_ID=$(get_env_var "COBO_ACA_IDENTITY_PRINCIPAL_ID") +AI_FOUNDRY_RESOURCE_ID=$(get_env_var "AI_FOUNDRY_RESOURCE_ID") +AI_FOUNDRY_PROJECT_RESOURCE_ID=$(get_env_var "AI_FOUNDRY_PROJECT_RESOURCE_ID") +PROJECT_PRINCIPAL_ID=$(get_env_var "AI_FOUNDRY_PROJECT_PRINCIPAL_ID") +PROJECT_TENANT_ID=$(get_env_var "AI_FOUNDRY_PROJECT_TENANT_ID") +RESOURCE_ID=$(get_env_var "SERVICE_API_RESOURCE_ID") +AGENT_NAME=$(get_env_var "AGENT_NAME") + +# Validate required variables +[ -z "$AI_FOUNDRY_RESOURCE_ID" ] && { echo "Error: AI_FOUNDRY_RESOURCE_ID not set" >&2; exit 1; } +[ -z "$AI_FOUNDRY_PROJECT_RESOURCE_ID" ] && { echo "Error: AI_FOUNDRY_PROJECT_RESOURCE_ID not set" >&2; exit 1; } +[ -z "$CONTAINER_APP_PRINCIPAL_ID" ] && { echo "Error: COBO_ACA_IDENTITY_PRINCIPAL_ID not set" >&2; exit 1; } +[ -z "$PROJECT_PRINCIPAL_ID" ] && { echo "Error: AI_FOUNDRY_PROJECT_PRINCIPAL_ID not set" >&2; exit 1; } +[ -z "$PROJECT_TENANT_ID" ] && { echo "Error: AI_FOUNDRY_PROJECT_TENANT_ID not set" >&2; exit 1; } +[ -z "$RESOURCE_ID" ] && { echo "Error: SERVICE_API_RESOURCE_ID not set" >&2; exit 1; } +[ -z "$AGENT_NAME" ] && { echo "Error: AGENT_NAME not set" >&2; exit 1; } + +# Extract project information from resource IDs +IFS='/' read -ra PARTS <<< "$AI_FOUNDRY_PROJECT_RESOURCE_ID" +PROJECT_SUBSCRIPTION_ID="${PARTS[2]}" +PROJECT_RESOURCE_GROUP="${PARTS[4]}" +PROJECT_AI_FOUNDRY_NAME="${PARTS[8]}" +PROJECT_NAME="${PARTS[10]}" + +# Set subscription +echo "Setting subscription: $PROJECT_SUBSCRIPTION_ID" +az account set --subscription "$PROJECT_SUBSCRIPTION_ID" + +# Get AI Foundry region +AI_FOUNDRY_REGION=$(az cognitiveservices account show \ + --name "$PROJECT_AI_FOUNDRY_NAME" \ + --resource-group "$PROJECT_RESOURCE_GROUP" \ + --query location -o tsv | tr -d '\r\n') + +echo "AI Foundry region: $AI_FOUNDRY_REGION" +echo "Project: $PROJECT_NAME" +echo "Agent: $AGENT_NAME" + +# NOTE: Azure AI User role assignment is now handled in Bicep during deployment +# See infra/cobo-agent.bicep for the role assignment configuration + +# Configure Container App Authentication +echo "" +echo "======================================" +echo "Configuring Container App Authentication" +echo "======================================" + +echo "Retrieving Application ID (Client ID) for AI Foundry Project..." +echo "Principal ID (Object ID): $PROJECT_PRINCIPAL_ID" + +# Query Azure AD to get the Application ID from the Service Principal +PROJECT_CLIENT_ID=$(az ad sp show --id "$PROJECT_PRINCIPAL_ID" --query appId -o tsv 2>/dev/null | tr -d '\r\n') + +if [ -z "$PROJECT_CLIENT_ID" ]; then + echo "Error: Failed to retrieve Application ID from Azure AD for Principal ID: $PROJECT_PRINCIPAL_ID" >&2 + exit 1 +fi + +echo "✓ Retrieved Application ID (Client ID): $PROJECT_CLIENT_ID" + +echo "" +echo "Configuring authentication for Container App..." +echo "Container App Resource ID: $RESOURCE_ID" + +# Build auth configuration JSON +AUTH_CONFIG=$(cat <&1) + +if [ $? -ne 0 ]; then + echo "Error: Failed to configure Container App authentication. Error: $AUTH_RESULT" >&2 + exit 1 +fi + +echo "✓ Container App authentication configured successfully" + +# Verify authentication configuration +echo "" +echo "Verifying authentication configuration..." +AUTH_CONFIG_JSON=$(az rest --method GET \ + --uri "https://management.azure.com$RESOURCE_ID/authConfigs/current?api-version=2024-03-01" 2>/dev/null) + +if [ -n "$AUTH_CONFIG_JSON" ]; then + # Use python3 to parse JSON properly + PLATFORM_ENABLED=$(echo "$AUTH_CONFIG_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('properties', {}).get('platform', {}).get('enabled', 'unknown'))" 2>/dev/null || echo "unknown") + UNAUTH_ACTION=$(echo "$AUTH_CONFIG_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('properties', {}).get('globalValidation', {}).get('unauthenticatedClientAction', 'unknown'))" 2>/dev/null || echo "unknown") + AAD_ENABLED=$(echo "$AUTH_CONFIG_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('properties', {}).get('identityProviders', {}).get('azureActiveDirectory', {}).get('enabled', 'unknown'))" 2>/dev/null || echo "unknown") + CLIENT_ID=$(echo "$AUTH_CONFIG_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('properties', {}).get('identityProviders', {}).get('azureActiveDirectory', {}).get('registration', {}).get('clientId', 'unknown'))" 2>/dev/null || echo "unknown") + ISSUER=$(echo "$AUTH_CONFIG_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('properties', {}).get('identityProviders', {}).get('azureActiveDirectory', {}).get('registration', {}).get('openIdIssuer', 'unknown'))" 2>/dev/null || echo "unknown") + + echo "✓ Authentication Platform Enabled: $PLATFORM_ENABLED" + echo "✓ Unauthenticated Client Action: $UNAUTH_ACTION" + echo "✓ Azure AD Enabled: $AAD_ENABLED" + echo "✓ Client ID: $CLIENT_ID" + echo "✓ Issuer: $ISSUER" + echo "✓ Allowed Audiences: https://management.azure.com, api://$PROJECT_CLIENT_ID, https://ai.azure.com, https://containeragents.ai.azure.com" +fi + +echo "" +echo "======================================" +echo "Container App Authentication Setup Complete" +echo "======================================" + +# Restart the latest Container App revision to apply authentication changes +echo "" +echo "Restarting Container App to apply authentication changes..." + +# Extract subscription, resource group and app name from resource ID +# Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} +SUBSCRIPTION=$(echo "$RESOURCE_ID" | sed -n 's|.*/subscriptions/\([^/]*\)/.*|\1|p') +RESOURCE_GROUP=$(echo "$RESOURCE_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') +APP_NAME=$(echo "$RESOURCE_ID" | sed -n 's|.*/containerApps/\([^/]*\)$|\1|p') + +if [ -z "$SUBSCRIPTION" ] || [ -z "$RESOURCE_GROUP" ] || [ -z "$APP_NAME" ]; then + echo "Warning: Could not parse Container App resource ID for restart" >&2 +else + # Get the latest active revision name + LATEST_REVISION=$(az containerapp revision list \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --subscription "$SUBSCRIPTION" \ + --query "[?properties.active==\`true\`] | [0].name" \ + -o tsv 2>/dev/null | tr -d '\r\n') + + if [ -n "$LATEST_REVISION" ]; then + echo "Latest active revision: $LATEST_REVISION" + + # Restart the revision + az containerapp revision restart \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --subscription "$SUBSCRIPTION" \ + --revision "$LATEST_REVISION" >/dev/null 2>&1 + + if [ $? -eq 0 ]; then + echo "✓ Container App revision restarted successfully" + else + echo "Warning: Failed to restart Container App revision, but continuing..." >&2 + fi + else + echo "Warning: Could not find active revision to restart" >&2 + fi +fi + +# Wait for authentication settings to propagate and restart to complete +echo "" +echo "ℹ️ Waiting 60 seconds for authentication settings to propagate and restart to complete..." +sleep 60 +echo "✓ Wait complete. Proceeding with agent registration." + + # Deactivate hello-world revision first + echo "" + echo "======================================" + echo "Deactivating Hello-World Revision" + echo "======================================" + echo "ℹ️ Azure Container Apps requires an image during provision, but with remote Docker" + echo " build, the app image doesn't exist yet. A hello-world placeholder image is used" + echo " during 'azd provision', then replaced with your app image during 'azd deploy'." + echo " Now that your app is deployed, we'll deactivate the placeholder revision." + echo "" + + # Extract subscription, resource group and app name from resource ID + # Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} + SUBSCRIPTION=$(echo "$RESOURCE_ID" | sed -n 's|.*/subscriptions/\([^/]*\)/.*|\1|p') + RESOURCE_GROUP=$(echo "$RESOURCE_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + APP_NAME=$(echo "$RESOURCE_ID" | sed -n 's|.*/containerApps/\([^/]*\)$|\1|p') + + if [ -z "$SUBSCRIPTION" ] || [ -z "$RESOURCE_GROUP" ] || [ -z "$APP_NAME" ]; then + echo "Warning: Could not parse subscription, resource group or app name from resource ID" >&2 + else + # Get all revisions with their images + REVISIONS_JSON=$(az containerapp revision list \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --subscription "$SUBSCRIPTION" \ + --query "[].{name:name, image:properties.template.containers[0].image, active:properties.active}" \ + -o json | tr -d '\r\n') + + if [ -z "$REVISIONS_JSON" ]; then + echo "Warning: Could not retrieve revisions" >&2 + else + # Find hello-world revision by checking BOTH: + # 1. Image contains 'containerapps-helloworld' + # 2. Revision name does NOT contain '--azd-' (azd-generated revisions have this pattern) + HELLO_WORLD_REVISION=$(parse_json_value "$REVISIONS_JSON" "[r['name'] for r in data if 'containerapps-helloworld' in r.get('image', '') and '--azd-' not in r['name']]") + HELLO_WORLD_REVISION=$(echo "$HELLO_WORLD_REVISION" | tr -d "[]'\"" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + if [ -z "$HELLO_WORLD_REVISION" ]; then + echo "No hello-world revision found (already removed or using custom image)" + else + echo "Found hello-world revision: $HELLO_WORLD_REVISION" + + # Verify the image to be extra safe + IMAGE_CHECK=$(parse_json_value "$REVISIONS_JSON" "[r['image'] for r in data if r['name'] == '$HELLO_WORLD_REVISION'][0]") + echo "Image: $IMAGE_CHECK" + + # Double-check before deactivating + if [[ "$IMAGE_CHECK" != *"containerapps-helloworld"* ]]; then + echo "Warning: Revision does not have hello-world image, skipping for safety" >&2 + elif [[ "$HELLO_WORLD_REVISION" == *"--azd-"* ]]; then + echo "Warning: Revision name contains '--azd-' pattern, skipping for safety" >&2 + else + # Check if it's already inactive + IS_ACTIVE=$(parse_json_value "$REVISIONS_JSON" "[r['active'] for r in data if r['name'] == '$HELLO_WORLD_REVISION'][0]") + + if [ "$IS_ACTIVE" = "False" ] || [ "$IS_ACTIVE" = "false" ]; then + echo "Revision is already inactive" + else + echo "Deactivating revision..." + az containerapp revision deactivate \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --subscription "$SUBSCRIPTION" \ + --revision "$HELLO_WORLD_REVISION" 2>&1 + + if [ $? -eq 0 ]; then + echo "✓ Hello-world revision deactivated successfully" + else + echo "Warning: Failed to deactivate hello-world revision" >&2 + fi + fi + fi + fi + fi + fi + + # Get the Container App endpoint (FQDN) for testing + echo "Retrieving Container App endpoint..." + CONTAINER_APP_FQDN=$(az resource show --ids "$RESOURCE_ID" --query properties.configuration.ingress.fqdn -o tsv | tr -d '\r\n') + + if [ -n "$CONTAINER_APP_FQDN" ]; then + ACA_ENDPOINT="https://$CONTAINER_APP_FQDN" + echo "Container App endpoint: $ACA_ENDPOINT" + else + echo "Warning: Failed to retrieve Container App endpoint." >&2 + ACA_ENDPOINT="" + fi + + # Get AI Foundry Project endpoint from resource properties + echo "Retrieving AI Foundry Project API endpoint..." + AI_FOUNDRY_PROJECT_ENDPOINT=$(az resource show --ids "$AI_FOUNDRY_PROJECT_RESOURCE_ID" --query "properties.endpoints.\"AI Foundry API\"" -o tsv | tr -d '\r\n') + + if [ -n "$AI_FOUNDRY_PROJECT_ENDPOINT" ]; then + echo "AI Foundry Project API endpoint: $AI_FOUNDRY_PROJECT_ENDPOINT" + else + echo "Warning: Failed to retrieve AI Foundry Project API endpoint." >&2 + fi + + # Acquire AAD token for audience https://ai.azure.com + TOKEN=$(az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv) + + if [ -n "$TOKEN" ]; then + + # Get latest revision and build ingress suffix + LATEST_REVISION=$(az containerapp show --ids "$RESOURCE_ID" \ + --query properties.latestRevisionName -o tsv | tr -d '\r\n') + INGRESS_SUFFIX="--${LATEST_REVISION##*--}" + [ "$INGRESS_SUFFIX" = "--$LATEST_REVISION" ] && INGRESS_SUFFIX="--$LATEST_REVISION" + + # Construct agent registration URI (always use regional ARM endpoint) + WORKSPACE_NAME="$PROJECT_AI_FOUNDRY_NAME@$PROJECT_NAME@AML" + API_PATH="/agents/v2.0/subscriptions/$PROJECT_SUBSCRIPTION_ID/resourceGroups/$PROJECT_RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$WORKSPACE_NAME/agents/$AGENT_NAME/versions?api-version=2025-05-15-preview" + + # Always use regional ARM API endpoint based on AI Foundry location + URI="https://$AI_FOUNDRY_REGION.api.azureml.ms$API_PATH" + echo "Using regional ARM API endpoint for region: $AI_FOUNDRY_REGION" + + + # Build JSON payload + PAYLOAD=$(cat <&1) + + HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d') + HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -n1) + + echo "Response Status: $HTTP_STATUS" + echo "Response Body:" + echo "$HTTP_BODY" + echo "" + + if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "201" ]; then + echo "✓ Agent registered successfully" + + # Extract version using python3 + AGENT_VERSION=$(parse_json_value "$HTTP_BODY" "data.get('version', '')" | tr -d '\r\n ') + echo "Agent version: $AGENT_VERSION" + break + elif [ "$HTTP_STATUS" = "500" ] && [ $ATTEMPT -lt $((MAX_RETRIES - 1)) ]; then + echo "Warning: Registration failed with 500 error (permission propagation delay)" + echo "Waiting $RETRY_DELAY seconds before retry..." + sleep $RETRY_DELAY + else + echo "Error: Registration failed" >&2 + [ "$HTTP_STATUS" != "500" ] && break + fi + done + + # Test authentication and agent + if [ -n "$AGENT_VERSION" ]; then + # Test 1: Unauthenticated access (should return 401) + echo "" + echo "======================================" + echo "Testing Unauthenticated Access" + echo "======================================" + + UNAUTH_URI="$ACA_ENDPOINT/responses" + UNAUTH_PAYLOAD='{"input": "test"}' + echo "POST URL: $UNAUTH_URI" + echo "Request Body: $UNAUTH_PAYLOAD" + + UNAUTH_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$UNAUTH_URI" \ + -H "content-type: application/json" \ + -d "$UNAUTH_PAYLOAD" 2>&1) + + UNAUTH_BODY=$(echo "$UNAUTH_RESPONSE" | sed '$d') + UNAUTH_STATUS=$(echo "$UNAUTH_RESPONSE" | tail -n1) + + echo "Response Status: $UNAUTH_STATUS" + echo "Response Body: $UNAUTH_BODY" + echo "" + + if [ "$UNAUTH_STATUS" = "401" ]; then + echo "✓ Authentication enforced (got 401)" + else + echo "Warning: Expected 401, got $UNAUTH_STATUS" >&2 + fi + + # Test 2: Data plane call with authenticated request + echo "" + echo "======================================" + echo "Testing Agent Data Plane" + echo "======================================" + + DATA_PLANE_PAYLOAD=$(cat <&1) + + DATA_PLANE_BODY=$(echo "$DATA_PLANE_RESPONSE" | sed '$d') + DATA_PLANE_STATUS=$(echo "$DATA_PLANE_RESPONSE" | tail -n1) + + echo "Response Status: $DATA_PLANE_STATUS" + echo "Response Body:" + echo "$DATA_PLANE_BODY" + echo "" + + if [ "$DATA_PLANE_STATUS" = "200" ] || [ "$DATA_PLANE_STATUS" = "201" ]; then + echo "✓ Agent responded successfully" + echo "Agent Output:" + parse_json_value "$DATA_PLANE_BODY" "data.get('output', '')" + else + echo "Warning: Data plane call failed" >&2 + fi + fi + + # Print Azure Portal link + echo "" + echo "======================================" + echo "Azure Portal" + echo "======================================" + echo "https://portal.azure.com/#@/resource$RESOURCE_ID" +fi + +echo "" +echo "✓ Post-deployment completed successfully"