diff --git a/.github/workflows/terraform-offline.yml b/.github/workflows/terraform-offline.yml new file mode 100644 index 0000000..43997bc --- /dev/null +++ b/.github/workflows/terraform-offline.yml @@ -0,0 +1,37 @@ +name: Terraform offline acceptance + +on: + pull_request: + paths: + - 'samples/terraform/**' + - '.github/workflows/terraform-offline.yml' + push: + paths: + - 'samples/terraform/**' + - '.github/workflows/terraform-offline.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline: + runs-on: windows-2022 + timeout-minutes: 20 + env: + ARM_SKIP_PROVIDER_REGISTRATION: 'true' + TF_IN_AUTOMATION: 'true' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: 1.11.4 + terraform_wrapper: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12.10' + - name: Credential-free checks + shell: pwsh + run: .\samples\terraform\scripts\Test-Offline.ps1 diff --git a/samples/README.md b/samples/README.md index 543af22..118dff5 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,8 +1,9 @@ # Samples -A curated catalog of samples that build on the [AI Gateway tier in Azure API Management](https://aka.ms/aigateway/docs). This folder catalogs samples that live in their own repositories; it does not host sample code. +A curated catalog of samples that build on the [AI Gateway tier in Azure API Management](https://aka.ms/aigateway/docs), including external examples and a repository-local Terraform reference. | Sample | Language | Description | | ------ | -------- | ----------- | | Foundry AI Gateway E2E | [Python](https://aka.ms/foundry-aigateway-python) / [.NET (C#)](https://aka.ms/foundry-aigateway-dotnet) | A Daily Digest Agent deployed as a Foundry Hosted Agent whose model and GitHub MCP traffic is routed through AI Gateway. | | IaC only | [Bicep](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/ai-gateway) | Infrastructure as code for provisioning infrastructure using Bicep, scripts, or azd. Direct links to the building blocks: [AI Gateway](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/ai-gateway), [Foundry Models](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/foundry-models), and [Foundry Hosted Agent](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/foundry-agents). | +| Terraform reference (legacy preview) | [Terraform](terraform/README.md) | AI Gateway and Foundry infrastructure with Public or Private-only networking, API-key or optional Entra access, and optional private hosted-agent infrastructure. Uses the time-limited legacy SKU; see the sample's migration notice before deployment. | diff --git a/samples/terraform/.terraform.lock.hcl b/samples/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..92635a7 --- /dev/null +++ b/samples/terraform/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/azure/azapi" { + version = "2.13.0" + constraints = "~> 2.13.0" + hashes = [ + "h1:kwPFUxbutc+GXSr/OJ1QbxwS+beaMXR5GmqVBxG9Y9U=", + "zh:05df91a17cbb653627833746127d622502c3f502eee384aadb34e9dda1156ffd", + "zh:21111155e27c9d0792a17141daf8535a688175a31a34d139110091ee596c359d", + "zh:565a9f26141b6f67ba0672d728a596d0c3e903104ee463e7c9eb58d580561e8e", + "zh:7afceb1d1c8fff83b442cfca0e83dfaddd5ab0c67cdf7c81e46877876416b3bb", + "zh:8a4a723da2a241b398b2e990c505ccd7f327e848deaf2ad05a84c44af82462ab", + "zh:a5409c46bd9c2e8571b2c4217f5fd3a2dcda6019d26b174f7430776bdda4805b", + "zh:b2d5ca4680e7c122a01389d1bf4bb8d2e14add7871bfdc2eb8b0f09b2ce7221f", + "zh:b4969c32ce8ed49c60c63653d70ae2fb489c4992b12140d5c6969e6f4ee85825", + "zh:bbe4a39522cff59977e1bf623e7d95267d819ef3cf9ba0f597eb066ce1b11b70", + "zh:c4d13147fed2fb35501dab059fb08f0fc15e55519d88c06ccc379a131885b454", + "zh:e00639a5fc6e6f1df62451be45771d05b82be3356e1df3c84f4e3dca5523b1d4", + "zh:e2948388d78d392b7992cc0e76d4f266545ffc8984ba458d7fa6884a5f04f376", + "zh:feb0c5035caed5bd0bc516221669c79c22c8b387b33958659e617c5ed18dcadc", + ] +} diff --git a/samples/terraform/README.md b/samples/terraform/README.md new file mode 100644 index 0000000..44d2e02 --- /dev/null +++ b/samples/terraform/README.md @@ -0,0 +1,104 @@ +# AI Gateway and Foundry with Terraform + +Deploy AI Gateway, Foundry model deployments and infrastructure for a separately +deployed hosted agent. The gateway handles access and policy; the model generates +the answer. This is standalone Terraform, not an `azd` application. + +> [!NOTE] +> **Legacy SKU and migration deadline:** This sample uses +> `Microsoft.ApiManagement/service` with SKU `AIGateway`. Microsoft requires +> migration **before January 15, 2027**. This SKU is retained here for the +> private-endpoint scenario: as of October 25, 2026, the newer +> `Microsoft.ApiManagement/aigateways` resource does **not** support private +> endpoints. This is a time-limited preview reference, not a long-term deployment +> target. Plan migration without assuming private-endpoint feature parity; +> this sample does not perform that migration. See Microsoft's +> [migration deadline and limitations](https://learn.microsoft.com/azure/api-management/ai-gateway-resource). + +> [!IMPORTANT] +> **Terraform Registry disclaimer (checked October 25, 2026):** No dedicated +> Microsoft-published Registry module was identified for the standalone +> `AIGateway` preview used here. General API Management and community gateway +> modules do exist; this directory is a repository-specific Terraform +> configuration, not an official Registry module. Instead, it uses Microsoft's +> **`Azure/azapi` provider** to manage explicit ARM resource types and preview +> API versions. See [how this is implemented](deployment.md#terraform-implementation) +> for the distinction and validation limits. + +## Two networking options + +| Configuration | Gateway public access | Model public access | +| --- | --- | --- | +| **Public** (default) | Enabled | Enabled | +| **Private-only** | Disabled | Disabled | + +### Public + +![Public application authenticates with Entra ID or an API key; AI Gateway uses its separate managed identity for Foundry Models](docs/images/architecture-public.svg) + +Clients use an API key or optional Entra token; the gateway authorizes to Foundry +Models using its managed identity. Agent hosting resources are provisioned, but the +container image and agent application are deployed separately. + +### Private-only + +![Private application authenticates with Entra ID or an API key through the gateway private endpoint; gateway managed identity reaches the private model backend](docs/images/architecture-private-only.svg) + +Private endpoints (PE), DNS and outbound VNet integration connect the private +request path. The optional private agent requires creation-time network injection +and a later application deployment/runtime-role binding. It is part of +Private-only, not a third networking option. + +In both diagrams, the **Application** is the caller: it sends an **Entra token +with an invocation role, or an API key** to the gateway. Its hosting location is +your choice; Private-only additionally requires private DNS and a network route. +The optional hosted agent is a separate application, not a required model-call hop. + +These are logical flows, not subnet-placement diagrams. The Foundry inbound PE, +agent outbound injection, gateway inbound PE and gateway outbound integration are +separate controls. Identity, control-plane and telemetry traffic are not claimed +to be fully private. Registry/Storage and monitoring are supporting resources, +not extra model-serving hops; registry public access is unchanged by the agent extension. + +## Access + +Grant identity-based access with **Microsoft Entra ID**, or manage **API keys**. +Access is independent of Public/Private-only. + +| Option | Configuration | +| --- | --- | +| Microsoft Entra ID | Add caller object IDs to `gateway_access_principals`; grants **AI Gateway Models User** for all models in this gateway. | +| API keys (existing default) | Leave the map empty. Existing keys remain available even when Entra callers are added. | + +The existing agent and private test VM keep their API-key flow. This is additive, +**not an Entra-only switch**. See [Access setup](deployment.md#access) for +post-deployment configuration and `Test-ModelAliases.ps1 -Authentication EntraId`. + +**Live-verified over the private route:** both sample models returned **200** with +managed-identity Entra access, **403** without the invocation role, and **401** +for missing/invalid credentials. API-key calls also passed; gateway/backend +traces matched. The temporary grant was removed afterward. This verifies the +managed-identity caller flow, not custom delegated-user applications. + +## Deploy + +Follow the **[deployment guide](deployment.md)**, next to this README, for prerequisites, inputs, +monitoring bootstrap, the chosen network path, private-agent setup and acceptance. +Keep the selected private profile on all later plans. The retained +`private-connectivity` profile is only an intermediate provisioning stage. + +Review preview support, policies, regional availability and costs before applying. +The sample retains the `Microsoft.ApiManagement/service` / `AIGateway` contract; +check its compatibility with your environment. Supporting resources and model +calls incur charges; the gateway preview has no SLA. + +| File | Purpose | +| --- | --- | +| [terraform.tfvars.example](terraform.tfvars.example) | Starting inputs for a new environment | +| [variables.tf](variables.tf) / [outputs.tf](outputs.tf) | Complete input and output contract | +| [profiles/private-only.tfvars.json](profiles/private-only.tfvars.json) | Final private gateway/model posture | +| [scripts](scripts) / [tests](tests) | Deployment checks and offline regression tests | + +Diagrams generated with Azure Architecture Diagram Builder MCP; specialized +service icons corrected in the SVG export using the +[official Azure icon set](https://learn.microsoft.com/azure/architecture/icons/). diff --git a/samples/terraform/ai-gateway.tf b/samples/terraform/ai-gateway.tf new file mode 100644 index 0000000..a04a5c5 --- /dev/null +++ b/samples/terraform/ai-gateway.tf @@ -0,0 +1,173 @@ +resource "azapi_resource" "gateway" { + type = "Microsoft.ApiManagement/service@2025-09-01-preview" + name = local.names.gateway + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + sku = { name = "AIGateway", capacity = 1 } + properties = { + publisherEmail = var.publisher_email + publisherName = var.publisher_name + publicNetworkAccess = var.gateway_public_network_access + virtualNetworkType = var.enable_private_networking ? "External" : "None" + virtualNetworkConfiguration = var.enable_private_networking ? { + subnetResourceId = azapi_resource.private_subnet["gateway"].id + } : null + } + } + response_export_values = ["properties.gatewayUrl"] + lifecycle { + prevent_destroy = true + } + timeouts { + create = "60m" + update = "60m" + delete = "60m" + } + depends_on = [azapi_resource.private_dns_link] +} + +resource "azapi_resource" "connector_gateway" { + # This preview type is not yet present in AzAPI's bundled schema. + schema_validation_enabled = false + type = "Microsoft.Web/connectorGateways@2026-05-01-preview" + name = local.names.gateway + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + body = { properties = {} } + depends_on = [azapi_resource.gateway] +} + +locals { + # The AIGateway service creates its default workspace; do not manage it twice. + gateway_workspace_id = "${azapi_resource.gateway.id}/workspaces/default" + gateway_monitoring = { + dcr_id = try(coalesce( + try(azapi_resource.insights["gateway"].output.properties.DataCollectionRuleResourceId, null), + try(azapi_resource.insights["gateway"].output.properties.DataCollectionRuleId, null) + ), "") + metrics_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPMetricsEndpoint), "") + logs_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPLogsEndpoint), "") + traces_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPTracesEndpoint), "") + } +} + +resource "azapi_resource" "telemetry_exporter" { + # This preview type is not yet present in AzAPI's bundled schema. + schema_validation_enabled = false + type = "Microsoft.ApiManagement/service/workspaces/telemetryExporters@2025-09-01-preview" + name = "appinsights" + parent_id = local.gateway_workspace_id + body = { + properties = { + kind = "OpenTelemetry" + payloadCapture = false + applicationInsights = { + resourceId = azapi_resource.insights["gateway"].id + } + openTelemetry = { + metricsEndpoint = local.gateway_monitoring.metrics_endpoint + logsEndpoint = local.gateway_monitoring.logs_endpoint + tracesEndpoint = local.gateway_monitoring.traces_endpoint + credentials = { + managedIdentity = { resource = "https://monitor.azure.com" } + } + } + } + } + lifecycle { + precondition { + condition = alltrue([ + for endpoint in [ + local.gateway_monitoring.metrics_endpoint, + local.gateway_monitoring.logs_endpoint, + local.gateway_monitoring.traces_endpoint + ] : can(regex("^https://[^/?#]+/[^?#]+$", endpoint)) + ]) + error_message = "Gateway OTLP endpoints are incomplete. Enable gateway Insights first, run scripts/Get-GatewayMonitoring.ps1, then create a fresh plan; see deployment.md#monitoring-setup." + } + } + depends_on = [azapi_resource.role_assignment["gateway-monitoring-publisher"]] +} + +resource "azapi_resource" "foundry_provider" { + # This preview type is not yet present in AzAPI's bundled schema. + schema_validation_enabled = false + type = "Microsoft.ApiManagement/service/workspaces/modelProviders@2025-09-01-preview" + name = "foundry" + parent_id = local.gateway_workspace_id + body = { + properties = { + kind = "Foundry" + displayName = "Foundry" + description = "Managed-identity access to the dedicated Foundry model account." + foundry = { + endpoint = "${trimsuffix(azapi_resource.models.output.properties.endpoint, "/")}/" + resourceIds = [azapi_resource.models.id] + authentication = { + kind = "ManagedIdentity" + managedIdentity = { resource = "https://cognitiveservices.azure.com/" } + } + } + } + } + depends_on = [azapi_resource.role_assignment["gateway-foundry-user"]] +} + +resource "azapi_resource" "catalog_model" { + # This preview type is not yet present in AzAPI's bundled schema. + schema_validation_enabled = false + for_each = var.model_deployments + type = "Microsoft.ApiManagement/service/workspaces/modelProviders/models@2025-09-01-preview" + name = each.key + parent_id = azapi_resource.foundry_provider.id + body = { + properties = { + description = each.value.description + displayName = each.value.model_name + apiFormat = each.value.api_format + supportedEndpoints = each.value.endpoints + deployment = { + resourceId = azapi_resource.model_deployment[each.key].id + modelName = each.key + modelVersion = each.value.model_version + } + policies = [{ + type = "tokenLimit" + period = "minute" + count = each.value.token_limit + counterKey = ["identity"] + }] + } + } +} + +resource "azapi_resource" "model_alias" { + # Runtime routing requires an alias in addition to the provider's catalog entry. + schema_validation_enabled = false + for_each = var.model_deployments + type = "Microsoft.ApiManagement/service/workspaces/aliases@2025-09-01-preview" + name = each.key + parent_id = local.gateway_workspace_id + body = { + properties = { + aliasName = each.key + description = each.value.description + backendModels = [{ + resourceId = azapi_resource.catalog_model[each.key].id + }] + } + } +} + +resource "azapi_resource" "runtime_api_key" { + # This preview type is not yet present in AzAPI's bundled schema. + schema_validation_enabled = false + type = "Microsoft.ApiManagement/service/apiKeys@2025-09-01-preview" + name = "default" + parent_id = azapi_resource.gateway.id + body = { properties = { displayName = "All APIs subscription" } } + response_export_values = [] +} diff --git a/samples/terraform/deployment.md b/samples/terraform/deployment.md new file mode 100644 index 0000000..8ca765f --- /dev/null +++ b/samples/terraform/deployment.md @@ -0,0 +1,426 @@ +# Deployment guide + +[Overview and diagrams](README.md) + +> [!NOTE] +> **Legacy SKU and migration deadline:** This sample uses +> `Microsoft.ApiManagement/service` with SKU `AIGateway`. Microsoft requires +> migration **before January 15, 2027**. This SKU is retained here for the +> private-endpoint scenario: as of October 25, 2026, the newer +> `Microsoft.ApiManagement/aigateways` resource does **not** support private +> endpoints. This is a time-limited preview reference, not a long-term deployment +> target. Plan migration without assuming private-endpoint feature parity; +> this sample does not perform that migration. See Microsoft's +> [migration deadline and limitations](https://learn.microsoft.com/azure/api-management/ai-gateway-resource). + +## Terraform implementation + +> [!IMPORTANT] +> **Terraform Registry disclaimer (checked October 25, 2026):** No dedicated +> Microsoft-published Registry module was identified for this standalone +> `AIGateway` preview. The [general API Management AVM](https://registry.terraform.io/modules/Azure/avm-res-apimanagement-service/azurerm) +> and community gateway modules are not the same as a dedicated module for this +> preview. This directory is a repository-defined root configuration, not an official +> Registry module to install. + +A **module** packages reusable Terraform configuration; a **provider** implements +communication with Azure. We use Microsoft's +[AzAPI provider](https://learn.microsoft.com/azure/developer/terraform/overview-azapi-provider), +declared as `Azure/azapi` in [versions.tf](versions.tf), rather than a custom +provider or a gateway-specific module. + +[ai-gateway.tf](ai-gateway.tf) declares `azapi_resource` with +`Microsoft.ApiManagement/service@2025-09-01-preview` and SKU `AIGateway`. +It also explicitly defines the gateway's child resources and their API versions. +AzAPI sends these definitions to Azure Resource Manager (ARM) and Terraform +manages their state and dependencies. The documented +[AzureRM API Management SKU list](https://registry.terraform.io/providers/hashicorp/azurerm/5.11.0/docs/resources/api_management) +does not include `AIGateway`; that is distinct from saying Terraform cannot deploy it. + +Some preview child resources are not in AzAPI's bundled schema, so their local +`schema_validation_enabled` check is selectively disabled in the source. +This does **not** bypass Azure API validation, RBAC or Azure Policy. API contracts +remain explicit sample-maintenance responsibilities; `terraform validate` and +mock tests alone cannot prove that Azure will accept a deployment. + +## Runbook order + +Do not execute every code block from top to bottom: Public and Private-only are +alternative paths. Commands use PowerShell 7; replace `<...>` placeholders first. +Run deployment commands one at a time and stop on errors; these blocks are not +unattended deployment scripts. + +1. Complete [prerequisites](#prerequisites) and [inputs and state](#inputs-and-state). +2. Choose Public or Private-only. Add optional [Entra caller inputs](#access) and, + if needed, the [private-agent flags](#optional-private-hosted-agent) before + the first full plan. Do not run their probes or deploy an agent yet. +3. Bootstrap [monitoring](#monitoring-setup), using the profile for your starting state. +4. Follow **one** path: [Public](#public) or [Private-only](#private-only). + Review and apply the required plans, then run the appropriate client probes. +5. Deploy the optional agent application separately, if selected, then complete + [acceptance](#acceptance-and-maintenance). Existing environments must retain + their original state and complete inputs throughout. + +## Prerequisites + +Use Git, Terraform 1.11+ (below 2.0), Azure CLI, and PowerShell 7 for the helper scripts. +Python 3 is needed for offline tests. Check model versions, capacity and hosted-agent +availability in the chosen regions, plus inherited Azure Policy, required tags and +permissions to manage resources and scoped role assignments. + +This sample targets `Microsoft.ApiManagement/service` with SKU `AIGateway`. +Check availability for that exact resource contract, subscription and region; +the newer `Microsoft.ApiManagement/aigateways` resource is not a drop-in replacement. +Review Microsoft's [resource compatibility and lifecycle guidance](https://learn.microsoft.com/azure/api-management/ai-gateway-resource) +before deployment. This sample does not migrate resource types. + +Register the providers used by your configuration: `Microsoft.ApiManagement`, +`Microsoft.Web`, `Microsoft.CognitiveServices`, `Microsoft.Storage`, +`Microsoft.ContainerRegistry`, `Microsoft.OperationalInsights`, `Microsoft.Insights` +and `Microsoft.Monitor`; also `Microsoft.Network` for networking, `Microsoft.Compute` +for the optional test VM, and `Microsoft.App` for private hosted agents. + +Public/private routing is independent of [Access](#access): callers can use an +API key or an Entra token with an invocation role. The gateway uses its own managed +identity for backend model calls. Adding Entra does not remove existing keys or +supply a missing private network route. + +## Inputs and state + +Before creating deployment inputs, configure local-only exclusions in +`.git/info/exclude` (this file is never committed): + +```powershell +@' +/samples/terraform/.terraform/ +/samples/terraform/*.tfstate* +/samples/terraform/*.tfplan +/samples/terraform/*.tfvars +/samples/terraform/*.tfvars.json +/samples/terraform/crash*.log +/samples/terraform/*override.tf +/samples/terraform/*override.tf.json +/samples/terraform/**/__pycache__/ +/samples/terraform/acceptance-results/ +'@ | Add-Content -LiteralPath (git rev-parse --path-format=absolute --git-path info/exclude) +``` + +Tracked profiles and `terraform.tfvars.example` remain versioned. Exclusions are +not secret protection for already tracked files; review `git diff --cached` before +every commit and never force-add deployment inputs, state or credentials. + +From the root of your local clone, enter the Terraform directory: + +```powershell +Set-Location .\samples\terraform +``` + +Run all remaining commands there. For a **new environment only**, copy +[terraform.tfvars.example](terraform.tfvars.example) to `terraform.tfvars` and +set the subscription, environment name, publisher email, regions and model map. +The map keys become client-facing aliases. See [variables.tf](variables.tf) +and [outputs.tf](outputs.tf) for the complete input/output contract. + +For an existing deployment, reuse its **authoritative state/backend and complete +input files**. Never overwrite inputs with the example, initialize a second state +against the same resources, or apply an old saved plan. State, plan files, keys and +operational evidence stay out of Git; protect remote state and its access controls. +The commands below assume approved inputs in auto-loaded `terraform.tfvars`. +If using additional `-var-file` arguments, include them on **every plan**, before +the final network profile. + +```powershell +az login +az account set --subscription "" +terraform init +terraform fmt -check -recursive +terraform validate +``` + +Stop on any command failure. Review every saved plan before applying it; reject +unexpected replacement, deletion, public reopening or permission expansion. +Changing names/regions or consolidating an older multi-resource-group layout is +a migration, not a harmless update. The current sample uses one application group; +gateway OTLP can additionally create Azure-managed monitoring helper groups. + +## Access + +Grant identity-based access with **Microsoft Entra ID**, or manage **API keys**. +API-key behavior remains the default. To add Entra callers, extend the existing +ignored input file; do not replace its other settings: + +```hcl +gateway_access_principals = { + application = { + principal_id = "" + principal_type = "ServicePrincipal" + } +} +``` + +Use Entra **object IDs**, not application/client IDs, from the gateway +subscription's tenant. Types are `User`, `Group` or `ServicePrincipal` (default, +including managed identities); each object ID may appear once. Terraform grants +**AI Gateway Models User** (`a6b68e3d-a1f0-4c60-8678-237c050cf4ea`) at gateway scope: +all models, not MCP tools, management operations or key retrieval. For one-model +access, use a separately reviewed alias-scoped assignment instead of this map. +The executor needs role-assignment permission at the gateway scope. + +This works as an **additive post-deployment change**: reuse the original state and +complete inputs, retain the final Private-only profile last, and review a fresh +saved plan before applying it. Keep map keys stable and entries on subsequent +plans; removing an entry removes its managed role. Import equivalent pre-existing +assignments at the intended address or keep them externally managed, not duplicated. + +Use audience `https://azure-api.net/aigateway`; SDK clients calling as themselves +request `https://azure-api.net/aigateway/.default`. Custom apps signing in users +need delegated `https://azure-api.net/aigateway/Data.All` consent plus the user's +invocation role. This custom-app consent setup does not apply to managed identities, +client credentials or built-in Azure CLI/PowerShell clients. Send +`Authorization: Bearer `, never an ARM or model-backend token. See the +[portal security and identity guide](https://ai.gateway.azure.com/docs/security-identity). + +```powershell +# Run these probes only after deployment, from a client with the selected route. +.\scripts\Test-ModelAliases.ps1 -Authentication EntraId +# Existing default: +.\scripts\Test-ModelAliases.ps1 -Authentication ApiKey +``` + +The Entra probe uses the current Azure CLI identity, does not retrieve keys and +does not fall back to them. It calls every compatible alias and incurs inference +charges. The caller needs the selected network/DNS route and propagated RBAC. +Verify both authorized and unauthorized identities and fresh telemetry; offline +checks are not a live Entra proof. + +**Live-verified:** a managed-identity caller reached both sample aliases over the +private endpoint: HTTP 403 before the Terraform-managed invocation grant, HTTP +200 afterward, with matching gateway/backend traces. Missing, invalid and +wrong-audience credentials returned 401; API-key calls remained successful. +The temporary grant was removed and the final plan had no changes. This does +not validate custom delegated-user clients or migrate the hosted agent. + +**Not Entra-only:** keys are neither disabled nor deleted. The existing agent and +private VM retain their key-reader roles and API-key calls. The gateway's backend +Foundry User binding stays unchanged. An empty map also does not remove roles +managed outside this sample. + +## Monitoring setup + +The gateway exporter needs real OpenTelemetry Protocol (OTLP) endpoints and a +linked Data Collection Rule (DCR), discovered from Application Insights. +First create that monitoring destination, then create a fresh full deployment plan. +Do not supply guessed endpoints or copy generated IDs. + +**Choose the starting state first.** Copy the assignment from exactly one row +into the same PowerShell session before running the commands below. If you use +extra environment `-var-file` arguments, put them before the listed profile/overrides. + +| Starting state | Set the monitoring plan arguments | +| --- | --- | +| Intentionally Public | `$networkArgs = @()` | +| Already Private-only | `$networkArgs = @('-var-file=profiles\private-only.tfvars.json')` | +| New private environment or public-to-private transition, with temporary public access explicitly approved | `$networkArgs = @('-var-file=profiles\private-connectivity.tfvars.json', '-var=models_public_network_access=Enabled')` | + +For new private environments where even temporary public access is prohibited, +**stop**: this guide does not provide a validated private-from-first-create procedure. + +```powershell +if ($null -eq $networkArgs) { throw 'Choose the monitoring arguments for your starting state first.' } +terraform plan @networkArgs '-target=azapi_resource.insights["gateway"]' '-out=monitoring-destination.tfplan' +# Stop on plan failure. Inspect the saved plan before deciding to apply: +terraform show monitoring-destination.tfplan +# Only after explicit review: +terraform apply monitoring-destination.tfplan + +$metadata = terraform output -json gateway_monitoring +if ($LASTEXITCODE -ne 0) { throw 'Cannot read monitoring metadata.' } +$metadata = $metadata | ConvertFrom-Json +.\scripts\Get-GatewayMonitoring.ps1 ` + -SubscriptionId '' ` + -ApplicationInsightsId $metadata.insights_id ` + -ExpectedLogAnalyticsId $metadata.log_analytics_id -WaitSeconds 120 +``` + +Never drop the private profile on an existing private environment. Review targeted plans +too: they can include dependencies. The helper is read-only and checks destination +readiness, DCR/DCE ownership and the linked Log Analytics/Azure Monitor workspaces. +After it succeeds, create a **fresh full plan** for your selected path. Terraform +then configures the exporter and the gateway's publisher role on the linked DCR. + +For a bounded existing-exporter repair, target `azapi_resource.telemetry_exporter` +with the same inputs. If Azure rejects an in-place exporter-kind change, stop: +replacement needs its own reviewed `-replace=azapi_resource.telemetry_exporter` +plan and explicit deletion/recreation approval. It is not atomic and must not +replace the gateway or delete monitoring data. + +## Public + +Use only when public gateway/model endpoints are intentional. It is the Terraform +default, **not** a required prerequisite for Private-only. + +```powershell +terraform plan -out=deployment.tfplan +terraform show deployment.tfplan +# Review, then apply only the successful saved plan: +terraform apply deployment.tfplan +.\scripts\Test-ModelAliases.ps1 +terraform plan -detailed-exitcode +``` + +The probe makes billable calls to every configured chat-completions alias from +your machine. All aliases must answer; final plan exit `0` means no changes, +`1` means an error, and `2` means changes requiring review. + +## Private-only + +The final [private-only profile](profiles/private-only.tfvars.json) disables +**both gateway and model public access**. Private clients reach the gateway's +private endpoint; separate gateway outbound VNet integration reaches the model +private endpoint. Private DNS, endpoint approval and working routes are all required. + +For an **already private** deployment, skip temporary-public staging. Keep +`-var-file=profiles\private-only.tfvars.json` last on every full, monitoring and +drift plan. Never apply the intermediate profile to an already-disabled gateway. + +For a **new environment or one transitioning from public**, use these provisioning +stages only where temporary public access is explicitly permitted and the legacy +resource contract is available. The retained +`private-connectivity` file is an intermediate stage, **not a third deployment path**: + +| Stage | Plan arguments, after all environment inputs | Required gate before continuing | +| --- | --- | --- | +| Prepare private networking | `-var-file=profiles\private-connectivity.tfvars.json -var=models_public_network_access=Enabled` | Review/apply; gateway/model remain public. Approve private endpoints and prove private DNS/inference. | +| Close model public access | `-var-file=profiles\private-connectivity.tfvars.json` | Review/apply; model disabled. Repeat the private probe. | +| Close gateway public access | `-var-file=profiles\private-only.tfvars.json` | Review/apply; both disabled. Recheck private inference and authenticated public-network rejection. | + +Save each plan with `-out=.tfplan`, inspect it with +`terraform show .tfplan`, and apply only that reviewed plan. +Do not reopen access to bypass a failure or Azure Policy. If temporary public access +is prohibited, do not use this staged procedure unchanged: validate a supported +private creation sequence first. First-create behavior is not proven by a +successful update of an existing gateway. + +Wait for actual ARM provisioning completion and approved/succeeded private +connections. On an asynchronous timeout, inspect actual state before making a new +plan; an accepted update is not proof of completion. All callers, including agents +and CI, need their own private connectivity before gateway public access is closed. + +Use an existing authorized private client or enable the optional billable test VM: + +```hcl +enable_private_test_client = true +private_test_client_ssh_public_key = "ssh-ed25519 " +``` + +The VM has no public IP; the probe uses Run Command, not inbound SSH. Its runtime +role permits only gateway key retrieval at the gateway scope. The Terraform +executor additionally needs custom role-definition management and role-assignment +permissions; Contributor or role-assignment access alone is insufficient. +The test operator needs VM read, Run Command, start and deallocate permissions. + +Start the VM using `terraform output -raw private_test_vm_id`, then: + +```powershell +.\scripts\Test-PrivateGateway.ps1 +terraform plan -var-file=profiles\private-only.tfvars.json -detailed-exitcode +``` + +This helper requires the sample VM. It checks private DNS and every model alias, +retrieving the gateway key only in runtime memory. The reference models support +Chat Completions, `max_tokens` and `temperature`; other API types need compatible +probes. A successful private call alone does **not** prove public access is blocked. +Deallocate the VM after testing and verify its power state. + +## Optional private hosted agent + +For a new private agent, enable the networking configuration **before account +creation**; it cannot simply be added to an existing agent account afterward: + +```hcl +enable_private_networking = true +enable_private_hosted_agent = true +# location must equal gateway_location for this extension. +``` + +Keep the final private-only profile last. The extension adds an account/project, +dedicated outbound-injection subnet, inbound Foundry private endpoint, registry +private endpoint/DNS and scoped project permissions. It reuses the gateway/model +route and monitoring. Foundry public access is disabled; ACR public access is +unchanged. DNS changes affect every client in the linked VNet. + +The Foundry private endpoint is **inbound**; agent network injection is **outbound**. +The account has `prevent_destroy`; turning off the flag or changing injection +requires an approved migration. Basic agent data is platform-managed, not a +customer-owned private Storage/Cosmos DB/Search setup. This is not deny-all egress: +Entra, ARM and telemetry connectivity still need validation; no NAT/firewall or +fully private monitoring path is added. + +Terraform does **not** deploy an image or agent application: + +1. Read `terraform output -json private_hosted_agent` for the **new** project and + endpoints; compatibility outputs `project_id`/`project_endpoint` refer to the + original project. Verify account/capability-host provisioning and private DNS, + including registry login and regional data endpoints. +2. Deploy your application from a private executor using the + [hosted-agent/private ACR guide](https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent-private-azure-container-registry). + Do not reprovision Terraform-owned infrastructure through a second workflow. + For a prebuilt image, explicitly configure image passthrough by digest rather + than assuming a package argument suppresses builds. Scope any build/deploy + permissions to the required resources. +3. Discover the actual deployed **runtime managed-identity object ID**. Verify + runtime AcrPull on the exact registry separately from Terraform's project + AcrPull. Record the owner of any role assigned outside Terraform. +4. Set `private_hosted_agent_runtime_principal_id` to that verified ID and review + a new plan with complete inputs and the private-only profile last. This binds + narrow gateway key retrieval; the agent still calls the gateway with an API key. + Never persist that key in tfvars, state, logs or agent metadata. Verify both + runtime roles again if a new agent version changes identity. + +Remove only job-created temporary roles and deallocate the executor after use. +Keep the runtime-principal input on subsequent plans, or its binding can be removed. + +Changing `enable_private_test_client` changes the private agent's key-reader role. +Terraform replaces that role assignment rather than updating its immutable role. +Upgrading an older assignment also requires a one-time replacement; review the +plan and allow for a brief interruption of the agent's key retrieval. + +## Acceptance and maintenance + +Require fresh results in the selected environment, not screenshots from another +deployment: each alias succeeds, missing/invalid gateway keys are rejected, expected +roles/connections and private DNS are correct, and gateway/backend telemetry +correlates to the same request. For Private-only, a **valid authenticated request +from outside the private route** must receive explicit network denial; a 401, +authentication-related 403, timeout or DNS error is not equivalent. + +For a hosted agent, invoke a fresh session/version and correlate its response to +successful gateway **and intended model backend** spans in `OTelSpans` by `TraceId` +and time window. Confirm gateway/model/Foundry public settings, cleanup of temporary +roles/VMs, and a no-change plan with **all** original and runtime inputs. + +For the optional guarded VM-based acceptance report: + +```powershell +.\scripts\Test-CustomerAcceptance.ps1 ` + -SubscriptionId '' -TenantId '' ` + -ResourceGroup '' ` + -ReportDirectory 'C:\secure\acceptance' ` + -NetworkProfile private-only -RunPrivateProbe +``` + +Pass any extra inputs with `-AdditionalVarFile`. This does not test hosted-agent +runtime or public network rejection. Its overall result remains blocked until a +separate authenticated portal review is recorded using the generated checklist +and `Complete-PortalAcceptance.ps1`; that validator checks evidence completeness, +not the UI itself. Never treat blocked or unexecuted checks as passed. + +Run credential-free regression checks with `.\scripts\Test-Offline.ps1`. +Azure Policy can deny, modify or create related resources; inspect actual posture +and drift after remediation instead of blindly undoing enforced settings. + +Model calls, Premium ACR, Storage, monitoring and optional compute incur charges. +The gateway preview has no SLA. Before cleanup, review a destroy plan, data-retention +requirements, `prevent_destroy` guards, externally managed roles and Azure-managed +monitoring groups. Destroying Terraform resources does not certify removal of all +service-managed resources or charges; do not bypass protections automatically. diff --git a/samples/terraform/docs/images/architecture-private-only.svg b/samples/terraform/docs/images/architecture-private-only.svg new file mode 100644 index 0000000..a1535ab --- /dev/null +++ b/samples/terraform/docs/images/architecture-private-only.svg @@ -0,0 +1,118 @@ + + + Private-only | Application access to AI Gateway + Generated by Azure Architecture Diagram Builder MCP render_diagram. Export finishing preserves MCP service positions and connection paths; uses official Azure V24 specialty icons, a generic client glyph, neutral card borders, descriptive badges and a flow legend. A bold arrow and highlighted client-authentication panel identify the application's Entra token plus invocation role OR API-key access, with the official Microsoft Entra ID icon beside the Entra option. PE means private endpoint; MI means managed identity. Private-only refers to gateway/model/selected agent ingress, not all egress. + + + + + + Private-only | Application access to AI Gateway + + + + PRIVATE NETWORK ACCESS + VNet / connected network + + + + + + + + CLIENT AUTHENTICATION / PRIVATE HTTPS + Entra ID token + invocation role + OR API key + + + + + + + + + MI / PE + + + + + + + + Key / PE + + + + + + + + Image / PE + + + + + + + + Telemetry + + + + + + + Application + Entra ID or API key + + + + + + + + AI Gateway + Private ingress + + + + + + + Foundry Models + Model deployments + + + + + + + Foundry agent + Optional private agent + + + + + + + Registry + + ACR + + + + + + + + Monitoring + + App Insights + + + + Application access: Entra token + invocation role OR API key. Backend access: the gateway's separate managed identity. + Bold blue: client authentication | Blue: model request | Gray dashed: optional application / image | Purple dashed: telemetry + PE = private endpoint. Private DNS/routing is required. Agent and gateway outbound VNet integration are separate from inbound Private Link. + Azure Architecture Diagram Builder MCP | Official Azure icons + \ No newline at end of file diff --git a/samples/terraform/docs/images/architecture-public.svg b/samples/terraform/docs/images/architecture-public.svg new file mode 100644 index 0000000..e319b8a --- /dev/null +++ b/samples/terraform/docs/images/architecture-public.svg @@ -0,0 +1,113 @@ + + + Public | Application access to AI Gateway + Generated by Azure Architecture Diagram Builder MCP render_diagram. Export finishing preserves MCP service positions and connection paths; uses official Azure V24 specialty icons, a generic client glyph, neutral card borders, descriptive badges and a flow legend. A bold arrow and highlighted client-authentication panel identify the application's Entra token plus invocation role OR API-key access, with the official Microsoft Entra ID icon beside the Entra option. PE means private endpoint; MI means managed identity. Private-only refers to gateway/model/selected agent ingress, not all egress. + + + + + + Public | Application access to AI Gateway + + + + + + + + CLIENT AUTHENTICATION / HTTPS + Entra ID token + invocation role + OR API key + + + + + + + + + Gateway MI + + + + + + + + API key + + + + + + + + Image + + + + + + + + Telemetry + + + + + + + Application + Entra ID or API key + + + + + + + + AI Gateway + Public ingress + + + + + + + Foundry Models + Model deployments + + + + + + + Hosted agent + Optional application + + + + + + + Registry + + ACR + + + + + + + + Monitoring + + App Insights + + + + Application access: Entra token + invocation role OR API key. Backend access: the gateway's separate managed identity. + Bold blue: client authentication | Blue: model request | Gray dashed: optional application / image | Purple dashed: telemetry + Gateway MI = gateway managed identity. Terraform prepares hosting resources; deploy the agent application separately. + Azure Architecture Diagram Builder MCP | Official Azure icons + \ No newline at end of file diff --git a/samples/terraform/foundry-agents.tf b/samples/terraform/foundry-agents.tf new file mode 100644 index 0000000..60613fe --- /dev/null +++ b/samples/terraform/foundry-agents.tf @@ -0,0 +1,139 @@ +resource "azapi_resource" "agents" { + type = "Microsoft.CognitiveServices/accounts@2025-04-01-preview" + name = local.names.agents + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + kind = "AIServices" + sku = { name = "S0" } + properties = { + allowProjectManagement = true + customSubDomainName = local.names.agents + disableLocalAuth = true + publicNetworkAccess = "Enabled" + networkAcls = { + defaultAction = "Allow" + virtualNetworkRules = [] + ipRules = [] + } + } + } +} + +resource "azapi_resource" "storage" { + type = "Microsoft.Storage/storageAccounts@2024-01-01" + name = local.names.storage + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + body = { + kind = "StorageV2" + sku = { name = "Standard_LRS" } + properties = { + minimumTlsVersion = "TLS1_2" + allowBlobPublicAccess = false + allowSharedKeyAccess = false + publicNetworkAccess = var.storage_public_network_access + networkAcls = { + bypass = "AzureServices" + defaultAction = "Allow" + virtualNetworkRules = [] + } + } + } + response_export_values = ["properties.primaryEndpoints.blob"] +} + +resource "azapi_resource" "registry" { + type = "Microsoft.ContainerRegistry/registries@2023-07-01" + name = local.names.registry + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + sku = { name = "Premium" } + properties = { + adminUserEnabled = false + publicNetworkAccess = "Enabled" + zoneRedundancy = "Disabled" + } + } + response_export_values = ["properties.loginServer"] +} + +resource "azapi_resource" "project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + name = local.names.project + parent_id = azapi_resource.agents.id + location = var.location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + properties = { + displayName = var.project_display_name + description = var.project_description + } + } +} + +resource "azapi_resource" "storage_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = local.names.storage + parent_id = azapi_resource.project.id + body = { + properties = { + category = "AzureStorageAccount" + target = azapi_resource.storage.output.properties.primaryEndpoints.blob + authType = "AAD" + metadata = { + ApiType = "Azure" + ResourceId = azapi_resource.storage.id + location = var.location + } + } + } + depends_on = [azapi_resource.role_assignment["project-storage"]] +} + +resource "azapi_resource" "registry_connection" { + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = "${local.names.registry}-conn" + parent_id = azapi_resource.project.id + body = { + properties = { + category = "ContainerRegistry" + target = azapi_resource.registry.output.properties.loginServer + authType = "ManagedIdentity" + isSharedToAll = false + credentials = { + clientId = azapi_resource.project.identity[0].principal_id + resourceId = azapi_resource.registry.id + } + metadata = { ResourceId = azapi_resource.registry.id } + } + } + depends_on = [azapi_resource.role_assignment["project-acr-pull"]] +} + +resource "azapi_resource" "insights_connection" { + type = "Microsoft.CognitiveServices/accounts/connections@2025-06-01" + name = azapi_resource.insights["agents"].name + parent_id = azapi_resource.agents.id + body = { + properties = { + category = "AppInsights" + authType = "ApiKey" + isSharedToAll = true + target = azapi_resource.insights["agents"].id + credentials = { key = azapi_resource.insights["agents"].output.properties.ConnectionString } + metadata = { + ApiType = "Azure" + ResourceId = azapi_resource.insights["agents"].id + displayName = azapi_resource.insights["agents"].name + } + } + } +} diff --git a/samples/terraform/foundry-models.tf b/samples/terraform/foundry-models.tf new file mode 100644 index 0000000..1f56eff --- /dev/null +++ b/samples/terraform/foundry-models.tf @@ -0,0 +1,46 @@ +resource "azapi_resource" "models" { + type = "Microsoft.CognitiveServices/accounts@2025-04-01-preview" + name = local.names.models + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + kind = "AIServices" + sku = { name = "S0" } + properties = { + allowProjectManagement = false + customSubDomainName = local.names.models + disableLocalAuth = true + publicNetworkAccess = var.models_public_network_access + networkAcls = { + defaultAction = var.models_public_network_access == "Disabled" ? "Deny" : "Allow" + virtualNetworkRules = [] + ipRules = [] + } + } + } + response_export_values = ["properties.endpoint"] +} + +resource "azapi_resource" "model_deployment" { + for_each = var.model_deployments + type = "Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview" + name = each.key + parent_id = azapi_resource.models.id + # Cognitive Services permits only one deployment operation per account at a time. + locks = [azapi_resource.models.id] + body = { + sku = { + name = each.value.sku_name + capacity = each.value.capacity + } + properties = { + model = { + name = each.value.model_name + version = each.value.model_version + format = each.value.model_format + } + } + } +} diff --git a/samples/terraform/main.tf b/samples/terraform/main.tf new file mode 100644 index 0000000..169f865 --- /dev/null +++ b/samples/terraform/main.tf @@ -0,0 +1,67 @@ +locals { + subscription_scope = "/subscriptions/${var.subscription_id}" + resource_token = substr(sha256("${var.subscription_id}:${var.environment_name}:${var.location}"), 0, 12) + backend_token = var.backend_revision == "" ? local.resource_token : substr(sha256("${local.resource_token}:${var.backend_revision}"), 0, 12) + tags = merge(var.tags, { environment = var.environment_name }) + names = { + gateway = "aigw-${local.resource_token}" + models = "aimodels-${local.backend_token}" + agents = "aiagents-${local.backend_token}" + project = "proj-${local.backend_token}" + storage = "st${local.backend_token}" + registry = "acr${local.backend_token}" + } +} + +resource "azapi_resource" "resource_group" { + # Keep the existing gateway address and name so consolidation cannot replace it. + for_each = toset(var.retain_legacy_resource_groups ? ["gateway", "models", "agents"] : ["gateway"]) + type = "Microsoft.Resources/resourceGroups@2021-04-01" + name = "rg-${var.environment_name}-${local.resource_token}-${each.key}" + parent_id = local.subscription_scope + location = var.location + tags = local.tags + body = { properties = {} } +} + +resource "azapi_resource" "logs" { + for_each = toset(["gateway", "agents"]) + type = "Microsoft.OperationalInsights/workspaces@2023-09-01" + name = "${local.names[each.key]}-logs" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + body = { + properties = { + retentionInDays = 30 + features = { enableLogAccessUsingOnlyResourcePermissions = true } + sku = { name = "PerGB2018" } + } + } +} + +resource "azapi_resource" "insights" { + # OTLP enablement is supported by the service but absent from the bundled schema. + schema_validation_enabled = each.key != "gateway" + for_each = toset(["gateway", "agents"]) + type = "Microsoft.Insights/components@2020-02-02" + name = "${local.names[each.key]}-insights" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + body = { + kind = "web" + properties = merge({ + Application_Type = "web" + IngestionMode = "LogAnalytics" + WorkspaceResourceId = azapi_resource.logs[each.key].id + }, each.key == "gateway" ? { AzureMonitorWorkspaceIngestionMode = "Enabled" } : {}) + } + response_export_values = each.key == "gateway" ? [ + "properties.OTLPMetricsEndpoint", + "properties.OTLPLogsEndpoint", + "properties.OTLPTracesEndpoint", + "properties.DataCollectionRuleResourceId", + "properties.DataCollectionRuleId" + ] : ["properties.ConnectionString"] +} diff --git a/samples/terraform/outputs.tf b/samples/terraform/outputs.tf new file mode 100644 index 0000000..1693d26 --- /dev/null +++ b/samples/terraform/outputs.tf @@ -0,0 +1,113 @@ +output "resource_group_name" { + description = "Shared application group; excludes linked Azure-managed OTLP helper groups and the DCR-scoped publisher assignment." + value = azapi_resource.resource_group["gateway"].name +} + +output "resource_groups" { + description = "Compatibility aliases: gateway, models and agents all refer to the same shared resource group." + value = { for key in ["gateway", "models", "agents"] : key => azapi_resource.resource_group["gateway"].name } +} + +output "gateway_id" { + description = "ARM resource ID of the AIGateway service." + value = azapi_resource.gateway.id +} + +output "gateway_endpoint" { + description = "Runtime endpoint returned by the gateway service." + value = azapi_resource.gateway.output.properties.gatewayUrl +} + +output "gateway_api_key_id" { + description = "API key resource ID only; no runtime key value is exported." + value = azapi_resource.runtime_api_key.id +} + +output "model_deployment_ids" { + description = "Model deployment ARM IDs keyed by the names registered in the gateway." + value = { for key, deployment in azapi_resource.model_deployment : key => deployment.id } +} + +output "model_api_configuration" { + description = "Non-secret API metadata used to check compatibility before running the sample's chat-completions probes." + value = { for key, model in var.model_deployments : key => { + api_format = model.api_format + endpoints = model.endpoints + } } +} + +output "foundry_models_id" { + description = "Dedicated Foundry model account ARM ID." + value = azapi_resource.models.id +} + +output "project_id" { + description = "Foundry hosted-agent project ARM ID." + value = azapi_resource.project.id +} + +output "project_endpoint" { + description = "Foundry project data-plane endpoint." + value = "https://${local.names.agents}.services.ai.azure.com/api/projects/${local.names.project}" +} + +output "container_registry_endpoint" { + description = "Registry login server for separately built hosted-agent images." + value = azapi_resource.registry.output.properties.loginServer +} + +output "storage_account_name" { + description = "Storage account for project artifacts." + value = azapi_resource.storage.name +} + +output "application_insights_ids" { + description = "Separate Application Insights resource IDs for gateway telemetry and agent tracing." + value = { for key, insights in azapi_resource.insights : key => insights.id } +} + +output "gateway_monitoring" { + description = "Nonsecret OTLP metadata returned by gateway Insights; use Get-GatewayMonitoring.ps1 to verify readiness and linked managed resources." + value = merge(local.gateway_monitoring, { + insights_id = azapi_resource.insights["gateway"].id + log_analytics_id = azapi_resource.logs["gateway"].id + }) +} + +output "private_endpoint_ids" { + description = "Gateway, model and optional private agent and registry private endpoint IDs." + value = merge( + { for key, endpoint in azapi_resource.private_endpoint : key => endpoint.id }, + { for key, endpoint in azapi_resource.private_agent_endpoint : key => endpoint.id } + ) +} + +output "private_hosted_agent" { + description = "Additional private project setup, not proof of a deployed agent. Original project_id/project_endpoint remain unchanged. No keys are exported." + value = var.enable_private_hosted_agent ? { + account_id = azapi_resource.private_agents[0].id + project_id = azapi_resource.private_project[0].id + project_endpoint = "https://${local.private_agent_name}.services.ai.azure.com/api/projects/${local.private_project_name}" + project_principal_id = azapi_resource.private_project[0].identity[0].principal_id + subnet_id = azapi_resource.private_subnet["agents"].id + gateway_endpoint = azapi_resource.gateway.output.properties.gatewayUrl + registry_endpoint = azapi_resource.registry.output.properties.loginServer + key_reader_role_definition_id = var.enable_private_test_client ? azapi_resource.private_test_key_role[0].id : azapi_resource.private_agent_key_role[0].id + runtime_key_binding_configured = var.private_hosted_agent_runtime_principal_id != "" + } : null +} +output "private_test_vm_id" { + description = "Optional private test VM ID. Start/deallocate separately; power state is not Terraform configuration." + value = var.enable_private_test_client ? azapi_resource.private_test_vm[0].id : null +} + +output "private_probe_configuration" { + description = "Non-secret inputs for scripts/private-path-probe.py on the optional test VM." + value = var.enable_private_test_client ? { + gateway_id = azapi_resource.gateway.id + gateway_endpoint = azapi_resource.gateway.output.properties.gatewayUrl + model_endpoint = azapi_resource.models.output.properties.endpoint + model_names = keys(var.model_deployments) + private_endpoint_subnet = local.private_subnets["endpoints"] + } : null +} diff --git a/samples/terraform/private-hosted-agent.tf b/samples/terraform/private-hosted-agent.tf new file mode 100644 index 0000000..c78b530 --- /dev/null +++ b/samples/terraform/private-hosted-agent.tf @@ -0,0 +1,233 @@ +locals { + private_agent_name = "${local.names.agents}-private" + private_project_name = "${local.names.project}-private" + private_agent_endpoints = var.enable_private_hosted_agent ? { + agents = { + resource_id = azapi_resource.private_agents[0].id + group_id = "account" + dns_keys = ["cognitiveservices", "openai", "services"] + } + registry = { + resource_id = azapi_resource.registry.id + group_id = "registry" + dns_keys = ["registry"] + } + } : {} + private_agent_roles = var.enable_private_hosted_agent ? merge({ + project-acr-pull = { + scope = azapi_resource.registry.id + principal_id = azapi_resource.private_project[0].identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + } + }, var.developer_principal_id == "" ? {} : { + developer-project = { + scope = azapi_resource.private_project[0].id + principal_id = var.developer_principal_id + principal_type = var.developer_principal_type + role_id = "53ca6127-db72-4b80-b1b0-d745d6d5456d" + } + developer-account-reader = { + scope = azapi_resource.private_agents[0].id + principal_id = var.developer_principal_id + principal_type = var.developer_principal_type + role_id = "acdd72a7-3385-48ef-bd42-f606fba81ae7" + } + }) : {} +} + +resource "azapi_resource" "private_agents" { + count = var.enable_private_hosted_agent ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts@2025-06-01" + name = local.private_agent_name + parent_id = azapi_resource.resource_group["gateway"].id + location = var.location + tags = local.tags + replace_triggers_refs = ["properties.networkInjections", "properties.customSubDomainName"] + identity { type = "SystemAssigned" } + body = { + kind = "AIServices" + sku = { name = "S0" } + properties = { + allowProjectManagement = true + customSubDomainName = local.private_agent_name + disableLocalAuth = true + publicNetworkAccess = "Disabled" + networkAcls = { + defaultAction = "Deny" + virtualNetworkRules = [] + ipRules = [] + } + networkInjections = [{ + scenario = "agent" + subnetArmId = azapi_resource.private_subnet["agents"].id + useMicrosoftManagedNetwork = false + }] + } + } + lifecycle { + prevent_destroy = true + } +} + +resource "azapi_resource" "private_project" { + count = var.enable_private_hosted_agent ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects@2025-06-01" + name = local.private_project_name + parent_id = azapi_resource.private_agents[0].id + location = var.location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + properties = { + displayName = "${var.project_display_name} (private)" + description = "Additional VNet-injected Basic project; use the existing private AI Gateway for models." + } + } + depends_on = [azapi_resource.private_agent_dns_zone_group["agents"]] +} + +# Separate collection keeps additions from deferring the legacy gateway approval read. +resource "azapi_resource" "private_agent_endpoint" { + for_each = local.private_agent_endpoints + type = "Microsoft.Network/privateEndpoints@2024-05-01" + name = "${local.names.gateway}-${each.key}-pe" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + replace_triggers_refs = ["properties.privateLinkServiceConnections"] + body = { + properties = { + subnet = { id = azapi_resource.private_subnet["endpoints"].id } + privateLinkServiceConnections = [{ + name = each.key + properties = { + privateLinkServiceId = each.value.resource_id + groupIds = [each.value.group_id] + } + }] + } + } +} + +resource "azapi_resource" "private_agent_dns_zone_group" { + for_each = local.private_agent_endpoints + type = "Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01" + name = "default" + parent_id = azapi_resource.private_agent_endpoint[each.key].id + body = { + properties = { + privateDnsZoneConfigs = [for key in each.value.dns_keys : { + name = key + properties = { privateDnsZoneId = azapi_resource.private_dns_zone[key].id } + }] + } + } +} + +resource "azapi_resource" "private_project_capability_host" { + # Foundry sample11 uses this field, absent from AzAPI 2.13's project-host schema. + schema_validation_enabled = false + count = var.enable_private_hosted_agent ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview" + name = "caphostproj" + parent_id = azapi_resource.private_project[0].id + body = { + properties = { capabilityHostKind = "Agents" } + } +} + +resource "azapi_resource" "private_agent_role_assignment" { + for_each = local.private_agent_roles + type = "Microsoft.Authorization/roleAssignments@2022-04-01" + name = uuidv5("url", "${each.value.scope}/${each.value.principal_id}/${each.value.role_id}") + parent_id = each.value.scope + body = { + properties = { + principalId = each.value.principal_id + principalType = each.value.principal_type + roleDefinitionId = "${local.subscription_scope}/providers/Microsoft.Authorization/roleDefinitions/${each.value.role_id}" + } + } +} + +resource "azapi_resource" "private_registry_connection" { + count = var.enable_private_hosted_agent ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-06-01" + name = "${local.names.registry}-conn" + parent_id = azapi_resource.private_project[0].id + body = { + properties = { + category = "ContainerRegistry" + target = azapi_resource.registry.output.properties.loginServer + authType = "ManagedIdentity" + isSharedToAll = false + credentials = { + clientId = azapi_resource.private_project[0].identity[0].principal_id + resourceId = azapi_resource.registry.id + } + metadata = { ResourceId = azapi_resource.registry.id } + } + } + depends_on = [ + azapi_resource.private_agent_role_assignment["project-acr-pull"], + azapi_resource.private_agent_dns_zone_group["registry"] + ] +} + +resource "azapi_resource" "private_insights_connection" { + count = var.enable_private_hosted_agent ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/connections@2025-06-01" + name = azapi_resource.insights["agents"].name + parent_id = azapi_resource.private_agents[0].id + body = { + properties = { + category = "AppInsights" + authType = "ApiKey" + isSharedToAll = true + target = azapi_resource.insights["agents"].id + credentials = { key = azapi_resource.insights["agents"].output.properties.ConnectionString } + metadata = { + ApiType = "Azure" + ResourceId = azapi_resource.insights["agents"].id + displayName = azapi_resource.insights["agents"].name + } + } + } +} + +resource "azapi_resource" "private_agent_key_role" { + count = var.enable_private_hosted_agent && !var.enable_private_test_client ? 1 : 0 + type = "Microsoft.Authorization/roleDefinitions@2022-04-01" + name = uuidv5("url", "${azapi_resource.resource_group["gateway"].id}/private-agent-key-reader") + parent_id = local.subscription_scope + body = { + properties = { + roleName = "AI Gateway private agent key reader ${local.resource_token}" + description = "Retrieve gateway runtime keys for the separately deployed private hosted agent." + type = "CustomRole" + assignableScopes = [azapi_resource.resource_group["gateway"].id] + permissions = [{ + actions = ["Microsoft.ApiManagement/service/apiKeys/listSecrets/action"] + notActions = [] + dataActions = [] + notDataActions = [] + }] + } + } +} + +resource "azapi_resource" "private_agent_key_binding" { + count = var.enable_private_hosted_agent && var.private_hosted_agent_runtime_principal_id != "" ? 1 : 0 + type = "Microsoft.Authorization/roleAssignments@2022-04-01" + # A role change must replace the assignment, not update its immutable roleDefinitionId. + name = uuidv5("url", "${azapi_resource.gateway.id}/${var.private_hosted_agent_runtime_principal_id}/${var.enable_private_test_client ? azapi_resource.private_test_key_role[0].name : azapi_resource.private_agent_key_role[0].name}") + parent_id = azapi_resource.gateway.id + body = { + properties = { + principalId = var.private_hosted_agent_runtime_principal_id + principalType = "ServicePrincipal" + roleDefinitionId = var.enable_private_test_client ? azapi_resource.private_test_key_role[0].id : azapi_resource.private_agent_key_role[0].id + } + } +} diff --git a/samples/terraform/private-network.tf b/samples/terraform/private-network.tf new file mode 100644 index 0000000..c19b72d --- /dev/null +++ b/samples/terraform/private-network.tf @@ -0,0 +1,208 @@ +locals { + private_dns_zones = var.enable_private_networking ? merge({ + gateway = "privatelink.azure-api.net" + cognitiveservices = "privatelink.cognitiveservices.azure.com" + openai = "privatelink.openai.azure.com" + services = "privatelink.services.ai.azure.com" + }, var.enable_private_hosted_agent ? { registry = "privatelink.azurecr.io" } : {}) : {} + private_subnets = var.enable_private_networking ? merge({ + gateway = cidrsubnet(var.private_network_address_prefix, 8, 0) + endpoints = cidrsubnet(var.private_network_address_prefix, 8, 1) + client = cidrsubnet(var.private_network_address_prefix, 8, 2) + }, var.enable_private_hosted_agent ? { agents = cidrsubnet(var.private_network_address_prefix, 8, 3) } : {}) : {} + private_endpoints = var.enable_private_networking ? { + gateway = { + resource_id = azapi_resource.gateway.id + group_id = "Gateway" + dns_keys = ["gateway"] + connection_name = var.gateway_private_endpoint_manual_approval ? "gateway-manual" : "gateway" + connection_property = var.gateway_private_endpoint_manual_approval ? "manualPrivateLinkServiceConnections" : "privateLinkServiceConnections" + } + models = { + resource_id = azapi_resource.models.id + group_id = "account" + dns_keys = ["cognitiveservices", "openai", "services"] + connection_name = "models" + connection_property = "privateLinkServiceConnections" + } + } : {} +} + +resource "azapi_resource" "private_vnet" { + count = var.enable_private_networking ? 1 : 0 + type = "Microsoft.Network/virtualNetworks@2024-05-01" + name = "${local.names.gateway}-vnet" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + body = { + properties = { addressSpace = { addressPrefixes = [var.private_network_address_prefix] } } + } +} + +resource "azapi_resource" "private_nsg" { + for_each = var.enable_private_networking ? toset(concat(["gateway", "client"], var.enable_private_hosted_agent ? ["agents"] : [])) : toset([]) + type = "Microsoft.Network/networkSecurityGroups@2024-05-01" + name = "${local.names.gateway}-${each.key}-nsg" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + body = { + properties = { + securityRules = each.key == "client" ? [{ + name = "DenyAllInbound" + properties = { + priority = 100 + direction = "Inbound" + access = "Deny" + protocol = "*" + sourcePortRange = "*" + destinationPortRange = "*" + sourceAddressPrefix = "*" + destinationAddressPrefix = "*" + } + }] : [for index, service in(each.key == "agents" ? ["AzureActiveDirectory", "AzureResourceManager", "AzureContainerRegistry", "AzureMonitor"] : ["Storage", "AzureKeyVault"]) : { + name = "Allow${service}Https" + properties = { + priority = 100 + index + direction = "Outbound" + access = "Allow" + protocol = "Tcp" + sourcePortRange = "*" + destinationPortRange = "443" + sourceAddressPrefix = "VirtualNetwork" + destinationAddressPrefix = service + } + }] + } + } +} + +resource "azapi_resource" "private_subnet" { + for_each = local.private_subnets + type = "Microsoft.Network/virtualNetworks/subnets@2024-05-01" + name = each.key + parent_id = azapi_resource.private_vnet[0].id + locks = [azapi_resource.private_vnet[0].id] + body = { + properties = merge( + { addressPrefix = each.value }, + each.key == "endpoints" ? { privateEndpointNetworkPolicies = "Disabled" } : {}, + each.key != "endpoints" ? { + networkSecurityGroup = { id = azapi_resource.private_nsg[each.key].id } + } : {}, + contains(["gateway", "agents"], each.key) ? { + delegations = [{ + name = "${each.key}-integration" + properties = { serviceName = each.key == "agents" ? "Microsoft.App/environments" : "Microsoft.Web/serverFarms" } + }] + } : {}, + # Only the isolated PoC client uses default outbound internet for its VM agent. + each.key == "client" ? { defaultOutboundAccess = true } : {} + ) + } +} + +resource "azapi_resource" "private_dns_zone" { + for_each = local.private_dns_zones + type = "Microsoft.Network/privateDnsZones@2020-06-01" + name = each.value + parent_id = azapi_resource.resource_group["gateway"].id + location = "global" + tags = local.tags + body = { properties = {} } +} + +resource "azapi_resource" "private_dns_link" { + for_each = local.private_dns_zones + type = "Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01" + name = "gateway-vnet" + parent_id = azapi_resource.private_dns_zone[each.key].id + location = "global" + body = { + properties = { + registrationEnabled = false + virtualNetwork = { id = azapi_resource.private_vnet[0].id } + } + } +} + +resource "azapi_resource" "private_endpoint" { + for_each = local.private_endpoints + type = "Microsoft.Network/privateEndpoints@2024-05-01" + name = "${local.names.gateway}-${each.value.connection_name}-pe" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + # Body-relative paths cover known targets; unknown IDs also need -replace (see reference). + replace_triggers_refs = ["properties.${each.value.connection_property}"] + body = { + properties = { + subnet = { id = azapi_resource.private_subnet["endpoints"].id } + (each.value.connection_property) = [{ + name = each.value.connection_name + properties = { + privateLinkServiceId = each.value.resource_id + groupIds = [each.value.group_id] + } + }] + } + } +} + +data "azapi_resource" "gateway_private_endpoint_connection" { + count = var.enable_private_networking && var.gateway_private_endpoint_manual_approval ? 1 : 0 + type = "Microsoft.ApiManagement/service/privateEndpointConnections@2025-09-01-preview" + name = local.private_endpoints["gateway"].connection_name + parent_id = azapi_resource.gateway.id + response_export_values = ["properties.privateEndpoint.id", "properties.privateLinkServiceConnectionState.status"] + depends_on = [azapi_resource.private_endpoint["gateway"]] +} + +resource "azapi_resource_action" "gateway_private_endpoint_approval" { + count = var.enable_private_networking && var.gateway_private_endpoint_manual_approval ? 1 : 0 + type = "Microsoft.ApiManagement/service/privateEndpointConnections@2025-09-01-preview" + # Keep identity stable when another endpoint defers the connection read. + resource_id = "${azapi_resource.gateway.id}/privateEndpointConnections/${local.private_endpoints["gateway"].connection_name}" + method = data.azapi_resource.gateway_private_endpoint_connection[0].output.properties.privateLinkServiceConnectionState.status == "Approved" ? "GET" : "PUT" + when = "apply" + locks = [azapi_resource.gateway.id] + # Legacy APIM rejects repeat approval; PUT also requires the read-only body.id. + body = data.azapi_resource.gateway_private_endpoint_connection[0].output.properties.privateLinkServiceConnectionState.status == "Approved" ? null : { + id = data.azapi_resource.gateway_private_endpoint_connection[0].id + properties = { + privateLinkServiceConnectionState = { + status = "Approved" + description = "Approved private gateway connectivity repair" + } + } + } + depends_on = [azapi_resource.private_endpoint["gateway"]] + lifecycle { + replace_triggered_by = [azapi_resource.private_endpoint["gateway"]] + precondition { + condition = lower(data.azapi_resource.gateway_private_endpoint_connection[0].output.properties.privateEndpoint.id) == lower(azapi_resource.private_endpoint["gateway"].id) + error_message = "Refusing to approve an APIM connection belonging to another private endpoint." + } + precondition { + condition = contains(["Pending", "Approved"], data.azapi_resource.gateway_private_endpoint_connection[0].output.properties.privateLinkServiceConnectionState.status) + error_message = "The gateway private connection must be Pending or Approved; investigate rejected or disconnected connections before retrying." + } + } +} + +resource "azapi_resource" "private_dns_zone_group" { + for_each = local.private_endpoints + type = "Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01" + name = "default" + parent_id = azapi_resource.private_endpoint[each.key].id + body = { + properties = { + privateDnsZoneConfigs = [for key in each.value.dns_keys : { + name = key + properties = { privateDnsZoneId = azapi_resource.private_dns_zone[key].id } + }] + } + } + depends_on = [azapi_resource_action.gateway_private_endpoint_approval] +} diff --git a/samples/terraform/private-test-client.tf b/samples/terraform/private-test-client.tf new file mode 100644 index 0000000..29e9582 --- /dev/null +++ b/samples/terraform/private-test-client.tf @@ -0,0 +1,106 @@ +resource "azapi_resource" "private_test_nic" { + count = var.enable_private_test_client ? 1 : 0 + type = "Microsoft.Network/networkInterfaces@2024-05-01" + name = "${local.names.gateway}-test-nic" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + body = { + properties = { + ipConfigurations = [{ + name = "private" + properties = { + privateIPAllocationMethod = "Dynamic" + subnet = { id = azapi_resource.private_subnet["client"].id } + } + }] + } + } +} + +resource "azapi_resource" "private_test_vm" { + count = var.enable_private_test_client ? 1 : 0 + type = "Microsoft.Compute/virtualMachines@2024-07-01" + name = "${local.names.gateway}-test" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + identity { type = "SystemAssigned" } + body = { + properties = { + hardwareProfile = { vmSize = var.private_test_client_vm_size } + storageProfile = { + imageReference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } + osDisk = { + createOption = "FromImage" + deleteOption = "Delete" + managedDisk = { storageAccountType = "StandardSSD_LRS" } + } + } + osProfile = { + computerName = "gateway-test" + adminUsername = "azureuser" + linuxConfiguration = { + disablePasswordAuthentication = true + provisionVMAgent = true + ssh = { + publicKeys = [{ + path = "/home/azureuser/.ssh/authorized_keys" + keyData = trimspace(var.private_test_client_ssh_public_key) + }] + } + } + } + networkProfile = { + networkInterfaces = [{ + id = azapi_resource.private_test_nic[0].id + properties = { primary = true, deleteOption = "Detach" } + }] + } + securityProfile = { + securityType = "TrustedLaunch" + uefiSettings = { secureBootEnabled = true, vTpmEnabled = true } + } + } + } +} + +resource "azapi_resource" "private_test_key_role" { + count = var.enable_private_test_client ? 1 : 0 + type = "Microsoft.Authorization/roleDefinitions@2022-04-01" + name = uuidv5("url", "${azapi_resource.resource_group["gateway"].id}/private-test-key-reader") + parent_id = local.subscription_scope + body = { + properties = { + roleName = "AI Gateway PoC key reader ${local.resource_token}" + description = "Only retrieve gateway runtime keys for the isolated private-path proof client." + type = "CustomRole" + assignableScopes = [azapi_resource.resource_group["gateway"].id] + permissions = [{ + actions = ["Microsoft.ApiManagement/service/apiKeys/listSecrets/action"] + notActions = [] + dataActions = [] + notDataActions = [] + }] + } + } +} + +resource "azapi_resource" "private_test_key_binding" { + count = var.enable_private_test_client ? 1 : 0 + type = "Microsoft.Authorization/roleAssignments@2022-04-01" + name = uuidv5("url", "${azapi_resource.gateway.id}/${azapi_resource.private_test_vm[0].identity[0].principal_id}/private-test-key-reader") + parent_id = azapi_resource.gateway.id + body = { + properties = { + principalId = azapi_resource.private_test_vm[0].identity[0].principal_id + principalType = "ServicePrincipal" + roleDefinitionId = azapi_resource.private_test_key_role[0].id + } + } +} diff --git a/samples/terraform/profiles/private-connectivity.tfvars.json b/samples/terraform/profiles/private-connectivity.tfvars.json new file mode 100644 index 0000000..c32899a --- /dev/null +++ b/samples/terraform/profiles/private-connectivity.tfvars.json @@ -0,0 +1,6 @@ +{ + "enable_private_networking": true, + "gateway_private_endpoint_manual_approval": true, + "gateway_public_network_access": "Enabled", + "models_public_network_access": "Disabled" +} diff --git a/samples/terraform/profiles/private-only.tfvars.json b/samples/terraform/profiles/private-only.tfvars.json new file mode 100644 index 0000000..53e6c0f --- /dev/null +++ b/samples/terraform/profiles/private-only.tfvars.json @@ -0,0 +1,6 @@ +{ + "enable_private_networking": true, + "gateway_private_endpoint_manual_approval": true, + "gateway_public_network_access": "Disabled", + "models_public_network_access": "Disabled" +} diff --git a/samples/terraform/rbac.tf b/samples/terraform/rbac.tf new file mode 100644 index 0000000..88b9a0b --- /dev/null +++ b/samples/terraform/rbac.tf @@ -0,0 +1,84 @@ +locals { + cognitive_roles = { + contributor = "25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68" + openai-user = "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd" + user = "a97b65f3-24c7-4388-baec-2e87135dc908" + } + role_bindings = merge( + { + gateway-foundry-user = { + scope = azapi_resource.models.id + principal_id = azapi_resource.gateway.identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = "53ca6127-db72-4b80-b1b0-d745d6d5456d" + } + gateway-monitoring-publisher = { + scope = local.gateway_monitoring.dcr_id + principal_id = azapi_resource.gateway.identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = "3913510d-42f4-4e42-8a64-420c390055eb" + } + project-storage = { + scope = azapi_resource.storage.id + principal_id = azapi_resource.project.identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + } + project-acr-pull = { + scope = azapi_resource.registry.id + principal_id = azapi_resource.project.identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + } + }, + { for key, principal in var.gateway_access_principals : "gateway-models-user-${key}" => { + scope = azapi_resource.gateway.id + principal_id = principal.principal_id + principal_type = principal.principal_type + role_id = "a6b68e3d-a1f0-4c60-8678-237c050cf4ea" + } }, + { for key, role in local.cognitive_roles : "project-${key}" => { + scope = azapi_resource.agents.id + principal_id = azapi_resource.project.identity[0].principal_id + principal_type = "ServicePrincipal" + role_id = role + } }, + var.developer_principal_id == "" ? {} : merge( + { for pair in setproduct(["models", "agents"], keys(local.cognitive_roles)) : "developer-${pair[0]}-${pair[1]}" => { + scope = pair[0] == "models" ? azapi_resource.models.id : azapi_resource.agents.id + principal_id = var.developer_principal_id + principal_type = var.developer_principal_type + role_id = local.cognitive_roles[pair[1]] + } }, + { developer-storage = { + scope = azapi_resource.storage.id + principal_id = var.developer_principal_id + principal_type = var.developer_principal_type + role_id = "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + } } + ) + ) +} + +resource "azapi_resource" "role_assignment" { + for_each = local.role_bindings + type = "Microsoft.Authorization/roleAssignments@2022-04-01" + name = uuidv5("url", "${each.value.scope}/${each.value.principal_id}/${each.value.role_id}") + parent_id = each.value.scope + body = { + properties = { + principalId = each.value.principal_id + principalType = each.value.principal_type + roleDefinitionId = "${local.subscription_scope}/providers/Microsoft.Authorization/roleDefinitions/${each.value.role_id}" + } + } + lifecycle { + precondition { + condition = each.key != "gateway-monitoring-publisher" || can(regex( + "(?i)^/subscriptions/${var.subscription_id}/resourceGroups/[^/?#]+/providers/Microsoft.Insights/dataCollectionRules/[^/?#]+$", + each.value.scope + )) + error_message = "Gateway publishing requires the actual same-subscription OTLP DCR resource ID from Insights. Missing/immutable-only linkage must be resolved before applying; never substitute a group/subscription scope." + } + } +} diff --git a/samples/terraform/scripts/Complete-PortalAcceptance.ps1 b/samples/terraform/scripts/Complete-PortalAcceptance.ps1 new file mode 100644 index 0000000..1c8acbc --- /dev/null +++ b/samples/terraform/scripts/Complete-PortalAcceptance.ps1 @@ -0,0 +1,69 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BackendReport, + [Parameter(Mandatory)][string]$PortalEvidence +) + +$ErrorActionPreference = 'Stop' +$backend = Get-Content -LiteralPath $BackendReport -Raw | ConvertFrom-Json -Depth 50 +$portal = Get-Content -LiteralPath $PortalEvidence -Raw | ConvertFrom-Json -Depth 20 +if ($backend.schemaVersion -ne 1 -or $portal.schemaVersion -ne 1 -or $backend.backendStatus -ne 'PASS') { + throw 'A passing version-1 backend report is required; portal observations cannot override backend failure.' +} +$requiredBackend = @('context', 'placement', 'resources', 'roles', 'connections', 'monitoring', 'private-network', 'drift', 'runtime', 'vm-cleanup', 'telemetry') +foreach ($name in $requiredBackend) { + if ($backend.checks.$name.status -ne 'PASS') { throw "Missing passing backend check: $name" } +} +foreach ($field in @('runId', 'subscriptionId', 'tenantId', 'resourceGroup', 'commit')) { + if (-not $backend.$field -or $portal.$field -cne $backend.$field) { throw "Portal context mismatch: $field" } +} +$reviewTime = [DateTimeOffset]::Parse($portal.reviewedUtc) +if ($reviewTime -lt [DateTimeOffset]::Parse($backend.completedUtc) -or $reviewTime -gt [DateTimeOffset]::UtcNow.AddMinutes(5) -or + [string]::IsNullOrWhiteSpace($portal.reviewer) -or $portal.method -ne 'authenticated-browser') { + throw 'Record a real authenticated-browser reviewer and a review time after backend acceptance.' +} +$expected = @('resource-context', 'configuration', 'playground', 'metrics', 'error-states', 'browser-health') +if (@($portal.checks).Count -ne $expected.Count -or + @(Compare-Object $expected @($portal.checks.name)).Count) { throw 'Portal checklist is incomplete or contains duplicate/unknown checks.' } +$evidenceDirectory = Split-Path ([IO.Path]::GetFullPath($PortalEvidence)) -Parent +if ($evidenceDirectory -ne (Split-Path ([IO.Path]::GetFullPath($BackendReport)) -Parent)) { + throw 'Keep the portal manifest and its evidence alongside the backend report.' +} +foreach ($check in $portal.checks) { + if ($check.status -ne 'PASS' -or [string]::IsNullOrWhiteSpace($check.observed) -or + [string]::IsNullOrWhiteSpace($check.evidence) -or [IO.Path]::IsPathRooted($check.evidence) -or + '..' -in ($check.evidence -split '[/\\]') -or + -not (Test-Path -LiteralPath (Join-Path $evidenceDirectory $check.evidence) -PathType Leaf)) { + throw "Portal check requires PASS, an observation and a local relative evidence file: $($check.name)" + } +} +$metrics = $portal.metrics +if ([string]::IsNullOrWhiteSpace($metrics.source) -or [string]::IsNullOrWhiteSpace($metrics.explanation) -or + $null -eq $metrics.portalValue -or $null -eq $metrics.referenceValue -or + $metrics.portalValue -is [string] -or $metrics.referenceValue -is [string] -or + $metrics.portalValue -is [bool] -or $metrics.referenceValue -is [bool] -or + [double]$metrics.portalValue -le 0 -or [double]$metrics.referenceValue -le 0 -or + -not [double]::IsFinite([double]$metrics.portalValue) -or -not [double]::IsFinite([double]$metrics.referenceValue) -or + [double]$metrics.portalValue -ne [double]$metrics.referenceValue) { + throw 'Metrics require a positive numeric portal/reference match with a documented source and comparison.' +} +$windowStart = [DateTimeOffset]::Parse($metrics.windowStartUtc) +$windowEnd = [DateTimeOffset]::Parse($metrics.windowEndUtc) +$probeStart = [DateTimeOffset]::Parse($backend.checks.telemetry.evidence.windowStartUtc) +$probeEnd = [DateTimeOffset]::Parse($backend.checks.telemetry.evidence.windowEndUtc) +if ($windowEnd -le $windowStart -or $windowStart -gt $probeStart -or $windowEnd -lt $probeEnd -or + $windowEnd -gt $reviewTime.AddMinutes(5) -or ($windowEnd - $windowStart).TotalHours -gt 24) { + throw 'Use a bounded metrics window covering this probe, not historical/unrelated traffic.' +} +$backend.overallStatus = 'PASS' +$backend.portal = @{ + status = 'PASS' + verification = 'operator-attested; evidence completeness validated, UI not automatically executed' + evidence = [IO.Path]::GetFileName($PortalEvidence) + reviewedUtc = $portal.reviewedUtc +} +$destination = Join-Path (Split-Path ([IO.Path]::GetFullPath($BackendReport)) -Parent) 'acceptance-with-portal.json' +if (Test-Path -LiteralPath $destination) { throw 'A completed report already exists. Preserve it; use a new acceptance run for another review.' } +$backend | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $destination -Encoding utf8 +Write-Host "PASS: backend plus operator-attested portal acceptance. Report: $destination" diff --git a/samples/terraform/scripts/Get-GatewayMonitoring.ps1 b/samples/terraform/scripts/Get-GatewayMonitoring.ps1 new file mode 100644 index 0000000..014e88e --- /dev/null +++ b/samples/terraform/scripts/Get-GatewayMonitoring.ps1 @@ -0,0 +1,117 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory)][guid]$SubscriptionId, + [Parameter(Mandatory)][string]$ApplicationInsightsId, + [Parameter(Mandatory)][string]$ExpectedLogAnalyticsId, + [ValidateRange(0, 600)][int]$WaitSeconds = 0 +) + +$ErrorActionPreference = 'Stop' +$subscription = $SubscriptionId.ToString() +function Stop-Monitoring([string]$Message) { throw "MONITORING: $Message" } + +function Assert-MonitoringId([string]$Id, [string]$Type) { + $pattern = '^/subscriptions/' + [regex]::Escape($subscription) + + '/resourceGroups/[^/?#]+/providers/' + [regex]::Escape($Type) + '/[^/?#]+$' + if ($Id -notmatch $pattern -or $Id -match '[%\\]') { + Stop-Monitoring "Invalid or cross-subscription $Type linkage." + } +} + +function Read-MonitoringResource([string]$Id, [string]$Version) { + $text = & az rest --method get --url "https://management.azure.com${Id}?api-version=$Version" ` + --subscription $subscription --only-show-errors --output json 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Monitoring "ARM read failed (exit $LASTEXITCODE); response omitted." } + try { $text -join "`n" | ConvertFrom-Json -Depth 100 } + catch { Stop-Monitoring 'Invalid ARM JSON; response omitted.' } +} + +function Get-MonitoringUri([string]$Value) { + $uri = $null + if (-not [uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne 'https' -or -not $uri.IsDefaultPort -or $uri.UserInfo -or + $uri.Query -or $uri.Fragment -or -not $uri.Host.EndsWith('.monitor.azure.com')) { + Stop-Monitoring 'Expected a credential-free HTTPS Azure Monitor endpoint.' + } + $uri +} + +Assert-MonitoringId $ApplicationInsightsId 'Microsoft.Insights/components' +Assert-MonitoringId $ExpectedLogAnalyticsId 'Microsoft.OperationalInsights/workspaces' +$applicationGroup = $ApplicationInsightsId -replace '/providers/.*$', '' +$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds) +do { + $insights = Read-MonitoringResource $ApplicationInsightsId '2020-02-02' + $p = $insights.properties + $dcrId = [string]$p.DataCollectionRuleResourceId + if (-not $dcrId) { $dcrId = [string]$p.DataCollectionRuleId } + $endpoints = @{ + metricsEndpoint = [string]$p.OTLPMetricsEndpoint + logsEndpoint = [string]$p.OTLPLogsEndpoint + tracesEndpoint = [string]$p.OTLPTracesEndpoint + } + if ($p.AzureMonitorWorkspaceIngestionMode -eq 'Enabled' -and $dcrId -and + @($endpoints.Values | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -eq 0) { break } + $remaining = ($deadline - [DateTimeOffset]::UtcNow).TotalSeconds + if ($remaining -le 0) { Stop-Monitoring 'OTLP destination metadata incomplete; bounded readiness wait expired. No exporter update is safe.' } + Start-Sleep -Seconds ([Math]::Min(3, [Math]::Ceiling($remaining))) +} while ($true) + +# Never guess a resource ID from an immutable DCR identifier or generated name. +Assert-MonitoringId $dcrId 'Microsoft.Insights/dataCollectionRules' +Assert-MonitoringId $p.WorkspaceResourceId 'Microsoft.OperationalInsights/workspaces' +if ($p.WorkspaceResourceId -ine $ExpectedLogAnalyticsId -or + -not $p.WorkspaceResourceId.StartsWith("$applicationGroup/", [StringComparison]::OrdinalIgnoreCase)) { + Stop-Monitoring 'Gateway Log Analytics workspace differs from the expected application destination.' +} +$law = Read-MonitoringResource $p.WorkspaceResourceId '2023-09-01' +if ($law.properties.customerId -notmatch '^[a-fA-F0-9-]{36}$') { Stop-Monitoring 'Missing Log Analytics query workspace ID.' } +$dcr = Read-MonitoringResource $dcrId '2024-03-11' +$dceId = [string]$dcr.properties.dataCollectionEndpointId +Assert-MonitoringId $dceId 'Microsoft.Insights/dataCollectionEndpoints' +$accounts = @($dcr.properties.destinations.monitoringAccounts) +$logDestinations = @($dcr.properties.destinations.logAnalytics) +if ($accounts.Count -ne 1 -or $logDestinations.Count -ne 1 -or + $logDestinations[0].workspaceResourceId -ine $p.WorkspaceResourceId) { + Stop-Monitoring 'DCR must reference one monitoring account and the existing gateway Log Analytics workspace.' +} +$amwId = [string]$accounts[0].accountResourceId +Assert-MonitoringId $amwId 'Microsoft.Monitor/accounts' +$amw = Read-MonitoringResource $amwId '2023-04-03' +$dce = Read-MonitoringResource $dceId '2024-03-11' +foreach ($resource in @($insights, $law, $dcr, $dce, $amw)) { + if ($resource.properties.provisioningState -ne 'Succeeded') { Stop-Monitoring 'Linked monitoring resource is not provisioned successfully.' } +} +$immutableId = [string]$dcr.properties.immutableId +if ($immutableId -notmatch '^dcr-[a-zA-Z0-9]+$') { Stop-Monitoring 'Missing/invalid immutable DCR identifier.' } +foreach ($name in $endpoints.Keys) { + $uri = Get-MonitoringUri $endpoints[$name] + $base = if ($name -eq 'metricsEndpoint') { $dce.properties.metricsIngestion.endpoint } else { $dce.properties.logsIngestion.endpoint } + $baseUri = Get-MonitoringUri $base + if ($uri.Authority -ine $baseUri.Authority -or + -not $uri.AbsolutePath.StartsWith("/dataCollectionRules/$immutableId/", [StringComparison]::Ordinal)) { + Stop-Monitoring 'OTLP endpoint does not belong to the linked DCE/DCR.' + } +} +$queryEndpoint = [string]$amw.properties.metrics.prometheusQueryEndpoint +$null = Get-MonitoringUri $queryEndpoint +$managedGroups = @(@($dcrId, $dceId, $amwId) | ForEach-Object { $_ -replace '/providers/.*$', '' } | + Where-Object { $_ -ine $applicationGroup } | Sort-Object -Unique) +foreach ($id in $managedGroups) { + $group = Read-MonitoringResource $id '2021-04-01' + if ($group.managedBy -ine $ApplicationInsightsId -and $group.managedBy -ine $amwId) { + Stop-Monitoring 'External monitoring group is not demonstrably managed by the linked Insights/Monitor account.' + } +} +[pscustomobject]@{ + insightsId = $ApplicationInsightsId + logAnalyticsId = [string]$p.WorkspaceResourceId + logAnalyticsWorkspaceId = [string]$law.properties.customerId + dataCollectionRuleId = $dcrId + dataCollectionEndpointId = $dceId + azureMonitorWorkspaceId = $amwId + managedGroupIds = $managedGroups + endpoints = $endpoints + prometheusQueryEndpoint = $queryEndpoint.TrimEnd('/') +} diff --git a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 new file mode 100644 index 0000000..0b79868 --- /dev/null +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -0,0 +1,545 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory)][guid]$SubscriptionId, + [Parameter(Mandatory)][guid]$TenantId, + [Parameter(Mandatory)][ValidatePattern('^[a-zA-Z0-9_.()-]+$')][string]$ResourceGroup, + [Parameter(Mandatory)][string]$ReportDirectory, + [string[]]$AdditionalVarFile = @(), + [switch]$RunPrivateProbe, + [ValidateRange(0, 900)][int]$TelemetryWaitSeconds = 300, + [ValidateSet('private-connectivity', 'private-only')][string]$NetworkProfile = 'private-connectivity' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$subscription = $SubscriptionId.ToString() +$tenant = $TenantId.ToString() +$groupId = "/subscriptions/$subscription/resourceGroups/$ResourceGroup" +$runId = [guid]::NewGuid().ToString('N') +$directory = Join-Path ([IO.Path]::GetFullPath($ReportDirectory)) $runId +$null = New-Item -ItemType Directory -Path $directory +$context = @{} +$checks = [ordered]@{} +$report = [ordered]@{ + schemaVersion = 1 + runId = $runId + startedUtc = [DateTimeOffset]::UtcNow.ToString('o') + subscriptionId = $subscription + tenantId = $tenant + resourceGroup = $ResourceGroup + scope = 'Existing legacy gateway; selected network posture and private route. Public network rejection and hosted-agent runtime are not tested.' + profile = "profiles/$NetworkProfile.tfvars.json" + commit = $null + backendStatus = 'BLOCKED' + overallStatus = 'BLOCKED' + checks = $checks + portal = @{ status = 'NOT RUN'; evidence = 'portal-evidence.json'; verification = 'operator-attested' } +} + +function Stop-Check([string]$Message) { throw "ACCEPTANCE: $Message" } + +function Read-Json([string]$Text) { + try { $Text | ConvertFrom-Json -Depth 100 } + catch { Stop-Check 'Invalid JSON response; raw content omitted.' } +} + +function ConvertTo-AzureCliArguments([string[]]$Arguments, [bool]$WindowsBatch) { + foreach ($argument in $Arguments) { + if ($WindowsBatch -and $argument -match '^https?://') { + if ($argument -match '["\r\n]') { throw 'ACCEPTANCE: Unsafe Azure CLI URL argument.' } + # az.cmd otherwise lets cmd.exe split query parameters at ampersands. + '"' + $argument + '"' + } + else { $argument } + } +} + +function Invoke-AzureJson([string[]]$Arguments) { + $batch = $IsWindows -and (Get-Command az -ErrorAction Stop).Source -match '\.(cmd|bat)$' + $Arguments = @(ConvertTo-AzureCliArguments $Arguments $batch) + $text = & az @Arguments --subscription $subscription --only-show-errors --output json 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Check "Azure CLI failed (exit $LASTEXITCODE); response omitted." } + Read-Json ($text -join "`n") +} + +function Assert-GroupId([string]$Id) { + if (-not $Id.StartsWith("$groupId/", [StringComparison]::OrdinalIgnoreCase) -or $Id -match '[?#]') { + Stop-Check 'Unexpected resource scope in deployment outputs/state.' + } +} + +function Read-Arm([string]$Id, [string]$Version) { + Assert-GroupId $Id + Invoke-AzureJson @('rest', '--method', 'get', '--url', "https://management.azure.com${Id}?api-version=$Version") +} + +function Read-ArmItems([string]$Id, [string]$Version, [switch]$LinkedDcr) { + if ($LinkedDcr) { + if (-not $context.monitoring -or $Id -ine "$($context.monitoring.dataCollectionRuleId)/providers/Microsoft.Authorization/roleAssignments") { + Stop-Check 'Only the verified linked DCR role assignments may be read outside the application group.' + } + } + else { Assert-GroupId $Id } + $url = "https://management.azure.com${Id}?api-version=$Version" + $seen = [Collections.Generic.HashSet[string]]::new() + while ($url) { + if (-not $url.StartsWith("https://management.azure.com${Id}?", [StringComparison]::OrdinalIgnoreCase) -or + -not $seen.Add($url)) { Stop-Check 'Unexpected or repeated ARM pagination URL.' } + $page = Invoke-AzureJson @('rest', '--method', 'get', '--url', $url) + $page.value + $url = $page.nextLink + } +} + +function Get-MonitoringTopology { + if (-not $context.monitoring) { + $context.monitoring = & (Join-Path $PSScriptRoot 'Get-GatewayMonitoring.ps1') ` + -SubscriptionId $SubscriptionId -ApplicationInsightsId $context.outputs.application_insights_ids.value.gateway ` + -ExpectedLogAnalyticsId $context.outputs.gateway_monitoring.value.log_analytics_id + } + $context.monitoring +} + +function Assert-RoleBinding([string]$Name, [string]$Principal, [string]$Scope, [string]$Role, [switch]$LinkedDcr) { + $roleId = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/$Role" + $assignments = @(Read-ArmItems "$Scope/providers/Microsoft.Authorization/roleAssignments" '2022-04-01' -LinkedDcr:$LinkedDcr) + $match = @($assignments | Where-Object { + $_.properties.scope -ieq $Scope -and $_.properties.principalId -eq $Principal -and + $_.properties.principalType -eq 'ServicePrincipal' -and + $_.properties.roleDefinitionId -ieq $roleId -and -not $_.properties.condition + }) + if ($match.Count -ne 1) { Stop-Check "Incorrect exact role binding: $Name" } +} + +function Assert-Ready($Resource) { + if ($Resource.properties.provisioningState -ne 'Succeeded') { + Stop-Check "Resource not ready: $($Resource.id)" + } +} + +function Get-StateResources($Module) { + $Module.resources + foreach ($child in $Module.child_modules) { Get-StateResources $child } +} + +function Invoke-Check([string]$Name, [string[]]$Needs, [scriptblock]$Action) { + $checks[$Name] = @{ status = 'NOT RUN'; detail = ''; evidence = $null } + $missing = @($Needs | Where-Object { $checks[$_].status -ne 'PASS' }) + if ($missing.Count) { + $checks[$Name].status = 'BLOCKED' + $checks[$Name].detail = "Prerequisites: $($missing -join ', ')" + } + else { + try { + $checks[$Name].evidence = & $Action + $checks[$Name].status = 'PASS' + } + catch { + $checks[$Name].status = 'FAIL' + $message = $_.Exception.Message + $checks[$Name].detail = if ($message -match '^(?:ACCEPTANCE|MONITORING): (.*)$') { + $Matches[1] + } else { 'Unexpected check error; raw error omitted to protect credentials.' } + } + } + Write-Host "$Name`: $($checks[$Name].status) $($checks[$Name].detail)" +} + +Invoke-Check 'context' @() { + $context.networkProfile = Read-Json (Get-Content -LiteralPath (Join-Path $root $report.profile) -Raw) + if ($context.networkProfile.enable_private_networking -ne $true -or + $context.networkProfile.models_public_network_access -ne 'Disabled' -or + $context.networkProfile.gateway_public_network_access -notin @('Enabled', 'Disabled')) { + Stop-Check 'Selected file is not a valid private-network profile.' + } + $account = Invoke-AzureJson @('account', 'show') + if ($account.id -ne $subscription -or $account.tenantId -ne $tenant) { Stop-Check 'Subscription/tenant mismatch.' } + $json = & terraform "-chdir=$root" output -json 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Check 'Cannot read Terraform outputs.' } + $context.outputs = Read-Json ($json -join "`n") + $o = $context.outputs + if ($o.resource_group_name.value -ne $ResourceGroup -or + @($o.resource_groups.value.PSObject.Properties).Count -ne 3 -or + @(Compare-Object @('agents', 'gateway', 'models') @($o.resource_groups.value.PSObject.Properties.Name)).Count -or + @($o.resource_groups.value.PSObject.Properties | Where-Object { $_.Value -ne $ResourceGroup }).Count) { + Stop-Check 'Outputs do not identify the expected single resource group.' + } + $context.gatewayId = [string]$o.gateway_id.value + $context.modelsId = [string]$o.foundry_models_id.value + $context.projectId = [string]$o.project_id.value + $context.agentsId = $context.projectId -replace '/projects/[^/]+$', '' + $context.storageId = "$groupId/providers/Microsoft.Storage/storageAccounts/$($o.storage_account_name.value)" + $context.registryId = "$groupId/providers/Microsoft.ContainerRegistry/registries/$(([string]$o.container_registry_endpoint.value).Split('.')[0])" + foreach ($id in @($context.gatewayId, $context.modelsId, $context.projectId, $context.agentsId, + $context.storageId, $context.registryId, $o.private_test_vm_id.value, + $o.application_insights_ids.value.gateway, $o.application_insights_ids.value.agents, + $o.gateway_monitoring.value.log_analytics_id)) { + Assert-GroupId ([string]$id) + } + $commit = & git -C $root rev-parse HEAD 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Check 'Cannot identify source commit.' } + $dirty = & git -C $root status --porcelain --untracked-files=normal 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Check 'Cannot inspect source checkout.' } + if ($dirty) { Stop-Check 'Customer acceptance requires a clean tracked checkout; commit reviewed changes first.' } + $report.commit = [string]$commit + @{ gatewayId = $context.gatewayId; projectId = $context.projectId } +} + +Invoke-Check 'placement' @('context') { + $json = & terraform "-chdir=$root" show -json 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Check 'Cannot inspect managed resource placement.' } + $state = Read-Json ($json -join "`n") + $resources = @(Get-StateResources $state.values.root_module | Where-Object { $_.mode -eq 'managed' }) + if (-not $resources.Count) { Stop-Check 'No managed resources found.' } + $groups = @($resources | Where-Object { $_.values.type -like 'Microsoft.Resources/resourceGroups@*' }) + if ($groups.Count -ne 1 -or $groups[0].values.id -ine $groupId) { + Stop-Check 'State must manage exactly one application resource group; migration retention must be finished.' + } + foreach ($resource in $resources) { + if ($resource -in $groups) { continue } + if ($resource.values.type -like 'Microsoft.Authorization/roleDefinitions@*') { + $scopes = @($resource.values.body.properties.assignableScopes) + if ($scopes.Count -ne 1 -or $scopes[0] -ine $groupId) { Stop-Check 'Unexpected custom-role assignable scope.' } + } + elseif ($resource.address -eq 'azapi_resource.role_assignment["gateway-monitoring-publisher"]') { + $topology = Get-MonitoringTopology + $gateway = Read-Arm $context.gatewayId '2025-09-01-preview' + $properties = $resource.values.body.properties + $rolePrefix = "$($topology.dataCollectionRuleId)/providers/Microsoft.Authorization/roleAssignments/" + if ($resource.values.parent_id -ine $topology.dataCollectionRuleId -or + -not ([string]$resource.values.id).StartsWith($rolePrefix, [StringComparison]::OrdinalIgnoreCase) -or + ([string]$resource.values.id).Substring($rolePrefix.Length) -notmatch '^[a-fA-F0-9-]{36}$' -or + -not $gateway.identity.principalId -or $gateway.identity.tenantId -ne $tenant -or + $properties.principalId -ne $gateway.identity.principalId -or + $properties.principalType -ne 'ServicePrincipal' -or $properties.condition -or + $properties.roleDefinitionId -ine "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/3913510d-42f4-4e42-8a64-420c390055eb") { + Stop-Check 'External publishing assignment is not the exact Insights-linked DCR/gateway identity/role.' + } + } + else { Assert-GroupId ([string]$resource.values.id) } + } + @{ managedResources = $resources.Count; applicationGroups = 1 } +} + +Invoke-Check 'resources' @('context') { + $context.gateway = Read-Arm $context.gatewayId '2025-09-01-preview' + $context.models = Read-Arm $context.modelsId '2025-04-01-preview' + $context.agents = Read-Arm $context.agentsId '2025-04-01-preview' + $context.project = Read-Arm $context.projectId '2025-04-01-preview' + $context.storage = Read-Arm $context.storageId '2024-01-01' + $context.registry = Read-Arm $context.registryId '2023-07-01' + foreach ($key in @('gateway', 'models', 'agents', 'project', 'storage', 'registry')) { Assert-Ready $context[$key] } + if ($context.gateway.sku.name -ne 'AIGateway') { Stop-Check 'Unexpected gateway SKU.' } + foreach ($resource in @($context.gateway, $context.project)) { + if (-not $resource.identity.principalId -or $resource.identity.tenantId -ne $tenant) { + Stop-Check 'Missing or wrong-tenant runtime identity.' + } + } + $deployments = @($context.outputs.model_deployment_ids.value.PSObject.Properties) + if (-not $deployments.Count) { Stop-Check 'No model deployments configured.' } + foreach ($deployment in $deployments) { + Assert-Ready (Read-Arm $deployment.Value '2025-04-01-preview') + $metadata = $context.outputs.model_api_configuration.value.($deployment.Name) + if ($metadata.api_format -ne 'OpenAIChatCompletions' -or '/openai/v1/chat/completions' -notin $metadata.endpoints) { + Stop-Check 'Every alias must have compatible chat-completions metadata before runtime testing.' + } + $catalogId = "$($context.gatewayId)/workspaces/default/modelProviders/foundry/models/$($deployment.Name)" + $catalog = Read-Arm $catalogId '2025-09-01-preview' + $alias = Read-Arm "$($context.gatewayId)/workspaces/default/aliases/$($deployment.Name)" '2025-09-01-preview' + if ($catalog.properties.deployment.resourceId -ine $deployment.Value -or + ('apiFormat' -in $catalog.properties.PSObject.Properties.Name -and $catalog.properties.apiFormat -ne $metadata.api_format) -or + '/openai/v1/chat/completions' -notin $catalog.properties.supportedEndpoints -or + $alias.properties.aliasName -ne $deployment.Name -or + @($alias.properties.backendModels).Count -ne 1 -or + $alias.properties.backendModels[0].resourceId -ine $catalogId) { Stop-Check 'Catalog model or alias route mismatch.' } + } + @{ + modelAliases = @($deployments.Name); gatewaySku = 'AIGateway' + apiFormatVerification = 'Output contract and live supported endpoints; apiFormat checked when returned (preview GET can omit it).' + } +} + +Invoke-Check 'roles' @('resources') { + $bindings = @( + @('gateway-foundry-user', $context.gateway.identity.principalId, $context.modelsId, '53ca6127-db72-4b80-b1b0-d745d6d5456d'), + @('project-storage', $context.project.identity.principalId, $context.storageId, 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'), + @('project-acr-pull', $context.project.identity.principalId, $context.registryId, '7f951dda-4ed3-4680-a7ca-43fe172d538d'), + @('project-contributor', $context.project.identity.principalId, $context.agentsId, '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68'), + @('project-openai-user', $context.project.identity.principalId, $context.agentsId, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'), + @('project-user', $context.project.identity.principalId, $context.agentsId, 'a97b65f3-24c7-4388-baec-2e87135dc908') + ) + foreach ($binding in $bindings) { + Assert-RoleBinding $binding[0] $binding[1] $binding[2] $binding[3] + } + @{ verifiedBindings = 6; additionalDeveloperRoles = 'Not certified by this check' } +} + +Invoke-Check 'connections' @('resources') { + $projectConnections = @(Read-ArmItems "$($context.projectId)/connections" '2025-04-01-preview') + $accountConnections = @(Read-ArmItems "$($context.agentsId)/connections" '2025-06-01') + $insightsId = $context.outputs.application_insights_ids.value.agents + $expected = @( + @{ name = $context.storage.name; items = $projectConnections; category = 'AzureStorageAccount'; auth = 'AAD'; target = $context.storage.properties.primaryEndpoints.blob; resource = $context.storageId }, + @{ name = "$($context.registry.name)-conn"; items = $projectConnections; category = 'ContainerRegistry'; auth = 'ManagedIdentity'; target = $context.registry.properties.loginServer; resource = $context.registryId }, + @{ name = $insightsId.Split('/')[-1]; items = $accountConnections; category = 'AppInsights'; auth = 'ApiKey'; target = $insightsId; resource = $insightsId } + ) + foreach ($connection in $expected) { + $match = @($connection.items | Where-Object { + $_.name -eq $connection.name -and $_.properties.category -eq $connection.category -and + $_.properties.authType -eq $connection.auth -and $_.properties.target -eq $connection.target -and + $_.properties.metadata.ResourceId -ieq $connection.resource + }) + if ($match.Count -ne 1) { Stop-Check "Connection metadata mismatch: $($connection.name)" } + } + $provider = Read-Arm "$($context.gatewayId)/workspaces/default/modelProviders/foundry" '2025-09-01-preview' + $foundry = $provider.properties.foundry + if ($foundry.authentication.kind -ne 'ManagedIdentity' -or + $foundry.authentication.managedIdentity.resource -ne 'https://cognitiveservices.azure.com/' -or + @($foundry.resourceIds).Count -ne 1 -or $foundry.resourceIds[0] -ine $context.modelsId -or + ([string]$foundry.endpoint).TrimEnd('/') -ne ([string]$context.models.properties.endpoint).TrimEnd('/')) { + Stop-Check 'Gateway model-provider configuration mismatch.' + } + @{ verifiedConnections = 3; backendAuthentication = 'ManagedIdentity' } +} + +Invoke-Check 'monitoring' @('resources') { + foreach ($name in @('gateway', 'agents')) { + $insights = Read-Arm $context.outputs.application_insights_ids.value.$name '2020-02-02' + Assert-Ready $insights + Assert-Ready (Read-Arm $insights.properties.WorkspaceResourceId '2023-09-01') + } + $topology = Get-MonitoringTopology + $exporter = Read-Arm "$($context.gatewayId)/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' + if ([string]::IsNullOrWhiteSpace([string]$exporter.properties.kind)) { + Stop-Check 'Telemetry exporter kind is missing; the portal cannot recognize the OpenTelemetry configuration.' + } + if ($exporter.properties.kind -ne 'OpenTelemetry' -or + $exporter.properties.payloadCapture -ne $false -or + $exporter.properties.applicationInsights.resourceId -ine $topology.insightsId -or + $exporter.properties.applicationInsights.connectionString -or + $exporter.properties.openTelemetry.credentials.managedIdentity.resource -cne 'https://monitor.azure.com' -or + $exporter.properties.openTelemetry.credentials.managedIdentity.clientId -or + $exporter.properties.openTelemetry.credentials.headers) { + Stop-Check 'Telemetry exporter kind, target or payload-capture mismatch.' + } + foreach ($name in @('metricsEndpoint', 'logsEndpoint', 'tracesEndpoint')) { + if ($exporter.properties.openTelemetry.$name -cne $topology.endpoints[$name]) { + Stop-Check "Exporter $name differs from the actual Insights OTLP endpoint." + } + } + Assert-RoleBinding 'gateway-monitoring-publisher' $context.gateway.identity.principalId ` + $topology.dataCollectionRuleId '3913510d-42f4-4e42-8a64-420c390055eb' -LinkedDcr + @{ + readyMonitoringStacks = 2; delivery = 'Requires telemetry check' + verifiedPublisherBindings = 1; destination = $topology + exporterVerification = 'OpenTelemetry, all endpoints, system identity audience, linked destination and exact DCR role checked; delivery/UI are separate gates.' + } +} + +Invoke-Check 'private-network' @('resources') { + if ($context.models.properties.publicNetworkAccess -ne 'Disabled' -or + $context.models.properties.networkAcls.defaultAction -ne 'Deny' -or + $context.gateway.properties.publicNetworkAccess -ne $context.networkProfile.gateway_public_network_access -or + $context.gateway.properties.virtualNetworkType -ne 'External') { Stop-Check 'Private profile posture mismatch.' } + $subnetId = [string]$context.gateway.properties.virtualNetworkConfiguration.subnetResourceId + Assert-GroupId $subnetId + $endpointSubnetId = ($subnetId -replace '/subnets/[^/]+$', '/subnets/endpoints') + $endpointSubnet = Read-Arm $endpointSubnetId '2024-05-01' + if (-not $context.outputs.private_probe_configuration.value.private_endpoint_subnet -or + $endpointSubnet.properties.addressPrefix -ne $context.outputs.private_probe_configuration.value.private_endpoint_subnet) { + Stop-Check 'Probe subnet does not match the deployed endpoint subnet.' + } + foreach ($name in @('gateway', 'models')) { + $endpointId = $context.outputs.private_endpoint_ids.value.$name + $endpoint = Read-Arm $endpointId '2024-05-01' + Assert-Ready $endpoint + $connections = @( + @($endpoint.properties.privateLinkServiceConnections) + + @($endpoint.properties.manualPrivateLinkServiceConnections) | + Where-Object { $null -ne $_ } + ) + $target = if ($name -eq 'gateway') { $context.gatewayId } else { $context.modelsId } + $group = if ($name -eq 'gateway') { 'Gateway' } else { 'account' } + if ($connections.Count -ne 1 -or + $connections[0].properties.privateLinkServiceConnectionState.status -ne 'Approved' -or + $connections[0].properties.privateLinkServiceId -ine $target -or + @($connections[0].properties.groupIds).Count -ne 1 -or $connections[0].properties.groupIds[0] -ne $group -or + $endpoint.properties.subnet.id -ine $endpointSubnetId) { Stop-Check "Private endpoint mismatch: $name" } + $zoneGroup = Read-Arm "$endpointId/privateDnsZoneGroups/default" '2024-05-01' + Assert-Ready $zoneGroup + $zones = @($zoneGroup.properties.privateDnsZoneConfigs) + $expectedZones = if ($name -eq 'gateway') { @('privatelink.azure-api.net') } else { + @('privatelink.cognitiveservices.azure.com', 'privatelink.openai.azure.com', 'privatelink.services.ai.azure.com') + } + $actualIds = @($zones | ForEach-Object { $_.properties.privateDnsZoneId }) + if ($actualIds.Count -ne $expectedZones.Count) { Stop-Check "DNS zone count mismatch: $name" } + foreach ($zone in $expectedZones) { + $zoneId = "$groupId/providers/Microsoft.Network/privateDnsZones/$zone" + if ($zoneId -notin $actualIds) { Stop-Check "Missing private DNS zone: $zone" } + $link = Read-Arm "$zoneId/virtualNetworkLinks/gateway-vnet" '2020-06-01' + Assert-Ready $link + if ($link.properties.virtualNetwork.id -ine ($subnetId -replace '/subnets/[^/]+$', '')) { + Stop-Check 'Private DNS link targets the wrong VNet.' + } + } + } + @{ + gatewayPublicAccess = $context.gateway.properties.publicNetworkAccess + expectedGatewayPublicAccess = $context.networkProfile.gateway_public_network_access + modelsPublicAccess = $context.models.properties.publicNetworkAccess + privateOnlyIngress = 'NOT certified' + publicNetworkRejection = 'NOT RUN' + } +} + +Invoke-Check 'drift' @('context') { + if ($env:TF_CLI_ARGS -or $env:TF_CLI_ARGS_plan) { Stop-Check 'Remove hidden TF_CLI_ARGS/TF_CLI_ARGS_plan before checking drift.' } + $arguments = @("-chdir=$root", 'plan', '-input=false', '-no-color', '-lock-timeout=15s', '-detailed-exitcode') + foreach ($file in $AdditionalVarFile) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { Stop-Check 'Additional var-file does not exist; use an absolute path.' } + $arguments += "-var-file=$([IO.Path]::GetFullPath($file))" + } + $arguments += "-var-file=$($report.profile)" + $previous = $env:ARM_SKIP_PROVIDER_REGISTRATION + try { + $env:ARM_SKIP_PROVIDER_REGISTRATION = 'true' + $raw = & terraform @arguments 2>&1 + $code = $LASTEXITCODE + $raw = $null + } + finally { $env:ARM_SKIP_PROVIDER_REGISTRATION = $previous } + if ($code -ne 0) { Stop-Check "Terraform drift check exit $code (2 = pending changes, 1 = error); raw plan omitted." } + @{ terraformExitCode = 0 } +} + +$runtimeNeeds = @('context', 'placement', 'resources', 'roles', 'connections', 'monitoring', 'private-network', 'drift') +if ($RunPrivateProbe) { + Invoke-Check 'runtime' $runtimeNeeds { + $vmId = $context.outputs.private_test_vm_id.value + $view = Invoke-AzureJson @('vm', 'get-instance-view', '--ids', $vmId) + if ('PowerState/deallocated' -notin @($view.instanceView.statuses.code)) { + Stop-Check 'Test VM must be deallocated before this runner takes ownership of its power lifecycle.' + } + $context.testStartUtc = [DateTimeOffset]::UtcNow + try { + $null = Invoke-AzureJson @('vm', 'start', '--ids', $vmId) + $probe = & (Join-Path $PSScriptRoot 'Test-PrivateGateway.ps1') -RunId $runId | ConvertFrom-Json + if (-not $probe.passed -or $probe.run_id -ne $runId) { Stop-Check 'Private probe did not return this run ID and a pass.' } + $aliases = @($context.outputs.model_deployment_ids.value.PSObject.Properties.Name) + $tested = @($probe.inference | ForEach-Object { $_.model }) + if (@(Compare-Object $aliases $tested).Count -or @($probe.inference).Count -ne $aliases.Count -or + @($probe.inference | Where-Object { $_.status -ne 200 -or $_.reply -ne 'OK' -or $_.trace_id -notmatch '^[a-f0-9]{32}$' }).Count) { + Stop-Check 'Private probe alias results are incomplete.' + } + if (@($probe.authentication).Count -ne 2 -or + @($probe.authentication | Where-Object { $_.status -notin @(401, 403) }).Count -or + @(Compare-Object @('missing-key', 'invalid-key') @($probe.authentication.case)).Count) { Stop-Check 'Negative authentication evidence is incomplete.' } + $context.probe = $probe + $probe + } + finally { + $context.testEndUtc = [DateTimeOffset]::UtcNow + Invoke-Check 'vm-cleanup' @() { + $null = Invoke-AzureJson @('vm', 'deallocate', '--ids', $vmId) + $view = Invoke-AzureJson @('vm', 'get-instance-view', '--ids', $vmId) + if ('PowerState/deallocated' -notin @($view.instanceView.statuses.code)) { + Stop-Check 'VM deallocation not confirmed; operator intervention required.' + } + @{ powerState = 'PowerState/deallocated' } + } + if ($checks['vm-cleanup'].status -ne 'PASS') { Stop-Check 'Test VM cleanup failed; deallocate it manually now.' } + } + } +} +else { + $checks.runtime = @{ status = 'NOT RUN'; detail = 'Requires explicit -RunPrivateProbe (billable calls and VM start/deallocation).'; evidence = $null } +} + +Invoke-Check 'telemetry' @('runtime', 'monitoring') { + $traces = @($context.probe.inference.trace_id | Sort-Object -Unique) + if ($traces.Count -ne @($context.probe.inference).Count) { Stop-Check 'Trace IDs must be unique per model call.' } + $traceFilter = ($traces | ForEach-Object { "'$_'" }) -join ',' + $start = $context.testStartUtc.AddMinutes(-1).UtcDateTime.ToString('o') + $end = $context.testEndUtc.AddMinutes(1).UtcDateTime.ToString('o') + $filter = "TimeGenerated between (datetime($start) .. datetime($end)) and TraceId in ($traceFilter)" + $query = @" +union +(OTelSpans | where $filter | where Kind =~ "Server" +| extend Route=tostring(Attributes["http.route"]) +| extend Workspace=coalesce(tostring(Attributes["azure.ai_gateway.workspace"]), extract(@"/([^/]+)/models/", 1, Route)) +| where Workspace == "default" and Route contains "/models/" +| where coalesce(toint(Attributes["http.response.status_code"]), toint(Attributes["http.status_code"]), toint(ResultCode)) == 200 +| project Signal="trace", TraceId), +(OTelLogs | where $filter | project Signal="log", TraceId) +| distinct Signal, TraceId +"@ + $url = "https://api.loganalytics.io/v1/workspaces/$($context.monitoring.logAnalyticsWorkspaceId)/query?query=$([uri]::EscapeDataString($query))" + $insightsLabel = $context.monitoring.insightsId.ToLowerInvariant() | ConvertTo-Json -Compress + $selector = '{__name__="azure.ai_gateway.client.token.usage","microsoft.appresourceid"=' + $insightsLabel + ',"azure.ai_gateway.workspace"="default"}' + $startUnix = ([DateTimeOffset]::Parse($start)).ToUnixTimeSeconds() + $endUnix = ([DateTimeOffset]::Parse($end)).ToUnixTimeSeconds() + # Exclude samples from before the test window that Prometheus lookback can carry forward. + $metricQuery = "($selector > 0) and (timestamp($selector) >= $startUnix)" + $metricUrl = "$($context.monitoring.prometheusQueryEndpoint)/api/v1/query_range?query=$([uri]::EscapeDataString($metricQuery))&start=$([uri]::EscapeDataString($start))&end=$([uri]::EscapeDataString($end))&step=15s" + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TelemetryWaitSeconds) + do { + $response = Invoke-AzureJson @('rest', '--method', 'get', '--resource', 'https://api.loganalytics.io', '--url', $url) + if ($response.error -or @($response.tables).Count -ne 1) { Stop-Check 'Telemetry API returned an error or unexpected schema.' } + $table = $response.tables[0] + if (@($table.columns).Count -ne 2 -or $table.columns[0].name -ne 'Signal' -or $table.columns[1].name -ne 'TraceId') { + Stop-Check 'Telemetry correlation schema unsupported; delivery not proven.' + } + $found = @($table.rows | Where-Object { $_[0] -eq 'trace' } | ForEach-Object { $_[1] }) + $logs = @($table.rows | Where-Object { $_[0] -eq 'log' -and $_[1] -in $traces } | ForEach-Object { $_[1] }) + $metrics = Invoke-AzureJson @('rest', '--method', 'get', '--resource', 'https://prometheus.monitor.azure.com', '--url', $metricUrl) + if ($metrics.status -ne 'success' -or $metrics.error -or $metrics.data.resultType -ne 'matrix' -or $metrics.warnings) { + Stop-Check 'Prometheus query failed or returned incomplete/unsupported results.' + } + $series = @($metrics.data.result | Where-Object { + $_.metric.__name__ -ceq 'azure.ai_gateway.client.token.usage' -and + $_.metric.'microsoft.appresourceid' -ceq $context.monitoring.insightsId.ToLowerInvariant() -and + $_.metric.'azure.ai_gateway.workspace' -ceq 'default' + }) + $samples = @($series | ForEach-Object { $_.values } | Where-Object { + $value = 0.0 + $timestamp = 0.0 + @($_).Count -eq 2 -and [double]::TryParse([string]$_[0], [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture, [ref]$timestamp) -and + $timestamp -ge $startUnix -and $timestamp -le $endUnix -and + [double]::TryParse([string]$_[1], [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture, [ref]$value) -and [double]::IsFinite($value) -and $value -gt 0 + }) + if (@($traces | Where-Object { $_ -notin $found }).Count -eq 0 -and $logs.Count -gt 0 -and $samples.Count -gt 0) { + return @{ + matchedTraceIds = $traces; loggedTraceIds = $logs; query = $query + metricQuery = $metricQuery; positiveMetricSamples = $samples.Count + metricAttribution = 'Insights/workspace/window scoped; not an exact per-trace token total.' + windowStartUtc = $start; windowEndUtc = $end + } + } + $remaining = ($deadline - [DateTimeOffset]::UtcNow).TotalSeconds + if ($remaining -le 0) { break } + Start-Sleep -Seconds ([Math]::Min(15, [Math]::Ceiling($remaining))) + } while ($true) + Stop-Check 'OTLP delivery unproven: require successful server spans for every probe trace, at least one correlated log, and positive scoped token metric samples within the bounded wait.' +} + +$statuses = @($checks.Values | ForEach-Object { $_.status }) +$report.backendStatus = if ('FAIL' -in $statuses) { 'FAIL' } elseif (@($statuses | Where-Object { $_ -ne 'PASS' }).Count) { 'BLOCKED' } else { 'PASS' } +$report.completedUtc = [DateTimeOffset]::UtcNow.ToString('o') +$report.overallStatus = if ($report.backendStatus -eq 'FAIL') { 'FAIL' } else { 'BLOCKED' } +$report | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath (Join-Path $directory 'acceptance.json') -Encoding utf8 +$portalChecks = @('resource-context', 'configuration', 'playground', 'metrics', 'error-states', 'browser-health') +$portal = [ordered]@{ + schemaVersion = 1; runId = $runId; subscriptionId = $subscription; tenantId = $tenant + resourceGroup = $ResourceGroup; commit = $report.commit; reviewedUtc = ''; reviewer = '' + method = 'authenticated-browser'; checks = @($portalChecks | ForEach-Object { + @{ name = $_; status = 'NOT RUN'; evidence = ''; observed = '' } + }) + metrics = @{ source = ''; windowStartUtc = ''; windowEndUtc = ''; portalValue = $null; referenceValue = $null; explanation = '' } +} +$portal | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $directory 'portal-evidence.json') -Encoding utf8 +Write-Host "Backend: $($report.backendStatus); overall: $($report.overallStatus). Evidence: $directory" +if ($report.backendStatus -ne 'PASS') { throw 'Customer backend acceptance incomplete. See sanitized acceptance.json.' } +Write-Host 'Backend passed. Portal acceptance is still required; use Complete-PortalAcceptance.ps1 after the browser review.' diff --git a/samples/terraform/scripts/Test-ModelAliases.ps1 b/samples/terraform/scripts/Test-ModelAliases.ps1 new file mode 100644 index 0000000..39ef4ad --- /dev/null +++ b/samples/terraform/scripts/Test-ModelAliases.ps1 @@ -0,0 +1,80 @@ +# Sends a short chat request to every configured model alias through the gateway. +# Run from samples/terraform after a successful apply, signed in with az login. +# ApiKey needs key-read permission; EntraId needs the AI Gateway Models User role. +# Model calls incur inference charges. + +param( + [ValidateSet('ApiKey', 'EntraId')] + [string]$Authentication = 'ApiKey' +) + +$ErrorActionPreference = "Stop" + +$root = Split-Path $PSScriptRoot -Parent +$outputsJson = & terraform "-chdir=$root" output -json +if ($LASTEXITCODE -ne 0) { throw "Cannot read Terraform outputs." } +$outputs = $outputsJson | ConvertFrom-Json +$gatewayUrl = ([string]$outputs.gateway_endpoint.value).TrimEnd('/') +$keyId = $outputs.gateway_api_key_id.value +$modelNames = @($outputs.model_deployment_ids.value.PSObject.Properties.Name) +if (!$gatewayUrl -or $modelNames.Count -eq 0) { + throw "Gateway endpoint or model aliases are missing." +} +if ($Authentication -eq 'ApiKey' -and !$keyId) { + throw "Gateway API key resource ID is missing." +} +$modelApis = $outputs.model_api_configuration.value +if (-not $modelApis) { + throw "Missing model_api_configuration output. Review and apply the output update with your existing inputs/profile before testing." +} +foreach ($model in $modelNames) { + $api = $modelApis.PSObject.Properties[$model].Value + if ($api.api_format -cne 'OpenAIChatCompletions' -or + @($api.endpoints) -cnotcontains '/openai/v1/chat/completions') { + throw "Unsupported probe API for ${model}: requires OpenAIChatCompletions and /openai/v1/chat/completions. Use a model-specific test; this is not a network failure." + } +} + +$apiKey = $null +$accessToken = $null +try { + if ($Authentication -eq 'EntraId') { + $accessToken = az account get-access-token ` + --resource https://azure-api.net/aigateway ` + --query accessToken --output tsv --only-show-errors + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) { + throw "Unable to acquire an AI Gateway Entra token. Check your Azure CLI login and tenant; no API-key fallback is attempted." + } + $headers = @{ Authorization = "Bearer $accessToken" } + } + else { + $apiKey = az rest --method post ` + --url "https://management.azure.com${keyId}/listSecrets?api-version=2025-09-01-preview" ` + --query primaryKey --output tsv --only-show-errors + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($apiKey)) { + throw "Unable to retrieve the gateway key. Check your Azure identity and permissions." + } + $headers = @{ "Api-Key" = $apiKey } + } + + foreach ($model in $modelNames) { + $body = @{ + model = $model + messages = @(@{ role = "user"; content = "Say hello in one short sentence." }) + } | ConvertTo-Json -Depth 5 + + $reply = Invoke-RestMethod -Method Post ` + -Uri "$gatewayUrl/default/models/openai/v1/chat/completions" ` + -Headers $headers -ContentType "application/json" -Body $body + + if (!$reply.choices -or !$reply.choices[0].message.content) { + throw "No chat-completion text returned for alias '$model'." + } + "{0}: {1}" -f $model, $reply.choices[0].message.content + } +} +finally { + $apiKey = $null + $accessToken = $null + $headers = $null +} diff --git a/samples/terraform/scripts/Test-Offline.ps1 b/samples/terraform/scripts/Test-Offline.ps1 new file mode 100644 index 0000000..e201efd --- /dev/null +++ b/samples/terraform/scripts/Test-Offline.ps1 @@ -0,0 +1,24 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +foreach ($command in @( + @('init', '-backend=false', '-input=false', '-lockfile=readonly'), + @('fmt', '-check', '-recursive'), + @('validate'), + @('test') +)) { + & terraform "-chdir=$root" @command + if ($LASTEXITCODE -ne 0) { throw "Terraform $($command[0]) failed." } +} +& python -B -m unittest discover -s (Join-Path $root 'tests') -p 'test_*.py' +if ($LASTEXITCODE -ne 0) { throw 'Python regression tests failed.' } +foreach ($path in Get-ChildItem -LiteralPath $root -Filter '*.ps1' -Recurse -File) { + $tokens = $null + $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($path.FullName, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw "PowerShell parse failure: $($path.Name)" } +} +& (Join-Path $root 'tests\Test-ModelScripts.ps1') +& (Join-Path $root 'tests\Test-CustomerAcceptance.ps1') +& (Join-Path $root 'tests\Test-PortalAcceptance.ps1') +Write-Host 'Offline checks passed. This does not certify Azure deployment, model runtime or portal behavior.' diff --git a/samples/terraform/scripts/Test-PrivateGateway.ps1 b/samples/terraform/scripts/Test-PrivateGateway.ps1 new file mode 100644 index 0000000..b1cf3b2 --- /dev/null +++ b/samples/terraform/scripts/Test-PrivateGateway.ps1 @@ -0,0 +1,59 @@ +param( + [ValidatePattern('^[a-f0-9]{32}$')] + [string]$RunId +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$outputsJson = & terraform "-chdir=$root" output -json 2>$null +if ($LASTEXITCODE -ne 0) { throw 'Cannot read Terraform outputs.' } +$outputs = $outputsJson | ConvertFrom-Json +$configuration = $outputs.private_probe_configuration.value +$vmId = $outputs.private_test_vm_id.value +if (-not $configuration -or -not $vmId) { + throw 'Enable and apply private networking and the test client first.' +} +$modelApis = $outputs.model_api_configuration.value +if (-not $modelApis) { + throw 'Missing model_api_configuration output. Review and apply the output update with your existing inputs/profile before testing.' +} +$configuration | Add-Member -NotePropertyName model_apis -NotePropertyValue $modelApis +if ($RunId) { $configuration | Add-Member -NotePropertyName run_id -NotePropertyValue $RunId } +$subscription = ($vmId -split '/')[2] +$configurationJson = $configuration | ConvertTo-Json -Depth 10 -Compress +$configuration64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($configurationJson)) +$source64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'private-path-probe.py'))) +$script = @" +set -eu +python3 - '$configuration64' <<'PY' +import base64 +import sys +sys.argv = ["private-path-probe.py", "--configuration", base64.b64decode(sys.argv[1]).decode()] +exec(compile(base64.b64decode("$source64"), "private-path-probe.py", "exec")) +PY +"@ +$scriptPath = [IO.Path]::GetTempFileName() +try { + [IO.File]::WriteAllText($scriptPath, $script.Replace("`r`n", "`n"), [Text.UTF8Encoding]::new($false)) + $raw = & az vm run-command invoke --subscription $subscription --ids $vmId ` + --command-id RunShellScript --scripts "@$scriptPath" --output json --only-show-errors 2>$null + if ($LASTEXITCODE -ne 0) { throw 'Azure VM Run Command failed.' } + $result = $raw | ConvertFrom-Json + $reports = @( + foreach ($entry in $result.value) { + foreach ($line in ($entry.message -split '\r?\n')) { + if ($line.TrimStart().StartsWith('{')) { + $line | ConvertFrom-Json + } + } + } + ) + if ($reports.Count -ne 1) { + throw 'Expected one sanitized probe report; raw VM output is not printed.' + } + $reports[0] | ConvertTo-Json -Depth 10 + if ($reports[0].passed -ne $true) { throw 'The private gateway probe failed.' } +} +finally { + [IO.File]::Delete($scriptPath) +} diff --git a/samples/terraform/scripts/private-path-probe.py b/samples/terraform/scripts/private-path-probe.py new file mode 100644 index 0000000..ac2350e --- /dev/null +++ b/samples/terraform/scripts/private-path-probe.py @@ -0,0 +1,161 @@ +"""Run inside the private test VM; emit only non-secret DNS/inference results.""" + +import argparse +import ipaddress +import json +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +def request_json(opener, url, headers=None, body=None): + payload = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(url, data=payload, headers=headers or {}) + with opener.open(request, timeout=90) as response: + return response.status, json.load(response) + + +def private_dns(host, subnet): + addresses = sorted({entry[4][0] for entry in socket.getaddrinfo(host, 443, socket.AF_INET)}) + if not addresses or not all(ipaddress.ip_address(address) in subnet for address in addresses): + raise RuntimeError(f"{host} did not resolve exclusively inside {subnet}: {addresses}") + return {"host": host, "addresses": addresses, "expected_subnet": str(subnet)} + + +def validate_model_apis(configuration): + models = configuration.get("model_names") + apis = configuration.get("model_apis") + if not models or not isinstance(apis, dict): + raise RuntimeError("Model API metadata is required; use the current Terraform outputs and wrapper.") + for model in models: + api = apis.get(model) + if ( + not isinstance(api, dict) + or api.get("api_format") != "OpenAIChatCompletions" + or "/openai/v1/chat/completions" not in (api.get("endpoints") or []) + ): + raise RuntimeError( + f"Unsupported probe API for {model}: requires OpenAIChatCompletions and " + "/openai/v1/chat/completions. Use a model-specific test; this is not a network failure." + ) + + +def check_key_rejection(opener, url, body, headers): + try: + request_json(opener, url, headers, body) + except urllib.error.HTTPError as error: + if error.code == 401: + return error.code + if error.code == 403: + try: + response = json.load(error) + except (ValueError, UnicodeError): + raise RuntimeError("Unclassified HTTP 403; authentication rejection not proven") from None + if not isinstance(response, dict): + raise RuntimeError("Unclassified HTTP 403; authentication rejection not proven") from None + detail = response.get("error") + nested_message = detail.get("message", "") if isinstance(detail, dict) else "" + message = str(response.get("message", nested_message)).lower() + if any(term in message for term in ("api key", "api-key", "authentication", "subscription key")): + return error.code + raise RuntimeError(f"Authentication rejection not proven: HTTP {error.code}") from None + raise RuntimeError("Gateway accepted a missing or invalid runtime key") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--configuration", required=True, help="JSON from private_probe_configuration output.") + args = parser.parse_args() + configuration = json.loads(args.configuration) + validate_model_apis(configuration) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + subnet = ipaddress.ip_network(configuration["private_endpoint_subnet"]) + gateway = configuration["gateway_endpoint"].rstrip("/") + dns = [ + private_dns(urllib.parse.urlsplit(endpoint).hostname, subnet) + for endpoint in (gateway, configuration["model_endpoint"]) + ] + _, identity = request_json( + opener, + "http://169.254.169.254/metadata/identity/oauth2/token" + "?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F", + {"Metadata": "true"}, + ) + key_url = ( + "https://management.azure.com" + configuration["gateway_id"] + + "/apiKeys/default/listSecrets?api-version=2025-09-01-preview" + ) + # A new role assignment can take time to reach ARM's authorization cache. + for attempt in range(12): + try: + _, secrets = request_json( + opener, key_url, {"Authorization": "Bearer " + identity["access_token"]}, {} + ) + break + except urllib.error.HTTPError as error: + if error.code != 403 or attempt == 11: + raise RuntimeError(f"Managed identity key retrieval failed: HTTP {error.code}") from None + time.sleep(15) + key = secrets["primaryKey"] + results = [] + url = gateway + "/default/models/openai/v1/chat/completions" + for model in configuration["model_names"]: + trace_id = uuid.uuid4().hex + status, response = request_json( + opener, + url, + { + "Content-Type": "application/json", + "Api-Key": key, + "traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01", + }, + { + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly OK."}], + "max_tokens": 8, + "temperature": 0, + }, + ) + text = response["choices"][0]["message"]["content"].strip() + if status != 200 or text != "OK": + raise RuntimeError(f"Inference failed for {model}: HTTP {status}, unexpected reply") + usage = response.get("usage") + safe_usage = None if not isinstance(usage, dict) else { + name: usage[name] for name in ("prompt_tokens", "completion_tokens", "total_tokens") + if type(usage.get(name)) is int and usage[name] >= 0 + } + results.append({ + "model": model, "status": status, "reply": text, + "trace_id": trace_id, "usage": safe_usage, + }) + body = { + "model": configuration["model_names"][0], + "messages": [{"role": "user", "content": "Reply with exactly OK."}], + "max_tokens": 8, + "temperature": 0, + } + authentication = [ + {"case": name, "status": check_key_rejection(opener, url, body, headers)} + for name, headers in ( + ("missing-key", {"Content-Type": "application/json"}), + ("invalid-key", {"Content-Type": "application/json", "Api-Key": "invalid-acceptance-key"}), + ) + ] + print(json.dumps({ + "run_id": configuration.get("run_id"), + "dns": dns, "inference": results, "authentication": authentication, "passed": True, + })) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, IndexError, RuntimeError) as error: + # Do not emit request headers, tokens, keys, or unfiltered service bodies. + message = f"HTTP {error.code}" if isinstance(error, urllib.error.HTTPError) else str(error) + print(json.dumps({"passed": False, "error": message}), file=sys.stderr) + sys.exit(1) diff --git a/samples/terraform/terraform.tfvars.example b/samples/terraform/terraform.tfvars.example new file mode 100644 index 0000000..27d4ff1 --- /dev/null +++ b/samples/terraform/terraform.tfvars.example @@ -0,0 +1,43 @@ +subscription_id = "00000000-0000-0000-0000-000000000000" +environment_name = "aigateway" +location = "eastus2" +gateway_location = "eastus2" +publisher_email = "platform@example.com" + +# Optional additive Access configuration, independent of Public/Private-only. +# Uses Entra object IDs, not application/client IDs. Empty by default. +# Grants model invocation across this gateway; does not revoke API keys. +# gateway_access_principals = { +# application = { +# principal_id = "" +# principal_type = "ServicePrincipal" +# } +# } + +# Public is the default; select profiles/private-only.tfvars.json for the final private posture. +# profiles/private-connectivity.tfvars.json is an intermediate provisioning stage only. +# See deployment.md before staging; never reopen an already-private endpoint. +# enable_private_networking = true +# gateway_public_network_access = "Enabled" +# models_public_network_access = "Enabled" +# private_network_address_prefix = "10.247.0.0/16" +# +# Optional billable VM for private-path testing; no public IP or inbound access. +# enable_private_test_client = true +# private_test_client_ssh_public_key = "ssh-ed25519 " + +# Examples, not a guarantee of regional availability or quota. Choose supported versions. +model_deployments = { + gpt-mini = { + model_name = "gpt-4.1-mini" + model_version = "2025-04-14" + capacity = 10 + token_limit = 10000 + } + gpt-full = { + model_name = "gpt-4.1" + model_version = "2025-04-14" + capacity = 10 + token_limit = 10000 + } +} diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 new file mode 100644 index 0000000..deea965 --- /dev/null +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -0,0 +1,442 @@ +$ErrorActionPreference = 'Stop' +$runner = Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts\Test-CustomerAcceptance.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile($runner, [ref]$null, [ref]$null) +$converter = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-AzureCliArguments' +}, $true) +. ([scriptblock]::Create($converter.Extent.Text)) +$url = 'https://test.example/query_range?query=metric%20%3E%200&start=1&end=2&step=15s' +$arguments = @('rest', '--url', $url) +$batchArguments = @(ConvertTo-AzureCliArguments $arguments $true) +if ($batchArguments.Count -ne 3 -or $batchArguments[2] -cne ('"' + $url + '"') -or + $batchArguments[0] -cne 'rest' -or $batchArguments[1] -cne '--url') { + throw 'Windows batch URL quoting changed argument boundaries.' +} +if (@(Compare-Object $arguments @(ConvertTo-AzureCliArguments $arguments $false)).Count) { + throw 'Non-batch Azure CLI arguments must remain unchanged.' +} +$rejected = $false +try { $null = ConvertTo-AzureCliArguments @('https://test.example/"&echo invalid') $true } +catch { $rejected = $_.Exception.Message -eq 'ACCEPTANCE: Unsafe Azure CLI URL argument.' } +if (-not $rejected) { throw 'Unsafe batch URL was not rejected.' } +$subscription = '00000000-0000-0000-0000-000000000001' +$tenant = '00000000-0000-0000-0000-000000000002' +$group = "/subscriptions/$subscription/resourceGroups/test" +$gateway = "$group/providers/Microsoft.ApiManagement/service/gateway" +$models = "$group/providers/Microsoft.CognitiveServices/accounts/models" +$agents = "$group/providers/Microsoft.CognitiveServices/accounts/agents" +$project = "$agents/projects/project" +$storage = "$group/providers/Microsoft.Storage/storageAccounts/storage" +$registry = "$group/providers/Microsoft.ContainerRegistry/registries/registry" +$vm = "$group/providers/Microsoft.Compute/virtualMachines/test" +$vnet = "$group/providers/Microsoft.Network/virtualNetworks/vnet" +$monitoringGroup = "/subscriptions/$subscription/resourceGroups/managed-monitoring" +$dcr = "$monitoringGroup/providers/Microsoft.Insights/dataCollectionRules/gateway" +$dce = "$monitoringGroup/providers/Microsoft.Insights/dataCollectionEndpoints/gateway" +$amw = "$monitoringGroup/providers/Microsoft.Monitor/accounts/gateway" +$publisherRole = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/3913510d-42f4-4e42-8a64-420c390055eb" +$testDirectory = Join-Path ([IO.Path]::GetTempPath()) "gateway-acceptance-tests-$([guid]::NewGuid().ToString('N'))" +$null = New-Item -ItemType Directory -Path $testDirectory + +function New-Fixture { + $global:acceptanceTest = @{ + scenario = ''; calls = [Collections.Generic.List[string]]::new(); power = 'deallocated' + profile = 'private-connectivity' + resources = @{}; traces = @(('a' * 32), ('b' * 32)) + outputs = @{ + resource_group_name = @{ value = 'test' } + resource_groups = @{ value = @{ gateway = 'test'; models = 'test'; agents = 'test' } } + gateway_id = @{ value = $gateway }; foundry_models_id = @{ value = $models }; project_id = @{ value = $project } + storage_account_name = @{ value = 'storage' }; container_registry_endpoint = @{ value = 'registry.azurecr.io' } + private_test_vm_id = @{ value = $vm } + application_insights_ids = @{ value = @{} }; private_endpoint_ids = @{ value = @{} } + gateway_monitoring = @{ value = @{ log_analytics_id = "$group/providers/Microsoft.OperationalInsights/workspaces/gateway" } } + model_deployment_ids = @{ value = @{} }; model_api_configuration = @{ value = @{} } + private_probe_configuration = @{ value = @{ + gateway_id = $gateway; gateway_endpoint = 'https://gateway.example'; model_endpoint = 'https://models.example' + model_names = @('full', 'mini'); private_endpoint_subnet = '10.247.1.0/24' + } } + } + } + $f = $global:acceptanceTest + foreach ($id in @($gateway, $models, $agents, $project, $storage, $registry)) { + $f.resources[$id] = @{ id = $id; name = $id.Split('/')[-1]; properties = @{ provisioningState = 'Succeeded' } } + } + $f.resources[$gateway].sku = @{ name = 'AIGateway' } + $f.resources[$gateway].identity = @{ principalId = 'gateway-principal'; tenantId = $tenant } + $f.resources[$project].identity = @{ principalId = 'project-principal'; tenantId = $tenant } + $f.resources[$gateway].properties += @{ publicNetworkAccess = 'Enabled'; virtualNetworkType = 'External'; virtualNetworkConfiguration = @{ subnetResourceId = "$vnet/subnets/gateway" } } + $f.resources[$models].properties += @{ publicNetworkAccess = 'Disabled'; networkAcls = @{ defaultAction = 'Deny' }; endpoint = 'https://models.example/' } + $f.resources[$storage].properties.primaryEndpoints = @{ blob = 'https://storage.blob.core.windows.net/' } + $f.resources[$registry].properties.loginServer = 'registry.azurecr.io' + $f.resources["$vnet/subnets/endpoints"] = @{ properties = @{ addressPrefix = '10.247.1.0/24' } } + foreach ($name in @('gateway', 'agents')) { + $insightsId = "$group/providers/Microsoft.Insights/components/$name" + $workspaceId = "$group/providers/Microsoft.OperationalInsights/workspaces/$name" + $f.outputs.application_insights_ids.value[$name] = $insightsId + $f.resources[$insightsId] = @{ properties = @{ provisioningState = 'Succeeded'; WorkspaceResourceId = $workspaceId; AppId = $tenant } } + $f.resources[$workspaceId] = @{ properties = @{ provisioningState = 'Succeeded'; customerId = $tenant } } + } + $insightsId = $f.outputs.application_insights_ids.value.gateway + $endpoints = @{ + metricsEndpoint = 'https://test.metrics.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Metrics/otlp/v1/metrics' + logsEndpoint = 'https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Logs/otlp/v1/logs' + tracesEndpoint = 'https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Traces/otlp/v1/traces' + } + $f.resources[$insightsId].properties += @{ + AzureMonitorWorkspaceIngestionMode = 'Enabled'; DataCollectionRuleResourceId = $dcr + OTLPMetricsEndpoint = $endpoints.metricsEndpoint; OTLPLogsEndpoint = $endpoints.logsEndpoint + OTLPTracesEndpoint = $endpoints.tracesEndpoint + } + $f.resources[$monitoringGroup] = @{ managedBy = $insightsId } + $f.resources[$dcr] = @{ properties = @{ + provisioningState = 'Succeeded'; immutableId = 'dcr-test'; dataCollectionEndpointId = $dce + destinations = @{ + monitoringAccounts = @(@{ accountResourceId = $amw }) + logAnalytics = @(@{ workspaceResourceId = $f.resources[$insightsId].properties.WorkspaceResourceId }) + } + } } + $f.resources[$dce] = @{ properties = @{ + provisioningState = 'Succeeded' + metricsIngestion = @{ endpoint = 'https://test.metrics.ingest.monitor.azure.com' } + logsIngestion = @{ endpoint = 'https://test.ingest.monitor.azure.com' } + } } + $f.resources[$amw] = @{ properties = @{ + provisioningState = 'Succeeded'; metrics = @{ prometheusQueryEndpoint = 'https://test.prometheus.monitor.azure.com' } + } } + $f.publisherState = @{ + address = 'azapi_resource.role_assignment["gateway-monitoring-publisher"]'; mode = 'managed' + values = @{ + id = "$dcr/providers/Microsoft.Authorization/roleAssignments/$tenant" + parent_id = $dcr; type = 'Microsoft.Authorization/roleAssignments@2022-04-01' + body = @{ properties = @{ principalId = 'gateway-principal'; principalType = 'ServicePrincipal'; roleDefinitionId = $publisherRole } } + } + } + foreach ($name in @('full', 'mini')) { + $deploymentId = "$models/deployments/$name" + $catalogId = "$gateway/workspaces/default/modelProviders/foundry/models/$name" + $f.outputs.model_deployment_ids.value[$name] = $deploymentId + $f.outputs.model_api_configuration.value[$name] = @{ api_format = 'OpenAIChatCompletions'; endpoints = @('/openai/v1/chat/completions') } + $f.resources[$deploymentId] = @{ properties = @{ provisioningState = 'Succeeded' } } + $f.resources[$catalogId] = @{ properties = @{ deployment = @{ resourceId = $deploymentId }; apiFormat = 'OpenAIChatCompletions'; supportedEndpoints = @('/openai/v1/chat/completions') } } + $f.resources["$gateway/workspaces/default/aliases/$name"] = @{ properties = @{ aliasName = $name; backendModels = @(@{ resourceId = $catalogId }) } } + } + foreach ($name in @('gateway', 'models')) { + $endpointId = "$group/providers/Microsoft.Network/privateEndpoints/$name" + $target = if ($name -eq 'gateway') { $gateway } else { $models } + $groupIds = if ($name -eq 'gateway') { @('Gateway') } else { @('account') } + $zones = if ($name -eq 'gateway') { @('privatelink.azure-api.net') } else { + @('privatelink.cognitiveservices.azure.com', 'privatelink.openai.azure.com', 'privatelink.services.ai.azure.com') + } + $f.outputs.private_endpoint_ids.value[$name] = $endpointId + $f.resources[$endpointId] = @{ properties = @{ + provisioningState = 'Succeeded'; subnet = @{ id = "$vnet/subnets/endpoints" }; privateLinkServiceConnections = @(@{ properties = @{ + privateLinkServiceId = $target; groupIds = @($groupIds); privateLinkServiceConnectionState = @{ status = 'Approved' } + } }) + } } + $configs = @($zones | ForEach-Object { @{ properties = @{ privateDnsZoneId = "$group/providers/Microsoft.Network/privateDnsZones/$_" } } }) + $f.resources["$endpointId/privateDnsZoneGroups/default"] = @{ properties = @{ provisioningState = 'Succeeded'; privateDnsZoneConfigs = $configs } } + foreach ($zone in $zones) { + $f.resources["$group/providers/Microsoft.Network/privateDnsZones/$zone/virtualNetworkLinks/gateway-vnet"] = @{ + properties = @{ provisioningState = 'Succeeded'; virtualNetwork = @{ id = $vnet } } + } + } + } + $f.resources["$project/connections"] = @{ value = @( + @{ name = 'storage'; properties = @{ category = 'AzureStorageAccount'; authType = 'AAD'; target = 'https://storage.blob.core.windows.net/'; metadata = @{ ResourceId = $storage } } }, + @{ name = 'registry-conn'; properties = @{ category = 'ContainerRegistry'; authType = 'ManagedIdentity'; target = 'registry.azurecr.io'; metadata = @{ ResourceId = $registry } } } + ) } + $f.resources["$agents/connections"] = @{ value = @( + @{ name = 'agents'; properties = @{ category = 'AppInsights'; authType = 'ApiKey'; target = $f.outputs.application_insights_ids.value.agents; metadata = @{ ResourceId = $f.outputs.application_insights_ids.value.agents } } } + ) } + $f.resources["$gateway/workspaces/default/modelProviders/foundry"] = @{ properties = @{ foundry = @{ + authentication = @{ kind = 'ManagedIdentity'; managedIdentity = @{ resource = 'https://cognitiveservices.azure.com/' } } + resourceIds = @($models); endpoint = 'https://models.example/' + } } } + $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"] = @{ properties = @{ + kind = 'OpenTelemetry'; payloadCapture = $false; applicationInsights = @{ resourceId = $insightsId } + openTelemetry = $endpoints + @{ credentials = @{ managedIdentity = @{ resource = 'https://monitor.azure.com' } } } + } } + foreach ($binding in @( + @($models, 'gateway-principal', '53ca6127-db72-4b80-b1b0-d745d6d5456d'), + @($dcr, 'gateway-principal', '3913510d-42f4-4e42-8a64-420c390055eb'), + @($storage, 'project-principal', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'), + @($registry, 'project-principal', '7f951dda-4ed3-4680-a7ca-43fe172d538d'), + @($agents, 'project-principal', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68'), + @($agents, 'project-principal', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'), + @($agents, 'project-principal', 'a97b65f3-24c7-4388-baec-2e87135dc908') + )) { + $id = "$($binding[0])/providers/Microsoft.Authorization/roleAssignments" + if (-not $f.resources.ContainsKey($id)) { $f.resources[$id] = @{ value = @() } } + $f.resources[$id].value += @{ properties = @{ + scope = $binding[0]; principalId = $binding[1]; principalType = 'ServicePrincipal' + roleDefinitionId = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/$($binding[2])" + } } + } +} + +function git { + $global:LASTEXITCODE = 0 + if ('rev-parse' -in $args) { return ('c' * 40) } + if ($global:acceptanceTest.scenario -eq 'dirty') { ' M main.tf' } +} + +function terraform { + $global:LASTEXITCODE = 0 + $f = $global:acceptanceTest + if ('output' -in $args) { + if ($f.scenario -eq 'terraform-error') { $global:LASTEXITCODE = 1; return 'SECRET-DO-NOT-LOG' } + if ($f.scenario -eq 'malformed') { return 'SECRET-DO-NOT-LOG-invalid-json' } + return ($f.outputs | ConvertTo-Json -Depth 20) + } + if ('plan' -in $args) { + $f.planArguments = @($args) + if ($args[-1] -cne "-var-file=profiles/$($f.profile).tfvars.json") { throw 'Selected private profile must be last.' } + $effectiveAccess = $null + foreach ($argument in $args) { + if ($argument -like '-var-file=*') { + $path = $argument.Substring('-var-file='.Length) + if (-not [IO.Path]::IsPathRooted($path)) { $path = Join-Path (Split-Path $PSScriptRoot -Parent) $path } + $values = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $effectiveAccess = $values.gateway_public_network_access + } + } + $expectedAccess = if ($f.profile -eq 'private-only') { 'Disabled' } else { 'Enabled' } + if ($effectiveAccess -cne $expectedAccess) { throw 'Drift plan does not preserve selected gateway ingress.' } + if ($f.scenario -in @('drift', 'private-only-drift')) { $global:LASTEXITCODE = 2 } + if ($f.scenario -eq 'plan-error') { $global:LASTEXITCODE = 1 } + return 'SECRET-DO-NOT-LOG' + } + if ('show' -in $args) { + $resources = @( + @{ mode = 'managed'; values = @{ id = $group; type = 'Microsoft.Resources/resourceGroups@2021-04-01' } }, + @{ mode = 'managed'; values = @{ id = $gateway; type = 'Microsoft.ApiManagement/service@2025-09-01-preview' } }, + $f.publisherState + ) + if ($f.scenario -eq 'extra-group') { $resources += @{ mode = 'managed'; values = @{ id = "$group-old"; type = 'Microsoft.Resources/resourceGroups@2021-04-01' } } } + if ($f.scenario -eq 'unrelated-managed-looking-resource') { + $resources += @{ mode = 'managed'; values = @{ id = "$monitoringGroup/providers/Microsoft.Storage/storageAccounts/unrelated"; type = 'Microsoft.Storage/storageAccounts@2024-01-01' } } + } + return (@{ values = @{ root_module = @{ resources = $resources } } } | ConvertTo-Json -Depth 10) + } + throw 'Unexpected Terraform command.' +} + +function az { + $global:LASTEXITCODE = 0 + $f = $global:acceptanceTest + $f.calls.Add(($args -join ' ')) + if ($args[0] -eq 'account') { + $actualTenant = if ($f.scenario -eq 'wrong-tenant') { $subscription } else { $tenant } + return (@{ id = $subscription; tenantId = $actualTenant } | ConvertTo-Json) + } + if ($args[0] -eq 'vm') { + if ($args[1] -eq 'get-instance-view') { + return (@{ instanceView = @{ statuses = @(@{ code = "PowerState/$($f.power)" }) } } | ConvertTo-Json -Depth 5) + } + if ($args[1] -eq 'start') { $f.power = 'running'; return '{}' } + if ($args[1] -eq 'deallocate') { + if ($f.scenario -eq 'cleanup-error') { $global:LASTEXITCODE = 1; return 'SECRET-DO-NOT-LOG' } + $f.power = 'deallocated'; return '{}' + } + if ($args[1] -eq 'run-command') { + $path = ([string]$args[[Array]::IndexOf($args, '--scripts') + 1]).Substring(1) + $source = Get-Content -LiteralPath $path -Raw + if ($source -notmatch "python3 - '([^']+)'") { throw 'Missing probe configuration.' } + $configuration = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Matches[1])) | ConvertFrom-Json + $f.scriptPath = $path + $probe = @{ + run_id = $configuration.run_id; passed = ($f.scenario -ne 'runtime-error') + inference = @( + @{ model = 'full'; status = 200; reply = 'OK'; trace_id = $f.traces[0] }, + @{ model = 'mini'; status = 200; reply = 'OK'; trace_id = $f.traces[1] } + ) + authentication = @(@{ case = 'missing-key'; status = 401 }, @{ case = 'invalid-key'; status = 401 }) + } + $message = $probe | ConvertTo-Json -Depth 10 -Compress + return (@{ value = @(@{ message = $message }) } | ConvertTo-Json -Depth 5) + } + throw 'Unexpected VM operation.' + } + $url = [string]$args[[Array]::IndexOf($args, '--url') + 1] + if ($url.StartsWith('https://api.loganalytics.io/')) { + $decoded = [uri]::UnescapeDataString($url) + if ($decoded -notmatch 'OTelSpans' -or $decoded -notmatch 'OTelLogs' -or $decoded -notmatch 'Kind =~ "Server"' -or + $decoded -notmatch '== 200' -or $decoded -notmatch $f.traces[0] -or $decoded -notmatch $f.traces[1]) { + throw 'OTLP query contract/correlation missing.' + } + $rows = @(@('trace', $f.traces[0]), @('trace', $f.traces[1]), @('log', $f.traces[0])) + if ($f.scenario -eq 'missing-telemetry') { $rows = @() } + if ($f.scenario -eq 'missing-logs') { $rows = @(@('trace', $f.traces[0]), @('trace', $f.traces[1])) } + if ($f.scenario -eq 'uncorrelated-telemetry') { $rows = @(@('trace', ('d' * 32)), @('log', ('d' * 32))) } + $columns = @(@{ name = 'Signal' }, @{ name = 'TraceId' }) + if ($f.scenario -eq 'telemetry-schema') { $columns = @(@{ name = 'operation_Id' }) } + return (@{ tables = @(@{ columns = $columns; rows = $rows }) } | ConvertTo-Json -Depth 10) + } + if ($url.StartsWith('https://test.prometheus.monitor.azure.com/')) { + $decoded = [uri]::UnescapeDataString($url) + if ($decoded -notmatch 'azure.ai_gateway.client.token.usage' -or $decoded -notmatch 'microsoft.appresourceid' -or + $decoded -notmatch '"azure.ai_gateway.workspace"="default"' -or 'https://prometheus.monitor.azure.com' -notin $args) { + throw 'Prometheus scope/authentication missing.' + } + if ($f.scenario -eq 'metrics-error') { return '{"status":"error","error":"SECRET-DO-NOT-LOG"}' } + $items = @(@{ metric = @{ + __name__ = 'azure.ai_gateway.client.token.usage' + 'microsoft.appresourceid' = $f.outputs.application_insights_ids.value.gateway.ToLowerInvariant() + 'azure.ai_gateway.workspace' = 'default' + }; values = @(,@([DateTimeOffset]::UtcNow.ToUnixTimeSeconds(), '5')) }) + if ($f.scenario -eq 'missing-metrics') { $items = @() } + if ($f.scenario -eq 'wrong-metric-scope') { $items[0].metric.'azure.ai_gateway.workspace' = 'other' } + if ($f.scenario -eq 'stale-metrics') { $items[0].values[0][0] = [DateTimeOffset]::UtcNow.AddDays(-1).ToUnixTimeSeconds() } + if ($f.scenario -eq 'nonfinite-metrics') { $items[0].values[0][1] = 'NaN' } + return (@{ status = 'success'; data = @{ resultType = 'matrix'; result = $items } } | ConvertTo-Json -Depth 10) + } + $id = ([uri]$url).AbsolutePath + if ($f.scenario -eq 'arm-error') { $global:LASTEXITCODE = 1; return 'SECRET-DO-NOT-LOG' } + if (-not $f.resources.ContainsKey($id)) { throw "Unexpected fixture resource: $id" } + $f.resources[$id] | ConvertTo-Json -Depth 20 +} + +$cases = [ordered]@{ + 'private-only-pass' = @('PASS', 'runtime'); 'private-only-readonly' = @('BLOCKED', 'runtime') + 'private-only-public' = @('FAIL', 'private-network'); 'hybrid-disabled' = @('FAIL', 'private-network') + 'private-only-pending' = @('FAIL', 'private-network'); 'private-only-drift' = @('FAIL', 'drift') + pass = @('PASS', 'runtime'); readonly = @('BLOCKED', 'runtime') + 'omitted-api-format' = @('PASS', 'runtime'); 'lowercase-exporter-kind' = @('PASS', 'runtime') + 'arm-dcr-fallback' = @('PASS', 'runtime') + 'missing-exporter-kind' = @('FAIL', 'monitoring'); 'null-exporter-kind' = @('FAIL', 'monitoring') + 'wrong-tenant' = @('FAIL', 'context'); 'wrong-scope' = @('FAIL', 'context') + 'missing-output' = @('FAIL', 'context'); malformed = @('FAIL', 'context') + 'terraform-error' = @('FAIL', 'context'); dirty = @('FAIL', 'context') + 'extra-group' = @('FAIL', 'placement'); 'arm-error' = @('FAIL', 'resources') + 'failed-endpoint' = @('FAIL', 'private-network'); 'wrong-alias' = @('FAIL', 'resources') + 'manual-approved' = @('PASS', 'runtime'); 'manual-pending' = @('FAIL', 'private-network') + 'manual-wrong-target' = @('FAIL', 'private-network'); 'mixed-connections' = @('FAIL', 'private-network') + 'wrong-api-format' = @('FAIL', 'resources'); 'wrong-exporter-kind' = @('FAIL', 'monitoring') + drift = @('FAIL', 'drift'); 'plan-error' = @('FAIL', 'drift') + 'runtime-error' = @('FAIL', 'runtime'); 'missing-telemetry' = @('FAIL', 'telemetry') + 'cleanup-error' = @('FAIL', 'vm-cleanup'); 'running-vm' = @('FAIL', 'runtime') + 'missing-otlp-endpoint' = @('FAIL', 'monitoring'); 'wrong-otlp-endpoint' = @('FAIL', 'monitoring') + 'wrong-audience' = @('FAIL', 'monitoring'); 'legacy-exporter-kind' = @('FAIL', 'monitoring') + 'wrong-publisher-role' = @('FAIL', 'monitoring'); 'wrong-publisher-principal' = @('FAIL', 'monitoring') + 'wrong-publisher-state' = @('FAIL', 'placement'); 'unmanaged-helper-group' = @('FAIL', 'placement') + 'unrelated-managed-looking-resource' = @('FAIL', 'placement') + 'wrong-law' = @('FAIL', 'monitoring'); 'immutable-dcr-only' = @('FAIL', 'monitoring') + 'cross-subscription-dcr' = @('FAIL', 'monitoring') + 'missing-logs' = @('FAIL', 'telemetry'); 'missing-metrics' = @('FAIL', 'telemetry') + 'metrics-error' = @('FAIL', 'telemetry'); 'telemetry-schema' = @('FAIL', 'telemetry') + 'uncorrelated-telemetry' = @('FAIL', 'telemetry') + 'wrong-metric-scope' = @('FAIL', 'telemetry'); 'stale-metrics' = @('FAIL', 'telemetry') + 'nonfinite-metrics' = @('FAIL', 'telemetry'); 'relinked-law' = @('FAIL', 'monitoring') + 'wrong-dce-endpoint' = @('FAIL', 'monitoring'); 'unsafe-query-endpoint' = @('FAIL', 'monitoring') +} +$runtimeCases = @('private-only-pass', 'pass', 'manual-approved', 'omitted-api-format', 'lowercase-exporter-kind', 'arm-dcr-fallback', 'runtime-error', + 'missing-telemetry', 'missing-logs', 'missing-metrics', 'metrics-error', 'telemetry-schema', 'uncorrelated-telemetry', + 'wrong-metric-scope', 'stale-metrics', 'nonfinite-metrics', 'cleanup-error') +try { + foreach ($case in $cases.Keys) { + New-Fixture + $f = $global:acceptanceTest + $f.scenario = $case + $parameters = @{} + if ($case.StartsWith('private-only-')) { + $f.profile = 'private-only' + $f.resources[$gateway].properties.publicNetworkAccess = 'Disabled' + $parameters.NetworkProfile = 'private-only' + } + $additionalFile = Join-Path $testDirectory 'opposite-ingress.tfvars.json' + $oppositeAccess = if ($f.profile -eq 'private-only') { 'Enabled' } else { 'Disabled' } + @{ gateway_public_network_access = $oppositeAccess } | ConvertTo-Json | Set-Content -LiteralPath $additionalFile + if ($case -in @('manual-approved', 'manual-pending', 'manual-wrong-target', 'mixed-connections')) { + $properties = $f.resources[$f.outputs.private_endpoint_ids.value.gateway].properties + $properties.manualPrivateLinkServiceConnections = $properties.privateLinkServiceConnections + if ($case -ne 'mixed-connections') { $properties.privateLinkServiceConnections = @() } + if ($case -eq 'manual-pending') { + $properties.manualPrivateLinkServiceConnections[0].properties.privateLinkServiceConnectionState.status = 'Pending' + } + if ($case -eq 'manual-wrong-target') { + $properties.manualPrivateLinkServiceConnections[0].properties.privateLinkServiceId = $models + } + } + switch ($case) { + 'private-only-public' { $f.resources[$gateway].properties.publicNetworkAccess = 'Enabled' } + 'hybrid-disabled' { $f.resources[$gateway].properties.publicNetworkAccess = 'Disabled' } + 'private-only-pending' { $f.resources[$f.outputs.private_endpoint_ids.value.gateway].properties.privateLinkServiceConnections[0].properties.privateLinkServiceConnectionState.status = 'Pending' } + 'wrong-scope' { $f.outputs.gateway_id.value = $gateway.Replace('/resourceGroups/test/', '/resourceGroups/other/') } + 'missing-output' { $f.outputs.Remove('resource_group_name') } + 'failed-endpoint' { $f.resources[$f.outputs.private_endpoint_ids.value.gateway].properties.provisioningState = 'Failed' } + 'wrong-alias' { $f.resources["$gateway/workspaces/default/aliases/full"].properties.aliasName = 'other' } + 'wrong-api-format' { $f.resources["$gateway/workspaces/default/modelProviders/foundry/models/full"].properties.apiFormat = 'Other' } + 'wrong-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = 'Other' } + 'lowercase-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = 'openTelemetry' } + 'legacy-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = 'applicationInsights' } + 'missing-otlp-endpoint' { $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.Remove('OTLPTracesEndpoint') } + 'wrong-otlp-endpoint' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.openTelemetry.logsEndpoint = 'https://other.ingest.monitor.azure.com/logs' } + 'wrong-audience' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.openTelemetry.credentials.managedIdentity.resource = 'https://wrong.example' } + 'wrong-publisher-role' { $f.resources["$dcr/providers/Microsoft.Authorization/roleAssignments"].value[0].properties.roleDefinitionId = 'wrong' } + 'wrong-publisher-principal' { $f.resources["$dcr/providers/Microsoft.Authorization/roleAssignments"].value[0].properties.principalId = 'wrong' } + 'wrong-publisher-state' { $f.publisherState.values.body.properties.principalId = 'wrong' } + 'unmanaged-helper-group' { $f.resources[$monitoringGroup].managedBy = "$group/providers/Microsoft.Monitor/accounts/unrelated" } + 'wrong-law' { $f.resources[$dcr].properties.destinations.logAnalytics[0].workspaceResourceId = "$group/providers/Microsoft.OperationalInsights/workspaces/agents" } + 'immutable-dcr-only' { + $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.Remove('DataCollectionRuleResourceId') + $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.DataCollectionRuleId = 'dcr-test' + } + 'arm-dcr-fallback' { + $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.Remove('DataCollectionRuleResourceId') + $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.DataCollectionRuleId = $dcr + } + 'cross-subscription-dcr' { $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.DataCollectionRuleResourceId = $dcr.Replace($subscription, $tenant) } + 'relinked-law' { + $f.resources[$f.outputs.application_insights_ids.value.gateway].properties.WorkspaceResourceId = "$group/providers/Microsoft.OperationalInsights/workspaces/agents" + $f.resources[$dcr].properties.destinations.logAnalytics[0].workspaceResourceId = "$group/providers/Microsoft.OperationalInsights/workspaces/agents" + } + 'wrong-dce-endpoint' { $f.resources[$dce].properties.metricsIngestion.endpoint = 'https://other.metrics.ingest.monitor.azure.com' } + 'unsafe-query-endpoint' { $f.resources[$amw].properties.metrics.prometheusQueryEndpoint = 'https://unrelated.example' } + 'missing-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.Remove('kind') } + 'null-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = $null } + 'omitted-api-format' { + foreach ($name in @('full', 'mini')) { $f.resources["$gateway/workspaces/default/modelProviders/foundry/models/$name"].properties.Remove('apiFormat') } + } + 'running-vm' { $f.power = 'running' } + } + $destination = Join-Path $testDirectory $case + $caught = $null + $console = @() + try { + $console = @(& $runner -SubscriptionId $subscription -TenantId $tenant -ResourceGroup test ` + -ReportDirectory $destination -AdditionalVarFile $additionalFile @parameters ` + -RunPrivateProbe:($case -notin @('readonly', 'private-only-readonly')) -TelemetryWaitSeconds 0 6>&1) + } + catch { $caught = $_.Exception.Message } + $file = Get-ChildItem -LiteralPath $destination -Recurse -Filter acceptance.json + $text = Get-Content -LiteralPath $file.FullName -Raw + $result = $text | ConvertFrom-Json -Depth 30 + if ($result.backendStatus -ne $cases[$case][0] -or $result.overallStatus -eq 'PASS' -or + ($cases[$case][0] -eq 'PASS' -and $caught) -or ($cases[$case][0] -ne 'PASS' -and -not $caught)) { + throw "Case $case returned $($result.backendStatus); error=$caught; checks=$($result.checks | ConvertTo-Json -Depth 5 -Compress)" + } + $expectedCheck = $cases[$case][1] + $expectedStatus = if ($cases[$case][0] -eq 'PASS') { 'PASS' } elseif ($case -in @('readonly', 'private-only-readonly')) { 'NOT RUN' } else { 'FAIL' } + if ($result.checks.$expectedCheck.status -ne $expectedStatus) { throw "Wrong failure boundary in $case." } + if ($result.profile -cne "profiles/$($f.profile).tfvars.json") { throw "Wrong reported profile in $case." } + if ($result.checks.'private-network'.status -eq 'PASS') { + $network = $result.checks.'private-network'.evidence + if ($network.gatewayPublicAccess -cne $f.resources[$gateway].properties.publicNetworkAccess -or + $network.expectedGatewayPublicAccess -cne $network.gatewayPublicAccess -or + $network.privateOnlyIngress -cne 'NOT certified' -or $network.publicNetworkRejection -cne 'NOT RUN') { + throw "Misleading network evidence in $case." + } + } + if ($f.planArguments -and "-var-file=$additionalFile" -notin $f.planArguments) { throw "Additional inputs omitted in $case." } + if ($case -in @('missing-exporter-kind', 'null-exporter-kind') -and + $result.checks.monitoring.detail -notmatch 'portal cannot recognize') { throw "Missing portal diagnostic in $case." } + if ($case -notin $runtimeCases -and + @($f.calls | Where-Object { $_ -match '^vm (start|deallocate|run-command)' }).Count) { throw "Unexpected VM mutation in $case." } + if ($case -in $runtimeCases -and $case -ne 'cleanup-error' -and $f.power -ne 'deallocated') { throw "VM left running in $case." } + if ($f.scriptPath -and (Test-Path -LiteralPath $f.scriptPath)) { throw 'Probe transport file was not cleaned up.' } + if ("$text`n$($console -join "`n")`n$caught" -match 'SECRET-DO-NOT-LOG') { throw "Secret leaked in $case." } + } + "Customer acceptance checks passed ($($cases.Count) scenarios; no Azure or model calls)." +} +finally { Remove-Item -LiteralPath $testDirectory -Recurse -Force } diff --git a/samples/terraform/tests/Test-ModelScripts.ps1 b/samples/terraform/tests/Test-ModelScripts.ps1 new file mode 100644 index 0000000..84576db --- /dev/null +++ b/samples/terraform/tests/Test-ModelScripts.ps1 @@ -0,0 +1,175 @@ +$ErrorActionPreference = 'Stop' +$scripts = Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts' +$global:modelScriptTest = @{} +$caseCount = 0 + +function New-TestOutputs { + @{ + gateway_endpoint = @{ value = 'https://gateway.example' } + gateway_api_key_id = @{ value = '/subscriptions/test/providers/Microsoft.ApiManagement/service/test/apiKeys/default' } + model_deployment_ids = @{ value = @{ mini = 'mini-id'; full = 'full-id' } } + model_api_configuration = @{ value = @{ + mini = @{ api_format = 'OpenAIChatCompletions'; endpoints = @('/openai/v1/chat/completions') } + full = @{ api_format = 'OpenAIChatCompletions'; endpoints = @('/openai/v1/chat/completions', '/openai/v1/responses') } + } } + private_probe_configuration = @{ value = @{ model_names = @('mini', 'full') } } + private_test_vm_id = @{ value = '/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/virtualMachines/test' } + } +} + +function terraform { + $global:LASTEXITCODE = $global:modelScriptTest.terraformExit + $global:modelScriptTest.outputs | ConvertTo-Json -Depth 10 +} + +function az { + $global:modelScriptTest.azCalls++ + $global:LASTEXITCODE = $global:modelScriptTest.azExit + if ($args[0] -eq 'account') { + if ($args[1] -ne 'get-access-token' -or + $args[[Array]::IndexOf($args, '--resource') + 1] -ne 'https://azure-api.net/aigateway' -or + $args[[Array]::IndexOf($args, '--query') + 1] -ne 'accessToken') { + throw 'Unexpected token command or audience.' + } + return $global:modelScriptTest.token + } + if ($global:modelScriptTest.authentication -eq 'EntraId') { + throw 'Entra mode must not retrieve an API key.' + } + if ($args[0] -eq 'vm') { + $index = [Array]::IndexOf($args, '--scripts') + if ($index -lt 0) { throw 'Missing Run Command script.' } + $path = ([string]$args[$index + 1]).Substring(1) + $source = Get-Content -LiteralPath $path -Raw + if ($source -notmatch "python3 - '([^']+)'") { throw 'Missing probe configuration.' } + $configuration = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Matches[1])) | ConvertFrom-Json + if ($configuration.model_apis.full.api_format -ne 'OpenAIChatCompletions' -or + '/openai/v1/chat/completions' -notin $configuration.model_apis.full.endpoints) { + throw 'The wrapper did not forward API metadata to the VM.' + } + $global:modelScriptTest.scriptPath = $path + return (@{ value = @(@{ message = '{"passed":true}' }) } | ConvertTo-Json -Depth 5) + } + 'test-key-must-not-appear' +} + +function Invoke-RestMethod { + param($Method, $Uri, $Headers, $ContentType, $Body) + if ($Uri -ne 'https://gateway.example/default/models/openai/v1/chat/completions') { + throw 'Unexpected inference target.' + } + if ($global:modelScriptTest.authentication -eq 'EntraId') { + if ($Headers.Authorization -cne 'Bearer test-token-must-not-appear' -or $Headers.ContainsKey('Api-Key')) { + throw 'Entra invocation must send only the bearer credential.' + } + } + elseif ($Headers['Api-Key'] -ne 'test-key-must-not-appear' -or $Headers.ContainsKey('Authorization')) { + throw 'Key invocation must send only the API-key credential.' + } + if ($global:modelScriptTest.restFailure) { throw $global:modelScriptTest.restFailure } + $global:modelScriptTest.models.Add(($Body | ConvertFrom-Json).model) + @{ choices = @(@{ message = @{ content = 'OK' } }) } +} + +function Reset-Test { + $script:caseCount++ + $global:modelScriptTest.outputs = New-TestOutputs + $global:modelScriptTest.terraformExit = 0 + $global:modelScriptTest.azExit = 0 + $global:modelScriptTest.azCalls = 0 + $global:modelScriptTest.authentication = 'ApiKey' + $global:modelScriptTest.token = 'test-token-must-not-appear' + $global:modelScriptTest.restFailure = $null + $global:modelScriptTest.models = [System.Collections.Generic.List[string]]::new() +} + +function Assert-Rejected { + param([string]$ScriptName, [string]$Expected, [int]$ExpectedAzCalls = 0, [hashtable]$ScriptArguments = @{}) + $failure = $null + try { & (Join-Path $scripts $ScriptName) @ScriptArguments | Out-Null } + catch { $failure = $_.Exception.Message } + if (-not $failure -or $failure -notmatch $Expected) { + throw "Expected rejection matching '$Expected', got '$failure'." + } + if ($global:modelScriptTest.azCalls -ne $ExpectedAzCalls -or $global:modelScriptTest.models.Count -ne 0) { + throw 'A rejected configuration reached an unexpected external call.' + } +} + +Reset-Test +$result = (& (Join-Path $scripts 'Test-ModelAliases.ps1')) -join "`n" +if ($global:modelScriptTest.azCalls -ne 1 -or $global:modelScriptTest.models.Count -ne 2 -or + 'mini' -notin $global:modelScriptTest.models -or 'full' -notin $global:modelScriptTest.models -or + $result -match 'test-key-must-not-appear') { + throw 'Compatible aliases were not all tested safely.' +} + +foreach ($case in @('responses', 'format', 'missing-alias', 'missing-output', 'empty-endpoints')) { + Reset-Test + switch ($case) { + 'responses' { $global:modelScriptTest.outputs.model_api_configuration.value.full.endpoints = @('/openai/v1/responses') } + 'format' { $global:modelScriptTest.outputs.model_api_configuration.value.full.api_format = 'Other' } + 'missing-alias' { $global:modelScriptTest.outputs.model_api_configuration.value.Remove('full') } + 'missing-output' { $global:modelScriptTest.outputs.Remove('model_api_configuration') } + 'empty-endpoints' { $global:modelScriptTest.outputs.model_api_configuration.value.full.endpoints = @() } + } + Assert-Rejected 'Test-ModelAliases.ps1' 'Unsupported probe API|Missing model_api_configuration' +} + +foreach ($name in @('Test-ModelAliases.ps1', 'Test-PrivateGateway.ps1')) { + Reset-Test + $global:modelScriptTest.terraformExit = 1 + Assert-Rejected $name 'Cannot read Terraform outputs' +} + +Reset-Test +$global:modelScriptTest.outputs.Remove('model_api_configuration') +Assert-Rejected 'Test-PrivateGateway.ps1' 'Missing model_api_configuration' + +Reset-Test +$global:modelScriptTest.azExit = 1 +Assert-Rejected 'Test-ModelAliases.ps1' 'Unable to retrieve the gateway key' 1 + +Reset-Test +$privateResult = & (Join-Path $scripts 'Test-PrivateGateway.ps1') | ConvertFrom-Json +if (-not $privateResult.passed -or $global:modelScriptTest.azCalls -ne 1 -or + (Test-Path -LiteralPath $global:modelScriptTest.scriptPath)) { + throw 'Private wrapper metadata transport or temporary-file cleanup failed.' +} + +Reset-Test +$global:modelScriptTest.authentication = 'EntraId' +$global:modelScriptTest.outputs.Remove('gateway_api_key_id') +$result = (& (Join-Path $scripts 'Test-ModelAliases.ps1') -Authentication EntraId) -join "`n" +if ($global:modelScriptTest.azCalls -ne 1 -or $global:modelScriptTest.models.Count -ne 2 -or + $result -match 'test-token-must-not-appear|test-key-must-not-appear') { + throw 'Entra must test every alias without requiring a key ID or exposing credentials.' +} + +foreach ($case in @('token-failure', 'empty-token', 'whitespace-token', 'unauthorized', 'forbidden')) { + Reset-Test + $global:modelScriptTest.authentication = 'EntraId' + $expected = 'Unable to acquire an AI Gateway Entra token' + switch ($case) { + 'token-failure' { $global:modelScriptTest.azExit = 1 } + 'empty-token' { $global:modelScriptTest.token = '' } + 'whitespace-token' { $global:modelScriptTest.token = ' ' } + 'unauthorized' { $global:modelScriptTest.restFailure = 'HTTP 401'; $expected = 'HTTP 401' } + 'forbidden' { $global:modelScriptTest.restFailure = 'HTTP 403'; $expected = 'HTTP 403' } + } + Assert-Rejected 'Test-ModelAliases.ps1' $expected 1 @{ Authentication = 'EntraId' } +} + +Reset-Test +$global:modelScriptTest.authentication = 'EntraId' +$global:modelScriptTest.outputs.model_api_configuration.value.full.endpoints = @('/openai/v1/responses') +Assert-Rejected 'Test-ModelAliases.ps1' 'Unsupported probe API' 0 @{ Authentication = 'EntraId' } + +Reset-Test +$global:modelScriptTest.outputs.Remove('gateway_api_key_id') +Assert-Rejected 'Test-ModelAliases.ps1' 'Gateway API key resource ID is missing' + +Reset-Test +Assert-Rejected 'Test-ModelAliases.ps1' 'Authentication|ValidateSet' 0 @{ Authentication = 'Invalid' } + +"Model-script checks passed ($caseCount cases; no Azure or model calls)." diff --git a/samples/terraform/tests/Test-PortalAcceptance.ps1 b/samples/terraform/tests/Test-PortalAcceptance.ps1 new file mode 100644 index 0000000..10699da --- /dev/null +++ b/samples/terraform/tests/Test-PortalAcceptance.ps1 @@ -0,0 +1,61 @@ +$ErrorActionPreference = 'Stop' +$script = Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts\Complete-PortalAcceptance.ps1' +$directory = Join-Path ([IO.Path]::GetTempPath()) "gateway-portal-tests-$([guid]::NewGuid().ToString('N'))" +$null = New-Item -ItemType Directory -Path $directory +$cases = @('pass', 'backend-fail', 'monitoring-fail', 'telemetry-fail', 'missing-backend-check', 'context', 'not-run', 'duplicate', 'no-evidence', 'old-review', 'old-metrics', 'different-metrics', 'zero-metrics', 'boolean-metrics') +try { + foreach ($case in $cases) { + $path = Join-Path $directory $case + $null = New-Item -ItemType Directory -Path $path + $now = [DateTimeOffset]::UtcNow + $backend = @{ + schemaVersion = 1; backendStatus = 'PASS'; overallStatus = 'BLOCKED'; portal = @{} + runId = 'test-run'; subscriptionId = 'test-subscription'; tenantId = 'test-tenant'; resourceGroup = 'test'; commit = 'test-commit' + completedUtc = $now.AddMinutes(-1).ToString('o'); checks = @{} + } + foreach ($name in @('context', 'placement', 'resources', 'roles', 'connections', 'monitoring', 'private-network', 'drift', 'runtime', 'vm-cleanup', 'telemetry')) { + $backend.checks[$name] = @{ status = 'PASS' } + } + $backend.checks.telemetry.evidence = @{ windowStartUtc = $now.AddMinutes(-4).ToString('o'); windowEndUtc = $now.AddMinutes(-2).ToString('o') } + $portal = @{ + schemaVersion = 1; runId = $backend.runId; subscriptionId = $backend.subscriptionId; tenantId = $backend.tenantId + resourceGroup = $backend.resourceGroup; commit = $backend.commit; reviewedUtc = $now.ToString('o') + reviewer = 'test-operator'; method = 'authenticated-browser' + checks = @(@('resource-context', 'configuration', 'playground', 'metrics', 'error-states', 'browser-health') | ForEach-Object { + @{ name = $_; status = 'PASS'; evidence = 'observation.txt'; observed = 'Offline fixture, not a live UI test.' } + }) + metrics = @{ + source = 'offline fixture'; windowStartUtc = $now.AddMinutes(-5).ToString('o'); windowEndUtc = $now.ToString('o') + portalValue = 2; referenceValue = 2; explanation = 'Offline fixture, not real telemetry.' + } + } + switch ($case) { + 'backend-fail' { $backend.backendStatus = 'FAIL' } + 'monitoring-fail' { $backend.checks.monitoring.status = 'FAIL' } + 'telemetry-fail' { $backend.checks.telemetry.status = 'FAIL' } + 'missing-backend-check' { $backend.checks.Remove('private-network') } + 'context' { $portal.runId = 'wrong-run' } + 'not-run' { $portal.checks[0].status = 'NOT RUN' } + 'duplicate' { $portal.checks[0].name = 'metrics' } + 'no-evidence' { $portal.checks[0].evidence = 'missing.txt' } + 'old-review' { $portal.reviewedUtc = $now.AddDays(-1).ToString('o') } + 'old-metrics' { $portal.metrics.windowStartUtc = $now.AddDays(-2).ToString('o') } + 'different-metrics' { $portal.metrics.portalValue = 3 } + 'zero-metrics' { $portal.metrics.portalValue = 0; $portal.metrics.referenceValue = 0 } + 'boolean-metrics' { $portal.metrics.portalValue = $true; $portal.metrics.referenceValue = $true } + } + $backendFile = Join-Path $path 'acceptance.json' + $portalFile = Join-Path $path 'portal-evidence.json' + $backend | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $backendFile + $portal | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $portalFile + 'Offline fixture only.' | Set-Content -LiteralPath (Join-Path $path 'observation.txt') + $failure = $null + try { & $script -BackendReport $backendFile -PortalEvidence $portalFile 6>$null } + catch { $failure = $_.Exception.Message } + if (($case -eq 'pass' -and $failure) -or ($case -ne 'pass' -and -not $failure)) { throw "Portal case $case failed: $failure" } + $completed = Join-Path $path 'acceptance-with-portal.json' + if ((Test-Path -LiteralPath $completed) -ne ($case -eq 'pass')) { throw 'Incorrect completed-report publication.' } + } + "Portal evidence checks passed ($($cases.Count) scenarios; no browser interactions)." +} +finally { Remove-Item -LiteralPath $directory -Recurse -Force } diff --git a/samples/terraform/tests/infrastructure.tftest.hcl b/samples/terraform/tests/infrastructure.tftest.hcl new file mode 100644 index 0000000..27de334 --- /dev/null +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -0,0 +1,827 @@ +mock_provider "azapi" {} + +override_resource { + target = azapi_resource.insights["gateway"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.Insights/components/gateway" + output = { + properties = { + DataCollectionRuleResourceId = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/managed-monitoring/providers/Microsoft.Insights/dataCollectionRules/gateway" + OTLPMetricsEndpoint = "https://test.metrics.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Metrics/otlp/v1/metrics" + OTLPLogsEndpoint = "https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Logs/otlp/v1/logs" + OTLPTracesEndpoint = "https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/streams/Microsoft-OTLP-Traces/otlp/v1/traces" + } + } + } +} + +override_resource { + target = azapi_resource.resource_group["gateway"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway" + } +} + +variables { + subscription_id = "00000000-0000-0000-0000-000000000001" + location = "eastus2" + gateway_location = "swedencentral" + publisher_email = "test@example.com" + enable_private_networking = false + enable_private_test_client = false + gateway_public_network_access = "Enabled" + models_public_network_access = "Enabled" + model_deployments = { + test-model = { + model_name = "test-model-name" + model_version = "test-version" + capacity = 7 + token_limit = 2300 + endpoints = ["/openai/v1/responses"] + } + } +} + +run "models_and_access_without_developer" { + command = plan + + assert { + condition = ( + length(azapi_resource.resource_group) == 1 && + azapi_resource.resource_group["gateway"].name == "rg-${var.environment_name}-${local.resource_token}-gateway" && + output.resource_group_name == azapi_resource.resource_group["gateway"].name && + jsonencode(keys(output.resource_groups)) == jsonencode(["agents", "gateway", "models"]) && + alltrue([for name in values(output.resource_groups) : name == output.resource_group_name]) && + alltrue([for parent in concat([ + azapi_resource.gateway.parent_id, + azapi_resource.connector_gateway.parent_id, + azapi_resource.models.parent_id, + azapi_resource.agents.parent_id, + azapi_resource.storage.parent_id, + azapi_resource.registry.parent_id + ], [for workspace in azapi_resource.logs : workspace.parent_id], + [for insights in azapi_resource.insights : insights.parent_id] + ) : parent == azapi_resource.resource_group["gateway"].id]) && + length(azapi_resource.logs) == 2 && + length(azapi_resource.insights) == 2 && + azapi_resource.gateway.body.sku.name == "AIGateway" + ) + error_message = "All building blocks must share the retained gateway group without changing its name/address, AIGateway SKU, monitoring stacks or compatibility output keys." + } + + override_resource { + target = azapi_resource.catalog_model["test-model"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test/providers/Microsoft.ApiManagement/service/test/workspaces/default/modelProviders/foundry/models/test-model" + } + } + + assert { + condition = ( + azapi_resource.model_deployment["test-model"].body.properties.model.name == "test-model-name" && + azapi_resource.model_deployment["test-model"].body.properties.model.version == "test-version" && + azapi_resource.model_deployment["test-model"].body.sku.capacity == 7 && + azapi_resource.catalog_model["test-model"].body.properties.deployment.modelName == "test-model" && + azapi_resource.catalog_model["test-model"].body.properties.policies[0].count == 2300 && + jsonencode(azapi_resource.catalog_model["test-model"].body.properties.policies[0].counterKey) == jsonencode(["identity"]) && + azapi_resource.catalog_model["test-model"].body.properties.supportedEndpoints[0] == "/openai/v1/responses" + ) + error_message = "Deployment inputs must propagate to both Foundry and the gateway catalog without conflating quota and token limits." + } + + assert { + condition = ( + output.model_api_configuration["test-model"].api_format == "OpenAIChatCompletions" && + jsonencode(output.model_api_configuration["test-model"].endpoints) == jsonencode(["/openai/v1/responses"]) + ) + error_message = "Probe metadata must preserve the configured API, including Responses-only models that the chat probes cannot test." + } + + assert { + condition = ( + length(azapi_resource.model_alias) == length(var.model_deployments) && + azapi_resource.model_alias["test-model"].body.properties.aliasName == "test-model" && + azapi_resource.model_alias["test-model"].body.properties.backendModels[0].resourceId == azapi_resource.catalog_model["test-model"].id + ) + error_message = "Every runtime model alias must route the configured deployment name to its provider catalog entry." + } + + assert { + condition = ( + azapi_resource.gateway.location == "swedencentral" && + azapi_resource.models.location == "eastus2" && + azapi_resource.agents.location == "eastus2" + ) + error_message = "The gateway region must be independently configurable from the Foundry region." + } + + assert { + condition = ( + length(azapi_resource.role_assignment) == 7 && + length(azapi_resource.private_endpoint) == 0 && + length(azapi_resource.private_test_vm) == 0 && + length(azapi_resource.private_agents) == 0 && + length(azapi_resource.private_agent_endpoint) == 0 && + length(azapi_resource.private_agent_role_assignment) == 0 && + length(azapi_resource.private_agent_key_role) == 0 && + output.private_hosted_agent == null && + alltrue([for key in keys(azapi_resource.role_assignment) : !startswith(key, "developer-")]) + ) + error_message = "No developer access may be created unless a principal is supplied." + } + + assert { + condition = ( + azapi_resource.models.body.properties.disableLocalAuth && + azapi_resource.agents.body.properties.disableLocalAuth && + !azapi_resource.storage.body.properties.allowSharedKeyAccess && + !azapi_resource.storage.body.properties.allowBlobPublicAccess && + !azapi_resource.registry.body.properties.adminUserEnabled && + !azapi_resource.telemetry_exporter.body.properties.payloadCapture + ) + error_message = "The sample must preserve identity-based backend access and avoid storage keys, public blobs, registry admin credentials, and payload capture." + } +} + +run "explicit_developer_service_principal" { + command = plan + variables { + developer_principal_id = "00000000-0000-0000-0000-000000000002" + developer_principal_type = "ServicePrincipal" + storage_public_network_access = "Disabled" + } + + assert { + condition = ( + length(azapi_resource.role_assignment) == 14 && + alltrue([ + for key, binding in azapi_resource.role_assignment : + binding.body.properties.principalId == var.developer_principal_id && + binding.body.properties.principalType == "ServicePrincipal" + if startswith(key, "developer-") + ]) + ) + error_message = "Explicit developer access must use the supplied principal ID/type for both accounts and storage." + } + + assert { + condition = azapi_resource.storage.body.properties.publicNetworkAccess == var.storage_public_network_access + error_message = "Storage networking must honor the configured policy-compatible access mode." + } +} + +run "reject_invalid_capacity" { + command = plan + variables { + model_deployments = { + invalid = { + model_name = "test-model" + model_version = "test-version" + capacity = 0 + token_limit = 1000 + } + } + } + expect_failures = [var.model_deployments] +} + +run "backend_rebuild_retains_gateway" { + command = plan + variables { + backend_revision = "single-group-20261023" + retain_legacy_resource_groups = true + } + + assert { + condition = ( + local.backend_token != local.resource_token && + local.names.gateway == "aigw-${local.resource_token}" && + azapi_resource.resource_group["gateway"].name == "rg-${var.environment_name}-${local.resource_token}-gateway" && + length(azapi_resource.resource_group) == 3 && + local.names.models == "aimodels-${local.backend_token}" && + local.names.agents == "aiagents-${local.backend_token}" && + local.names.project == "proj-${local.backend_token}" && + local.names.storage == "st${local.backend_token}" && + local.names.registry == "acr${local.backend_token}" && + alltrue([for parent in [ + azapi_resource.models.parent_id, + azapi_resource.agents.parent_id, + azapi_resource.storage.parent_id, + azapi_resource.registry.parent_id, + azapi_resource.logs["agents"].parent_id, + azapi_resource.insights["agents"].parent_id + ] : parent == azapi_resource.resource_group["gateway"].id]) && + alltrue([for name in values(output.resource_groups) : name == output.resource_group_name]) + ) + error_message = "A backend revision must change only backend names, keep gateway identity and place new resources in the shared group while old groups are retained for staged cleanup." + } +} + +run "reject_invalid_backend_revision" { + command = plan + variables { + backend_revision = "Invalid_Revision" + } + expect_failures = [var.backend_revision] +} + +run "reject_empty_models" { + command = plan + variables { + model_deployments = {} + } + expect_failures = [var.model_deployments] +} + +run "private_gateway_and_model_paths" { + command = plan + variables { + enable_private_networking = true + gateway_public_network_access = "Disabled" + models_public_network_access = "Disabled" + enable_private_test_client = true + private_test_client_ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyNotForDeployment" + } + override_resource { + target = azapi_resource.private_subnet["gateway"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test/providers/Microsoft.Network/virtualNetworks/test/subnets/gateway" + } + } + + assert { + condition = ( + length(azapi_resource.resource_group) == 1 && + alltrue([for parent in concat([ + azapi_resource.private_vnet[0].parent_id, + azapi_resource.private_test_nic[0].parent_id, + azapi_resource.private_test_vm[0].parent_id + ], [for endpoint in azapi_resource.private_endpoint : endpoint.parent_id], + [for zone in azapi_resource.private_dns_zone : zone.parent_id], + [for nsg in azapi_resource.private_nsg : nsg.parent_id] + ) : parent == azapi_resource.resource_group["gateway"].id]) && + jsonencode(azapi_resource.private_test_key_role[0].body.properties.assignableScopes) == jsonencode([azapi_resource.resource_group["gateway"].id]) && + alltrue([for endpoint in azapi_resource.private_endpoint : jsonencode(endpoint.replace_triggers_refs) == jsonencode(["properties.privateLinkServiceConnections"])]) + ) + error_message = "Private networking, DNS and the test client must remain in the shared gateway group, replace endpoints when service targets change and preserve the custom role's assignable scope." + } + + assert { + condition = ( + azapi_resource.gateway.body.properties.virtualNetworkType == "External" && + azapi_resource.gateway.body.properties.virtualNetworkConfiguration.subnetResourceId == azapi_resource.private_subnet["gateway"].id && + azapi_resource.gateway.body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.models.body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.models.body.properties.networkAcls.defaultAction == "Deny" + ) + error_message = "Private traffic needs External plus a subnet; None plus a subnet silently loses the integration. Public switches must propagate." + } + + assert { + condition = ( + azapi_resource.private_subnet["gateway"].body.properties.delegations[0].properties.serviceName == "Microsoft.Web/serverFarms" && + azapi_resource.private_subnet["gateway"].body.properties.addressPrefix != azapi_resource.private_subnet["endpoints"].body.properties.addressPrefix && + azapi_resource.private_subnet["endpoints"].body.properties.privateEndpointNetworkPolicies == "Disabled" && + azapi_resource.private_endpoint["gateway"].body.properties.privateLinkServiceConnections[0].properties.groupIds[0] == "Gateway" && + azapi_resource.private_endpoint["models"].body.properties.privateLinkServiceConnections[0].properties.groupIds[0] == "account" && + length(azapi_resource.private_dns_zone) == 4 && + length(azapi_resource.private_dns_zone_group["models"].body.properties.privateDnsZoneConfigs) == 3 && + alltrue([for link in azapi_resource.private_dns_link : !link.body.properties.registrationEnabled]) + ) + error_message = "Separate subnets, the exact PE group IDs, all model DNS zones, and non-registering VNet links are required." + } + + assert { + condition = ( + !can(azapi_resource.private_test_nic[0].body.properties.ipConfigurations[0].properties.publicIPAddress) && + azapi_resource.private_test_vm[0].body.properties.osProfile.linuxConfiguration.disablePasswordAuthentication && + azapi_resource.private_nsg["client"].body.properties.securityRules[0].properties.access == "Deny" && + jsonencode(azapi_resource.private_test_key_role[0].body.properties.permissions[0].actions) == jsonencode(["Microsoft.ApiManagement/service/apiKeys/listSecrets/action"]) && + azapi_resource.agents.body.properties.publicNetworkAccess == "Enabled" && + azapi_resource.registry.body.properties.publicNetworkAccess == "Enabled" + ) + error_message = "The private probe must have no public NIC/password/inbound access, only key retrieval permission, and must not change agent networking." + } +} + +run "private_connectivity_poc_profile" { + command = plan + variables { + enable_private_networking = jsondecode(file("profiles/private-connectivity.tfvars.json")).enable_private_networking + gateway_private_endpoint_manual_approval = jsondecode(file("profiles/private-connectivity.tfvars.json")).gateway_private_endpoint_manual_approval + gateway_public_network_access = jsondecode(file("profiles/private-connectivity.tfvars.json")).gateway_public_network_access + models_public_network_access = jsondecode(file("profiles/private-connectivity.tfvars.json")).models_public_network_access + } + + override_resource { + target = azapi_resource.gateway + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test/providers/Microsoft.ApiManagement/service/gateway" + identity = { + type = "SystemAssigned" + principal_id = "00000000-0000-0000-0000-000000000003" + tenant_id = "00000000-0000-0000-0000-000000000004" + } + output = { + properties = { gatewayUrl = "https://test-gateway.azure-api.net" } + } + } + } + + override_resource { + target = azapi_resource.models + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/models" + output = { + properties = { endpoint = "https://test-models.cognitiveservices.azure.com/" } + } + } + } + + override_resource { + target = azapi_resource.private_subnet["gateway"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test/providers/Microsoft.Network/virtualNetworks/test/subnets/gateway" + } + } + + assert { + condition = ( + azapi_resource.gateway.body.sku.name == "AIGateway" && + azapi_resource.gateway.body.properties.publicNetworkAccess == "Enabled" && + azapi_resource.gateway.body.properties.virtualNetworkType == "External" && + azapi_resource.gateway.body.properties.virtualNetworkConfiguration.subnetResourceId == azapi_resource.private_subnet["gateway"].id && + azapi_resource.models.body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.models.body.properties.networkAcls.defaultAction == "Deny" + ) + error_message = "The hybrid profile must retain AIGateway, public gateway ingress, outbound integration and private models." + } + + assert { + condition = ( + length(azapi_resource.private_endpoint) == 2 && + length(azapi_resource.private_dns_zone) == 4 && + azapi_resource.private_endpoint["gateway"].body.properties.manualPrivateLinkServiceConnections[0].properties.privateLinkServiceId == azapi_resource.gateway.id && + !can(azapi_resource.private_endpoint["gateway"].body.properties.privateLinkServiceConnections) && + endswith(azapi_resource.private_endpoint["gateway"].name, "-gateway-manual-pe") && + azapi_resource.private_endpoint["gateway"].body.properties.manualPrivateLinkServiceConnections[0].name == "gateway-manual" && + jsonencode(azapi_resource.private_endpoint["gateway"].replace_triggers_refs) == jsonencode(["properties.manualPrivateLinkServiceConnections"]) && + length(azapi_resource_action.gateway_private_endpoint_approval) == 1 && + data.azapi_resource.gateway_private_endpoint_connection[0].parent_id == azapi_resource.gateway.id && + data.azapi_resource.gateway_private_endpoint_connection[0].name == "gateway-manual" && + azapi_resource_action.gateway_private_endpoint_approval[0].when == "apply" && + azapi_resource_action.gateway_private_endpoint_approval[0].resource_id == "${azapi_resource.gateway.id}/privateEndpointConnections/gateway-manual" && + azapi_resource.private_endpoint["models"].body.properties.privateLinkServiceConnections[0].properties.privateLinkServiceId == azapi_resource.models.id && + length(azapi_resource.private_test_vm) == 0 && + azapi_resource.agents.body.properties.publicNetworkAccess == "Enabled" && + azapi_resource.registry.body.properties.publicNetworkAccess == "Enabled" + ) + error_message = "The profile must wire both private endpoints/DNS without forcing a billable VM or changing hosted-agent networking." + } + + assert { + condition = ( + azapi_resource.models.body.properties.disableLocalAuth && + azapi_resource.foundry_provider.body.properties.foundry.authentication.kind == "ManagedIdentity" && + azapi_resource.foundry_provider.body.properties.foundry.authentication.managedIdentity.resource == "https://cognitiveservices.azure.com/" && + azapi_resource.role_assignment["gateway-foundry-user"].parent_id == azapi_resource.models.id && + azapi_resource.role_assignment["gateway-foundry-user"].body.properties.principalId == azapi_resource.gateway.identity[0].principal_id && + endswith(azapi_resource.role_assignment["gateway-foundry-user"].body.properties.roleDefinitionId, "/53ca6127-db72-4b80-b1b0-d745d6d5456d") + ) + error_message = "The private backend must keep local auth disabled and use the gateway identity with Foundry User at model-account scope." + } + + assert { + condition = ( + azapi_resource.runtime_api_key.parent_id == azapi_resource.gateway.id && + azapi_resource.telemetry_exporter.parent_id == "${azapi_resource.gateway.id}/workspaces/default" && + azapi_resource.telemetry_exporter.body.properties.kind == "OpenTelemetry" && + !azapi_resource.telemetry_exporter.body.properties.payloadCapture && + azapi_resource.insights["gateway"].body.properties.AzureMonitorWorkspaceIngestionMode == "Enabled" && + !can(azapi_resource.insights["agents"].body.properties.AzureMonitorWorkspaceIngestionMode) && + !can(azapi_resource.telemetry_exporter.body.properties.applicationInsights.connectionString) && + azapi_resource.telemetry_exporter.body.properties.applicationInsights.resourceId == azapi_resource.insights["gateway"].id && + azapi_resource.telemetry_exporter.body.properties.openTelemetry.metricsEndpoint == azapi_resource.insights["gateway"].output.properties.OTLPMetricsEndpoint && + azapi_resource.telemetry_exporter.body.properties.openTelemetry.logsEndpoint == azapi_resource.insights["gateway"].output.properties.OTLPLogsEndpoint && + azapi_resource.telemetry_exporter.body.properties.openTelemetry.tracesEndpoint == azapi_resource.insights["gateway"].output.properties.OTLPTracesEndpoint && + azapi_resource.telemetry_exporter.body.properties.openTelemetry.credentials.managedIdentity.resource == "https://monitor.azure.com" && + azapi_resource.role_assignment["gateway-monitoring-publisher"].parent_id == azapi_resource.insights["gateway"].output.properties.DataCollectionRuleResourceId && + azapi_resource.role_assignment["gateway-monitoring-publisher"].body.properties.principalId == azapi_resource.gateway.identity[0].principal_id && + endswith(azapi_resource.role_assignment["gateway-monitoring-publisher"].body.properties.roleDefinitionId, "/3913510d-42f4-4e42-8a64-420c390055eb") + ) + error_message = "The profile must retain gateway API-key provisioning and workspace telemetry without payload capture." + } +} + +run "private_only_profile" { + command = plan + variables { + enable_private_networking = jsondecode(file("profiles/private-only.tfvars.json")).enable_private_networking + gateway_private_endpoint_manual_approval = jsondecode(file("profiles/private-only.tfvars.json")).gateway_private_endpoint_manual_approval + gateway_public_network_access = jsondecode(file("profiles/private-only.tfvars.json")).gateway_public_network_access + models_public_network_access = jsondecode(file("profiles/private-only.tfvars.json")).models_public_network_access + } + + assert { + condition = ( + azapi_resource.gateway.body.sku.name == "AIGateway" && + azapi_resource.gateway.body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.gateway.body.properties.virtualNetworkType == "External" && + azapi_resource.models.body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.models.body.properties.networkAcls.defaultAction == "Deny" && + length(azapi_resource.private_endpoint) == 2 && + length(azapi_resource.private_dns_zone) == 4 && + length(azapi_resource.private_endpoint["gateway"].body.properties.manualPrivateLinkServiceConnections) == 1 && + length(azapi_resource_action.gateway_private_endpoint_approval) == 1 && + length(azapi_resource.private_test_vm) == 0 && + azapi_resource.agents.body.properties.publicNetworkAccess == "Enabled" && + azapi_resource.registry.body.properties.publicNetworkAccess == "Enabled" + ) + error_message = "The private-only end-state profile must disable gateway/model public access, retain private networking, and not imply private agent execution or force a VM." + } +} + +run "reject_private_client_without_network" { + command = plan + variables { + enable_private_test_client = true + private_test_client_ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyNotForDeployment" + } + + expect_failures = [var.enable_private_test_client] +} + +run "reject_manual_endpoint_without_network" { + command = plan + variables { + gateway_private_endpoint_manual_approval = true + } + expect_failures = [var.gateway_private_endpoint_manual_approval] +} + +run "reject_incomplete_otlp_destination" { + command = plan + override_resource { + target = azapi_resource.insights["gateway"] + override_during = plan + values = { + output = { + properties = { + DataCollectionRuleResourceId = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/managed-monitoring/providers/Microsoft.Insights/dataCollectionRules/gateway" + } + } + } + } + expect_failures = [azapi_resource.telemetry_exporter] +} + +run "reject_subscription_scoped_monitoring_publisher" { + command = plan + override_resource { + target = azapi_resource.insights["gateway"] + override_during = plan + values = { + output = { + properties = { + DataCollectionRuleResourceId = "/subscriptions/00000000-0000-0000-0000-000000000001" + OTLPMetricsEndpoint = "https://test.metrics.ingest.monitor.azure.com/dataCollectionRules/dcr-test/metrics" + OTLPLogsEndpoint = "https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/logs" + OTLPTracesEndpoint = "https://test.ingest.monitor.azure.com/dataCollectionRules/dcr-test/traces" + } + } + } + } + expect_failures = [azapi_resource.role_assignment["gateway-monitoring-publisher"]] +} + +run "reject_public_disable_without_network" { + command = plan + variables { + gateway_public_network_access = "Disabled" + models_public_network_access = "Disabled" + } + expect_failures = [var.gateway_public_network_access, var.models_public_network_access] +} + +run "additional_private_hosted_agent" { + command = plan + variables { + location = "swedencentral" + enable_private_networking = true + enable_private_hosted_agent = true + gateway_public_network_access = "Disabled" + models_public_network_access = "Disabled" + developer_principal_id = "00000000-0000-0000-0000-000000000005" + } + override_resource { + target = azapi_resource.private_subnet["agents"] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.Network/virtualNetworks/test/subnets/agents" + } + } + override_resource { + target = azapi_resource.private_agents[0] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.CognitiveServices/accounts/private-agents" + } + } + override_resource { + target = azapi_resource.private_project[0] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.CognitiveServices/accounts/private-agents/projects/private" + identity = { + type = "SystemAssigned" + principal_id = "00000000-0000-0000-0000-000000000006" + tenant_id = "00000000-0000-0000-0000-000000000007" + } + } + } + assert { + condition = ( + azapi_resource.agents.body.properties.publicNetworkAccess == "Enabled" && + !can(azapi_resource.agents.body.properties.networkInjections) && + azapi_resource.project.name == local.names.project && + output.project_endpoint == "https://${local.names.agents}.services.ai.azure.com/api/projects/${local.names.project}" && + azapi_resource.registry.body.properties.publicNetworkAccess == "Enabled" && + length(azapi_resource.model_deployment) == 1 + ) + error_message = "The opt-in extension must retain the original account/project, registry access, compatibility output and existing models." + } + assert { + condition = ( + azapi_resource.private_agents[0].name != azapi_resource.agents.name && + azapi_resource.private_agents[0].body.properties.publicNetworkAccess == "Disabled" && + azapi_resource.private_agents[0].body.properties.disableLocalAuth && + azapi_resource.private_agents[0].body.properties.networkAcls.defaultAction == "Deny" && + azapi_resource.private_agents[0].body.properties.networkInjections[0].scenario == "agent" && + !azapi_resource.private_agents[0].body.properties.networkInjections[0].useMicrosoftManagedNetwork && + azapi_resource.private_agents[0].body.properties.networkInjections[0].subnetArmId == azapi_resource.private_subnet["agents"].id + ) + error_message = "The new account must be private and Entra-only with BYO agent injection in its creation body." + } + assert { + condition = ( + length(azapi_resource.private_subnet) == 4 && + azapi_resource.private_subnet["agents"].body.properties.addressPrefix == "10.247.3.0/24" && + azapi_resource.private_subnet["agents"].body.properties.delegations[0].properties.serviceName == "Microsoft.App/environments" && + azapi_resource.private_subnet["gateway"].body.properties.delegations[0].properties.serviceName == "Microsoft.Web/serverFarms" && + length(azapi_resource.private_nsg["agents"].body.properties.securityRules) == 4 && + length(azapi_resource.private_endpoint) == 2 && + length(azapi_resource.private_agent_endpoint) == 2 && + length(azapi_resource.private_dns_zone) == 5 && + azapi_resource.private_agent_endpoint["agents"].body.properties.privateLinkServiceConnections[0].properties.groupIds[0] == "account" && + azapi_resource.private_agent_endpoint["registry"].body.properties.privateLinkServiceConnections[0].properties.groupIds[0] == "registry" && + local.private_dns_zones.registry == "privatelink.azurecr.io" + ) + error_message = "Use a dedicated agent subnet, retain gateway delegation and add only the Foundry/registry private paths." + } + assert { + condition = ( + length(azapi_resource.private_project_capability_host[0].body.properties) == 1 && + azapi_resource.private_project_capability_host[0].body.properties.capabilityHostKind == "Agents" && + length(azapi_resource.private_agent_role_assignment) == 3 && + azapi_resource.private_agent_role_assignment["developer-project"].parent_id == azapi_resource.private_project[0].id && + azapi_resource.private_agent_role_assignment["developer-account-reader"].parent_id == azapi_resource.private_agents[0].id && + azapi_resource.private_registry_connection[0].body.properties.credentials.clientId == "00000000-0000-0000-0000-000000000006" && + length(azapi_resource.private_agent_key_binding) == 0 && + !output.private_hosted_agent.runtime_key_binding_configured && + azapi_resource.private_agent_key_role[0].body.properties.permissions[0].actions[0] == "Microsoft.ApiManagement/service/apiKeys/listSecrets/action" + ) + error_message = "Use Basic platform-managed data, scoped roles, the new project registry identity and no placeholder runtime key binding." + } +} + +run "private_agent_runtime_reuses_existing_key_role" { + command = plan + variables { + location = "swedencentral" + enable_private_networking = true + enable_private_hosted_agent = true + enable_private_test_client = true + private_test_client_ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyNotForDeployment" + private_hosted_agent_runtime_principal_id = "00000000-0000-0000-0000-000000000008" + } + override_resource { + target = azapi_resource.private_test_key_role[0] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-0000-0000-000000000009" + } + } + override_resource { + target = azapi_resource.gateway + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.ApiManagement/service/gateway" + output = { + properties = { gatewayUrl = "https://gateway.azure-api.net" } + } + } + } + assert { + condition = ( + length(azapi_resource.private_agent_key_role) == 0 && + length(azapi_resource.private_agent_key_binding) == 1 && + azapi_resource.private_agent_key_binding[0].parent_id == azapi_resource.gateway.id && + azapi_resource.private_agent_key_binding[0].body.properties.roleDefinitionId == azapi_resource.private_test_key_role[0].id && + azapi_resource.private_agent_key_binding[0].body.properties.principalId == var.private_hosted_agent_runtime_principal_id && + azapi_resource.private_agent_key_binding[0].body.properties.principalType == "ServicePrincipal" && + output.private_hosted_agent.runtime_key_binding_configured + ) + error_message = "Only bind the supplied runtime identity at gateway scope, reusing the existing narrow key-reader role." + } + assert { + condition = ( + azapi_resource.private_agent_key_binding[0].name == uuidv5("url", "${azapi_resource.gateway.id}/${var.private_hosted_agent_runtime_principal_id}/${azapi_resource.private_test_key_role[0].name}") && + azapi_resource.private_agent_key_binding[0].name != uuidv5("url", "${azapi_resource.gateway.id}/${var.private_hosted_agent_runtime_principal_id}/${uuidv5("url", "${azapi_resource.resource_group["gateway"].id}/private-agent-key-reader")}") + ) + error_message = "Enabling the test VM must use a role-specific assignment identity distinct from the dedicated agent-role assignment." + } +} + +run "reject_private_agent_without_network" { + command = plan + variables { + location = "swedencentral" + enable_private_hosted_agent = true + } + + expect_failures = [var.enable_private_hosted_agent] +} + +run "private_agent_runtime_without_probe_vm" { + command = plan + variables { + location = "swedencentral" + enable_private_networking = true + enable_private_hosted_agent = true + private_hosted_agent_runtime_principal_id = "00000000-0000-0000-0000-000000000008" + gateway_access_principals = { + independent-caller = { principal_id = "00000000-0000-0000-0000-000000000009" } + } + } + override_resource { + target = azapi_resource.private_agent_key_role[0] + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-0000-0000-000000000010" + } + } + override_resource { + target = azapi_resource.gateway + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.ApiManagement/service/gateway" + output = { + properties = { gatewayUrl = "https://gateway.azure-api.net" } + } + } + } + assert { + condition = ( + length(azapi_resource.private_test_key_role) == 0 && + length(azapi_resource.role_assignment) == 8 && + azapi_resource.runtime_api_key.name == "default" && + azapi_resource.role_assignment["gateway-models-user-independent-caller"].body.properties.principalId == var.gateway_access_principals["independent-caller"].principal_id && + length(azapi_resource.private_agent_role_assignment) == 1 && + length(azapi_resource.private_agent_key_role[0].body.properties.permissions[0].actions) == 1 && + length(azapi_resource.private_agent_key_role[0].body.properties.permissions[0].dataActions) == 0 && + azapi_resource.private_agent_key_binding[0].body.properties.roleDefinitionId == azapi_resource.private_agent_key_role[0].id && + output.private_hosted_agent.key_reader_role_definition_id == azapi_resource.private_agent_key_role[0].id + ) + error_message = "Without a probe VM, use only the dedicated narrow runtime key role and omit optional developer grants." + } + assert { + condition = ( + azapi_resource.private_agent_key_binding[0].name == uuidv5("url", "${azapi_resource.gateway.id}/${var.private_hosted_agent_runtime_principal_id}/${azapi_resource.private_agent_key_role[0].name}") && + azapi_resource.private_agent_key_binding[0].name != uuidv5("url", "${azapi_resource.gateway.id}/${var.private_hosted_agent_runtime_principal_id}/${uuidv5("url", "${azapi_resource.resource_group["gateway"].id}/private-test-key-reader")}") && + azapi_resource.private_agent_key_binding[0].parent_id == azapi_resource.gateway.id && + azapi_resource.private_agent_key_binding[0].body.properties.principalId == var.private_hosted_agent_runtime_principal_id + ) + error_message = "Disabling the test VM must restore a distinct dedicated-role assignment while preserving gateway scope and runtime principal." + } +} + +run "reject_private_agent_region_mismatch" { + command = plan + variables { + enable_private_networking = true + enable_private_hosted_agent = true + } + expect_failures = [var.enable_private_hosted_agent] +} + +run "reject_private_agent_non_rfc1918" { + command = plan + variables { + location = "swedencentral" + enable_private_networking = true + enable_private_hosted_agent = true + private_network_address_prefix = "100.64.0.0/16" + } + expect_failures = [var.enable_private_hosted_agent] +} + +run "reject_runtime_identity_without_private_agent" { + command = plan + variables { + private_hosted_agent_runtime_principal_id = "00000000-0000-0000-0000-000000000008" + } + expect_failures = [var.private_hosted_agent_runtime_principal_id] +} + +run "reject_malformed_runtime_identity" { + command = plan + variables { + location = "swedencentral" + enable_private_networking = true + enable_private_hosted_agent = true + private_hosted_agent_runtime_principal_id = "not-a-principal-id" + } + expect_failures = [var.private_hosted_agent_runtime_principal_id] +} + +run "additive_gateway_entra_access" { + command = plan + variables { + gateway_access_principals = { + application = { principal_id = "00000000-0000-0000-0000-000000000002" } + developer = { + principal_id = "00000000-0000-0000-0000-000000000003" + principal_type = "User" + } + team = { + principal_id = "00000000-0000-0000-0000-000000000004" + principal_type = "Group" + } + } + } + override_resource { + target = azapi_resource.gateway + override_during = plan + values = { + id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/shared-gateway/providers/Microsoft.ApiManagement/service/test" + output = { + properties = { gatewayUrl = "https://gateway.example" } + } + } + } + assert { + condition = ( + length(azapi_resource.role_assignment) == 10 && + alltrue([for key, principal in var.gateway_access_principals : + azapi_resource.role_assignment["gateway-models-user-${key}"].parent_id == azapi_resource.gateway.id && + azapi_resource.role_assignment["gateway-models-user-${key}"].body.properties.principalId == principal.principal_id && + azapi_resource.role_assignment["gateway-models-user-${key}"].body.properties.principalType == principal.principal_type && + azapi_resource.role_assignment["gateway-models-user-${key}"].body.properties.roleDefinitionId == "${local.subscription_scope}/providers/Microsoft.Authorization/roleDefinitions/a6b68e3d-a1f0-4c60-8678-237c050cf4ea" && + azapi_resource.role_assignment["gateway-models-user-${key}"].name == uuidv5("url", "${azapi_resource.gateway.id}/${principal.principal_id}/a6b68e3d-a1f0-4c60-8678-237c050cf4ea") + ]) && + azapi_resource.role_assignment["gateway-models-user-application"].body.properties.principalType == "ServicePrincipal" && + azapi_resource.runtime_api_key.name == "default" && + azapi_resource.gateway.body.properties.publicNetworkAccess == "Enabled" + ) + error_message = "Entra caller bindings must be deterministic gateway-scoped model-invocation grants for the supplied identities, without changing API keys, networking or backend roles." + } +} + +run "reject_invalid_gateway_caller_id" { + command = plan + variables { + gateway_access_principals = { caller = { principal_id = "not-an-object-id" } } + } + expect_failures = [var.gateway_access_principals] +} + +run "reject_invalid_gateway_caller_type" { + command = plan + variables { + gateway_access_principals = { + caller = { + principal_id = "00000000-0000-0000-0000-000000000002" + principal_type = "Application" + } + } + } + expect_failures = [var.gateway_access_principals] +} + +run "reject_duplicate_gateway_callers" { + command = plan + variables { + gateway_access_principals = { + first = { principal_id = "aaaaaaaa-0000-0000-0000-000000000002" } + second = { principal_id = "AAAAAAAA-0000-0000-0000-000000000002" } + } + } + expect_failures = [var.gateway_access_principals] +} diff --git a/samples/terraform/tests/test_private_path_probe.py b/samples/terraform/tests/test_private_path_probe.py new file mode 100644 index 0000000..e75f08b --- /dev/null +++ b/samples/terraform/tests/test_private_path_probe.py @@ -0,0 +1,114 @@ +import contextlib +import importlib.util +import io +import ipaddress +import json +import urllib.error +from pathlib import Path +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location( + "probe", Path(__file__).parents[1] / "scripts" / "private-path-probe.py" +) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + + +class PrivatePathProbeTests(unittest.TestCase): + def test_rejects_public_or_wrong_private_dns(self): + for addresses in (["10.247.1.4", "20.10.0.1"], ["10.20.1.4"], []): + records = [(2, 1, 6, "", (address, 443)) for address in addresses] + with self.subTest(addresses=addresses), patch.object(probe.socket, "getaddrinfo", return_value=records): + with self.assertRaises(RuntimeError): + probe.private_dns("gateway.example", ipaddress.ip_network("10.247.1.0/24")) + + def run_probe(self, replies): + configuration = { + "gateway_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.ApiManagement/service/test", + "gateway_endpoint": "https://gateway.example", + "model_endpoint": "https://model.example", + "private_endpoint_subnet": "10.247.1.0/24", + "model_names": ["mini", "full"], + "model_apis": { + model: { + "api_format": "OpenAIChatCompletions", + "endpoints": ["/openai/v1/chat/completions", "/openai/v1/responses"], + } + for model in ("mini", "full") + }, + } + responses = [ + (200, {"access_token": "test-token-must-not-appear"}), + (200, {"primaryKey": "test-key-must-not-appear"}), + ] + [(200, {"choices": [{"message": {"content": reply}}]}) for reply in replies] + [ + urllib.error.HTTPError("https://gateway.example", 401, "Unauthorized", {}, None), + urllib.error.HTTPError("https://gateway.example", 401, "Unauthorized", {}, None), + ] + output = io.StringIO() + with patch.object(probe.sys, "argv", ["probe", "--configuration", json.dumps(configuration)]), \ + patch.object(probe.socket, "getaddrinfo", return_value=[(2, 1, 6, "", ("10.247.1.4", 443))]), \ + patch.object(probe, "request_json", side_effect=responses), contextlib.redirect_stdout(output): + probe.main() + return output.getvalue() + + def test_checks_all_aliases_without_emitting_credentials(self): + output = self.run_probe(["OK", "OK"]) + result = json.loads(output) + self.assertTrue(result["passed"]) + self.assertEqual(["mini", "full"], [entry["model"] for entry in result["inference"]]) + self.assertEqual([401, 401], [entry["status"] for entry in result["authentication"]]) + for entry in result["inference"]: + self.assertRegex(entry["trace_id"], r"^[a-f0-9]{32}$") + self.assertNotIn("test-token-must-not-appear", output) + self.assertNotIn("test-key-must-not-appear", output) + + def test_rejects_success_status_with_wrong_model_reply(self): + with self.assertRaisesRegex(RuntimeError, "Inference failed for full"): + self.run_probe(["OK", "unexpected"]) + + def test_negative_auth_requires_a_real_rejection(self): + for code, message, accepted in ( + (401, "Unauthorized", True), + (403, "Invalid API key", True), + (403, "Blocked by firewall", False), + (500, "Internal error", False), + (429, "Rate limited", False), + ): + error = urllib.error.HTTPError( + "https://gateway.example", code, message, {}, + io.BytesIO(json.dumps({"message": message}).encode()), + ) + with self.subTest(code=code, message=message), \ + patch.object(probe, "request_json", side_effect=error): + if accepted: + self.assertEqual(code, probe.check_key_rejection(None, "https://gateway.example", {}, {})) + else: + with self.assertRaisesRegex(RuntimeError, "not proven"): + probe.check_key_rejection(None, "https://gateway.example", {}, {}) + with patch.object(probe, "request_json", return_value=(200, {})): + with self.assertRaisesRegex(RuntimeError, "accepted"): + probe.check_key_rejection(None, "https://gateway.example", {}, {}) + + def test_rejects_unsupported_apis_before_dns_or_credentials(self): + for metadata in ( + None, + {}, + {"mini": {"api_format": "OpenAIChatCompletions", "endpoints": ["/openai/v1/responses"]}}, + {"mini": {"api_format": "Other", "endpoints": ["/openai/v1/chat/completions"]}}, + {"mini": {"api_format": "OpenAIChatCompletions", "endpoints": []}}, + ): + configuration = {"model_names": ["mini"], "model_apis": metadata} + with self.subTest(metadata=metadata), \ + patch.object(probe.sys, "argv", ["probe", "--configuration", json.dumps(configuration)]), \ + patch.object(probe.socket, "getaddrinfo") as dns, \ + patch.object(probe, "request_json") as request: + with self.assertRaisesRegex(RuntimeError, "metadata|Unsupported probe API"): + probe.main() + dns.assert_not_called() + request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/samples/terraform/variables.tf b/samples/terraform/variables.tf new file mode 100644 index 0000000..9aa5771 --- /dev/null +++ b/samples/terraform/variables.tf @@ -0,0 +1,278 @@ +variable "subscription_id" { + description = "Azure subscription containing the shared resource group." + type = string + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) + error_message = "subscription_id must be a UUID." + } +} + +variable "gateway_access_principals" { + description = "Optional caller identities granted AI Gateway Models User at gateway scope. Additive Entra access for all gateway models; API keys and existing agent/test-VM authentication remain unchanged." + type = map(object({ + principal_id = string + principal_type = optional(string, "ServicePrincipal") + })) + default = {} + nullable = false + validation { + condition = alltrue([for principal in var.gateway_access_principals : try( + can(regex("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", principal.principal_id)) && + contains(["User", "Group", "ServicePrincipal"], principal.principal_type), false + )]) + error_message = "Each gateway caller requires an Entra object UUID and principal_type User, Group or ServicePrincipal." + } + validation { + condition = try( + length(distinct([for principal in var.gateway_access_principals : lower(principal.principal_id)])) == length(var.gateway_access_principals), + false + ) + error_message = "Each gateway caller object ID must appear only once, regardless of letter case." + } +} + +variable "environment_name" { + description = "Short environment label used in the shared resource group name and tags." + type = string + default = "aigateway" + validation { + condition = can(regex("^[a-z][a-z0-9-]{0,31}$", var.environment_name)) + error_message = "Use 1-32 lowercase letters, numbers, or hyphens, starting with a letter." + } +} + +variable "backend_revision" { + description = "Optional stable revision for deliberate backend recreation with fresh names. Does not rename the gateway or shared group. Changing it replaces backend resources." + type = string + default = "" + validation { + condition = var.backend_revision == "" || can(regex("^[a-z0-9][a-z0-9-]{0,31}$", var.backend_revision)) + error_message = "backend_revision must be empty or 1-32 lowercase letters, digits or hyphens, starting with a letter or digit." + } +} + +variable "retain_legacy_resource_groups" { + description = "Migration-only safety switch: retain the old empty Models/Agents groups during backend rebuild. Set false after acceptance and inventory review; new resources always use the gateway group." + type = bool + default = false +} + +variable "location" { + description = "Region supporting Foundry hosted agents, the selected models, storage, ACR, and monitoring. Confirm availability before applying." + type = string + validation { + condition = length(trimspace(var.location)) > 0 + error_message = "location must not be empty." + } +} + +variable "gateway_location" { + description = "Region in which the subscription is enabled for the preview AIGateway SKU." + type = string + validation { + condition = length(trimspace(var.gateway_location)) > 0 + error_message = "gateway_location must not be empty." + } +} + +variable "storage_public_network_access" { + description = "Storage public network access. Use Disabled when required by Azure Policy; private connectivity must then be provided separately for hosted agents." + type = string + default = "Enabled" + validation { + condition = contains(["Enabled", "Disabled"], var.storage_public_network_access) + error_message = "storage_public_network_access must be Enabled or Disabled." + } +} + +variable "enable_private_networking" { + description = "Create gateway/model private endpoints, DNS, and gateway outbound VNet integration. Does not by itself enable private hosted-agent execution." + type = bool + default = false +} + +variable "enable_private_hosted_agent" { + description = "Add a separate private Foundry account/project with Basic VNet injection, delegated subnet and private registry access. Keeps the original agent account and outputs unchanged; does not deploy an agent." + type = bool + default = false + validation { + condition = !var.enable_private_hosted_agent || ( + var.enable_private_networking && lower(var.location) == lower(var.gateway_location) + ) + error_message = "Private hosted agents require private networking and the same Foundry/VNet region (location must equal gateway_location)." + } + validation { + condition = !var.enable_private_hosted_agent || can(regex( + "^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)", var.private_network_address_prefix + )) + error_message = "The private hosted-agent VNet must use RFC1918 IPv4 address space." + } +} + +variable "private_hosted_agent_runtime_principal_id" { + description = "Optional actual managed-identity object ID discovered after private agent deployment. Grants gateway key retrieval at gateway scope only; never supply the old agent identity as a placeholder." + type = string + default = "" + validation { + condition = var.private_hosted_agent_runtime_principal_id == "" || ( + var.enable_private_hosted_agent && + can(regex("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", var.private_hosted_agent_runtime_principal_id)) + ) + error_message = "Supply an actual Entra object UUID only when enable_private_hosted_agent is true, or leave the runtime principal empty." + } +} + +variable "private_network_address_prefix" { + description = "Non-overlapping IPv4 /16 for the optional PoC VNet; three /24 subnets, plus subnet index 3 for optional private hosted agents, are derived from it." + type = string + default = "10.247.0.0/16" + validation { + condition = can(cidrnetmask(var.private_network_address_prefix)) && endswith(var.private_network_address_prefix, "/16") + error_message = "private_network_address_prefix must be a valid IPv4 /16." + } +} + +variable "gateway_public_network_access" { + description = "Gateway public ingress. For an existing gateway, select Disabled after private endpoint approval and private-route verification; preserve that end state on subsequent plans. First-create and Terraform update behavior require live validation." + type = string + default = "Enabled" + validation { + condition = ( + contains(["Enabled", "Disabled"], var.gateway_public_network_access) && + (var.gateway_public_network_access != "Disabled" || var.enable_private_networking) + ) + error_message = "Use Enabled or Disabled; disabling requires enable_private_networking." + } +} + +variable "models_public_network_access" { + description = "Keep Enabled until gateway outbound private connectivity is ready, then set Disabled and retest." + type = string + default = "Enabled" + validation { + condition = ( + contains(["Enabled", "Disabled"], var.models_public_network_access) && + (var.models_public_network_access != "Disabled" || var.enable_private_networking) + ) + error_message = "Use Enabled or Disabled; disabling requires enable_private_networking." + } +} + +variable "gateway_private_endpoint_manual_approval" { + description = "Use a separately named manual-request gateway endpoint and approve it after creation. Changing this replaces only the gateway private endpoint; review migration first." + type = bool + default = false + validation { + condition = !var.gateway_private_endpoint_manual_approval || var.enable_private_networking + error_message = "Manual gateway endpoint approval requires enable_private_networking." + } +} + +variable "enable_private_test_client" { + description = "Create a billable private Ubuntu test VM with outbound-only internet, no public IP and narrowly scoped API-key retrieval access." + type = bool + default = false + validation { + condition = !var.enable_private_test_client || var.enable_private_networking + error_message = "The private test client requires enable_private_networking." + } +} + +variable "private_test_client_ssh_public_key" { + description = "OpenSSH public key required only when the test VM is enabled. No private key or password belongs in Terraform." + type = string + default = "" + validation { + condition = ( + !var.enable_private_test_client || + can(regex("^ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( .*)?$", trimspace(var.private_test_client_ssh_public_key))) + ) + error_message = "Supply an OpenSSH ed25519 or RSA public key when enabling the private test client." + } +} + +variable "private_test_client_vm_size" { + description = "Small test VM SKU; verify regional quota and availability before enabling." + type = string + default = "Standard_B2s_v2" +} + +variable "publisher_email" { + description = "Publisher contact email for the API Management gateway." + type = string + validation { + condition = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.publisher_email)) + error_message = "publisher_email must be an email address." + } +} + +variable "publisher_name" { + description = "Publisher display name for the gateway." + type = string + default = "AI Gateway" +} + +variable "model_deployments" { + description = "Foundry deployments keyed by ARM-safe deployment name. Select model versions, deployment SKUs, and quota available to your subscription." + type = map(object({ + model_name = string + model_version = string + model_format = optional(string, "OpenAI") + sku_name = optional(string, "GlobalStandard") + capacity = number + token_limit = number + api_format = optional(string, "OpenAIChatCompletions") + endpoints = optional(list(string), ["/openai/v1/chat/completions", "/openai/v1/responses"]) + description = optional(string, "Foundry model routed through AI Gateway.") + })) + validation { + condition = length(var.model_deployments) > 0 && alltrue([ + for name, model in var.model_deployments : + can(regex("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", name)) && + length(trimspace(model.model_name)) > 0 && + length(trimspace(model.model_version)) > 0 && + model.capacity >= 1 && floor(model.capacity) == model.capacity && + model.token_limit >= 1 && floor(model.token_limit) == model.token_limit && + length(model.endpoints) > 0 + ]) + error_message = "Provide at least one model with an ARM-safe name, explicit model name/version, positive integer capacity/token_limit, and at least one endpoint." + } +} + +variable "developer_principal_id" { + description = "Optional Entra object ID for development access. Empty disables all developer role assignments." + type = string + default = "" + validation { + condition = var.developer_principal_id == "" || can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.developer_principal_id)) + error_message = "developer_principal_id must be empty or a UUID." + } +} + +variable "developer_principal_type" { + description = "Entra principal type for optional development access." + type = string + default = "User" + validation { + condition = contains(["User", "Group", "ServicePrincipal"], var.developer_principal_type) + error_message = "Use User, Group, or ServicePrincipal." + } +} + +variable "project_display_name" { + description = "Display name for the Foundry hosted-agent project." + type = string + default = "AI Gateway hosted agents" +} + +variable "project_description" { + description = "Description of the hosted-agent project." + type = string + default = "Hosted-agent infrastructure with a separate Foundry model account and AI Gateway." +} + +variable "tags" { + description = "Additional tags for supported Azure resources." + type = map(string) + default = {} +} diff --git a/samples/terraform/versions.tf b/samples/terraform/versions.tf new file mode 100644 index 0000000..d3bf791 --- /dev/null +++ b/samples/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.11.0, < 2.0.0" + + required_providers { + azapi = { + source = "Azure/azapi" + version = "~> 2.13.0" + } + } +} + +provider "azapi" { + subscription_id = var.subscription_id +}