diff --git a/.agentignore b/.agentignore index 3ad647e..cc5f753 100644 --- a/.agentignore +++ b/.agentignore @@ -8,6 +8,7 @@ azure.yaml .env .env.* +.ai-gateway-studio.json .azure/ .git/ diff --git a/.ai-gateway-studio.example.json b/.ai-gateway-studio.example.json new file mode 100644 index 0000000..909d31d --- /dev/null +++ b/.ai-gateway-studio.example.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "gatewayDeploymentMode": "existing", + "gatewayResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-gateway-rg/providers/Microsoft.ApiManagement/service/example-gateway", + "gatewayEndpoint": "https://example-gateway.example-region.ai.gateway-current.azure.com/", + "githubMcpEndpoint": "https://example-gateway.example-region.ai.gateway-current.azure.com/default/toolservers/github/mcp", + "modelAliases": { + "default": "gpt-latest", + "mini": "gpt-mini-latest" + } +} diff --git a/.dockerignore b/.dockerignore index e372bf3..80f0979 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,6 @@ __pycache__/ *.pyc .env +.ai-gateway-studio.json .azure/ .git/ diff --git a/.env.example b/.env.example index 984ad5e..107c6fd 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ AZURE_AI_GATEWAY_API_KEY="" AZURE_AI_GATEWAY_MODEL="gpt-latest" AZURE_AI_GATEWAY_MINI_MODEL="gpt-mini-latest" -# Foundry Toolbox endpoint created by the deployment. -TOOLBOX_ENDPOINT="https:///api/projects//toolboxes/repo-digest-tools/versions//mcp?api-version=v1" +# Foundry Toolbox consumer endpoint created by the deployment. +TOOLBOX_ENDPOINT="https:///api/projects//toolboxes/repo-digest-tools/mcp?api-version=v1" TOOLBOX_NAME="repo-digest-tools" # GitHub repository used when the request does not name one. diff --git a/.gitignore b/.gitignore index a429d2e..0818e82 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ Thumbs.db # Azure Developer CLI .azure/ + +# Environment-specific AI Gateway Studio handoff +.ai-gateway-studio.json diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index a60c90f..88ca4ad 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -29,6 +29,63 @@ Functions application. model provider, model registrations, runtime key, and monitoring. - `chat.py` is a local console client for the Responses endpoint. +## Deployment profiles + +`gatewayDeploymentMode` is the sample-owned deployment switch. `infra/main.parameters.json` +maps it from `GATEWAY_DEPLOYMENT_MODE` and defaults to `managed`. + +### Managed + +Managed mode preserves the original full-stack `azd up` behavior. It creates +all three resource groups and deploys: + +- The Foundry hosted-agent project, storage, Container Registry, agent + monitoring, project connections, hosted agent, routine, and Toolbox. +- The separate Foundry model account and both model deployments. +- AI Gateway, Gateway monitoring, runtime key, Foundry provider, model + registrations, Connector Namespace, and the GitHub ToolServer. + +The preprovision and postdown hooks own recovery, deletion, and purge only for +the managed Gateway tagged for the current azd environment. + +### Existing + +Existing mode creates only the Foundry hosted-agent project and its dependent +resources. The Gateway resource group, Foundry model resource group, +`foundry-models` module, and `ai-gateway` module all have the compiled ARM +condition `gatewayDeploymentMode == managed`, so Azure does not receive those +resources in existing mode. + +The `.ai-gateway-studio.json` input contract contains: + +```json +{ + "schemaVersion": 1, + "gatewayDeploymentMode": "existing", + "gatewayResourceId": "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/", + "gatewayEndpoint": "https:///", + "githubMcpEndpoint": "https:///default/toolservers//mcp", + "modelAliases": { + "default": "", + "mini": "" + } +} +``` + +The resource ID can use `Microsoft.ApiManagement/service` or +`Microsoft.ApiManagement/aigateways`. No secret is permitted in the handoff. +`scripts/configure-existing-gateway.sh` and +`scripts/configure-existing-gateway.ps1` validate the contract and select the +profile by writing local azd environment values. They do not provision or +modify Azure. + +In existing mode, both lifecycle scripts exit before discovery, recovery, +deletion, or purge. The configuration hooks skip provider checks, model +registration, GitHub credential acquisition, and the GitHub ToolServer `PUT`. +They can call the Gateway control plane only to list keys and invoke +`listSecrets`, then create Foundry project connections and Toolbox +configuration. + The scheduled routine is named `daily-repo-digest`. It runs at 9 AM in the configured timezone and asks the agent for a digest of `microsoft/agent-framework`. The Responses API also supports interactive @@ -182,7 +239,7 @@ the command line. - For a public repository you do not own, choose **Public repositories (read-only)**. This grants the read-only `pull` permission with no repository-permission selection required; skip to step 7. -6. **Repository permissions** (only when you selected a specific repository) — +6. **Repository permissions** (only when you selected a specific repository): set each of these to **Read-only** and leave everything else at **No access**: - Metadata (required; auto-selected) @@ -207,10 +264,10 @@ macOS or Linux (bash): ```bash read -rsp "Fine-grained GitHub token: " GH_TOKEN && echo export GH_TOKEN -azd provision +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd provision ``` -macOS (zsh — the default macOS shell). The `read` prompt syntax differs from +macOS (zsh, the default macOS shell). The `read` prompt syntax differs from bash: the prompt goes *inside* the variable spec as `VAR?prompt`, and `-p` must not be used (in zsh `-p` reads from a coprocess, so no prompt appears). Paste one line at a time so `read` does not consume the following lines as input: @@ -218,14 +275,14 @@ one line at a time so `read` does not consume the following lines as input: ```zsh read -rs "GH_TOKEN?Fine-grained GitHub token: " && echo export GH_TOKEN -azd provision +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd provision ``` Shell-agnostic alternative (hidden entry, works in both bash and zsh): ```bash export GH_TOKEN="$(python3 -c 'import getpass; print(getpass.getpass("Fine-grained GitHub token: "))')" -azd provision +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd provision ``` Windows (PowerShell 7): @@ -233,10 +290,11 @@ Windows (PowerShell 7): ```powershell $GH_TOKEN = Read-Host -Prompt "Fine-grained GitHub token" -AsSecureString $env:GH_TOKEN = [System.Net.NetworkCredential]::new("", $GH_TOKEN).Password -azd provision +$env:AZURE_DEV_USER_AGENT = "microsoft_foundry_skill"; azd provision ``` -`azd up` also works in place of `azd provision`. A GitHub App installation +`AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd up` also works in place of +`AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd provision`. A GitHub App installation access token (`ghs_...`) is an equally accepted least-privilege credential. ### 3. Confirm the warning is gone @@ -258,7 +316,7 @@ the portal as above is what enforces it. ## AI Gateway Bicep contracts -The root deployment creates three resource groups: +In managed mode, the root deployment creates three resource groups: - `foundryagents` for the Foundry project, agent hosting, storage, Container Registry, and hosted-agent monitoring. @@ -266,8 +324,13 @@ The root deployment creates three resource groups: - `gateway` for AI Gateway, Connector Namespace, Gateway monitoring, model provider, and model registrations. -The only cross-group runtime authorization is the AI Gateway system identity's -Foundry User role assignment on the model account. The public Azure +In existing mode, only `foundryagents` is created. The separate model account, +model deployments, AI Gateway, Gateway monitoring, runtime key, provider and +model catalog, Connector Namespace, and Gateway role assignment are all absent +from the evaluated deployment. + +In managed mode, the only cross-group runtime authorization is the AI Gateway +system identity's Foundry User role assignment on the model account. The public Azure role-definition ID used by the template is `53ca6127-db72-4b80-b1b0-d745d6d5456d`. @@ -357,10 +420,25 @@ execution. ## Runtime key handling -Bicep creates `apiKeys/default`. The postprovision hook retrieves the Gateway -key through `listSecrets` and retains `listValues` as a preview-contract -fallback. It can reuse the saved azd environment value when a later -reprovision cannot retrieve the value again. +Managed-mode Bicep creates `apiKeys/default`. In both modes, the postprovision +hook lists Gateway keys, selects the first active key, retrieves it through +`listSecrets`, and retains `listValues` as a preview-contract fallback. It can +reuse the saved azd environment value when a later reprovision cannot retrieve +the value again. + +The key is not a Bicep parameter or output. `azure.yaml` declares +`ai-gateway-model` as a `CustomKeys` Foundry project connection. The hosted +agent receives its nonsecret target and secret through the official runtime +connection placeholders: + +```yaml +AZURE_AI_GATEWAY_ENDPOINT: ${{connections.ai-gateway-model.target}} +AZURE_AI_GATEWAY_API_KEY: ${{connections.ai-gateway-model.credentials.Api-Key}} +``` + +The separate `aigw-github` `RemoteTool` connection stores the same key as an +`Api-Key` custom header for Toolbox. The hosted agent never receives the key +through ordinary `${AZURE_AI_GATEWAY_API_KEY}` substitution. The Gateway key is sent in the explicit `Api-Key` header. It is not sent as `Authorization: Bearer`. The backing Foundry account has local authentication @@ -383,6 +461,9 @@ identity cleanup has completed. disappear, purges the soft-deleted APIM service, and waits for identity cleanup. +Both scripts check `GATEWAY_DEPLOYMENT_MODE` first. Existing mode exits before +any Gateway lookup, recovery, `DELETE`, soft-delete purge, or identity wait. + The scripts use bounded exponential polling. Defaults are: - Initial interval: 5 seconds @@ -445,16 +526,20 @@ The local file contains: ```bash AZURE_AI_GATEWAY_ENDPOINT="https:///" +AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT="https:///default/toolservers//mcp" AZURE_AI_GATEWAY_API_KEY="" AZURE_AI_GATEWAY_MODEL="gpt-latest" AZURE_AI_GATEWAY_MINI_MODEL="gpt-mini-latest" -TOOLBOX_ENDPOINT="https://" +TOOLBOX_ENDPOINT="https:///toolboxes/repo-digest-tools/mcp?api-version=v1" TOOLBOX_NAME="repo-digest-tools" GITHUB_REPOSITORY="/" ``` +The Toolbox URL is the stable consumer endpoint. It omits +`/versions/` and always resolves the Toolbox default version. + For direct local execution, `main.py` disables only Microsoft OpenTelemetry SDK self-telemetry when the Foundry hosting marker is absent. Hosted observability remains enabled in Foundry. @@ -470,6 +555,7 @@ uv run python -m compileall -q . uv run python -m unittest discover -s tests bash tests/test-apim-lifecycle.sh bash tests/test-ai-gateway-model-registration.sh +bash tests/test-deployment-modes.sh az bicep build --file infra/foundry-agents/main.bicep az bicep build --file infra/ai-gateway/main.bicep az bicep build --file infra/foundry-models/main.bicep diff --git a/README.md b/README.md index 7053b21..20c3c36 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,107 @@ # Daily Repo Digest with AI Gateway -This sample runs a Microsoft Agent Framework repo-digest agent in Foundry Hosted Agents. Its model calls use AI Gateway, and Foundry Toolbox routes read-only GitHub MCP calls through AI Gateway. +This sample runs a Microsoft Agent Framework repo-digest agent in Foundry Hosted Agents. Model calls use AI Gateway, and Foundry Toolbox routes read-only GitHub MCP calls through AI Gateway. + +The sample supports two deployment profiles: + +| Profile | `GATEWAY_DEPLOYMENT_MODE` | Ownership | +| --- | --- | --- | +| Full-stack managed | `managed` (default) | The sample creates and manages the Foundry model account and deployments, AI Gateway, Gateway monitoring, runtime key, provider and model catalog, Connector Namespace, Foundry hosted-agent project, secure connections, and Toolbox. | +| Existing AI Gateway | `existing` | The sample creates only the Foundry hosted-agent project and its storage, registry, monitoring, secure connections, agent, routine, and Toolbox. It consumes the supplied AI Gateway without creating, recovering, deleting, purging, or updating any Gateway resource, model, provider, key, or GitHub ToolServer. | ## Prerequisites - Python 3.13+ and [uv](https://docs.astral.sh/uv/getting-started/installation/) -- [Azure Developer CLI 1.27.0+](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) -- [GitHub CLI](https://cli.github.com/) -- [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) on Windows or when using the PowerShell helper scripts -- An Azure subscription and a Github account +- [Azure Developer CLI 1.27.0+](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [GitHub CLI](https://cli.github.com/) for managed mode +- PowerShell 7 on Windows or when using the PowerShell helper scripts +- An Azure subscription and a GitHub account Install the Foundry azd extension: ```bash -azd ext install microsoft.foundry +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd ext install microsoft.foundry ``` -## Set up +## Deploy the full stack -Sign in with GitHub CLI, then deploy: +Managed mode remains the default. Sign in with GitHub CLI, then deploy: ```bash gh auth status --hostname github.com -azd up +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd up +uv sync +``` + +The hook reuses your GitHub CLI login for the read-only GitHub MCP tools. Any login works for a quick test; it only warns, and never fails, if the credential is broader than recommended. **For production and safety, strongly prefer a read-only, repo-scoped fine-grained token.** Your CLI login is account-wide, and the managed-mode hook stores the credential in the cloud AI Gateway ToolServer. + +To create the token and apply it correctly, including the exact GitHub portal settings that grant read access to a public repository you do not own and fix `403 Forbidden` or empty MCP results on `microsoft/agent-framework`, follow [Set up the GitHub credential](IMPLEMENTATION_NOTES.md#tighten-the-github-credential-to-least-privilege). + +## Deploy with an existing AI Gateway + +AI Gateway Studio or another administrator supplies a nonsecret `.ai-gateway-studio.json`. Copy the documented shape and replace every example value: + +```bash +cp .ai-gateway-studio.example.json .ai-gateway-studio.json +``` + +The contract is: + +```json +{ + "schemaVersion": 1, + "gatewayDeploymentMode": "existing", + "gatewayResourceId": "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/", + "gatewayEndpoint": "https:///", + "githubMcpEndpoint": "https:///default/toolservers//mcp", + "modelAliases": { + "default": "", + "mini": "" + } +} +``` + +`gatewayResourceId` can use `Microsoft.ApiManagement/service` or `Microsoft.ApiManagement/aigateways`. The endpoints and model aliases are nonsecret. Do not put an API key in this file. + +Validate the file and select the existing profile without provisioning Azure: + +```bash +# macOS or Linux +./scripts/configure-existing-gateway.sh + +# Windows +pwsh ./scripts/configure-existing-gateway.ps1 +``` + +The bootstrap writes only local azd environment settings. It does not call `azd provision`, `azd deploy`, or an Azure write operation. + +Deploy the sample-owned Foundry resources: + +```bash +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd up uv sync ``` -The hook reuses your GitHub CLI login for the read-only GitHub MCP tools. Any -login works for a quick test; it only warns (never fails) if the credential is -broader than recommended. **For production and safety, strongly prefer a -read-only, repo-scoped fine-grained token** — your CLI login is account-wide and -the hook stores the credential in the cloud AI Gateway ToolServer. +During `azd up`, the postprovision hook reads an active Gateway key with the Gateway `listSecrets` action. The Foundry deployment creates or updates only these project objects: + +- `aigw-github`, a `RemoteTool` connection whose target is `githubMcpEndpoint` and whose `Api-Key` header is stored by Foundry +- `ai-gateway-model`, a `CustomKeys` connection whose target is `gatewayEndpoint` and whose `Api-Key` credential is resolved by the hosted platform +- `repo-digest-tools`, a Toolbox that references `aigw-github` + +Existing mode never sends a Gateway `PUT`, `DELETE`, or purge request. It never changes the existing GitHub ToolServer. The caller needs permission to read an active Gateway key and create connections and Toolbox versions in the new Foundry project. + +## Secret boundary + +Gateway API keys are not Bicep parameters or outputs. The hosted agent does not receive the key through an ordinary `${VAR}` substitution. `azure.yaml` uses the official Foundry connection placeholders: + +```yaml +AZURE_AI_GATEWAY_ENDPOINT: ${{connections.ai-gateway-model.target}} +AZURE_AI_GATEWAY_API_KEY: ${{connections.ai-gateway-model.credentials.Api-Key}} +``` + +Foundry resolves these values from the `CustomKeys` project connection when the hosted container starts. Toolbox sends the same key from its separate `RemoteTool` connection. The agent sends the key only in the `Api-Key` header. -To create the token and apply it correctly — including the exact GitHub portal -settings that grant read access to a public repository you do not own (the fix -for a `403 Forbidden` / empty MCP results on `microsoft/agent-framework`) — -follow **[Set up the GitHub credential](IMPLEMENTATION_NOTES.md#tighten-the-github-credential-to-least-privilege)**. +For local development, the postprovision hook retains the key in the selected, gitignored azd environment. The helper copies it to a gitignored `.env`, creates the file with mode `600` on POSIX systems or a current-user ACL on Windows, and never prints the key. ## Run locally @@ -69,32 +135,32 @@ Or use the console: uv run chat.py ``` -## Deploy +## Invoke the deployed agent ```bash -azd up -azd ai agent invoke "Create a concise daily repo digest for microsoft/agent-framework." +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd ai agent invoke \ + "Create a concise daily repo digest for microsoft/agent-framework." ``` -If Azure leaves a failed AI Gateway after an activation or managed-identity conflict, rerun `azd up`. The preprovision hook deletes only the terminal-Failed `AIGateway` tagged for the current azd environment, purges its APIM soft-delete record, and waits for identity cleanup. `azd down` performs the same bounded purge and settle process in its postdown hook. If the bound expires, follow the exact retry guidance printed by the hook rather than relying on name availability alone. +Managed mode can recover a failed sample-owned Gateway. If Azure leaves it in a terminal failed state after an activation or managed-identity conflict, rerun `AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd up`. The preprovision hook deletes only the `AIGateway` tagged for the current azd environment, purges its APIM soft-delete record, and waits for identity cleanup. `AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd down` performs the same bounded purge and settle process in its postdown hook. Existing mode disables all of those lifecycle actions. Run the scheduled digest immediately: ```bash -azd ai routine dispatch daily-repo-digest +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd ai routine dispatch daily-repo-digest ``` ## Change the agent default repository ```bash -azd env set GITHUB_REPOSITORY "owner/repo" -azd provision +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd env set GITHUB_REPOSITORY "owner/repo" +AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd provision ``` The default is `microsoft/agent-framework`. The included scheduled routine keeps its explicit `microsoft/agent-framework` prompt. ## How it works -**Start with [`repo_digest_agent.py`](repo_digest_agent.py).** It connects the Agent Framework agent directly to the AI Gateway model route and uses Foundry Toolbox for the AI Gateway GitHub MCP route. [`github_mcp_middleware.py`](github_mcp_middleware.py) keeps GitHub results bounded, and [`main.py`](main.py) hosts the agent in Foundry. +Start with [`repo_digest_agent.py`](repo_digest_agent.py). It connects the Agent Framework agent directly to the AI Gateway model route and uses Foundry Toolbox for the AI Gateway GitHub MCP route. [`github_mcp_middleware.py`](github_mcp_middleware.py) keeps GitHub results bounded, and [`main.py`](main.py) hosts the agent in Foundry. -See [`IMPLEMENTATION_NOTES.md`](IMPLEMENTATION_NOTES.md), including the [GitHub credential boundary](IMPLEMENTATION_NOTES.md#github-credential-boundary) and [how to tighten the GitHub credential to least privilege](IMPLEMENTATION_NOTES.md#tighten-the-github-credential-to-least-privilege), for architecture, authentication, AI Gateway contracts, security boundaries, configuration, monitoring, and deployment details. +See [`IMPLEMENTATION_NOTES.md`](IMPLEMENTATION_NOTES.md) for architecture, authentication, deployment contracts, security boundaries, monitoring, lifecycle behavior, and validation. diff --git a/azure.yaml b/azure.yaml index 3ca405f..de0f779 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,7 +2,7 @@ requiredVersions: extensions: - azure.ai.agents: '>=0.1.34-preview' + azure.ai.agents: '>=1.0.0-beta.4' azure.ai.routines: '>=1.0.0-beta.1' microsoft.foundry: '>=1.0.0-beta.1' name: dailydigest @@ -11,6 +11,17 @@ metadata: services: ai-project: host: azure.ai.project + ai-gateway-model: + host: azure.ai.connection + uses: + - ai-project + category: CustomKeys + target: ${AZURE_AI_GATEWAY_ENDPOINT} + authType: CustomKeys + credentials: + type: CustomKeys + keys: + Api-Key: ${AZURE_AI_GATEWAY_API_KEY} daily-repo-digest: host: azure.ai.routine uses: @@ -34,6 +45,7 @@ services: remoteBuild: true uses: - ai-project + - ai-gateway-model config: startupCommand: uv run --frozen --no-dev python main.py container: @@ -43,13 +55,13 @@ services: description: Daily repo digest agent hosted by Microsoft Foundry and routed through AI Gateway. environmentVariables: - name: AZURE_AI_GATEWAY_ENDPOINT - value: ${AZURE_AI_GATEWAY_ENDPOINT} + value: ${{connections.ai-gateway-model.target}} - name: AZURE_AI_GATEWAY_MODEL value: ${AZURE_AI_GATEWAY_MODEL} - name: AZURE_AI_GATEWAY_MINI_MODEL value: ${AZURE_AI_GATEWAY_MINI_MODEL} - name: AZURE_AI_GATEWAY_API_KEY - value: ${AZURE_AI_GATEWAY_API_KEY} + value: ${{connections.ai-gateway-model.credentials.Api-Key}} - name: TOOLBOX_ENDPOINT value: ${TOOLBOX_ENDPOINT} - name: TOOLBOX_NAME diff --git a/infra/main.bicep b/infra/main.bicep index a09e599..3239195 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -5,6 +5,28 @@ targetScope = 'subscription' @description('azd environment name. Used for deterministic uniqueness and resource tagging.') param environmentName string = 'dailydigest' +@allowed([ + 'managed' + 'existing' +]) +@description('AI Gateway ownership mode. managed preserves the full-stack sample deployment; existing consumes a gateway supplied by the operator.') +param gatewayDeploymentMode string = 'managed' + +@description('Full ARM resource ID of the AI Gateway consumed when gatewayDeploymentMode is existing.') +param existingGatewayResourceId string = '' + +@description('Nonsecret runtime endpoint of the AI Gateway consumed when gatewayDeploymentMode is existing.') +param existingGatewayEndpoint string = '' + +@description('Nonsecret GitHub MCP endpoint already exposed by the existing AI Gateway.') +param existingGatewayGitHubMcpEndpoint string = '' + +@description('Model alias accepted by the existing AI Gateway for the full model route.') +param existingGatewayModelAlias string = '' + +@description('Model alias accepted by the existing AI Gateway for the efficient mini model route.') +param existingGatewayMiniModelAlias string = '' + @metadata({ azd: { type: 'location' @@ -117,6 +139,7 @@ param modelVersion string = '2026-07-09' var resourceToken = take(toLower(uniqueString(subscription().id, environmentName, location)), 8) var environmentLabel = toLower(environmentName) var tags = { 'azd-env-name': environmentName } +var deployManagedGateway = gatewayDeploymentMode == 'managed' var effectiveGatewayResourceGroupName = !empty(gatewayResourceGroupName) ? gatewayResourceGroupName : 'rg-${environmentLabel}-${resourceToken}-gateway' var effectiveFoundryModelsResourceGroupName = !empty(foundryModelsResourceGroupName) ? foundryModelsResourceGroupName : 'rg-${environmentLabel}-${resourceToken}-foundrymodels' var effectiveFoundryAgentsResourceGroupName = !empty(foundryAgentsResourceGroupName) ? foundryAgentsResourceGroupName : 'rg-${environmentLabel}-${resourceToken}-foundryagents' @@ -151,13 +174,13 @@ var gatewayModelDeployments = [ } ] -resource gatewayRg 'Microsoft.Resources/resourceGroups@2021-04-01' = { +resource gatewayRg 'Microsoft.Resources/resourceGroups@2021-04-01' = if (deployManagedGateway) { name: effectiveGatewayResourceGroupName location: location tags: tags } -resource foundryModelsRg 'Microsoft.Resources/resourceGroups@2021-04-01' = { +resource foundryModelsRg 'Microsoft.Resources/resourceGroups@2021-04-01' = if (deployManagedGateway) { name: effectiveFoundryModelsResourceGroupName location: location tags: tags @@ -169,7 +192,7 @@ resource foundryAgentsRg 'Microsoft.Resources/resourceGroups@2021-04-01' = { tags: tags } -module foundryModels './foundry-models/main.bicep' = { +module foundryModels './foundry-models/main.bicep' = if (deployManagedGateway) { name: 'foundry-models-${resourceToken}' scope: foundryModelsRg params: { @@ -199,7 +222,7 @@ module foundryAgents './foundry-agents/main.bicep' = { } } -module aiGateway './ai-gateway/main.bicep' = { +module aiGateway './ai-gateway/main.bicep' = if (deployManagedGateway) { name: 'ai-gateway-${resourceToken}' scope: gatewayRg params: { @@ -215,14 +238,14 @@ module aiGateway './ai-gateway/main.bicep' = { foundryProviderName: 'foundry' foundryProviderDisplayName: 'Foundry' foundryProviderDescription: 'Managed-identity Foundry provider for the deployments behind this sample.' - foundryEndpoint: foundryModels.outputs.aiServicesEndpoint + foundryEndpoint: foundryModels!.outputs.aiServicesEndpoint foundryResourceIds: [ - foundryModels.outputs.aiServicesId + foundryModels!.outputs.aiServicesId ] foundryModels: [for (deployment, i) in gatewayModelDeployments: { armName: deployment.deploymentName description: deployment.description - resourceId: foundryModels.outputs.modelDeploymentIds[i] + resourceId: foundryModels!.outputs.modelDeploymentIds[i] modelName: deployment.modelName modelVersion: deployment.modelVersion tokenLimit: deployment.capacity * 1000 @@ -239,11 +262,12 @@ output AZURE_LOCATION string = location output AZURE_TENANT_ID string = tenant().tenantId output RESOURCE_GROUP string = foundryAgentsRg.name output AZURE_RESOURCE_GROUP string = foundryAgentsRg.name -output AI_GATEWAY_RESOURCE_GROUP string = gatewayRg.name -output FOUNDRY_MODELS_RESOURCE_GROUP string = foundryModelsRg.name +output GATEWAY_DEPLOYMENT_MODE string = gatewayDeploymentMode +output AI_GATEWAY_RESOURCE_GROUP string = deployManagedGateway ? gatewayRg!.name : split(existingGatewayResourceId, '/')[4] +output FOUNDRY_MODELS_RESOURCE_GROUP string = deployManagedGateway ? foundryModelsRg!.name : '' output FOUNDRY_AGENTS_RESOURCE_GROUP string = foundryAgentsRg.name -output FOUNDRY_MODELS_RESOURCE_ID string = foundryModels.outputs.aiServicesId -output FOUNDRY_MODELS_NAME string = foundryModels.outputs.aiServicesName +output FOUNDRY_MODELS_RESOURCE_ID string = deployManagedGateway ? foundryModels!.outputs.aiServicesId : '' +output FOUNDRY_MODELS_NAME string = deployManagedGateway ? foundryModels!.outputs.aiServicesName : '' output AI_SERVICES_NAME string = foundryAgents.outputs.aiServicesName output PROJECT_ENDPOINT string = foundryAgents.outputs.projectEndpoint output FOUNDRY_PROJECT_ENDPOINT string = foundryAgents.outputs.projectEndpoint @@ -261,21 +285,22 @@ output FOUNDRY_APPLICATION_INSIGHTS_CONNECTION_NAME string = foundryAgents.outpu output GITHUB_REPOSITORY string = githubRepository output TOOLBOX_NAME string = 'repo-digest-tools' output ENABLE_AI_GATEWAY bool = true -output AI_GATEWAY_NAME string = effectiveAiGatewayName -output AI_GATEWAY_LOCATION string = aiGatewayLocation -output AI_GATEWAY_RESOURCE_ID string = aiGateway.outputs.apimGatewayId -output AI_GATEWAY_CONNECTOR_NAMESPACE_RESOURCE_ID string = aiGateway.outputs.connectorNamespaceId -output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_NAME string = aiGateway.outputs.foundryUserRoleAssignmentName -output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_PRINCIPAL_ID string = aiGateway.outputs.gatewayPrincipalId -output APPLICATION_INSIGHTS_NAME string = aiGateway.outputs.appInsightsName -output LOG_ANALYTICS_WORKSPACE_ID string = aiGateway.outputs.logAnalyticsWorkspaceId -output LOG_ANALYTICS_WORKSPACE_NAME string = aiGateway.outputs.logAnalyticsWorkspaceName -output AZURE_AI_GATEWAY_ENDPOINT string = 'https://${aiGateway.outputs.runtimeHostname}/' -output AZURE_AI_GATEWAY_MODEL string = modelDeploymentName -output AZURE_AI_GATEWAY_MINI_MODEL string = miniModelDeploymentName -output AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT string = modelDeploymentName -output AI_GATEWAY_INTERNAL_MODEL_NAME string = modelName -output AI_GATEWAY_INTERNAL_MODEL_VERSION string = modelVersion -output AI_GATEWAY_INTERNAL_MINI_MODEL_DEPLOYMENT string = miniModelDeploymentName -output AI_GATEWAY_INTERNAL_MINI_MODEL_NAME string = miniModelName -output AI_GATEWAY_INTERNAL_MINI_MODEL_VERSION string = miniModelVersion +output AI_GATEWAY_NAME string = deployManagedGateway ? effectiveAiGatewayName : last(split(existingGatewayResourceId, '/')) +output AI_GATEWAY_LOCATION string = deployManagedGateway ? aiGatewayLocation : '' +output AI_GATEWAY_RESOURCE_ID string = deployManagedGateway ? aiGateway!.outputs.apimGatewayId : existingGatewayResourceId +output AI_GATEWAY_CONNECTOR_NAMESPACE_RESOURCE_ID string = deployManagedGateway ? aiGateway!.outputs.connectorNamespaceId : '' +output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_NAME string = deployManagedGateway ? aiGateway!.outputs.foundryUserRoleAssignmentName : '' +output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_PRINCIPAL_ID string = deployManagedGateway ? aiGateway!.outputs.gatewayPrincipalId : '' +output APPLICATION_INSIGHTS_NAME string = deployManagedGateway ? aiGateway!.outputs.appInsightsName : '' +output LOG_ANALYTICS_WORKSPACE_ID string = deployManagedGateway ? aiGateway!.outputs.logAnalyticsWorkspaceId : '' +output LOG_ANALYTICS_WORKSPACE_NAME string = deployManagedGateway ? aiGateway!.outputs.logAnalyticsWorkspaceName : '' +output AZURE_AI_GATEWAY_ENDPOINT string = deployManagedGateway ? 'https://${aiGateway!.outputs.runtimeHostname}/' : existingGatewayEndpoint +output AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT string = deployManagedGateway ? 'https://${aiGateway!.outputs.runtimeHostname}/default/toolservers/github/mcp' : existingGatewayGitHubMcpEndpoint +output AZURE_AI_GATEWAY_MODEL string = deployManagedGateway ? modelDeploymentName : existingGatewayModelAlias +output AZURE_AI_GATEWAY_MINI_MODEL string = deployManagedGateway ? miniModelDeploymentName : existingGatewayMiniModelAlias +output AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT string = deployManagedGateway ? modelDeploymentName : '' +output AI_GATEWAY_INTERNAL_MODEL_NAME string = deployManagedGateway ? modelName : '' +output AI_GATEWAY_INTERNAL_MODEL_VERSION string = deployManagedGateway ? modelVersion : '' +output AI_GATEWAY_INTERNAL_MINI_MODEL_DEPLOYMENT string = deployManagedGateway ? miniModelDeploymentName : '' +output AI_GATEWAY_INTERNAL_MINI_MODEL_NAME string = deployManagedGateway ? miniModelName : '' +output AI_GATEWAY_INTERNAL_MINI_MODEL_VERSION string = deployManagedGateway ? miniModelVersion : '' diff --git a/infra/main.parameters.json b/infra/main.parameters.json index bc915c4..3edf1bd 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -7,6 +7,24 @@ }, "location": { "value": "${AZURE_LOCATION}" + }, + "gatewayDeploymentMode": { + "value": "${GATEWAY_DEPLOYMENT_MODE=managed}" + }, + "existingGatewayResourceId": { + "value": "${EXISTING_AI_GATEWAY_RESOURCE_ID=}" + }, + "existingGatewayEndpoint": { + "value": "${AZURE_AI_GATEWAY_ENDPOINT=}" + }, + "existingGatewayGitHubMcpEndpoint": { + "value": "${AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT=}" + }, + "existingGatewayModelAlias": { + "value": "${AZURE_AI_GATEWAY_MODEL=}" + }, + "existingGatewayMiniModelAlias": { + "value": "${AZURE_AI_GATEWAY_MINI_MODEL=}" } } } diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 0db0b19..eb7cb65 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -11,9 +11,23 @@ $toolboxConnectionName = "aigw-github" $toolboxName = "repo-digest-tools" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +function Invoke-Azd([string[]]$Arguments) { + $previousUserAgent = $env:AZURE_DEV_USER_AGENT + try { + $env:AZURE_DEV_USER_AGENT = "microsoft_foundry_skill" + & azd @Arguments + } finally { + if ($null -eq $previousUserAgent) { + Remove-Item Env:AZURE_DEV_USER_AGENT -ErrorAction SilentlyContinue + } else { + $env:AZURE_DEV_USER_AGENT = $previousUserAgent + } + } +} + function Get-AzdValue($Name) { try { - $value = azd env get-value $Name 2>$null + $value = Invoke-Azd @("env", "get-value", $Name) 2>$null return $value.Trim() } catch { return "" @@ -70,17 +84,40 @@ function Invoke-AzRestPutJson($Uri, $Body) { } function Get-GatewayApiKeyValue($GatewayResourceId) { - $listSecretsUri = "https://management.azure.com${GatewayResourceId}/apiKeys/default/listSecrets?api-version=$aiGatewayApiVersion" try { - $value = az rest --method post --uri $listSecretsUri --body "{}" --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" -o tsv 2>$null + $keyName = [string](az rest ` + --method get ` + --uri "https://management.azure.com${GatewayResourceId}/apiKeys?api-version=$aiGatewayApiVersion" ` + --query "value[?properties.state=='active'].name | [0]" ` + -o tsv 2>$null) + } catch { + $keyName = "" + } + if ([string]::IsNullOrWhiteSpace($keyName)) { + try { + $keyName = [string](az rest ` + --method get ` + --uri "https://management.azure.com${GatewayResourceId}/apiKeys?api-version=$aiGatewayApiVersion" ` + --query "value[0].name" ` + -o tsv 2>$null) + } catch { + return "" + } + } + if ([string]::IsNullOrWhiteSpace($keyName)) { return "" } + $keyName = $keyName.Trim() + + $listSecretsUri = "https://management.azure.com${GatewayResourceId}/apiKeys/$keyName/listSecrets?api-version=$aiGatewayApiVersion" + try { + $value = az rest --method post --uri $listSecretsUri --body "{}" --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue || value" -o tsv 2>$null if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } } catch { Write-Verbose "The current backend does not expose listSecrets; trying listValues." } - $listValuesUri = "https://management.azure.com${GatewayResourceId}/apiKeys/default/listValues?api-version=$aiGatewayApiVersion" + $listValuesUri = "https://management.azure.com${GatewayResourceId}/apiKeys/$keyName/listValues?api-version=$aiGatewayApiVersion" try { - $value = az rest --method post --uri $listValuesUri --body "{}" --query "primaryValue || properties.primaryValue || primaryKey || properties.primaryKey" -o tsv 2>$null + $value = az rest --method post --uri $listValuesUri --body "{}" --query "primaryValue || properties.primaryValue || primaryKey || properties.primaryKey || value" -o tsv 2>$null if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } } catch { return "" @@ -197,56 +234,91 @@ function Prepare-BicepRbac { } } +$gatewayDeploymentMode = First-Value @($env:GATEWAY_DEPLOYMENT_MODE, (Get-AzdValue "GATEWAY_DEPLOYMENT_MODE"), "managed") +if (@("managed", "existing") -notcontains $gatewayDeploymentMode) { + throw "GATEWAY_DEPLOYMENT_MODE must be managed or existing." +} + $mode = $args.Count -gt 0 ? $args[0] : "" if ($mode -eq "--prepare-bicep") { - & (Join-Path $PSScriptRoot "manage-ai-gateway-lifecycle.ps1") prepare - Prepare-BicepRbac + if ($gatewayDeploymentMode -eq "existing") { + Write-Host "Existing AI Gateway mode: skipping Gateway recovery and Bicep RBAC adoption." + } else { + & (Join-Path $PSScriptRoot "manage-ai-gateway-lifecycle.ps1") prepare + Prepare-BicepRbac + } exit 0 } if (-not [string]::IsNullOrWhiteSpace($mode)) { throw "Usage: $PSCommandPath [--prepare-bicep]" } -Write-Host "Finishing the Bicep-provisioned AI Gateway with the local GitHub MCP credential." +if ($gatewayDeploymentMode -eq "existing") { + Write-Host "Configuring Foundry to consume the existing AI Gateway." +} else { + Write-Host "Finishing the Bicep-provisioned AI Gateway with the local GitHub MCP credential." +} $environmentName = Require-Value "AZURE_ENV_NAME" (First-Value @($env:AZURE_ENV_NAME, (Get-AzdValue "AZURE_ENV_NAME"))) -$subscriptionId = Require-Value "AZURE_SUBSCRIPTION_ID" (First-Value @($env:AZURE_SUBSCRIPTION_ID, (Get-AzdValue "AZURE_SUBSCRIPTION_ID"), (az account show --query id -o tsv))) -$resourceGroup = Require-Value "AI_GATEWAY_RESOURCE_GROUP" (First-Value @($env:AI_GATEWAY_RESOURCE_GROUP, (Get-AzdValue "AI_GATEWAY_RESOURCE_GROUP"), $env:RESOURCE_GROUP, $env:AZURE_RESOURCE_GROUP, (Get-AzdValue "RESOURCE_GROUP"), (Get-AzdValue "AZURE_RESOURCE_GROUP"))) -$gatewayName = Require-Value "AI_GATEWAY_NAME" (First-Value @($env:AI_GATEWAY_NAME, (Get-AzdValue "AI_GATEWAY_NAME"))) $gatewayModel = Require-Value "AZURE_AI_GATEWAY_MODEL" (First-Value @($env:AZURE_AI_GATEWAY_MODEL, (Get-AzdValue "AZURE_AI_GATEWAY_MODEL"))) $gatewayMiniModel = Require-Value "AZURE_AI_GATEWAY_MINI_MODEL" (First-Value @($env:AZURE_AI_GATEWAY_MINI_MODEL, (Get-AzdValue "AZURE_AI_GATEWAY_MINI_MODEL"))) $githubRepository = First-Value @($env:GITHUB_REPOSITORY, (Get-AzdValue "GITHUB_REPOSITORY"), $defaultRepository) $projectEndpoint = Require-Value "FOUNDRY_PROJECT_ENDPOINT" (First-Value @($env:FOUNDRY_PROJECT_ENDPOINT, (Get-AzdValue "FOUNDRY_PROJECT_ENDPOINT"))) -$gatewayResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ApiManagement/service/$gatewayName" -$legacyGatewayResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ApiManagement/aigateways/$gatewayName" -$workspaceName = First-Value @($env:AI_GATEWAY_WORKSPACE_NAME, "default") -$workspaceResourceId = "$gatewayResourceId/workspaces/$workspaceName" -$gatewayUri = "https://management.azure.com${gatewayResourceId}?api-version=$aiGatewayApiVersion" +if ($gatewayDeploymentMode -eq "existing") { + $gatewayResourceId = Require-Value "EXISTING_AI_GATEWAY_RESOURCE_ID" (First-Value @( + $env:EXISTING_AI_GATEWAY_RESOURCE_ID, + (Get-AzdValue "EXISTING_AI_GATEWAY_RESOURCE_ID"), + (Get-AzdValue "AI_GATEWAY_RESOURCE_ID") + )) + $resourcePattern = "^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.ApiManagement/(service|aigateways)/[^/]+$" + if ($gatewayResourceId -notmatch $resourcePattern) { + throw "EXISTING_AI_GATEWAY_RESOURCE_ID must be a full AI Gateway ARM resource ID." + } + $gatewayUrl = Require-Value "AZURE_AI_GATEWAY_ENDPOINT" (First-Value @( + $env:AZURE_AI_GATEWAY_ENDPOINT, + (Get-AzdValue "AZURE_AI_GATEWAY_ENDPOINT") + )) + $githubMcpEndpoint = Require-Value "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT" (First-Value @( + $env:AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT, + (Get-AzdValue "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT") + )) +} else { + $subscriptionId = Require-Value "AZURE_SUBSCRIPTION_ID" (First-Value @($env:AZURE_SUBSCRIPTION_ID, (Get-AzdValue "AZURE_SUBSCRIPTION_ID"), (az account show --query id -o tsv))) + $resourceGroup = Require-Value "AI_GATEWAY_RESOURCE_GROUP" (First-Value @($env:AI_GATEWAY_RESOURCE_GROUP, (Get-AzdValue "AI_GATEWAY_RESOURCE_GROUP"), $env:RESOURCE_GROUP, $env:AZURE_RESOURCE_GROUP, (Get-AzdValue "RESOURCE_GROUP"), (Get-AzdValue "AZURE_RESOURCE_GROUP"))) + $gatewayName = Require-Value "AI_GATEWAY_NAME" (First-Value @($env:AI_GATEWAY_NAME, (Get-AzdValue "AI_GATEWAY_NAME"))) + $gatewayResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ApiManagement/service/$gatewayName" + $legacyGatewayResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ApiManagement/aigateways/$gatewayName" + $workspaceName = First-Value @($env:AI_GATEWAY_WORKSPACE_NAME, "default") + $workspaceResourceId = "$gatewayResourceId/workspaces/$workspaceName" + $gatewayUri = "https://management.azure.com${gatewayResourceId}?api-version=$aiGatewayApiVersion" + + if (-not (Test-AzRestResource $gatewayUri)) { + $legacyGatewayUri = "https://management.azure.com${legacyGatewayResourceId}?api-version=$legacyAiGatewayApiVersion" + if (Test-AzRestResource $legacyGatewayUri) { + throw "The environment uses the retired Microsoft.ApiManagement/aigateways resource. Use a fresh azd environment; this hook will not access hidden projected APIM resources." + } + throw "Bicep did not create the expected Microsoft.ApiManagement/service AI Gateway: $gatewayName" + } -if (-not (Test-AzRestResource $gatewayUri)) { - $legacyGatewayUri = "https://management.azure.com${legacyGatewayResourceId}?api-version=$legacyAiGatewayApiVersion" - if (Test-AzRestResource $legacyGatewayUri) { - throw "The environment uses the retired Microsoft.ApiManagement/aigateways resource. Use a fresh azd environment; this hook will not access hidden projected APIM resources." + $gatewayState = [string](az rest --method get --uri $gatewayUri --query properties.provisioningState -o tsv) + if ($gatewayState -ne "Succeeded") { + throw "The Bicep-provisioned AI Gateway is not ready: $gatewayState" } - throw "Bicep did not create the expected Microsoft.ApiManagement/service AI Gateway: $gatewayName" -} -$gatewayState = [string](az rest --method get --uri $gatewayUri --query properties.provisioningState -o tsv) -if ($gatewayState -ne "Succeeded") { - throw "The Bicep-provisioned AI Gateway is not ready: $gatewayState" -} + $identityType = [string](az rest --method get --uri $gatewayUri --query identity.type -o tsv) + if (@("SystemAssigned", "SystemAssigned, UserAssigned") -notcontains $identityType) { + throw "Bicep did not enable the required AI Gateway system-assigned identity." + } -$identityType = [string](az rest --method get --uri $gatewayUri --query identity.type -o tsv) -if (@("SystemAssigned", "SystemAssigned, UserAssigned") -notcontains $identityType) { - throw "Bicep did not enable the required AI Gateway system-assigned identity." + $gatewayUrl = Require-Value "AZURE_AI_GATEWAY_ENDPOINT" ([string](az rest --method get --uri $gatewayUri --query properties.gatewayUrl -o tsv)) + $githubMcpEndpoint = $gatewayUrl.TrimEnd("/") + "/default/toolservers/github/mcp" } - -$gatewayUrl = Require-Value "AZURE_AI_GATEWAY_ENDPOINT" ([string](az rest --method get --uri $gatewayUri --query properties.gatewayUrl -o tsv)) if (-not $gatewayUrl.EndsWith("/", [StringComparison]::Ordinal)) { $gatewayUrl += "/" } +if ($gatewayDeploymentMode -eq "managed") { $workspaceChildrenUri = "https://management.azure.com${workspaceResourceId}/modelProviders?api-version=$aiGatewayApiVersion" $workspaceChildrenReady = $false for ($attempt = 1; $attempt -le 30; $attempt++) { @@ -345,6 +417,9 @@ Invoke-AzRestPutJson $toolServerUri $toolServerBody $githubToken = $null $githubAuthorization = $null $toolServerBody = $null +} else { + Write-Host "Existing AI Gateway mode: preserving its provider, models, keys, and GitHub ToolServer." +} $savedGatewayApiKey = First-Value @($env:AZURE_AI_GATEWAY_API_KEY, (Get-AzdValue "AZURE_AI_GATEWAY_API_KEY")) $gatewayApiKey = Get-GatewayApiKeyValue $gatewayResourceId @@ -355,43 +430,50 @@ $gatewayApiKey = Require-Value "AZURE_AI_GATEWAY_API_KEY" $gatewayApiKey Test-GatewayModelRoute $gatewayUrl $gatewayModel $gatewayApiKey -azd env set AZURE_AI_GATEWAY_ENDPOINT $gatewayUrl -azd env set AZURE_AI_GATEWAY_MODEL $gatewayModel -azd env set AZURE_AI_GATEWAY_MINI_MODEL $gatewayMiniModel -azd env set GITHUB_REPOSITORY $githubRepository -azd env set AZURE_AI_GATEWAY_API_KEY $gatewayApiKey | Out-Null -azd env set TOOLBOX_NAME $toolboxName +Invoke-Azd @("env", "set", "AZURE_AI_GATEWAY_ENDPOINT", $gatewayUrl) +Invoke-Azd @("env", "set", "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT", $githubMcpEndpoint) +Invoke-Azd @("env", "set", "AZURE_AI_GATEWAY_MODEL", $gatewayModel) +Invoke-Azd @("env", "set", "AZURE_AI_GATEWAY_MINI_MODEL", $gatewayMiniModel) +Invoke-Azd @("env", "set", "GITHUB_REPOSITORY", $githubRepository) +Invoke-Azd @("env", "set", "AZURE_AI_GATEWAY_API_KEY", $gatewayApiKey) | Out-Null +Invoke-Azd @("env", "set", "TOOLBOX_NAME", $toolboxName) Write-Host "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer." -azd ai connection create $toolboxConnectionName ` - --kind remote-tool ` - --target ($gatewayUrl.TrimEnd("/") + "/default/toolservers/github/mcp") ` - --auth-type custom-keys ` - --custom-key "Api-Key=$gatewayApiKey" ` - --force ` - --no-prompt ` - --project-endpoint $projectEndpoint ` - -o json | Out-Null +Invoke-Azd @( + "ai", "connection", "create", $toolboxConnectionName, + "--kind", "remote-tool", + "--target", $githubMcpEndpoint, + "--auth-type", "custom-keys", + "--custom-key", "Api-Key=$gatewayApiKey", + "--force", + "--no-prompt", + "--project-endpoint", $projectEndpoint, + "-o", "json" +) | Out-Null $toolboxExists = $true try { - $toolboxJson = azd ai toolbox show $toolboxName ` - --no-prompt ` - --project-endpoint $projectEndpoint ` - -o json 2>$null + Invoke-Azd @( + "ai", "toolbox", "show", $toolboxName, + "--no-prompt", + "--project-endpoint", $projectEndpoint, + "-o", "json" + ) 2>$null | Out-Null } catch { $toolboxExists = $false } if (-not $toolboxExists) { Write-Host "Creating the Foundry Toolbox." - $toolboxJson = azd ai toolbox create $toolboxName ` - --from-file (Join-Path $repoRoot "toolbox.yaml") ` - --no-prompt ` - --project-endpoint $projectEndpoint ` - -o json + Invoke-Azd @( + "ai", "toolbox", "create", $toolboxName, + "--from-file", (Join-Path $repoRoot "toolbox.yaml"), + "--no-prompt", + "--project-endpoint", $projectEndpoint, + "-o", "json" + ) | Out-Null } -$toolboxEndpoint = ($toolboxJson | Out-String | ConvertFrom-Json).endpoint -azd env set TOOLBOX_ENDPOINT $toolboxEndpoint +$toolboxEndpoint = $projectEndpoint.TrimEnd("/") + "/toolboxes/$toolboxName/mcp?api-version=v1" +Invoke-Azd @("env", "set", "TOOLBOX_ENDPOINT", $toolboxEndpoint) Remove-AzdEnvValues @( "AI_SERVICES_NAME", "AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT", @@ -422,4 +504,8 @@ Remove-AzdEnvValues @( "FOUNDRY_API_KEY" ) -Write-Host "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." +if ($gatewayDeploymentMode -eq "existing") { + Write-Host "Existing AI Gateway setup complete. The hook changed only Foundry project connections and Toolbox configuration." +} else { + Write-Host "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." +} diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 930929a..2b4ebf0 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -12,9 +12,13 @@ TOOLBOX_CONNECTION_NAME="aigw-github" TOOLBOX_NAME="repo-digest-tools" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +run_azd() { + AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd "$@" +} + azd_value() { local value - if value="$(azd env get-value "$1" 2>/dev/null)"; then + if value="$(run_azd env get-value "$1" 2>/dev/null)"; then printf '%s' "$value" fi } @@ -53,12 +57,29 @@ PY } get_gateway_api_key_value() { + local gateway_id="$1" + local key_name local value + + key_name="$(az rest \ + --method get \ + --uri "https://management.azure.com${gateway_id}/apiKeys?api-version=${AI_GATEWAY_API_VERSION}" \ + --query "value[?properties.state=='active'].name | [0]" \ + -o tsv 2>/dev/null || true)" + if [ -z "$key_name" ]; then + key_name="$(az rest \ + --method get \ + --uri "https://management.azure.com${gateway_id}/apiKeys?api-version=${AI_GATEWAY_API_VERSION}" \ + --query "value[0].name" \ + -o tsv 2>/dev/null || true)" + fi + [ -n "$key_name" ] || return 1 + if value="$(az rest \ --method post \ - --uri "https://management.azure.com${gateway_resource_id}/apiKeys/default/listSecrets?api-version=${AI_GATEWAY_API_VERSION}" \ + --uri "https://management.azure.com${gateway_id}/apiKeys/${key_name}/listSecrets?api-version=${AI_GATEWAY_API_VERSION}" \ --body '{}' \ - --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" \ + --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue || value" \ -o tsv 2>/dev/null)" && [ -n "$value" ]; then printf '%s' "$value" return 0 @@ -66,9 +87,9 @@ get_gateway_api_key_value() { if value="$(az rest \ --method post \ - --uri "https://management.azure.com${gateway_resource_id}/apiKeys/default/listValues?api-version=${AI_GATEWAY_API_VERSION}" \ + --uri "https://management.azure.com${gateway_id}/apiKeys/${key_name}/listValues?api-version=${AI_GATEWAY_API_VERSION}" \ --body '{}' \ - --query "primaryValue || properties.primaryValue || primaryKey || properties.primaryKey" \ + --query "primaryValue || properties.primaryValue || primaryKey || properties.primaryKey || value" \ -o tsv 2>/dev/null)" && [ -n "$value" ]; then printf '%s' "$value" return 0 @@ -197,9 +218,22 @@ prepare_bicep_rbac() { -o tsv) } +gateway_deployment_mode="$(first_value "${GATEWAY_DEPLOYMENT_MODE:-}" "$(azd_value GATEWAY_DEPLOYMENT_MODE)" "managed")" +case "$gateway_deployment_mode" in + managed|existing) ;; + *) + echo "GATEWAY_DEPLOYMENT_MODE must be managed or existing." >&2 + exit 1 + ;; +esac + if [ "${1:-}" = "--prepare-bicep" ]; then - bash "$REPO_ROOT/infra/scripts/manage-ai-gateway-lifecycle.sh" prepare - prepare_bicep_rbac + if [ "$gateway_deployment_mode" = "existing" ]; then + echo "Existing AI Gateway mode: skipping Gateway recovery and Bicep RBAC adoption." + else + bash "$REPO_ROOT/infra/scripts/manage-ai-gateway-lifecycle.sh" prepare + prepare_bicep_rbac + fi exit 0 elif [ "$#" -gt 0 ]; then echo "Usage: $0 [--prepare-bicep]" >&2 @@ -212,17 +246,34 @@ cleanup() { } trap cleanup EXIT -echo "Finishing the Bicep-provisioned AI Gateway with the local GitHub MCP credential." +if [ "$gateway_deployment_mode" = "existing" ]; then + echo "Configuring Foundry to consume the existing AI Gateway." +else + echo "Finishing the Bicep-provisioned AI Gateway with the local GitHub MCP credential." +fi environment_name="$(require_value AZURE_ENV_NAME "$(first_value "${AZURE_ENV_NAME:-}" "$(azd_value AZURE_ENV_NAME)")")" -subscription_id="$(require_value AZURE_SUBSCRIPTION_ID "$(first_value "${AZURE_SUBSCRIPTION_ID:-}" "$(azd_value AZURE_SUBSCRIPTION_ID)" "$(az account show --query id -o tsv)")")" -resource_group="$(require_value AI_GATEWAY_RESOURCE_GROUP "$(first_value "${AI_GATEWAY_RESOURCE_GROUP:-}" "$(azd_value AI_GATEWAY_RESOURCE_GROUP)" "${RESOURCE_GROUP:-}" "${AZURE_RESOURCE_GROUP:-}" "$(azd_value RESOURCE_GROUP)" "$(azd_value AZURE_RESOURCE_GROUP)")")" -gateway_name="$(require_value AI_GATEWAY_NAME "$(first_value "${AI_GATEWAY_NAME:-}" "$(azd_value AI_GATEWAY_NAME)")")" gateway_model="$(require_value AZURE_AI_GATEWAY_MODEL "$(first_value "${AZURE_AI_GATEWAY_MODEL:-}" "$(azd_value AZURE_AI_GATEWAY_MODEL)")")" gateway_mini_model="$(require_value AZURE_AI_GATEWAY_MINI_MODEL "$(first_value "${AZURE_AI_GATEWAY_MINI_MODEL:-}" "$(azd_value AZURE_AI_GATEWAY_MINI_MODEL)")")" github_repository="$(first_value "${GITHUB_REPOSITORY:-}" "$(azd_value GITHUB_REPOSITORY)" "$DEFAULT_REPOSITORY")" project_endpoint="$(require_value FOUNDRY_PROJECT_ENDPOINT "$(first_value "${FOUNDRY_PROJECT_ENDPOINT:-}" "$(azd_value FOUNDRY_PROJECT_ENDPOINT)")")" +if [ "$gateway_deployment_mode" = "existing" ]; then + gateway_resource_id="$(require_value EXISTING_AI_GATEWAY_RESOURCE_ID "$(first_value "${EXISTING_AI_GATEWAY_RESOURCE_ID:-}" "$(azd_value EXISTING_AI_GATEWAY_RESOURCE_ID)" "$(azd_value AI_GATEWAY_RESOURCE_ID)")")" + case "$gateway_resource_id" in + /subscriptions/*/resourceGroups/*/providers/Microsoft.ApiManagement/service/* | \ + /subscriptions/*/resourceGroups/*/providers/Microsoft.ApiManagement/aigateways/*) ;; + *) + echo "EXISTING_AI_GATEWAY_RESOURCE_ID must be a full AI Gateway ARM resource ID." >&2 + exit 1 + ;; + esac + gateway_url="$(require_value AZURE_AI_GATEWAY_ENDPOINT "$(first_value "${AZURE_AI_GATEWAY_ENDPOINT:-}" "$(azd_value AZURE_AI_GATEWAY_ENDPOINT)")")" + github_mcp_endpoint="$(require_value AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT "$(first_value "${AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT:-}" "$(azd_value AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT)")")" +else +subscription_id="$(require_value AZURE_SUBSCRIPTION_ID "$(first_value "${AZURE_SUBSCRIPTION_ID:-}" "$(azd_value AZURE_SUBSCRIPTION_ID)" "$(az account show --query id -o tsv)")")" +resource_group="$(require_value AI_GATEWAY_RESOURCE_GROUP "$(first_value "${AI_GATEWAY_RESOURCE_GROUP:-}" "$(azd_value AI_GATEWAY_RESOURCE_GROUP)" "${RESOURCE_GROUP:-}" "${AZURE_RESOURCE_GROUP:-}" "$(azd_value RESOURCE_GROUP)" "$(azd_value AZURE_RESOURCE_GROUP)")")" +gateway_name="$(require_value AI_GATEWAY_NAME "$(first_value "${AI_GATEWAY_NAME:-}" "$(azd_value AI_GATEWAY_NAME)")")" gateway_resource_id="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.ApiManagement/service/${gateway_name}" legacy_gateway_resource_id="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.ApiManagement/aigateways/${gateway_name}" workspace_name="${AI_GATEWAY_WORKSPACE_NAME:-default}" @@ -254,11 +305,14 @@ case "$identity_type" in esac gateway_url="$(require_value AZURE_AI_GATEWAY_ENDPOINT "$(az rest --method get --uri "$gateway_uri" --query properties.gatewayUrl -o tsv)")" +github_mcp_endpoint="${gateway_url%/}/default/toolservers/github/mcp" +fi case "$gateway_url" in */) ;; *) gateway_url="${gateway_url}/" ;; esac +if [ "$gateway_deployment_mode" = "managed" ]; then workspace_children_ready=false for attempt in $(seq 1 30); do if az rest --method get --uri "https://management.azure.com${workspace_resource_id}/modelProviders?api-version=${AI_GATEWAY_API_VERSION}" -o none 2>/dev/null; then @@ -347,26 +401,30 @@ az rest \ rm -f "$tool_server_body" tool_server_body="" unset github_token github_authorization GITHUB_MCP_AUTHORIZATION +else + echo "Existing AI Gateway mode: preserving its provider, models, keys, and GitHub ToolServer." +fi saved_gateway_api_key="$(first_value "${AZURE_AI_GATEWAY_API_KEY:-}" "$(azd_value AZURE_AI_GATEWAY_API_KEY)")" -if ! gateway_api_key="$(get_gateway_api_key_value)"; then +if ! gateway_api_key="$(get_gateway_api_key_value "$gateway_resource_id")"; then gateway_api_key="$saved_gateway_api_key" fi gateway_api_key="$(require_value AZURE_AI_GATEWAY_API_KEY "$gateway_api_key")" verify_gateway_model_route "$gateway_url" "$gateway_model" "$gateway_api_key" -azd env set AZURE_AI_GATEWAY_ENDPOINT "$gateway_url" -azd env set AZURE_AI_GATEWAY_MODEL "$gateway_model" -azd env set AZURE_AI_GATEWAY_MINI_MODEL "$gateway_mini_model" -azd env set GITHUB_REPOSITORY "$github_repository" -azd env set AZURE_AI_GATEWAY_API_KEY "$gateway_api_key" >/dev/null -azd env set TOOLBOX_NAME "$TOOLBOX_NAME" +run_azd env set AZURE_AI_GATEWAY_ENDPOINT "$gateway_url" +run_azd env set AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT "$github_mcp_endpoint" +run_azd env set AZURE_AI_GATEWAY_MODEL "$gateway_model" +run_azd env set AZURE_AI_GATEWAY_MINI_MODEL "$gateway_mini_model" +run_azd env set GITHUB_REPOSITORY "$github_repository" +run_azd env set AZURE_AI_GATEWAY_API_KEY "$gateway_api_key" >/dev/null +run_azd env set TOOLBOX_NAME "$TOOLBOX_NAME" echo "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer." -azd ai connection create "$TOOLBOX_CONNECTION_NAME" \ +run_azd ai connection create "$TOOLBOX_CONNECTION_NAME" \ --kind remote-tool \ - --target "${gateway_url%/}/default/toolservers/github/mcp" \ + --target "$github_mcp_endpoint" \ --auth-type custom-keys \ --custom-key "Api-Key=${gateway_api_key}" \ --force \ @@ -374,19 +432,19 @@ azd ai connection create "$TOOLBOX_CONNECTION_NAME" \ --project-endpoint "$project_endpoint" \ -o json >/dev/null -if ! toolbox_json="$(azd ai toolbox show "$TOOLBOX_NAME" \ +if ! run_azd ai toolbox show "$TOOLBOX_NAME" \ --no-prompt \ --project-endpoint "$project_endpoint" \ - -o json 2>/dev/null)"; then + -o json >/dev/null 2>&1; then echo "Creating the Foundry Toolbox." - toolbox_json="$(azd ai toolbox create "$TOOLBOX_NAME" \ + run_azd ai toolbox create "$TOOLBOX_NAME" \ --from-file "$REPO_ROOT/toolbox.yaml" \ --no-prompt \ --project-endpoint "$project_endpoint" \ - -o json)" + -o json >/dev/null fi -toolbox_endpoint="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["endpoint"])' <<< "$toolbox_json")" -azd env set TOOLBOX_ENDPOINT "$toolbox_endpoint" +toolbox_endpoint="${project_endpoint%/}/toolboxes/${TOOLBOX_NAME}/mcp?api-version=v1" +run_azd env set TOOLBOX_ENDPOINT "$toolbox_endpoint" remove_azd_env_values \ AI_SERVICES_NAME \ AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT \ @@ -416,4 +474,8 @@ remove_azd_env_values \ GITHUB_TOKEN \ FOUNDRY_API_KEY -echo "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." +if [ "$gateway_deployment_mode" = "existing" ]; then + echo "Existing AI Gateway setup complete. The hook changed only Foundry project connections and Toolbox configuration." +else + echo "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." +fi diff --git a/infra/scripts/manage-ai-gateway-lifecycle.ps1 b/infra/scripts/manage-ai-gateway-lifecycle.ps1 index ad4732d..f2863f9 100644 --- a/infra/scripts/manage-ai-gateway-lifecycle.ps1 +++ b/infra/scripts/manage-ai-gateway-lifecycle.ps1 @@ -13,6 +13,20 @@ $deletedServiceApiVersion = "2024-05-01" $defaultAiGatewayLocation = "eastus2" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +function Invoke-Azd([string[]]$Arguments) { + $previousUserAgent = $env:AZURE_DEV_USER_AGENT + try { + $env:AZURE_DEV_USER_AGENT = "microsoft_foundry_skill" + & azd @Arguments + } finally { + if ($null -eq $previousUserAgent) { + Remove-Item Env:AZURE_DEV_USER_AGENT -ErrorAction SilentlyContinue + } else { + $env:AZURE_DEV_USER_AGENT = $previousUserAgent + } + } +} + function Get-IntegerSetting($Name, $Default) { $value = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrWhiteSpace($value)) { $value = [string]$Default } @@ -30,7 +44,7 @@ $operationTimeoutSeconds = Get-IntegerSetting "APIM_LIFECYCLE_OPERATION_TIMEOUT_ $recentDeploymentLimit = Get-IntegerSetting "APIM_LIFECYCLE_RECENT_DEPLOYMENT_LIMIT" 10 function Get-AzdValue($Name) { - $value = & azd env get-value $Name 2>$null + $value = Invoke-Azd @("env", "get-value", $Name) 2>$null if ($LASTEXITCODE -eq 0) { return ([string]$value).Trim() } return "" } @@ -45,6 +59,15 @@ function First-Value([object[]]$Values) { return "" } +$gatewayDeploymentMode = First-Value @($env:GATEWAY_DEPLOYMENT_MODE, (Get-AzdValue "GATEWAY_DEPLOYMENT_MODE"), "managed") +if ($gatewayDeploymentMode -eq "existing") { + Write-Host "Existing AI Gateway mode: lifecycle recovery, deletion, and purge are disabled." + exit 0 +} +if ($gatewayDeploymentMode -ne "managed") { + throw "AI Gateway lifecycle error: GATEWAY_DEPLOYMENT_MODE must be managed or existing." +} + function ConvertTo-LocationName($Location) { return ([string]$Location).Replace(" ", "").ToLowerInvariant() } diff --git a/infra/scripts/manage-ai-gateway-lifecycle.sh b/infra/scripts/manage-ai-gateway-lifecycle.sh index a16e678..59c9180 100755 --- a/infra/scripts/manage-ai-gateway-lifecycle.sh +++ b/infra/scripts/manage-ai-gateway-lifecycle.sh @@ -57,9 +57,13 @@ case "$mode" in *) usage; exit 2 ;; esac +run_azd() { + AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd "$@" +} + azd_value() { local value - if value="$(azd env get-value "$1" 2>/dev/null)"; then + if value="$(run_azd env get-value "$1" 2>/dev/null)"; then printf '%s' "$value" fi } @@ -74,6 +78,15 @@ first_value() { done } +gateway_deployment_mode="$(first_value "${GATEWAY_DEPLOYMENT_MODE:-}" "$(azd_value GATEWAY_DEPLOYMENT_MODE)" "managed")" +if [ "$gateway_deployment_mode" = "existing" ]; then + echo "Existing AI Gateway mode: lifecycle recovery, deletion, and purge are disabled." + exit 0 +fi +if [ "$gateway_deployment_mode" != "managed" ]; then + fail "GATEWAY_DEPLOYMENT_MODE must be managed or existing." +fi + normalize_location() { printf '%s' "$1" | tr -d ' ' | tr '[:upper:]' '[:lower:]' } diff --git a/scripts/configure-existing-gateway.ps1 b/scripts/configure-existing-gateway.ps1 new file mode 100755 index 0000000..29b3673 --- /dev/null +++ b/scripts/configure-existing-gateway.ps1 @@ -0,0 +1,88 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$ContractFile = ".ai-gateway-studio.json" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +if (-not (Test-Path -LiteralPath $ContractFile -PathType Leaf)) { + throw "Existing AI Gateway handoff file not found: $ContractFile" +} +if ($null -eq (Get-Command azd -ErrorAction SilentlyContinue)) { + throw "Azure Developer CLI is required to select the existing gateway profile." +} + +try { + $contract = Get-Content -LiteralPath $ContractFile -Raw | ConvertFrom-Json +} catch { + throw "Invalid AI Gateway handoff file ${ContractFile}: $($_.Exception.Message)" +} + +if ($contract.schemaVersion -ne 1) { + throw "schemaVersion must be 1." +} +if ($contract.gatewayDeploymentMode -ne "existing") { + throw "gatewayDeploymentMode must be existing." +} + +$resourcePattern = "^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.ApiManagement/(service|aigateways)/[^/]+$" +$gatewayResourceId = [string]$contract.gatewayResourceId +if ($gatewayResourceId -notmatch $resourcePattern) { + throw "gatewayResourceId must be a full Microsoft.ApiManagement/service or Microsoft.ApiManagement/aigateways ARM resource ID." +} + +function Get-HttpsUrl($Name, $Value) { + $text = [string]$Value + $uri = $null + if ([string]::IsNullOrWhiteSpace($text) -or + $text -match "[`t`r`n]" -or + -not [Uri]::TryCreate($text, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne "https") { + throw "$Name must be an absolute, single-line HTTPS URL." + } + return $text +} + +$gatewayEndpoint = (Get-HttpsUrl "gatewayEndpoint" $contract.gatewayEndpoint).TrimEnd("/") + "/" +$githubMcpEndpoint = (Get-HttpsUrl "githubMcpEndpoint" $contract.githubMcpEndpoint).TrimEnd("/") +if ($githubMcpEndpoint -notmatch "/default/toolservers/[^/]+/mcp$") { + throw "githubMcpEndpoint must end in /default/toolservers//mcp." +} + +$gatewayModel = [string]$contract.modelAliases.default +$gatewayMiniModel = [string]$contract.modelAliases.mini +foreach ($entry in @{ + "modelAliases.default" = $gatewayModel + "modelAliases.mini" = $gatewayMiniModel +}.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -match "[`t`r`n]") { + throw "$($entry.Key) must be a nonempty single-line string." + } +} + +function Set-AzdValue($Name, $Value) { + $previousUserAgent = $env:AZURE_DEV_USER_AGENT + try { + $env:AZURE_DEV_USER_AGENT = "microsoft_foundry_skill" + azd env set $Name $Value + } finally { + if ($null -eq $previousUserAgent) { + Remove-Item Env:AZURE_DEV_USER_AGENT -ErrorAction SilentlyContinue + } else { + $env:AZURE_DEV_USER_AGENT = $previousUserAgent + } + } +} + +Set-AzdValue "GATEWAY_DEPLOYMENT_MODE" "existing" +Set-AzdValue "EXISTING_AI_GATEWAY_RESOURCE_ID" $gatewayResourceId +Set-AzdValue "AZURE_AI_GATEWAY_ENDPOINT" $gatewayEndpoint +Set-AzdValue "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT" $githubMcpEndpoint +Set-AzdValue "AZURE_AI_GATEWAY_MODEL" $gatewayModel +Set-AzdValue "AZURE_AI_GATEWAY_MINI_MODEL" $gatewayMiniModel + +Write-Host "Selected the existing AI Gateway deployment profile from $ContractFile." +Write-Host "No Azure resources were provisioned or modified." diff --git a/scripts/configure-existing-gateway.sh b/scripts/configure-existing-gateway.sh new file mode 100755 index 0000000..75cf1b6 --- /dev/null +++ b/scripts/configure-existing-gateway.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +contract_file="${1:-.ai-gateway-studio.json}" + +if [ "$#" -gt 1 ]; then + echo "Usage: $0 [path-to-.ai-gateway-studio.json]" >&2 + exit 2 +fi +if [ ! -f "$contract_file" ]; then + echo "Existing AI Gateway handoff file not found: $contract_file" >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "Python 3 is required to validate $contract_file." >&2 + exit 1 +fi +if ! command -v azd >/dev/null 2>&1; then + echo "Azure Developer CLI is required to select the existing gateway profile." >&2 + exit 1 +fi + +IFS=$'\t' read -r \ + gateway_resource_id \ + gateway_endpoint \ + github_mcp_endpoint \ + gateway_model \ + gateway_mini_model < <(python3 - "$contract_file" <<'PY' +import json +import pathlib +import re +import sys +from urllib.parse import urlparse + +path = pathlib.Path(sys.argv[1]) +try: + contract = json.loads(path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"Invalid AI Gateway handoff file {path}: {exc}") + +if contract.get("schemaVersion") != 1: + raise SystemExit("schemaVersion must be 1.") +if contract.get("gatewayDeploymentMode") != "existing": + raise SystemExit("gatewayDeploymentMode must be existing.") + +resource_id = contract.get("gatewayResourceId", "") +resource_pattern = re.compile( + r"^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/" + r"Microsoft\.ApiManagement/(?:service|aigateways)/[^/]+$", + re.IGNORECASE, +) +if not isinstance(resource_id, str) or not resource_pattern.fullmatch(resource_id): + raise SystemExit( + "gatewayResourceId must be a full Microsoft.ApiManagement/service " + "or Microsoft.ApiManagement/aigateways ARM resource ID." + ) + +def require_https_url(name: str, value: object) -> str: + if not isinstance(value, str) or any(char in value for char in "\t\r\n"): + raise SystemExit(f"{name} must be a single-line HTTPS URL.") + parsed = urlparse(value) + if parsed.scheme != "https" or not parsed.netloc: + raise SystemExit(f"{name} must be an absolute HTTPS URL.") + return value + +gateway_endpoint = require_https_url("gatewayEndpoint", contract.get("gatewayEndpoint")) +gateway_endpoint = gateway_endpoint.rstrip("/") + "/" +github_endpoint = require_https_url( + "githubMcpEndpoint", contract.get("githubMcpEndpoint") +).rstrip("/") +if not re.search(r"/default/toolservers/[^/]+/mcp$", github_endpoint): + raise SystemExit( + "githubMcpEndpoint must end in /default/toolservers//mcp." + ) + +aliases = contract.get("modelAliases") +if not isinstance(aliases, dict): + raise SystemExit("modelAliases must be an object.") +models = [] +for name in ("default", "mini"): + value = aliases.get(name, "") + if ( + not isinstance(value, str) + or not value.strip() + or any(char in value for char in "\t\r\n") + ): + raise SystemExit(f"modelAliases.{name} must be a nonempty single-line string.") + models.append(value) + +print("\t".join([resource_id, gateway_endpoint, github_endpoint, *models])) +PY +) + +azd_set() { + AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd env set "$1" "$2" +} + +azd_set GATEWAY_DEPLOYMENT_MODE existing +azd_set EXISTING_AI_GATEWAY_RESOURCE_ID "$gateway_resource_id" +azd_set AZURE_AI_GATEWAY_ENDPOINT "$gateway_endpoint" +azd_set AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT "$github_mcp_endpoint" +azd_set AZURE_AI_GATEWAY_MODEL "$gateway_model" +azd_set AZURE_AI_GATEWAY_MINI_MODEL "$gateway_mini_model" + +echo "Selected the existing AI Gateway deployment profile from $contract_file." +echo "No Azure resources were provisioned or modified." diff --git a/scripts/create-dev-env.ps1 b/scripts/create-dev-env.ps1 index b2afe9c..4cc1f54 100755 --- a/scripts/create-dev-env.ps1 +++ b/scripts/create-dev-env.ps1 @@ -14,10 +14,18 @@ if ((Test-Path $outputPath) -and -not $Force) { } function Get-AzdValue($Name) { + $previousUserAgent = $env:AZURE_DEV_USER_AGENT try { + $env:AZURE_DEV_USER_AGENT = "microsoft_foundry_skill" return ([string](azd env get-value $Name 2>$null)).Trim() } catch { return "" + } finally { + if ($null -eq $previousUserAgent) { + Remove-Item Env:AZURE_DEV_USER_AGENT -ErrorAction SilentlyContinue + } else { + $env:AZURE_DEV_USER_AGENT = $previousUserAgent + } } } @@ -35,6 +43,7 @@ function Quote-EnvValue($Value) { } $endpoint = Require-AzdValue "AZURE_AI_GATEWAY_ENDPOINT" +$githubMcpEndpoint = Require-AzdValue "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT" $gatewayKey = Require-AzdValue "AZURE_AI_GATEWAY_API_KEY" $model = Require-AzdValue "AZURE_AI_GATEWAY_MODEL" $miniModel = Require-AzdValue "AZURE_AI_GATEWAY_MINI_MODEL" @@ -47,6 +56,7 @@ if ([string]::IsNullOrWhiteSpace($repository)) { $lines = @( "AZURE_AI_GATEWAY_ENDPOINT=$(Quote-EnvValue $endpoint)" + "AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT=$(Quote-EnvValue $githubMcpEndpoint)" "AZURE_AI_GATEWAY_API_KEY=$(Quote-EnvValue $gatewayKey)" "AZURE_AI_GATEWAY_MODEL=$(Quote-EnvValue $model)" "AZURE_AI_GATEWAY_MINI_MODEL=$(Quote-EnvValue $miniModel)" diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index e331c7d..3482391 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -17,7 +17,7 @@ if [ -e "$output" ] && [ "$force" != true ]; then fi azd_value() { - azd env get-value "$1" 2>/dev/null || true + AZURE_DEV_USER_AGENT=microsoft_foundry_skill azd env get-value "$1" 2>/dev/null || true } require_azd_value() { @@ -39,6 +39,7 @@ quote_env() { } endpoint="$(require_azd_value AZURE_AI_GATEWAY_ENDPOINT)" +github_mcp_endpoint="$(require_azd_value AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT)" key="$(require_azd_value AZURE_AI_GATEWAY_API_KEY)" model="$(require_azd_value AZURE_AI_GATEWAY_MODEL)" mini_model="$(require_azd_value AZURE_AI_GATEWAY_MINI_MODEL)" @@ -53,6 +54,7 @@ trap 'rm -f "$temp_file"' EXIT { printf 'AZURE_AI_GATEWAY_ENDPOINT=%s\n' "$(quote_env "$endpoint")" + printf 'AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT=%s\n' "$(quote_env "$github_mcp_endpoint")" printf 'AZURE_AI_GATEWAY_API_KEY=%s\n' "$(quote_env "$key")" printf 'AZURE_AI_GATEWAY_MODEL=%s\n' "$(quote_env "$model")" printf 'AZURE_AI_GATEWAY_MINI_MODEL=%s\n' "$(quote_env "$mini_model")" diff --git a/tests/test-apim-lifecycle.sh b/tests/test-apim-lifecycle.sh index 138769a..17c9ea4 100755 --- a/tests/test-apim-lifecycle.sh +++ b/tests/test-apim-lifecycle.sh @@ -47,6 +47,7 @@ if [ "${1:-}" = "env" ] && [ "${2:-}" = "get-value" ]; then printf '%s' "${STUB_GATEWAY_NAME:-aigw-abc12345}" ;; AI_GATEWAY_LOCATION) printf '%s' "${STUB_GATEWAY_LOCATION:-eastus2}" ;; + GATEWAY_DEPLOYMENT_MODE) printf '%s' "${STUB_GATEWAY_DEPLOYMENT_MODE:-managed}" ;; *) exit 1 ;; esac exit 0 @@ -267,6 +268,18 @@ assert_file_value "$case_dir/live" 1 [ ! -f "$case_dir/delete_count" ] || fail "healthy gateway was deleted" assert_contains "$output_file" "Preserving healthy environment-owned AI Gateway" +new_case existing-mode-preserved +printf '1' > "$case_dir/live" +printf 'Failed' > "$case_dir/provisioning_state" +STUB_GATEWAY_DEPLOYMENT_MODE=existing +export STUB_GATEWAY_DEPLOYMENT_MODE +run_lifecycle prepare +unset STUB_GATEWAY_DEPLOYMENT_MODE +assert_file_value "$case_dir/live" 1 +[ ! -f "$case_dir/delete_count" ] || fail "existing gateway was deleted" +[ ! -f "$case_dir/purge_count" ] || fail "existing gateway was purged" +assert_contains "$output_file" "lifecycle recovery, deletion, and purge are disabled" + new_case failed-owned-discovered printf '1' > "$case_dir/live" printf 'Failed' > "$case_dir/provisioning_state" @@ -436,5 +449,7 @@ assert_contains "$repo_root/azure.yaml" "manage-ai-gateway-lifecycle.sh cleanup" assert_contains "$repo_root/azure.yaml" "manage-ai-gateway-lifecycle.ps1 cleanup" assert_contains "$repo_root/infra/scripts/configure-ai-gateway.sh" "manage-ai-gateway-lifecycle.sh\" prepare" assert_contains "$repo_root/infra/scripts/configure-ai-gateway.ps1" "manage-ai-gateway-lifecycle.ps1\") prepare" +assert_contains "$lifecycle_script" "GATEWAY_DEPLOYMENT_MODE" +assert_contains "$powershell_script" "GATEWAY_DEPLOYMENT_MODE" echo "APIM lifecycle tests passed." diff --git a/tests/test-deployment-modes.sh b/tests/test-deployment-modes.sh new file mode 100755 index 0000000..da8e0b2 --- /dev/null +++ b/tests/test-deployment-modes.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compiled_template="$(mktemp)" +temp_root="$(mktemp -d)" +trap 'rm -f "$compiled_template"; rm -rf "$temp_root"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local expected="$2" + grep -Fq -- "$expected" "$file" || fail "expected '$expected' in $file" +} + +az bicep build \ + --file "$repo_root/infra/main.bicep" \ + --outfile "$compiled_template" \ + >/dev/null + +[ "$(jq -r '.parameters.gatewayDeploymentMode.defaultValue' "$compiled_template")" = "managed" ] || + fail "managed must remain the default deployment mode" +[ "$(jq -c '.parameters.gatewayDeploymentMode.allowedValues' "$compiled_template")" = '["managed","existing"]' ] || + fail "deployment mode must allow exactly managed and existing" + +for resource_name in \ + "effectiveGatewayResourceGroupName" \ + "effectiveFoundryModelsResourceGroupName" \ + "foundry-models-" \ + "ai-gateway-"; do + condition="$(jq -r --arg name "$resource_name" ' + .resources[] + | select((.name | tostring) | contains($name)) + | .condition + ' "$compiled_template")" + [ "$condition" = "[variables('deployManagedGateway')]" ] || + fail "$resource_name must be disabled in existing mode, found condition: $condition" +done + +for module_name in "foundry-models-" "ai-gateway-"; do + module_scope="$(jq -r --arg name "$module_name" ' + .resources[] + | select((.name | tostring) | contains($name)) + | .resourceGroup // "" + ' "$compiled_template")" + [ -n "$module_scope" ] || + fail "$module_name must remain scoped to its managed resource group" +done + +agent_module_condition="$(jq -r ' + .resources[] + | select((.name | tostring) | contains("foundry-agents-")) + | .condition // "" +' "$compiled_template")" +[ -z "$agent_module_condition" ] || + fail "Foundry hosted-agent resources must be deployed in both modes" + +assert_contains "$repo_root/azure.yaml" 'category: CustomKeys' +assert_contains "$repo_root/azure.yaml" 'value: ${{connections.ai-gateway-model.target}}' +assert_contains "$repo_root/azure.yaml" 'value: ${{connections.ai-gateway-model.credentials.Api-Key}}' +if grep -Fq 'value: ${AZURE_AI_GATEWAY_API_KEY}' "$repo_root/azure.yaml"; then + fail "Gateway keys must not use ordinary hosted-agent azd environment substitution" +fi + +python3 - "$repo_root/infra/scripts/manage-ai-gateway-lifecycle.ps1" <<'PY' +import pathlib +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +expected = """ return "" +} + +$gatewayDeploymentMode = First-Value""" +if expected not in text: + raise SystemExit( + "PowerShell existing-mode guard must be at top level after First-Value." + ) +PY + +stub_bin="$temp_root/bin" +mkdir -p "$stub_bin" +az_log="$temp_root/az.log" +azd_log="$temp_root/azd.log" + +cat > "$stub_bin/az" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${STUB_AZ_LOG:?}" +if [ "${1:-}" != "rest" ]; then + echo "unexpected az command: $*" >&2 + exit 2 +fi +uri="" +query="" +while [ "$#" -gt 0 ]; do + case "$1" in + --uri) uri="$2"; shift 2 ;; + --query) query="$2"; shift 2 ;; + *) shift ;; + esac +done +case "$uri" in + *"/apiKeys?"*) + printf '%s\n' "default" + ;; + *"/apiKeys/default/listSecrets?"*) + printf '%s\n' "test-gateway-key" + ;; + *) + echo "unexpected az rest URI: $uri (query: $query)" >&2 + exit 2 + ;; +esac +STUB + +cat > "$stub_bin/azd" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${STUB_AZD_LOG:?}" +if [ "${1:-}" = "env" ] && [ "${2:-}" = "get-value" ]; then + exit 1 +fi +if [ "${1:-}" = "env" ] && [ "${2:-}" = "set" ]; then + exit 0 +fi +if [ "${1:-}" = "ai" ] && [ "${2:-}" = "connection" ] && [ "${3:-}" = "create" ]; then + exit 0 +fi +if [ "${1:-}" = "ai" ] && [ "${2:-}" = "toolbox" ] && [ "${3:-}" = "show" ]; then + printf '%s\n' '{"name":"repo-digest-tools"}' + exit 0 +fi +echo "unexpected azd command: $*" >&2 +exit 2 +STUB + +cat > "$stub_bin/curl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +output_file="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output_file="$2"; shift 2 ;; + -w) shift 2 ;; + *) shift ;; + esac +done +[ -z "$output_file" ] || printf '%s' '{}' > "$output_file" +printf '%s' '200' +STUB + +chmod +x "$stub_bin/az" "$stub_bin/azd" "$stub_bin/curl" + +gateway_resource_id="/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.ApiManagement/service/shared-aigw" +gateway_endpoint="https://shared.example.ai.gateway-current.azure.com/" +github_mcp_endpoint="${gateway_endpoint}default/toolservers/github/mcp" + +env \ + PATH="$stub_bin:$PATH" \ + STUB_AZ_LOG="$az_log" \ + STUB_AZD_LOG="$azd_log" \ + GATEWAY_DEPLOYMENT_MODE=existing \ + AZURE_ENV_NAME=test-existing \ + EXISTING_AI_GATEWAY_RESOURCE_ID="$gateway_resource_id" \ + AZURE_AI_GATEWAY_ENDPOINT="$gateway_endpoint" \ + AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT="$github_mcp_endpoint" \ + AZURE_AI_GATEWAY_MODEL=gpt-shared \ + AZURE_AI_GATEWAY_MINI_MODEL=gpt-shared-mini \ + FOUNDRY_PROJECT_ENDPOINT=https://foundry.example.com/api/projects/sample \ + "$repo_root/infra/scripts/configure-ai-gateway.sh" \ + >"$temp_root/configure.out" + +if grep -Eiq '(^|[[:space:]])(put|delete)([[:space:]]|$)|deletedservices|toolServers/github|modelProviders' "$az_log"; then + fail "existing mode attempted to mutate or manage the existing Gateway: $(cat "$az_log")" +fi +assert_contains "$azd_log" "ai connection create aigw-github" +assert_contains "$azd_log" "--target $github_mcp_endpoint" +assert_contains "$azd_log" "env set TOOLBOX_ENDPOINT https://foundry.example.com/api/projects/sample/toolboxes/repo-digest-tools/mcp?api-version=v1" + +: > "$azd_log" +env \ + PATH="$stub_bin:$PATH" \ + STUB_AZD_LOG="$azd_log" \ + "$repo_root/scripts/configure-existing-gateway.sh" \ + "$repo_root/.ai-gateway-studio.example.json" \ + >"$temp_root/bootstrap.out" + +assert_contains "$azd_log" "env set GATEWAY_DEPLOYMENT_MODE existing" +assert_contains "$azd_log" "env set EXISTING_AI_GATEWAY_RESOURCE_ID" +assert_contains "$azd_log" "env set AZURE_AI_GATEWAY_GITHUB_MCP_ENDPOINT" +assert_contains "$temp_root/bootstrap.out" "No Azure resources were provisioned or modified." + +echo "Deployment mode tests passed."