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..cd21659 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,8 +1,8 @@ # 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). Samples are linked from their own repositories, with a local Terraform infrastructure sample below. | 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). | +| IaC only | [Bicep](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/ai-gateway) / [Terraform](terraform/README.md) | Infrastructure as code for AI Gateway, Foundry Models, and Foundry Hosted Agent infrastructure. Bicep 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). The Terraform sample provisions all three building blocks together and includes an optional [private-connectivity PoC profile](terraform/docs/private-connectivity.md) (gateway public access remains enabled). | diff --git a/samples/terraform/.gitignore b/samples/terraform/.gitignore new file mode 100644 index 0000000..5178412 --- /dev/null +++ b/samples/terraform/.gitignore @@ -0,0 +1,15 @@ +.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +!profiles/private-connectivity.tfvars.json +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +__pycache__/ +acceptance-results/ 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..a6e2898 --- /dev/null +++ b/samples/terraform/README.md @@ -0,0 +1,202 @@ +# AI Gateway and Foundry with Terraform + +Manage an Azure AI Gateway that routes calls to Foundry models. This is standalone +Terraform for the [Foundry AI Gateway Python sample](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway), not an `azd` app. + +> **Preview PoC, not production.** The AI Gateway preview has **no SLA**. Everything +> here is billable (model calls, Premium ACR, Storage, monitoring). Read the +> [limitations](docs/reference.md#limitations) before applying. + +> **Existing deployments only.** This sample retains the legacy +> `Microsoft.ApiManagement/service` resource with SKU `AIGateway`. New gateways +> cannot be created with that SKU, and existing ones must migrate before +> **January 15, 2027**. This sample does not perform that migration. +> See [resource type and lifecycle](docs/reference.md#resource-type-and-lifecycle). + +## Choose your networking path + +The sample includes **both public and private connectivity**. The public path is +the Terraform default; the private profile adds private endpoints and routing. + +| Path | Private endpoints | Gateway public access | Model public access | +| --- | --- | --- | --- | +| [Private connectivity](#private-connectivity-path) | Gateway and model account | **Enabled** | **Disabled** after staging | +| [Public endpoints](#public-endpoint-path) | None | Enabled | Enabled | + +**Pending: private-only gateway ingress (product-group investigation).** The +observed failure when disabling public access is being investigated with the +product group. No fix has been verified for this public-preview sample yet. +The private profile is intended to provide a private route while the gateway's +public endpoint remains **Enabled**. Verify both endpoints, DNS and inference +after every deployment; applying this profile alone is not proof that the route +works. Do not treat it as private-only. See the [status and observed behavior](docs/reference.md#known-blocker-private-only-gateway-ingress). + +**Already using the private profile?** Follow [existing private deployments](docs/private-connectivity.md#existing-private-deployments). +Keep the profile on every plan, including drift checks. Do not follow the public +path or repeat the temporary-public staging step for an already-private backend. + +## Before you start + +**Check these first:** + +- [ ] Terraform 1.11+ (below 2.0) and Azure CLI installed +- [ ] Existing legacy AIGateway deployment and subscription access to Foundry hosted agents in your regions +- [ ] Permission to create resources and role assignments (for example, Contributor plus Role Based Access Control Administrator) +- [ ] If enabling the test VM: permission to manage its **custom role definition**, not just role assignments; see [test-client permissions](docs/private-connectivity.md#test-client-permissions) +- [ ] Providers registered: `Microsoft.ApiManagement`, `Microsoft.Web`, `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.ContainerRegistry`, `Microsoft.OperationalInsights`, `Microsoft.Insights`, `Microsoft.Monitor` (this sample does not register them for you) +- [ ] Quota for the model and SKU in your region + +**Decide before you start:** + +- **Public endpoints OK?** If your policy prohibits public gateway or model endpoints, even temporarily, do not run this sample unchanged. +- **Need private hosted-agent execution?** Set it when the Foundry account is created. This sample does not configure it, and you cannot add it to an existing account later. +- **Need private-only gateway ingress?** Pending product-group investigation; the current profile does not meet this requirement. See the [status and observed behavior](docs/reference.md#known-blocker-private-only-gateway-ingress). + +## What gets deployed + +| Component | Purpose | +| --- | --- | +| **AI Gateway** | Front door: checks the runtime API key, applies token limits, routes to a model alias, records telemetry. It does not run models. | +| **Foundry model account** | Runs the models. The gateway authenticates with its managed identity, not an API key. | +| **Foundry hosted-agent account and project** | Prepared for a *later* agent app: Storage, Premium Container Registry, roles and connections. **No agent, image or running container is deployed.** | +| **Private network (private path)** | Gateway and model private endpoints, DNS, and gateway outbound VNet integration. See [private connectivity](docs/private-connectivity.md). | +| **Optional private test VM** | Probe client for the private path. It is not required if you already have a suitable private client. | + +Both paths manage all three core components. Hosted-agent infrastructure is +included even if you only test model calls. + +All application resources use **one shared resource group**: +`rg---gateway`. The existing gateway group name +and Terraform address are retained; the AIGateway SKU, private-network design and +agent monitoring are unchanged. Gateway OTLP enablement can create **Azure-managed +monitoring helper groups**, with one gateway publishing role on the linked DCR. +This explicit exception, its costs and the required two-stage setup are covered +in [gateway monitoring](docs/monitoring.md). + +**Upgrading from the former three-group layout is a migration, not a harmless +refactor.** Terraform can propose backend replacements and deletion of the old +groups. Do not apply until the exact migration/recreation and any data loss have +been approved. See [single-group consolidation](docs/reference.md#single-group-consolidation). + +**Authentication in both paths** (independent of public or private routing): + +```text +Client --(runtime API key)--> AI Gateway --(managed identity)--> Foundry model account +``` + +A **model alias** (for example, `gpt-mini`) is the name clients send. It does not have to match the underlying model name (for example, `gpt-4.1-mini`). + +## Shared setup + +Run from `samples/terraform` for either networking path. Use the existing +deployment's input file and Terraform state/backend. Do not start with an empty +state for resources already managed by this sample. + +**1. Sign in:** + +```powershell +az login +``` + +**2. Review the existing `terraform.tfvars`:** + +| Setting | What to set | +| --- | --- | +| `subscription_id` | Where resources are created | +| `environment_name` | Existing deployment label; changing it changes resource names | +| `location`, `gateway_location` | Foundry and gateway regions (independent of each other) | +| `publisher_email` | Gateway publisher contact | +| `model_deployments` | Model versions, capacity and token limits. Keys (`gpt-mini`, `gpt-full`) become client-facing aliases. | +| `storage_public_network_access` | `Disabled` only if your policy requires it | + +This file is git-ignored. Do not overwrite it with +[terraform.tfvars.example](terraform.tfvars.example), which documents the input +shape only. Optional settings are in the [input reference](docs/reference.md#inputs). + +**3. Initialize and check:** + +```powershell +az account set --subscription "" +terraform init +terraform fmt -check -recursive +terraform validate +``` + +**4. Bootstrap or upgrade gateway monitoring:** follow +[gateway monitoring](docs/monitoring.md) to enable the existing Insights +destination and verify its actual endpoints before the exporter/role plan. +Preserve your networking profile and review each saved plan. This adds +service-managed resources and is not a cost-free or fully reversible toggle. + +Then continue with **one** of the networking paths below. + +## Private connectivity path + +Use [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) +for private client-to-gateway and gateway-to-model traffic: + +```text +Private client -> gateway private endpoint -> gateway outbound VNet integration -> model private endpoint +``` + +Follow the [private-connectivity guide](docs/private-connectivity.md) for the +staged plan/apply, private probe, and VM lifecycle. The gateway public endpoint +stays enabled; model public access is disabled in the final profile. + +**Do not run the public-path plan below for this configuration.** Omitting the +private profile can plan removal of private networking or re-enable model public +access, depending on your other inputs. + +## Public endpoint path + +Use this path only for a deployment intentionally configured without private +networking. It is not a prerequisite for the private path. + +**1. Plan and review:** + +```powershell +terraform plan "-out=deployment.tfplan" +``` + +Stop if planning fails. Review the plan for public access, resource creation and +cost. Do not apply unexpected changes. + +**2. Apply the reviewed plan:** + +```powershell +terraform apply "deployment.tfplan" +``` + +**3. Test every alias:** + +```powershell +.\scripts\Test-ModelAliases.ps1 +``` + +Each alias should print a short reply, for example `gpt-mini: Hello!`. +This check uses your machine and public endpoints. It does **not** prove private routing. +The example calls every configured alias and assumes chat-completions models, +such as the two models in `terraform.tfvars.example`. Both test scripts reject +missing or incompatible API metadata before making model calls; they do not +silently skip aliases. See [probe compatibility](docs/reference.md#probe-compatibility) +for supported request parameters and updating older outputs. For other model +types, use their supported API and request body instead. + +**Done when:** every alias returns a response and `terraform plan -detailed-exitcode` exits `0`. + +## Additional runtime checks + +For a reproducible private-path rehearsal, use the +[end-to-end runbook](docs/end-to-end.md): credential-free CI, a guarded backend +verifier and a separate authenticated portal review including fresh metrics. +The portal evidence validator checks completeness; it does not automate the UI. + +Complete the [customer acceptance guide](docs/acceptance.md) for either path. +It includes commands, expected results, and failure boundaries for missing and +invalid keys, the six service RBAC assignments plus the DCR publishing role, Foundry connections, telemetry, +and the final drift check. A successful model call alone is not full acceptance. + +## Going further + +- [Reference](docs/reference.md): file guide, all inputs, outputs, networking, state and security, offline tests, limitations +- [Cost and cleanup](docs/reference.md#cost-and-cleanup) diff --git a/samples/terraform/ai-gateway.tf b/samples/terraform/ai-gateway.tf new file mode 100644 index 0000000..ecb7c84 --- /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 docs/monitoring.md." + } + } + 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/docs/acceptance.md b/samples/terraform/docs/acceptance.md new file mode 100644 index 0000000..b2c8724 --- /dev/null +++ b/samples/terraform/docs/acceptance.md @@ -0,0 +1,349 @@ +# Customer acceptance for an existing deployment + +Use this after the chosen [networking path](../README.md#choose-your-networking-path) +has been applied successfully. It is **not** a new-deployment or recovery guide. +The [legacy-resource limitation](reference.md#resource-type-and-lifecycle) and +**Pending private-only gateway ingress** remain in effect. + +**Automated private-profile entrypoint:** follow the [end-to-end runbook](end-to-end.md) +and `scripts\Test-CustomerAcceptance.ps1` for executable checks, sanitized reports, +same-private-route negative authentication, correlated telemetry and a separate +portal/metrics review. The detailed blocks below remain useful for manual +diagnosis and the public path; do not run them as a second billable test by default. + +Run the PowerShell blocks in order, in the same PowerShell **7** session, from +`samples\terraform`. Keep the original inputs and state/backend. Do not print raw +state, ARM responses, connection credentials, keys or telemetry connection strings. +These checks do not grant permissions or repair resources. Inference is billable. + +## 1. Confirm the target and read live resource identities + +Replace both placeholders with the approved customer context. The operator needs +resource/role-assignment read access and permission to query the gateway's +Log Analytics workspace and linked AMW Prometheus endpoint. Running the private probe also requires the +[test-client permissions](private-connectivity.md#test-client-permissions). + +```powershell +$ErrorActionPreference = 'Stop' +$subscription = '' +$tenant = '' +$accountJson = az account show --subscription $subscription --output json --only-show-errors +if ($LASTEXITCODE -ne 0) { throw 'Cannot read the approved Azure context.' } +$account = $accountJson | ConvertFrom-Json +if ($account.id -ne $subscription -or $account.tenantId -ne $tenant) { + throw 'Subscription/tenant mismatch. Stop; do not change context automatically.' +} +$outputJson = terraform output -json +if ($LASTEXITCODE -ne 0) { throw 'Cannot read deployment outputs.' } +$o = $outputJson | ConvertFrom-Json + +function Read-ArmUrl([string]$Url) { + $prefix = "https://management.azure.com/subscriptions/$subscription/" + if (-not $Url.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Unexpected ARM read scope.' + } + $json = az rest --subscription $subscription --method get --url $Url --output json --only-show-errors + if ($LASTEXITCODE -ne 0) { throw 'ARM read failed; acceptance is incomplete.' } + $json | ConvertFrom-Json -Depth 50 +} +function Get-Arm([string]$Id, [string]$Version) { + Read-ArmUrl "https://management.azure.com${Id}?api-version=$Version" +} +function Get-ArmItems([string]$Id, [string]$Version) { + $url = "https://management.azure.com${Id}?api-version=$Version" + while ($url) { + $page = Read-ArmUrl $url + $page.value + $url = $page.nextLink + } +} + +$gatewayId = [string]$o.gateway_id.value +$modelsId = [string]$o.foundry_models_id.value +$projectId = [string]$o.project_id.value +$agentsId = $projectId -replace '/projects/[^/]+$', '' +$agentsGroup = "/subscriptions/$subscription/resourceGroups/$($o.resource_groups.value.agents)" +$storageId = "$agentsGroup/providers/Microsoft.Storage/storageAccounts/$($o.storage_account_name.value)" +$registryName = ([string]$o.container_registry_endpoint.value).Split('.')[0] +$registryId = "$agentsGroup/providers/Microsoft.ContainerRegistry/registries/$registryName" +$gateway = Get-Arm $gatewayId '2025-09-01-preview' +$models = Get-Arm $modelsId '2025-04-01-preview' +$agents = Get-Arm $agentsId '2025-04-01-preview' +$project = Get-Arm $projectId '2025-04-01-preview' +$storage = Get-Arm $storageId '2024-01-01' +$registry = Get-Arm $registryId '2023-07-01' +foreach ($resource in @($gateway, $models, $agents, $project, $storage, $registry)) { + if ($resource.properties.provisioningState -ne 'Succeeded') { + throw "Resource is not ready: $($resource.id)" + } +} +foreach ($resource in @($gateway, $project)) { + if (-not $resource.identity.principalId -or $resource.identity.tenantId -ne $tenant) { + throw "Missing or wrong-tenant managed identity: $($resource.id)" + } +} +'Target resources and runtime principal IDs verified.' +``` + +**Expected:** the final message. A `404`, access denial, failed provisioning or +missing identity is a failure, not permission to recreate a resource. + +## 2. Verify the six exact service role bindings + +Compare **current live identities**, resource scopes and role-definition IDs. +Do not substitute an old principal ID, accept an assignment on the wrong +resource, or count unrelated/inherited roles as proof of the configured binding. + +```powershell +$bindings = @( + @{ name = 'gateway-foundry-user'; principal = $gateway.identity.principalId; scope = $modelsId; role = '53ca6127-db72-4b80-b1b0-d745d6d5456d' } + @{ name = 'project-storage'; principal = $project.identity.principalId; scope = $storageId; role = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' } + @{ name = 'project-acr-pull'; principal = $project.identity.principalId; scope = $registryId; role = '7f951dda-4ed3-4680-a7ca-43fe172d538d' } + @{ name = 'project-contributor'; principal = $project.identity.principalId; scope = $agentsId; role = '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68' } + @{ name = 'project-openai-user'; principal = $project.identity.principalId; scope = $agentsId; role = '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' } + @{ name = 'project-user'; principal = $project.identity.principalId; scope = $agentsId; role = 'a97b65f3-24c7-4388-baec-2e87135dc908' } +) +foreach ($binding in $bindings) { + $roleId = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/$($binding.role)" + $assignments = @(Get-ArmItems "$($binding.scope)/providers/Microsoft.Authorization/roleAssignments" '2022-04-01') + $match = @($assignments | Where-Object { + $_.properties.scope -ieq $binding.scope -and + $_.properties.principalId -eq $binding.principal -and + $_.properties.principalType -eq 'ServicePrincipal' -and + $_.properties.roleDefinitionId -ieq $roleId -and + -not $_.properties.condition + }) + if ($match.Count -ne 1) { throw "Expected exactly one configured binding: $($binding.name)" } + "$($binding.name): verified" +} +``` + +**Expected:** six `verified` lines. Optional developer roles are additional and +must be reviewed against the explicitly approved `developer_principal_id` and +`rbac.tf`; their existence does not replace these six bindings. The optional VM's +key-reader binding is separate: inspect its gateway-scoped assignment and custom +role in **Gateway > Access control (IAM)** using the scope/action table in the +[private guide](private-connectivity.md#test-client-permissions). The private probe +also exercises that VM identity's ability to retrieve the key. + +## 3. Verify the Foundry connections and gateway backend + +```powershell +$projectConnections = @(Get-ArmItems "$projectId/connections" '2025-04-01-preview') +$accountConnections = @(Get-ArmItems "$agentsId/connections" '2025-06-01') +$expectedConnections = @( + @{ name = $storage.name; items = $projectConnections; category = 'AzureStorageAccount'; auth = 'AAD'; target = $storage.properties.primaryEndpoints.blob; resource = $storageId } + @{ name = "$($registry.name)-conn"; items = $projectConnections; category = 'ContainerRegistry'; auth = 'ManagedIdentity'; target = $registry.properties.loginServer; resource = $registryId } + @{ name = ([string]$o.application_insights_ids.value.agents).Split('/')[-1]; items = $accountConnections; category = 'AppInsights'; auth = 'ApiKey'; target = $o.application_insights_ids.value.agents; resource = $o.application_insights_ids.value.agents } +) +foreach ($expected in $expectedConnections) { + $match = @($expected.items | Where-Object { + $_.name -eq $expected.name -and + $_.properties.category -eq $expected.category -and + $_.properties.authType -eq $expected.auth -and + $_.properties.target -eq $expected.target -and + $_.properties.metadata.ResourceId -ieq $expected.resource + }) + if ($match.Count -ne 1) { throw "Missing or incorrect connection: $($expected.name)" } + "$($expected.name): target/authentication metadata verified" +} +$provider = Get-Arm "$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 $modelsId -or + ([string]$foundry.endpoint).TrimEnd('/') -ne ([string]$models.properties.endpoint).TrimEnd('/')) { + throw 'Incorrect gateway-to-model backend/authentication.' +} +'Gateway model provider verified.' +``` + +**Expected:** three connection confirmations and the provider confirmation. +This verifies configuration metadata, **not** credential contents, Storage +object access, an ACR image pull or running hosted agents. The sample deploys no +agent/image. If agent execution is a customer requirement, require a separate +approved image-pull, artifact-access and agent-execution test before signing off; +do not label these checks as an agent-runtime pass. + +## 4. Exercise every alias on the intended route + +Check [probe compatibility](reference.md#probe-compatibility) first. Record the +UTC start/end times and the per-alias results without keys or prompts containing +customer data. + +For the **private path**, run `.\scripts\Test-PrivateGateway.ps1` from the +[private guide](private-connectivity.md#step-5-prove-the-final-private-path) with +the test VM running. Verify the live model account still has +`publicNetworkAccess = Disabled` and `networkAcls.defaultAction = Deny`; the +gateway currently remains `Enabled`. Both private endpoints must report +`Succeeded` and `Approved`: + +```powershell +if ($models.properties.publicNetworkAccess -ne 'Disabled' -or + $models.properties.networkAcls.defaultAction -ne 'Deny' -or + $gateway.properties.publicNetworkAccess -ne 'Enabled' -or + $gateway.properties.virtualNetworkType -ne 'External' -or + -not $gateway.properties.virtualNetworkConfiguration.subnetResourceId) { + throw 'Live network posture does not match the current private profile.' +} +foreach ($name in @('gateway', 'models')) { + $endpoint = Get-Arm $o.private_endpoint_ids.value.$name '2024-05-01' + $connections = @( + @($endpoint.properties.privateLinkServiceConnections) + + @($endpoint.properties.manualPrivateLinkServiceConnections) | + Where-Object { $null -ne $_ } + ) + $target = if ($name -eq 'gateway') { $gatewayId } else { $modelsId } + if ($endpoint.properties.provisioningState -ne 'Succeeded' -or + $connections.Count -ne 1 -or + $connections[0].properties.privateLinkServiceConnectionState.status -ne 'Approved' -or + $connections[0].properties.privateLinkServiceId -ine $target) { + throw "Private endpoint is not ready or targets the wrong resource: $name" + } +} +$testStartUtc = [DateTimeOffset]::UtcNow +.\scripts\Test-PrivateGateway.ps1 +$testEndUtc = [DateTimeOffset]::UtcNow +``` + +**Expected:** `"passed": true`, both DNS names resolving exclusively inside the +configured endpoint subnet, and HTTP `200`/`OK` for **every** alias. Keep equivalent +evidence if using your own private client. Deallocate the optional VM in a +`finally` block when automating, or follow Step 6 of the private guide immediately +after testing, **including on failure**. Verify `az vm get-instance-view +--subscription --ids --query instanceView.statuses +--output table` contains `PowerState/deallocated`; a merely stopped VM is not enough. + +For the **public path only**, instead record the times around +`.\scripts\Test-ModelAliases.ps1`; every alias must return nonempty text. +Do not run the public-path Terraform plan against a private deployment. + +## 5. Reject missing and invalid runtime keys + +The following PowerShell 7 check runs on the **operator's machine** against the +currently enabled public gateway endpoint, including when the backend uses the +private profile. It is a separate authentication check, **not** evidence of +private routing or network isolation. Use the same reachable endpoint/alias that +passed a valid-key call; the private VM result alone does not establish public +reachability. If required, perform an approved public positive call with +`Test-ModelAliases.ps1` first (no infrastructure change; uses the operator's +key-read permission). Do not enable public access or grant extra permissions +automatically to make a test pass. + +```powershell +$gatewayUrl = ([string]$o.gateway_endpoint.value).TrimEnd('/') +$alias = @($o.model_deployment_ids.value.PSObject.Properties.Name)[0] +$body = @{ model = $alias; messages = @(@{ role = 'user'; content = 'Say hello.' }) } | + ConvertTo-Json -Depth 5 +foreach ($case in @( + @{ name = 'missing-key'; headers = @{} }, + @{ name = 'invalid-key'; headers = @{ 'Api-Key' = 'intentionally-invalid-acceptance-key' } } +)) { + $response = Invoke-WebRequest -Method Post ` + -Uri "$gatewayUrl/default/models/openai/v1/chat/completions" ` + -Headers $case.headers -ContentType 'application/json' -Body $body ` + -SkipHttpErrorCheck -MaximumRedirection 0 -TimeoutSec 90 + if ([int]$response.StatusCode -notin @(401, 403)) { + throw "Authentication check failed: $($case.name), HTTP $($response.StatusCode)" + } + "$($case.name): HTTP $($response.StatusCode)" +} +``` + +**Expected:** two gateway authentication rejections (`401` or `403`); inspect +the response locally to distinguish gateway authentication from a proxy/firewall +denial. A successful valid-key control on the **same path**, followed by both +rejections, is required. DNS/TLS/timeouts, redirects, `404`, `429`, or a generic +network `403` do **not** pass this check. If this route cannot be exercised, record +it as **not verified**, or run equivalent requests from an approved private +client with a same-path valid-key control. Do not copy runtime keys into commands, +reports or screenshots. + +## 6. Verify telemetry delivery, not just exporter existence + +Complete [OTLP setup](monitoring.md) first. Keep the six service role checks above; +the DCR publishing role below is an additional, narrowly scoped assignment. + +```powershell +$destination = .\scripts\Get-GatewayMonitoring.ps1 -SubscriptionId $subscription ` + -ApplicationInsightsId $o.application_insights_ids.value.gateway ` + -ExpectedLogAnalyticsId $o.gateway_monitoring.value.log_analytics_id +$exporter = Get-Arm "$gatewayId/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' +if ($exporter.properties.kind -ne 'OpenTelemetry' -or + $exporter.properties.payloadCapture -ne $false -or + $exporter.properties.applicationInsights.resourceId -ine $destination.insightsId -or + $exporter.properties.openTelemetry.credentials.managedIdentity.resource -cne 'https://monitor.azure.com') { + throw 'Incorrect telemetry exporter target or payload-capture setting.' +} +foreach ($name in @('metricsEndpoint', 'logsEndpoint', 'tracesEndpoint')) { + if ($exporter.properties.openTelemetry.$name -cne $destination.endpoints[$name]) { throw "Incorrect $name." } +} +$publishing = @(Get-ArmItems "$($destination.dataCollectionRuleId)/providers/Microsoft.Authorization/roleAssignments" '2022-04-01' | + Where-Object { + $_.properties.scope -ieq $destination.dataCollectionRuleId -and + $_.properties.principalId -eq $gateway.identity.principalId -and + $_.properties.principalType -eq 'ServicePrincipal' -and -not $_.properties.condition -and + $_.properties.roleDefinitionId -ieq "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/3913510d-42f4-4e42-8a64-420c390055eb" + }) +if ($publishing.Count -ne 1) { throw 'Exact DCR publishing role not verified.' } +$destination.logAnalyticsId +$testStartUtc.ToString('o') +$testEndUtc.ToString('o') +``` + +Open **that gateway Log Analytics workspace > Logs** (not agent insights). +Allow several minutes for ingestion; replace the time bounds and trace IDs below +with the recorded probe values. The automated runner additionally enforces +successful gateway server-span status and scoped Prometheus metrics. + +```kusto +union withsource=TelemetryTable OTelSpans, OTelLogs +| where TimeGenerated between (datetime(2026-01-01T12:00:00Z) .. datetime(2026-01-01T12:15:00Z)) +| where TraceId in ("", "") +| project TimeGenerated, TelemetryTable, TraceId +| order by TimeGenerated asc +``` + +**Expected:** new gateway request/related telemetry attributable to the test +window and model requests, including successful calls. Inspect trace IDs and +available alias/path dimensions locally to distinguish unrelated concurrent +traffic. Do not export full `customDimensions`, request/response bodies or +credentials. If the preview emits a different schema, use the resource's actual +tables and record that schema; a query error, empty result, or exporter existence +alone is **not** proof of delivery. Do not enable payload capture to troubleshoot +this acceptance check. Retest after ingestion delay; unresolved delivery is an +open acceptance item, not a silent pass. + +Also verify fresh `azure.ai_gateway.client.token.usage` through the destination's +Prometheus endpoint, with the same Insights/workspace/time filters as the portal. +Use the query and interpretation in the [automated runbook](end-to-end.md): +positive metric samples are not an exact token total for just your calls. + +## 7. Finish with a drift check and explicit acceptance record + +For a private deployment: + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode +switch ($LASTEXITCODE) { + 0 { 'No pending Terraform changes.' } + 1 { throw 'Plan failed; acceptance is incomplete.' } + 2 { throw 'Pending changes require review; do not apply automatically.' } + default { throw 'Unexpected Terraform exit code.' } +} +``` + +For an intentionally public deployment, omit **only** the private `-var-file` +argument, retaining all its usual approved inputs/backend. Never omit it for a +private deployment. Keep plan output protected because it can contain secrets. + +Record subscription/tenant, resource IDs, timestamp, Git revision, selected +profile, aliases tested, six service bindings plus the DCR publishing role, connection metadata, authentication +statuses, telemetry evidence, drift exit code and VM power state. Mark every item +**passed**, **failed** or **not verified**; do not include keys or raw state. + +Acceptance remains limited to the demonstrated gateway/model route. Explicitly +record the still-public gateway endpoint and Pending private-only investigation. +If the customer requires private-only ingress, unsupported model tests, or +hosted-agent execution not demonstrated above, those requirements remain +**unmet** regardless of other checks passing. diff --git a/samples/terraform/docs/end-to-end.md b/samples/terraform/docs/end-to-end.md new file mode 100644 index 0000000..80c1294 --- /dev/null +++ b/samples/terraform/docs/end-to-end.md @@ -0,0 +1,224 @@ +# Customer-like rehearsal and portal acceptance + +The offline suite, live backend verifier and authenticated portal review are +separate gates. **None is a substitute for the other two.** The verifier never +deploys, repairs, grants roles, switches SKUs or deletes Azure resources. + +## Scope and environment gate + +Keep the legacy `AIGateway` SKU and private-endpoint architecture. All application +resources belong in one group; the VM's custom role definition is subscription +scoped, with its assignable scope limited to that group. Retained migration groups +must be cleaned up through a separately reviewed operation before final acceptance. +The only additional placement exception is the exact gateway publisher assignment +on the Insights-linked DCR in a verified Azure-managed monitoring group; see +[OTLP ownership and setup](monitoring.md). No blanket `MA_*` exemption exists. +During a three-group migration, first use the manual private/runtime checks in +[acceptance.md](acceptance.md) to establish backend health before approving old-group +cleanup. The strict final runner deliberately does not waive its placement gate. + +| Rehearsal | What it proves | Prerequisite | +| --- | --- | --- | +| Clean checkout, offline | Reproducible configuration and regression checks | No Azure credentials, state or customer input files | +| Existing legacy integration | Configuration and runtime of an existing gateway | Exclusive ownership of its authoritative state, approved inputs and scope | +| New customer / greenfield | Creation from scratch | Explicit product-group confirmation of legacy-SKU creation eligibility; currently documented as unsupported | + +Do not call an integration pass a greenfield pass. A container sandbox can isolate +tools but cannot emulate Azure provisioning, quotas, Private Link, DNS, managed +identities, ingestion or the real portal. No hosted agent/image is deployed by +this sample, so image pull, artifact access and agent execution need a separate +approved test. Private routing is **not** private-only gateway ingress. + +Before any new cloud rehearsal, record the approved subscription/tenant, regions, +unique environment name/group, model quota, permissions, cost ceiling, expiry, +cleanup owner and protected state location. Use a separate identity/state and +prefer a separate test subscription. A budget alert is not a hard spending cap. +Do not proceed without this approval and SKU eligibility. There is deliberately +no credentialed cloud deployment workflow yet. + +For an existing deployment, preserve its backend/state, exact stable naming inputs +and any migration revision. Never copy its state into a second active environment. +For a genuinely new, eligible environment use `terraform.tfvars.example` plus the +published private profile, not another developer's local migration overrides. +Follow the [private guide](private-connectivity.md) for preflight and staged, +reviewed plan/apply. Inspect exact target IDs, public access, role scopes and every +destroy/replace action; retain gateway `prevent_destroy`. The acceptance runner is +**post-deployment**, not an automatic deployment controller. + +## 1. Clean checkout and offline CI + +Use an approved immutable commit in a separate checkout. Record its SHA. Run with +PowerShell 7, Terraform 1.11.4 and Python 3.12+ from `samples\terraform`: + +```powershell +.\scripts\Test-Offline.ps1 +``` + +This initializes with the committed provider lockfile in readonly mode, checks +format/validation, runs mocked Terraform tests and the Python/PowerShell suites. +Use a clean checkout without local tfvars/state for this step; initialization is +not a migration of an existing backend. CI runs this same command on Windows, +without Azure secrets, OIDC permissions or cloud deployment on pull requests. +Provider installation needs internet access; "offline" means no Azure operations. + +## 2. Read-only backend checks + +Reconnect the approved state/backend and supply its existing private inputs. +Ensure the checkout is clean, Terraform is initialized and Azure CLI is signed +into the approved context. These checks need resource/role read access; telemetry +also needs Log Analytics and AMW Prometheus query access. + +```powershell +$parameters = @{ + SubscriptionId = '' + TenantId = '' + ResourceGroup = '' + ReportDirectory = 'C:\protected-evidence\gateway' +} +.\scripts\Test-CustomerAcceptance.ps1 @parameters +``` + +Reports go into a unique run-ID subdirectory. Store them outside the checkout or +in the ignored `acceptance-results` directory with access restricted to the test +team; IDs, IPs and screenshots still contain operational information. Do not +publish them to a public repository or an unrestricted CI artifact. + +The default checks context, a clean source commit, single-group state placement, +live identities/resources/catalog/aliases, six exact service roles, three +connections, OTLP destination/exporter and one exact DCR publishing role, endpoint provisioning **and** approval, DNS +links and a full drift plan. It does not start the VM, retrieve keys or call +models. Therefore runtime/telemetry remain NOT RUN/BLOCKED and it exits with an +incomplete-acceptance error, even if all read-only checks pass. + +The legacy preview's catalog GET can omit `apiFormat`. The runner records this +verification boundary: it requires compatible output metadata and live supported +endpoints/alias targets; when `apiFormat` is returned it must match. + +The exporter must return `kind = OpenTelemetry` (case-insensitive), the expected +Insights ID, all three actual OTLP endpoints, system-identity audience +`https://monitor.azure.com` and `payloadCapture = false`, without connection +strings or custom credential headers. Missing kind, incomplete endpoints or a +legacy Application Insights exporter fail. The runner verifies the linked +DCR/DCE/AMW, expected Log Analytics destination and managed-group ownership. +Do not create a second monitoring stack in the portal or assume ingestion delay +repairs missing configuration. Actual API behavior, delivery and visible portal +metrics still need runtime proof. + +Every drift plan includes `profiles\private-connectivity.tfvars.json` last. +For additional explicitly approved nonautomatic input files use +`-AdditionalVarFile 'C:\protected-inputs\environment.tfvars'`. Existing automatic +tfvars still apply: review them and `TF_VAR_*` values. Remove hidden +`TF_CLI_ARGS`/`TF_CLI_ARGS_plan`; targeted plans are not acceptance. State, raw +plan text and raw service errors are never included in reports. + +## 3. Explicit private runtime and telemetry + +Only after approving model and VM costs: + +```powershell +.\scripts\Test-CustomerAcceptance.ps1 @parameters -RunPrivateProbe -TelemetryWaitSeconds 300 +``` + +This flag permits starting the **output-identified test VM**, running its probe +and deallocating it. It requires the VM to be initially deallocated so it will +not take over someone else's running session. All prerequisite checks must pass +before it starts. It verifies deallocation in `finally`, including probe failure. +If the process/machine is killed, `finally` cannot guarantee cleanup: the named +cleanup owner must check power state and deallocate the exact VM manually. +No cleanup failure is a pass. + +The probe checks both DNS names inside the expected private subnet, makes one +positive chat call per alias (8 requested output tokens, 90-second HTTP timeout) +and then two same-route negative-key calls. Positives must return HTTP 200 and +exactly `OK`. Negative requests must fail with 401 or a key/authentication-specific +403; a generic firewall 403, 429, 500 or timeout is not authentication proof. +There is no load test or model-call retry. Existing managed-identity key lookup +retries at most 12 times, with 15-second waits for 403 propagation. + +Each positive request sends a unique W3C `traceparent`. The verifier queries the +linked Log Analytics workspace for those exact `TraceId` values in `OTelSpans` +and `OTelLogs`: successful gateway server spans for every call and at least one +correlated log. It also requires positive, fresh +`azure.ai_gateway.client.token.usage` samples through AMW Prometheus, scoped to +the same Insights resource, gateway workspace and UTC window. These samples are +not an exact per-run token total, especially under concurrent traffic. Missing +logs mean delivery is unproven; investigate emission rather than assuming every +successful request must log. Polling is bounded by the configured ingestion wait +(0-900 seconds, excluding individual CLI call duration). +It does **not** substitute historical traffic or exporter existence for delivery. +If this preview does not propagate W3C IDs into that schema, the check fails: +investigate actual exporter correlation support before changing the assertion. +Trace propagation and the telemetry schema still require real-environment proof. + +`acceptance.json` reports each check and non-secret evidence. CLI/HTTP operations +can also have platform-level waits; this runner is not a hard wall-clock or cost +limiter. Use a supervised run and a separate cost/VM watchdog for unattended use. + +## 4. AI Gateway portal review + +Open **https://ai.gateway.azure.com** in an authenticated browser with the intended +corporate account, not a lab/admin account. Verify the actual tenant and selected +subscription/resource against the report. Use a client with VNet connectivity +when exercising the private route. A portal playground might call the enabled +public gateway endpoint; record its actual route and do not label it private proof. + +The Networking page can display an approved/succeeded gateway-side connection +while the corresponding `Microsoft.Network/privateEndpoints` resource is failed. +Check both resource states and private DNS/routing; the portal row alone is not +proof of a working private endpoint. + +The runner creates `portal-evidence.json`. Only begin final functional acceptance +after `backendStatus` is PASS. Earlier browsing is a **diagnostic baseline**, not +a full UI pass. Use actual current UI labels and controls; no unverified selectors +or assumptions about preview navigation are baked into the scripts. + +| Check name | Required observation | +| --- | --- | +| `resource-context` | Correct tenant, subscription, shared group and gateway; no accidental resource/account switch after refresh | +| `configuration` | Provider/backend and every alias agree with the report/configuration; inspect details without saving changes | +| `playground` | One bounded benign call per alias, successful visible response, reported usage/errors sensible; record actual route and UTC times | +| `metrics` | Requests and available token/error/latency views load, use the selected resource/time filter, and show fresh test traffic; compare one positive numeric count with its underlying query using identical filters/aggregation | +| `error-states` | Clear empty state for a window with no traffic and understandable error for a safe unsupported alias/invalid key where the UI supports it; never delete resources or change policies to manufacture errors | +| `browser-health` | Navigation, refresh, loading/error completion and charts function; record durations, browser version, relevant console failures and failing request status codes without headers/bodies | + +Record both usability observations and defects, not just a screenshot of a chart. +For missing/unsupported controls use FAIL/BLOCKED, not a fabricated PASS. Do not +mark requests/tokens as healthy while only displaying old or unrelated traffic. +With shared background traffic, compare the same resource/window/status filters +in the UI and backend query; do not assume the displayed total equals just the +probe count. Metrics definitions/rounding must match before comparing values. + +For each checklist item fill `status`, `observed` and a relative `evidence` filename +for a redacted screenshot or observation text stored beside the manifest. Fill +`reviewer` and `reviewedUtc` only after the review. In `metrics`, record the +underlying `source` query/metric definition, UTC window (at most 24 hours and +covering the probe), numeric `portalValue` and `referenceValue`, and an +`explanation` of identical filters/aggregation. Never capture keys, sign-in screens, +authorization headers, connection strings or customer prompts; do not save HAR +files/raw network traces. The non-secret trace IDs in the backend report connect +the reference telemetry to this run. + +```powershell +.\scripts\Complete-PortalAcceptance.ps1 ` + -BackendReport 'C:\protected-evidence\gateway\\acceptance.json' ` + -PortalEvidence 'C:\protected-evidence\gateway\\portal-evidence.json' +``` + +This validates context, completeness, evidence files and the metrics comparison +and writes a separate `acceptance-with-portal.json`. It **does not automate the +browser or independently verify the observations**: UI acceptance is explicitly +operator-attested. Failed backend checks cannot be overridden, and the original +backend report is preserved. No complete end-to-end PASS is emitted without both. + +## 5. Repeatability, handover and cleanup + +Rerun the same approved commit/inputs against the same exclusively owned state. +Require another zero-drift result and new runtime/telemetry/UI evidence; do not +reuse an old portal pass. An independent greenfield test remains a separate gate. +Keep failed reports and non-secret correlation IDs for support; never repeatedly +recreate a failing gateway endpoint without a diagnosed fix. + +Hand over the approved commit, input/state ownership instructions, source CI +result, deployment evidence, both acceptance reports, known limitations and cleanup +owner. For teardown follow [protected cleanup](reference.md#cost-and-cleanup); +the runner does not disable gateway protection or automatically delete a group. diff --git a/samples/terraform/docs/monitoring.md b/samples/terraform/docs/monitoring.md new file mode 100644 index 0000000..bc32051 --- /dev/null +++ b/samples/terraform/docs/monitoring.md @@ -0,0 +1,199 @@ +# Gateway OpenTelemetry setup + +The current AI Gateway portal requires **OpenTelemetry**, the existing Application +Insights resource ID, and all three OTLP endpoints. A legacy connection-string +exporter, or merely changing its casing, does not establish this configuration. +The gateway uses its system identity with audience `https://monitor.azure.com` +and Monitoring Metrics Publisher (`3913510d-42f4-4e42-8a64-420c390055eb`) at the +**actual OTLP data collection rule (DCR)**. Payload capture stays disabled. +Agent Insights and its connection string are unchanged. + +## Ownership, permissions and cost + +All application resources, including both Insights components and Log Analytics +workspaces, remain in the shared application group. Enabling +`AzureMonitorWorkspaceIngestionMode` on gateway Insights can create an Azure +Monitor workspace (AMW), DCRs, data collection endpoints (DCEs) and **Azure-managed +helper groups**. The AMW default DCR/DCE can differ from the OTLP-specific pair. +These are service-owned dependencies, not additional application stacks. +Do not import, recreate or independently manage them with Terraform. + +Review these side effects and ingestion/query/retention charges before enabling. +Terraform's action count does not enumerate service-created resources. Restoring +the old exporter is **not** a complete rollback: helper resources/data and costs +can remain. Deleting them or disabling OTLP needs a separately reviewed plan. + +`Microsoft.Monitor`, `Microsoft.Insights` and `Microsoft.OperationalInsights` must +be registered. This sample does not register providers/features or bypass +eligibility, policy or managed-group deny assignments. Grant the one publishing +role directly on the linked DCR; do not use a nested ARM deployment in a protected +group or broaden scope to work around denial. Query operators separately need +Log Analytics query access and AMW Prometheus query access. Publisher access is +not reader access. + +## Two-stage bootstrap or legacy-exporter upgrade + +Use the existing authoritative state and approved inputs. Keep the private +profile on **every** plan for private deployments. Do not use these targeted +plans as final drift/acceptance evidence. No command below should be applied +without reviewing its saved plan and service-side effects. + +**Stage A: enable only the destination.** From `samples\terraform`: + +```powershell +terraform plan '-target=azapi_resource.insights["gateway"]' ` + '-var-file=profiles\private-connectivity.tfvars.json' '-out=monitoring-destination.tfplan' +if ($LASTEXITCODE -ne 0) { throw 'Destination plan failed.' } +# Review and approve the saved plan before running: +terraform apply 'monitoring-destination.tfplan' +if ($LASTEXITCODE -ne 0) { throw 'Destination apply failed; do not continue.' } +``` + +For an existing deployment this must be **one in-place gateway Insights update**, +with the same resource ID and Log Analytics association. Stop on gateway, agent, +network or other changes, especially replacement/deletion. A genuinely new, +explicitly eligible legacy deployment also creates prerequisite group/Log +Analytics resources; review those separately. Targeting cannot bypass SKU +creation restrictions. + +**Discover and verify readiness without copying generated IDs:** + +```powershell +$metadata = terraform output -json gateway_monitoring +if ($LASTEXITCODE -ne 0) { throw 'Cannot read monitoring metadata.' } +$metadata = $metadata | ConvertFrom-Json +$destination = .\scripts\Get-GatewayMonitoring.ps1 ` + -SubscriptionId '' ` + -ApplicationInsightsId $metadata.insights_id ` + -ExpectedLogAnalyticsId $metadata.log_analytics_id -WaitSeconds 120 +$destination | ConvertTo-Json -Depth 5 +``` + +This is read-only. It waits for enablement, endpoints and DCR linkage, then checks +provisioning, DCR/DCE endpoint ownership, AMW and the existing Log Analytics +destination. It verifies that every linked external group is managed by this +Insights component or AMW. Unknown topology, auth failure or timeout stops the +process; nothing falls back to a guessed endpoint or a broader scope. +The wait excludes individual CLI request durations. The helper accepts +`DataCollectionRuleResourceId` or an ARM-form `DataCollectionRuleId`. +**Immutable-only linkage is not supported by this implementation**: stop and +resolve the service metadata; do not invent an ARM ID or manually edit state. +Non-Succeeded linked resources also stop the check; rerun read-only after +investigating rather than repeatedly mutating the exporter. + +**Stage B: refresh, plan and review the exporter/role.** For a bounded repair: + +```powershell +terraform plan '-target=azapi_resource.telemetry_exporter' ` + '-var-file=profiles\private-connectivity.tfvars.json' '-out=monitoring-exporter.tfplan' +if ($LASTEXITCODE -ne 0) { throw 'Exporter plan failed.' } +# Review and approve the saved plan before running: +terraform apply 'monitoring-exporter.tfplan' +if ($LASTEXITCODE -ne 0) { throw 'Exporter apply failed.' } +``` + +Dependencies include the publisher role and existing gateway; targeting is not +a guarantee of scope. For an existing legacy-exporter repair expect exactly one +DCR role creation and one in-place exporter update. Stop for anything else. +If the service rejects changing exporter kind in place, stop and obtain specific +approval for deletion/recreation; this sample does not pre-delete or auto-retry. +For a new eligible environment use the normal full networking-path plan after +Stage A readiness, reviewing all application creations instead. + +### Existing legacy exporter: immutable kind + +The legacy API has returned `ValidationError` with +`Telemetry exporter kind cannot be changed after creation.` for an existing +connection-string exporter. Terraform's planned in-place update does not prove +that the service permits it. The publisher role can already have been created +before that failure; preserve the resulting state and verify the actual role. + +Prepare a **new**, explicitly reviewed replacement plan rather than retrying the +failed plan, deleting resources manually, or restoring old state: + +```powershell +terraform plan '-target=azapi_resource.telemetry_exporter' ` + '-replace=azapi_resource.telemetry_exporter' ` + '-var-file=profiles\private-connectivity.tfvars.json' ` + '-out=monitoring-exporter-replacement.tfplan' +if ($LASTEXITCODE -ne 0) { throw 'Exporter replacement plan failed.' } +``` + +For a failed Stage B where the publisher already exists, require exactly one +delete/create replacement of `azapi_resource.telemetry_exporter`, with the same +name and parent, and **no other resource actions**. Recheck the destination and +publisher before explicit destructive-action approval. Only then apply the +reviewed saved plan. This does not replace the gateway or delete monitoring +storage/data. It is not atomic: the old exporter is removed first, and if +creation fails the exporter can remain absent. Stop for separately approved +recovery rather than silently retrying or rolling back. + +Terraform refresh reads the real endpoints/DCR from Insights; no portal setup, +new provider or manual endpoint/ID input is required for supported metadata. +Allow propagation, then run a fresh **full**, untargeted drift plan and the +[acceptance gates](end-to-end.md). A targeted repair does not fix unrelated +Private Link, HTTP 500, retained-group or drift failures. + +## Delivery and portal evidence + +The private acceptance runner queries **OTelSpans** and **OTelLogs** in the linked +Log Analytics workspace using W3C `TraceId`. It requires successful gateway server +spans for every test call and at least one correlated log. No log observed means +logs delivery is **unproven**, not that successful requests must always log. +Investigate source emission before changing this gate. + +It queries `azure.ai_gateway.client.token.usage` at the linked AMW's Prometheus +endpoint, scoped by `microsoft.appresourceid`, `azure.ai_gateway.workspace` and +the test window. Positive fresh samples prove scoped metrics activity, **not** +an exact token total attributable only to this run. The metric query excludes +pre-window samples carried forward by Prometheus lookback. Shared concurrent +traffic still needs matching portal/backend filters and aggregation. +Do not substitute classic `requests.operation_Id`, historical traffic, exporter +existence or a screenshot of an empty chart for all three signal paths. + +Live service behavior and UI delivery are still deployment-time acceptance gates; +offline fixtures alone do not prove the preview works in a subscription. + +## Troubleshooting identity and delayed ingestion + +An ARM `principalId`, `Succeeded` provisioning state and matching role assignments +do **not** prove that the gateway's managed identity still exists. If inference +and telemetry fail together, verify the exact principal in the correct Entra +tenant. An authorized Graph check of the active service principal, deleted items +and directory audit can distinguish a deleted identity from an access or tenant +error. A failed or unauthorized lookup alone is not proof of deletion. + +Deleted managed identities cannot be restored through Graph. A confirmed orphan +requires a separately reviewed identity repair on the existing resource, followed +by replacement of its original-scope role assignments and Terraform state +reconciliation. Identity removal/re-enablement is not atomic and can affect other +dependencies, including Key Vault-backed certificates. Do not automatically reset +an identity, recreate the gateway or reuse a plan from before the identity change. + +Allow **up to 30 minutes** for the DCR publisher role to propagate before treating +an ingestion `403` as a persistent failure. This is separate from destination +provisioning and from the additional time needed for accepted records to appear +in Log Analytics. After propagation, generate a fresh bounded gateway request: +older spans may no longer be retried. Check its exact trace ID, correlated logs +and fresh, resource-scoped token samples rather than relying on old traffic. + +A separately authorized synthetic OTLP trace can test the destination without +changing the exporter. Require both successful submission and the exact stored +trace; a `2xx` alone proves acceptance, not query visibility. Mark synthetic data, +exclude it from gateway acceptance, and remove any temporary publishing role. +Verify real gateway traffic independently afterward. + +Token counters and trace-backed call counts can recover with different historical +coverage. Compare before/after counter samples with response usage; do not assume +that the portal's selected-range token total belongs entirely to the newest test. + +## Sources + +- [Official exact legacy API/SKU exporter example](https://github.com/Azure-Samples/AI-Gateway/blob/1679e3137d2fe7592d2a668d77ff71550d35b69e/modules/ai-gateway/v1/ai-gateway.bicep) +- [Complete lab, including DCR-scoped publishing](https://github.com/Azure-Samples/AI-Gateway/tree/main/labs/aigw-foundry-models) +- [Portal monitoring migration release](https://ai.gateway.azure.com/docs/releases/2026-08-28) +- [Direct OTLP ingestion](https://learn.microsoft.com/azure/azure-monitor/containers/opentelemetry-protocol-ingestion) +- [AMW managed infrastructure](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage) +- [Managed identity deletion and token caching](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identities-faq) +- [API Management managed identity lifecycle](https://learn.microsoft.com/azure/api-management/api-management-howto-use-managed-service-identity) +- [DCR publisher permissions and propagation](https://learn.microsoft.com/azure/azure-monitor/logs/tutorial-logs-ingestion-portal#assign-permissions-to-the-dcr) diff --git a/samples/terraform/docs/private-connectivity.md b/samples/terraform/docs/private-connectivity.md new file mode 100644 index 0000000..7427c45 --- /dev/null +++ b/samples/terraform/docs/private-connectivity.md @@ -0,0 +1,205 @@ +# Private connectivity path + +This is the private alternative to the [public endpoint path](../README.md#public-endpoint-path), +not an extra step after its public plan/apply. It applies to the existing legacy +gateway resource retained by this sample; see [resource type and lifecycle](reference.md#resource-type-and-lifecycle). + +**Goal:** prove this route works privately: + +```text +Private client -> gateway private endpoint -> private model endpoint +``` + +**Settings used by this profile:** + +| Setting | Value | +| --- | --- | +| `enable_private_networking` | `true` | +| `gateway_private_endpoint_manual_approval` | `true` (create Pending, then explicitly approve) | +| `gateway_public_network_access` | `Enabled` (stays on; public calls with a valid key still work) | +| `models_public_network_access` | `Disabled` after step 4 | + +Always pass the profile on every plan, including drift checks: +`-var-file=profiles\private-connectivity.tfvars.json`. It overrides `terraform.tfvars`. + +The gateway endpoint uses a manual request followed by an explicit APIM +connection approval. Terraform waits for approval before creating its DNS zone +group. Approval first verifies that the service-created connection belongs to +the expected endpoint. An explicit PUT action preserves the required body `id`; +the legacy API rejects ordinary update-resource requests that omit this field. +Already-approved connections are read with GET instead: the legacy API also +rejects redundant approval. After a newly Pending connection is approved, run +a second plan/apply to record this read-only GET state before the final drift +check. This does not recreate the endpoint or change its approval. +This avoids an observed legacy auto-approval failure: +`PrivateConnectionNotFound`, where Network expects a generated connection name +but APIM stores the caller-supplied name. `Approved` alone is not sufficient: +wait for the asynchronous operation, both provisioning states and private DNS, +then prove inference from the VNet. + +**Existing deployments:** this profile now selects the separately named +`-gateway-manual-pe`. Switching the flag replaces the gateway endpoint, not the +gateway service or the model endpoint. Review the plan before migrating. To +retain a healthy automatic endpoint, override +`-var=gateway_private_endpoint_manual_approval=false` after the profile on every +plan. Never import a new endpoint over the old state without first preserving +state and accounting for the old cloud resource. + +**Cleanup caveat:** the legacy service can retain an APIM connection record +after its failed Network endpoint and NIC have been deleted. In the observed +repair, deleting that orphan or first rejecting it returned an asynchronous +`UpdateApiServiceFailed`; the approved replacement kept working. Follow the +operation's polling URL: an initial `202` is not successful cleanup. Verify the +old endpoint/NIC are absent and the replacement still passes private inference. +Record the residual APIM reference rather than repeatedly retrying or rebuilding +the gateway. A zero-drift Terraform plan does not prove this unmanaged reference +has disappeared. + +See the [AI Gateway private networking guide](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) +for the Pending/Approve sequence. + +> **Pending: private-only gateway ingress.** The observed public-disable failure +> is under investigation with the product group. Until a fix is verified, the +> profile keeps gateway public access enabled and is **not private-only**. See the +> [status and observed behavior](reference.md#known-blocker-private-only-gateway-ingress). +> +> Hosted-agent networking and proof of agent execution remain outside this profile. + +**Already have a working private deployment?** Go to +[existing private deployments](#existing-private-deployments); do not repeat +staging or re-enable model public access. + +## Prerequisites + +Complete the [main prerequisites](../README.md#before-you-start) and +[shared setup](../README.md#shared-setup), retaining the existing input file and +state/backend. Do not run the public-path plan/apply. Additional requirements: + +- Complete the [two-stage OTLP destination setup](monitoring.md) before planning + the exporter. Keep the private profile on those plans too; readiness discovery + must not re-enable a private model backend or rebuild the gateway. +- `Microsoft.Network` registered +- For the test VM: `Microsoft.Compute`, regional VM quota, and permission to use VM Run Command + +### Test-client permissions + +The Terraform deployment identity and the VM's managed identity have different +jobs. Creating role assignments alone does **not** authorize creating a custom +role definition. + +| Identity | Required permission and scope | +| --- | --- | +| Terraform deployment identity | `Microsoft.Authorization/roleDefinitions/read`, `write`, and `delete` to manage the custom role through its lifecycle. The definition is addressed under the subscription, but its only `assignableScopes` entry is the gateway resource group. The identity must be authorized to manage definitions for that assignable scope. | +| Terraform deployment identity | `Microsoft.Authorization/roleAssignments/read`, `write`, and `delete` at the gateway resource for the VM's binding, in addition to the six service bindings and the exact OTLP DCR publisher binding. Respect managed-group deny assignments. | +| Test operator | Read the VM and its instance view; `Microsoft.Compute/virtualMachines/runCommand/action` to run the probe, `start/action` and `deallocate/action` for its power lifecycle, scoped to the test VM. | +| Test VM managed identity | Only `Microsoft.ApiManagement/service/apiKeys/listSecrets/action`, assigned **on the gateway resource**, not the subscription. Terraform creates this role/binding. | + +Ask your subscription administrator to verify effective permissions, assignment +conditions and deny assignments **before enabling the VM**. Contributor alone +cannot manage RBAC, and Role Based Access Control Administrator does not by itself +grant custom role-definition management. Do not solve this by granting Owner to +the VM or broadening its key-reader role. See Microsoft's +[custom-role permissions](https://learn.microsoft.com/azure/role-based-access-control/custom-roles#who-can-create-delete-update-or-view-a-custom-role). +Deletion rights are needed for cleanup too; do not remove a role definition while +assignments still reference it. + +If these permissions cannot be granted, keep `enable_private_test_client = false` +and use an approved existing private client with its own approved authentication. +This avoids the custom test-VM role, not the six core service roles or DCR publishing role. + +## Step 1: Choose your test client + +Your laptop is not on the VNet. The easiest option is the optional test VM (no VPN, no inbound SSH). Add these to `terraform.tfvars`: + +```hcl +enable_private_test_client = true +private_test_client_ssh_public_key = "ssh-ed25519 " +``` + +- Use a **public** key only. Never paste a private key or password. +- The VM is billable, has no public IP and denies inbound traffic. The probe uses Run Command, not SSH. +- Already have a client on the VNet? Skip the VM and run equivalent DNS and inference checks from it. `Test-PrivateGateway.ps1` requires the VM. + +## Step 2: Create networking with models temporarily public + +Use this stage when adding private networking to an existing legacy gateway +whose model backend is still public. It is not the final posture, and it is not +a way to create a new legacy gateway. + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-var=models_public_network_access=Enabled" "-out=private-connectivity-stage1.tfplan" +``` + +Review the plan: gateway and model public access are both enabled, and networking +plus the VM (if enabled) are created. Do not apply unexpected replacements. + +```powershell +terraform apply "private-connectivity-stage1.tfplan" +``` + +## Step 3: Check the private route + +```powershell +.\scripts\Test-PrivateGateway.ps1 +``` + +The wrapper runs the probe inside the VM, which must already be running. It checks +that the gateway and model hostnames resolve to the private endpoint subnet and +that every alias answers `OK`. The VM gets the gateway key through managed +identity. No key is printed or passed as an argument. + +**Model prerequisite:** all aliases must support the probe's OpenAI Chat +Completions request, including `max_tokens` and `temperature`. The sample's +`gpt-4.1` and `gpt-4.1-mini` configuration is the reference. Responses-only, +embeddings, image, or other API formats need their own test. The probe rejects +incompatible API metadata before DNS/key retrieval; it does not interpret that +rejection as a networking failure. See [probe compatibility](reference.md#probe-compatibility). + +**Stop if this fails.** Check endpoint approval, private DNS, permissions and outbound integration. A pass here is only an initial check, because model public access is still on. + +## Step 4: Disable model public access + +Plan **without** the temporary override: + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" +``` + +Expected change: model public access becomes `Disabled` (network ACL Deny). Gateway public access stays `Enabled`. + +```powershell +terraform apply "private-connectivity.tfplan" +``` + +## Step 5: Prove the final private path + +```powershell +.\scripts\Test-PrivateGateway.ps1 +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode +``` + +**Done when:** the probe reports `"passed": true` with `OK` for every alias, and the plan exits `0`. + +The probe also rejects missing/invalid keys on the same route after positive +controls and emits per-call trace IDs. It does not itself verify all role +bindings, connections or telemetry ingestion. +Complete the [customer acceptance guide](acceptance.md) before handing over the +deployment, not the public-path plan/apply. A DNS/inference pass is only the +private-route part of acceptance. The [end-to-end runner](end-to-end.md) adds +VM lifecycle handling, backend checks, correlated telemetry and portal evidence. + +## Step 6: Stop the VM when you are done + +```powershell +$vmId = terraform output -raw private_test_vm_id +$subscriptionId = ($vmId -split '/')[2] +az vm deallocate --subscription $subscriptionId --ids $vmId +``` + +Restart with `az vm start` using the same arguments. Disks, private endpoints, DNS, model deployments and monitoring still incur charges. + +## Existing private deployments + +If you already have a working private backend with model public access disabled, start at Step 4, review the plan, then run Step 5. + +Do not apply stale private-only plans, and do not re-enable public access as an automatic recovery step. diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md new file mode 100644 index 0000000..333111f --- /dev/null +++ b/samples/terraform/docs/reference.md @@ -0,0 +1,293 @@ +# Reference + +Details for both [networking paths](../README.md#choose-your-networking-path). +Review the limitations before changing an existing deployment. + +## File guide + +| File | What it controls | +| --- | --- | +| [terraform.tfvars.example](../terraform.tfvars.example) | Template for your ignored `terraform.tfvars` | +| [profiles/private-connectivity.tfvars.json](../profiles/private-connectivity.tfvars.json) | Four private-connectivity settings, including explicit gateway endpoint approval (no secrets) | +| [variables.tf](../variables.tf) / [outputs.tf](../outputs.tf) | Inputs, validation, outputs | +| [ai-gateway.tf](../ai-gateway.tf) | Gateway, provider, catalog, aliases, runtime key, telemetry exporter | +| [foundry-models.tf](../foundry-models.tf) | Model account and versioned deployments | +| [foundry-agents.tf](../foundry-agents.tf) | Agent account/project, Storage, Premium ACR, connections | +| [main.tf](../main.tf) / [rbac.tf](../rbac.tf) | Naming, shared resource group, monitoring, role assignments | +| [private-network.tf](../private-network.tf) / [private-test-client.tf](../private-test-client.tf) | Optional VNet, private endpoints, DNS, probe VM | +| [scripts/Test-PrivateGateway.ps1](../scripts/Test-PrivateGateway.ps1) / [private-path-probe.py](../scripts/private-path-probe.py) | Private probe (wrapper sends the Python to the VM) | +| [scripts/Test-ModelAliases.ps1](../scripts/Test-ModelAliases.ps1) | Public test of every alias | +| [scripts/Get-GatewayMonitoring.ps1](../scripts/Get-GatewayMonitoring.ps1) / [monitoring.md](monitoring.md) | Read-only bounded OTLP readiness, linked managed groups, two-stage setup | +| [tests](../tests) | Offline Terraform and Python tests | +| [acceptance.md](acceptance.md) | Customer runtime checks, expected results and evidence boundaries | +| [versions.tf](../versions.tf) / [.terraform.lock.hcl](../.terraform.lock.hcl) | Provider versions and checksums (commit the lock file) | + +## Inputs + +Required values are in the [shared setup](../README.md#shared-setup). + +| Input | Purpose and default | +| --- | --- | +| `environment_name` | Naming and tag label. Default `aigateway`. | +| `storage_public_network_access` | Default `Enabled`. Set `Disabled` if policy requires it. No private storage path is created. | +| `developer_principal_id` | Entra object ID for developer access. Empty means no developer roles. | +| `developer_principal_type` | `User`, `Group` or `ServicePrincipal`. Default `User`. | +| `publisher_name`, `project_display_name`, `project_description`, `tags` | Display metadata and tags | +| `private_network_address_prefix` | Private-path VNet IPv4 /16. Default `10.247.0.0/16`. Pick a non-overlapping range before creating the network. | +| `enable_private_test_client` | Default `false`. Requires private networking. | +| `private_test_client_ssh_public_key` | OpenSSH **public** key, required when the VM is created | +| `private_test_client_vm_size` | Default `Standard_B2s_v2`. Check availability and quota. | + +**Each `model_deployments` entry** requires `model_name`, `model_version`, `capacity`, and `token_limit`. + +- `capacity` is a quota unit specific to the model SKU. +- `token_limit` is the gateway's tokens-per-minute limit per identity. +- They are independent. Do not convert one to the other. + +Optional fields: `model_format`, `sku_name`, `api_format`, `endpoints`, `description`. Match the API format and endpoints to the model. See [variables.tf](../variables.tf) for types and validation. + +## Outputs + +| Output | Use it for | +| --- | --- | +| `gateway_endpoint` | Base URL for runtime calls | +| `gateway_api_key_id` | ARM ID for retrieving the key. **Not the key value.** | +| `model_deployment_ids` | Model IDs keyed by alias | +| `model_api_configuration` | API format and endpoints per alias; compatibility check for both sample probes | +| `project_endpoint`, `project_id` | Hosted-agent app setup (not a model-call URL) | +| `container_registry_endpoint`, `storage_account_name` | Image and artifact setup for the agent app | +| `resource_group_name` | The shared application group; excludes verified Azure-managed OTLP helper groups | +| `resource_groups` | Compatibility map: `gateway`, `models` and `agents` all contain that same group name | +| `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource and telemetry lookup | +| `gateway_monitoring` | Nonsecret actual OTLP endpoints/DCR, Insights and expected Log Analytics IDs; not proof of readiness/delivery | +| `private_endpoint_ids`, `private_test_vm_id`, `private_probe_configuration` | Private-path lookup (VM outputs only when the test client is enabled) | + +Outputs do **not** include runtime key values or telemetry connection strings. +Services use managed identities. Get the key through your approved secret process. +Never put keys in `.tfvars`, terminal output or source control. + +### Probe compatibility + +Terraform supports more model APIs than the supplied test scripts. Both +`Test-ModelAliases.ps1` and `Test-PrivateGateway.ps1` test **every** alias through +`/default/models/openai/v1/chat/completions`. They require each alias to declare +`api_format = "OpenAIChatCompletions"` and include +`"/openai/v1/chat/completions"` in `endpoints`. The new +`model_api_configuration` output carries these values; incompatible or missing +metadata stops the test rather than skipping a model. + +This is a necessary compatibility check, not proof that a model accepts every +parameter. The public test sends `messages` and expects `choices[0].message.content`. +The private test additionally uses `max_tokens = 8`, `temperature = 0`, and expects +the exact reply `OK`. The example GPT-4.1 models are the reference configuration. +Do not assume a reasoning model supports those parameters just because it exposes +Chat Completions. + +For Responses-only models, embeddings, image generation or other APIs, supply a +model-specific test on the **same intended network path**, with the documented +request/response format and explicit success criteria for every alias. Do not +change the declared endpoints just to bypass this check. A format/parameter +rejection, content mismatch, quota error or token-limit `429` is not by itself a +DNS/private-network failure. + +**Existing state without the new output:** use the normal reviewed plan/apply +flow with the unchanged input file and, for private deployments, the explicit +private profile. An output-only update should contain no resource actions. Stop +if unexpected resource changes appear; do not replay an old plan or apply an +unrelated repair just to run a test. These instructions do not refresh or modify +your existing state automatically. + +## Networking + +The [private connectivity path](private-connectivity.md) adds: + +- A VNet with an outbound integration subnet delegated to `Microsoft.Web/serverFarms` +- Separate endpoint and client subnets +- Two private endpoints (gateway group `Gateway`, model group `account`) +- Four private DNS zones + +Tested gateway contract: `2025-09-01-preview` with `virtualNetworkType = "External"`. +Inbound Private Link and outbound integration solve different problems. Neither replaces the other. + +The optional Ubuntu VM has no public IP, denies inbound traffic, and has a narrow custom role for reading the gateway key. Its subnet allows default outbound internet for VM agent and ARM operations, so it is not air-gapped. + +**Not configured:** corporate DNS, peering, VPN, forced tunneling, and custom firewall rules. Review the [official networking guide](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) for same-region subnets, DNS and NSG requirements. + +Disabling the feature flag can destroy network resources. Review that change separately. This network does not isolate the agent account, Storage or ACR. + +## State and security + +- **Local state** is used here. State and plans can contain sensitive data, including telemetry connection strings. Restrict access and never commit them. `.gitignore` covers state, plans, provider downloads and user `.tfvars`. +- For shared or production use, move to a remote backend with encryption and locking (for example, Azure Blob Storage with Entra auth). Migrate with `terraform init -migrate-state`. +- Storage blocks anonymous blobs and shared keys. ACR admin users and Foundry local auth are disabled. Gateway payload capture is disabled. +- Keep policy-required storage restrictions. Private hosted-agent storage access is **not** verified here. +- Project roles mirror the upstream Bicep, including Cognitive Services Contributor. Developer access also grants Storage Blob Data Contributor. Enable it on purpose and review least privilege. + +### Single-group consolidation + +The root module now manages only `azapi_resource.resource_group["gateway"]`, +retaining its existing `rg---gateway` name. +Models, agents, Storage, ACR and both monitoring stacks share that group with +the gateway and optional network/test client. Child resources remain under their +service parents, and the custom role definition remains subscription-scoped with +only this group as its assignable scope. Region inputs are still independent; +sharing a group does not move resources to a different region. + +**Monitoring exception:** gateway OTLP enablement also provisions linked, +Azure-managed monitoring resources/groups. The exact DCR-scoped publisher role +can live outside the application group. Final acceptance verifies the linkage, +managed-group owner, gateway principal and exact role; a managed-looking name +alone is never an exemption. See [monitoring ownership and setup](monitoring.md). + +The legacy gateway has `prevent_destroy = true`: a replacement/destroy plan must +fail rather than silently remove a resource whose SKU cannot be recreated. +Keep the existing environment, location inputs and authoritative state. Do not +discard state or change the environment label to get a "fresh" gateway. + +For an existing three-group deployment: + +1. Preserve a protected state backup and coordinate all Terraform writers. +2. Review a fresh **full** plan with the original inputs and the explicit private + profile. Changed parent IDs normally require backend replacements; removing + the old group instances from the configuration also proposes their deletion. + This code change does not perform an Azure resource move. +3. Choose and approve a supported resource move or a deliberate rebuild before + applying. Generic CognitiveServices move support does not establish support + with private endpoints. Rebuilds can lose Storage data, registry images and + telemetry history. Existing global names and soft-deleted Foundry backends can + prevent recreation; resolve these explicitly, never by automatic purge. +4. Reconcile changed identities, resource-scoped roles, connection IDs and private + endpoint targets. A private endpoint cannot change its target service in place. + The replacement trigger covers known target changes, but AzAPI 2.13 skips + reference triggers when its body contains unknown values, including a newly + recreated account's ID. For this consolidation, the full plan **must** include + `-replace='azapi_resource.private_endpoint["models"]'` alongside the explicit + private profile. Confirm the Models endpoint is replaced, not updated, before + approving any apply. Preserve the AIGateway API/authentication and network + settings. Reject any unexpected gateway or network replacement. +5. Verify private inference and telemetry before cleanup. **Do not apply a plan + deleting either old group** until every contained resource is accounted for + and its removal is approved. Azure group deletion can remove resources not + tracked by Terraform. A successful but empty generic inventory is not enough + when direct resource GETs still find resources. + +The legacy `resource_groups` output keys remain available for scripts, but now +resolve to the same name. A plan against the old layout is not an output-only +update. Offline tests establish placement and contracts, not migration safety. + +For an approved rebuild, set a stable `backend_revision` in your deployment +inputs to generate fresh backend names without changing the gateway or group. +Keep that revision for subsequent deployments; changing or removing it causes +another replacement. The default empty revision preserves existing names. + +Set `retain_legacy_resource_groups = true` only during migration to keep the old +groups managed until backend acceptance. It does not place any new service in +those groups. After verifying their contents and approving cleanup, set it back +to `false` and review a new full plan before applying the two group deletions. +The normal final configuration manages exactly one group. + +## Offline checks + +No Azure resources or authentication needed. Use a clean checkout without +deployment tfvars/state. The local/CI entrypoint includes every regression suite: + +```powershell +.\scripts\Test-Offline.ps1 +``` + +The Terraform tests use mocked AzAPI; script tests stub network/CLI calls. They +check shared-group placement, preserved monitoring/output contracts, model/API metadata and token-limit wiring, gateway location, identity-based +backends, opt-in developer roles, invalid inputs, the private profile, and rejection +of unsupported probe APIs. Python 3 and PowerShell 7 are required for the script +tests. These are **not** runtime tests. For actual acceptance, use the +[customer acceptance guide](acceptance.md). + +## Implementation notes + +- Follows the upstream [Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra) and a [model-registration fix](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/commit/03fcd3788aea06cf6000af19890f8164303348d5). +- AzAPI defines the preview ARM resources directly. Model operations are serialized per account with locks. The automatic gateway `default` workspace is referenced, not recreated. +- Token policies use `["identity"]`, not the older scalar `"Identity"` key. +- AzAPI 2.13 has no bundled schemas for several preview resources, so those blocks set `schema_validation_enabled = false`. ARM still validates them at deploy time. Offline success does not prove preview access, capacity or API acceptance. + +## Limitations + +General constraints checked **October 19, 2026**; resource-type lifecycle below +checked **October 22, 2026**. Preview features can change. Recheck the official +sources before changing or replacing a deployment. + +### Resource type and lifecycle + +This sample keeps `Microsoft.ApiManagement/service@2025-09-01-preview` with SKU +`AIGateway`. According to the [Microsoft migration guide](https://learn.microsoft.com/azure/api-management/ai-gateway-resource): + +- New resources with this legacy SKU can no longer be created. +- Existing resources must migrate **before January 15, 2027** and are permanently + removed after retirement. +- The replacement `Microsoft.ApiManagement/aigateways` is a different resource + type and requires a new gateway, not an in-place API-version edit. +- The replacement currently does **not** support gateway private endpoints. + The private-connectivity instructions here describe the retained legacy + resource, not the replacement. + +No API-version change or migration is implemented by this sample. Its networking +paths document the existing configuration, not a recommendation for new legacy +deployments. + +### Microsoft-documented constraints + +- **AI Gateway preview:** best-effort, **no SLA**. APIs, limits, regions and pricing can change. Documented regions include East US 2 and Sweden Central, with access varying by subscription. ([overview](https://learn.microsoft.com/azure/api-management/ai-gateway-overview)) +- **Hosted-agent VNet injection is set at account creation** and cannot be added later. This sample does not configure it. If you need private agent execution, choose a supported network-secured Foundry setup before creating the account. ([Foundry networking limits](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations)) +- **Private endpoints can be added later** to an existing legacy `service`/`AIGateway` resource, with outbound integration configured separately. Private endpoints alone do not give the gateway or agents outbound access. This does not establish support on the replacement `aigateways` resource. ([private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking)) +- **Private ACR image pulls** are supported for Foundry projects created after June 25, 2026. Older projects need public ACR reachability. This sample leaves ACR public. + +### Known blocker: private-only gateway ingress + +**Status: Pending - product-group investigation (October 23, 2026).** Resolution +of the observed public-disable failure is being clarified with the product group. +No fix has been verified for this public-preview sample yet. The current profile +therefore keeps gateway public access enabled; this status is not a promise of +private-only access or a confirmed permanent product limitation. + +**Observed in our tests, not a universal SKU limit.** Microsoft [documents disabling public access after private connectivity is verified](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking#inbound-private-link). In our **October 18, 2026, Sweden Central** tests: + +- Both aliases passed private DNS and inference checks with model public access disabled (ACL Deny). +- Disabling gateway public access failed with `UpdateApiServiceFailed`, through both Terraform and the CLI's public-access PATCH. Private access kept working, but public access stayed on. +- A minimal gateway in the same subscription and region reproduced the failure. Its private endpoint still worked. Test resources were deleted. + +Root cause is unknown. Do not set gateway public access to `Disabled` in this PoC or describe it as private-only. + +Before this pending item can be closed, a private-only version must show: + +1. The gateway reports `Disabled`. +2. Every alias works privately. +3. A previously working valid-key public call gets a network rejection (not just a 401). +4. The planned end state has no pending changes. + +No automatic recovery or destructive workaround is included. + +## Cost and cleanup + +Both paths create billable resources. Model calls, Premium ACR, Storage and +monitoring all cost money. Deallocate the optional VM as described in +[private connectivity](private-connectivity.md#step-6-stop-the-vm-when-you-are-done); +stopping the VM does not stop the other charges. + +The gateway has `prevent_destroy = true`: an ordinary full `terraform destroy` +is intentionally blocked. Do not remove this protection, remove resources from +state or delete the group to work around it. This legacy gateway might not be +recreatable. Full teardown needs explicit approval for permanent gateway/data +loss, exact resource inventory (including resources not managed by this state), +state backup and a separately reviewed protection change and destroy plan. +Keep the private profile and all original inputs on that plan. If retaining the +gateway, design and review a partial teardown with its owner instead; there is +no universally safe one-command cleanup. Acceptance automation never destroys +resources or removes the protection. + +## References + +- [Upstream Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra) +- [AzAPI provider](https://registry.terraform.io/providers/Azure/azapi/latest/docs) +- [AI Gateway overview](https://learn.microsoft.com/azure/api-management/ai-gateway-overview) +- [AI Gateway private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) +- [Foundry Agent Service networking](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations) +- [Model and tool management](https://learn.microsoft.com/azure/api-management/ai-gateway-manage-models-tools) 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..3ac20eb --- /dev/null +++ b/samples/terraform/outputs.tf @@ -0,0 +1,96 @@ +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 and model private endpoint IDs when private networking is enabled." + value = { for key, endpoint in azapi_resource.private_endpoint : key => endpoint.id } +} + +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-network.tf b/samples/terraform/private-network.tf new file mode 100644 index 0000000..daa4268 --- /dev/null +++ b/samples/terraform/private-network.tf @@ -0,0 +1,207 @@ +locals { + private_dns_zones = var.enable_private_networking ? { + gateway = "privatelink.azure-api.net" + cognitiveservices = "privatelink.cognitiveservices.azure.com" + openai = "privatelink.openai.azure.com" + services = "privatelink.services.ai.azure.com" + } : {} + private_subnets = var.enable_private_networking ? { + 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) + } : {} + 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(["gateway", "client"]) : 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 ["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 } + } : {}, + each.key == "gateway" ? { + delegations = [{ + name = "gateway-integration" + properties = { serviceName = "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] +} + +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" + resource_id = data.azapi_resource.gateway_private_endpoint_connection[0].id + 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] + 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/rbac.tf b/samples/terraform/rbac.tf new file mode 100644 index 0000000..722c94a --- /dev/null +++ b/samples/terraform/rbac.tf @@ -0,0 +1,78 @@ +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, 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..43cb921 --- /dev/null +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -0,0 +1,519 @@ +#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 +) + +$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; private route, not private-only ingress or hosted-agent runtime' + profile = 'profiles/private-connectivity.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 Invoke-AzureJson([string[]]$Arguments) { + $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' @() { + $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 'Enabled' -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 = 'Enabled'; modelsPublicAccess = 'Disabled'; privateOnlyIngress = 'NOT certified' } +} + +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=profiles/private-connectivity.tfvars.json' + $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..91622ce --- /dev/null +++ b/samples/terraform/scripts/Test-ModelAliases.ps1 @@ -0,0 +1,59 @@ +# 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. +# Needs permission to read the gateway key (Microsoft.ApiManagement/service/apiKeys/listSecrets/action). +# Model calls incur inference charges. + +$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 !$keyId -or $modelNames.Count -eq 0) { + throw "Gateway endpoint, API key resource ID, or model aliases are 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 +try { + $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 + $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..947c95d --- /dev/null +++ b/samples/terraform/terraform.tfvars.example @@ -0,0 +1,33 @@ +subscription_id = "00000000-0000-0000-0000-000000000000" +environment_name = "aigateway" +location = "eastus2" +gateway_location = "eastus2" +publisher_email = "platform@example.com" + +# Select profiles/private-connectivity.tfvars.json explicitly for the private-connectivity PoC. +# It keeps gateway public access Enabled and disables model public access; see README staging. +# Optional private gateway/model networking; prove connectivity before disabling public access. +# 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..d841e0a --- /dev/null +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -0,0 +1,384 @@ +$ErrorActionPreference = 'Stop' +$runner = Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts\Test-CustomerAcceptance.ps1' +$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' + 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) { + if ('-var-file=profiles/private-connectivity.tfvars.json' -notin $args) { throw 'Private profile missing.' } + if ($f.scenario -eq '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]@{ + 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 = @('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 + 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) { + '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 -RunPrivateProbe:($case -ne '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 -eq 'readonly') { 'NOT RUN' } else { 'FAIL' } + if ($result.checks.$expectedCheck.status -ne $expectedStatus) { throw "Wrong failure boundary 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..4d7a5e7 --- /dev/null +++ b/samples/terraform/tests/Test-ModelScripts.ps1 @@ -0,0 +1,116 @@ +$ErrorActionPreference = 'Stop' +$scripts = Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts' +$global:modelScriptTest = @{} + +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 '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' -or + $Headers['Api-Key'] -ne 'test-key-must-not-appear') { + throw 'Unexpected inference target/authentication.' + } + $global:modelScriptTest.models.Add(($Body | ConvertFrom-Json).model) + @{ choices = @(@{ message = @{ content = 'OK' } }) } +} + +function Reset-Test { + $global:modelScriptTest.outputs = New-TestOutputs + $global:modelScriptTest.terraformExit = 0 + $global:modelScriptTest.azExit = 0 + $global:modelScriptTest.azCalls = 0 + $global:modelScriptTest.models = [System.Collections.Generic.List[string]]::new() +} + +function Assert-Rejected { + param([string]$ScriptName, [string]$Expected, [int]$ExpectedAzCalls = 0) + $failure = $null + try { & (Join-Path $scripts $ScriptName) | 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.' +} + +'Model-script checks passed (11 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..243f5b7 --- /dev/null +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -0,0 +1,477 @@ +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 && + 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 PoC profile must retain AIGateway, outbound integration and private models without requesting blocked private-only ingress." + } + + 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.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 "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] +} 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..487dc4f --- /dev/null +++ b/samples/terraform/variables.tf @@ -0,0 +1,223 @@ +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 "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. Hosted-agent networking is not changed." + type = bool + default = false +} + +variable "private_network_address_prefix" { + description = "Non-overlapping IPv4 /16 for the optional PoC VNet; three /24 subnets 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 = "Keep Enabled for the private-connectivity PoC. Disabled requests private-only ingress and requires separate live validation; see the observed preview limitation." + 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 +}