From f12291da375602e93319c9320c6c93702baf6252 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:53:18 +0200 Subject: [PATCH 01/11] feat(samples): add Terraform AI Gateway sample with private-connectivity PoC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d152d9d0-aa57-42d9-8fdf-8c24618112cd --- samples/README.md | 4 +- samples/terraform/.gitignore | 13 + samples/terraform/.terraform.lock.hcl | 23 ++ samples/terraform/README.md | 350 ++++++++++++++++++ samples/terraform/ai-gateway.tf | 141 +++++++ samples/terraform/foundry-agents.tf | 139 +++++++ samples/terraform/foundry-models.tf | 46 +++ samples/terraform/main.tf | 57 +++ samples/terraform/outputs.tf | 75 ++++ samples/terraform/private-network.tf | 160 ++++++++ samples/terraform/private-test-client.tf | 106 ++++++ .../profiles/private-connectivity.tfvars.json | 5 + samples/terraform/rbac.tf | 63 ++++ .../terraform/scripts/Test-PrivateGateway.ps1 | 50 +++ .../terraform/scripts/private-path-probe.py | 89 +++++ samples/terraform/terraform.tfvars.example | 33 ++ .../terraform/tests/infrastructure.tftest.hcl | 301 +++++++++++++++ .../tests/test_private_path_probe.py | 59 +++ samples/terraform/variables.tf | 197 ++++++++++ samples/terraform/versions.tf | 14 + 20 files changed, 1923 insertions(+), 2 deletions(-) create mode 100644 samples/terraform/.gitignore create mode 100644 samples/terraform/.terraform.lock.hcl create mode 100644 samples/terraform/README.md create mode 100644 samples/terraform/ai-gateway.tf create mode 100644 samples/terraform/foundry-agents.tf create mode 100644 samples/terraform/foundry-models.tf create mode 100644 samples/terraform/main.tf create mode 100644 samples/terraform/outputs.tf create mode 100644 samples/terraform/private-network.tf create mode 100644 samples/terraform/private-test-client.tf create mode 100644 samples/terraform/profiles/private-connectivity.tfvars.json create mode 100644 samples/terraform/rbac.tf create mode 100644 samples/terraform/scripts/Test-PrivateGateway.ps1 create mode 100644 samples/terraform/scripts/private-path-probe.py create mode 100644 samples/terraform/terraform.tfvars.example create mode 100644 samples/terraform/tests/infrastructure.tftest.hcl create mode 100644 samples/terraform/tests/test_private_path_probe.py create mode 100644 samples/terraform/variables.tf create mode 100644 samples/terraform/versions.tf diff --git a/samples/README.md b/samples/README.md index 543af22..4dc7917 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/README.md#private-connectivity-poc-profile) (gateway public access remains enabled). | diff --git a/samples/terraform/.gitignore b/samples/terraform/.gitignore new file mode 100644 index 0000000..cdc798b --- /dev/null +++ b/samples/terraform/.gitignore @@ -0,0 +1,13 @@ +.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 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..b209b8e --- /dev/null +++ b/samples/terraform/README.md @@ -0,0 +1,350 @@ +# AI Gateway and Foundry infrastructure with Terraform + +Provision the three infrastructure building blocks used by the +[Foundry AI Gateway Python sample](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway): +AI Gateway, a dedicated Foundry model account, and a separate Foundry hosted-agent +account/project. This is a standalone Terraform sample, not an `azd` application. + +The configuration follows the upstream +[Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra). +It uses the AzAPI provider to express the preview ARM resources directly. +The current token-policy shape and runtime model aliases incorporate the +[upstream model-registration fix](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/commit/03fcd3788aea06cf6000af19890f8164303348d5). + +## What is provisioned + +| Building block | Resources | +| --- | --- | +| [AI Gateway](ai-gateway.tf) | API Management with the **AIGateway** SKU and system-assigned identity, connector gateway, Foundry model provider, model catalog entries with per-identity token limits, runtime model aliases, runtime API key resource, and Application Insights telemetry exporter. | +| [Foundry Models](foundry-models.tf) | Separate AI Services account with local authentication disabled and explicitly versioned model deployments. The gateway identity receives Foundry User access on this account. | +| [Foundry Hosted Agent infrastructure](foundry-agents.tf) | Project-enabled AI Services account, project identity, Storage, Premium Container Registry, project connections, and agent tracing through Application Insights. | +| [Shared resources](main.tf) and [RBAC](rbac.tf) | Three resource groups, separate gateway/agent Log Analytics workspaces and Application Insights components, project access to its account/storage/registry, and optional developer access. | +| Optional [private-connectivity PoC profile](#private-connectivity-poc-profile) | Gateway/model private endpoints and DNS, gateway outbound VNet integration, private model access, and an optional private test client. Keeps the dedicated AIGateway SKU and explicitly leaves gateway public access enabled. | + +Model operations are serialized per account using AzAPI resource locks. The +gateway's automatically created `default` workspace is referenced rather than +created a second time. +Each catalog entry has a matching workspace alias; the alias is required to route +runtime requests to the model. Token policies use the current `["identity"]` +counter-key format rather than the older scalar `"Identity"` format. + +**Infrastructure only:** this does not build/push an agent image, create an agent +definition, start a hosted-agent container, register GitHub MCP tools, configure +GitHub credentials, or create a daily-digest schedule. Use the end-to-end sample +for application setup after provisioning. It does not replace its `azd` hooks or +produce an `azd` environment. + +## Prerequisites + +- Terraform **1.11 or later**, below 2.0, and Azure CLI. +- An Azure subscription enabled for the **preview AIGateway tier** and Foundry + hosted agents in your selected regions. The gateway region is independent from + the Foundry region. +- Permission to create resource groups/resources and role assignments at their + scopes, such as Contributor plus Role Based Access Control Administrator, + subject to your organization's policies. +- Registered providers: `Microsoft.ApiManagement`, `Microsoft.Web`, + `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.ContainerRegistry`, + `Microsoft.OperationalInsights`, and `Microsoft.Insights`. This sample does not + register providers on your behalf. +- Available model versions and sufficient deployment quota for the selected + model SKU/region. Example values are illustrative, not an availability promise. + +Confirm preview access, model availability, and organizational network policies +before applying. Region/model availability is not inferred by this configuration. + +## Configure and provision + +Run from this directory in PowerShell: + +```powershell +az login +Copy-Item terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars: subscription, regions, publisher, model versions, and quota. +az account set --subscription "" + +terraform init +terraform fmt -check -recursive +terraform validate +terraform plan "-out=deployment.tfplan" +terraform apply "deployment.tfplan" +``` + +Review the plan before applying. These commands create billable resources, +including the Foundry deployments, Premium ACR, Storage, and monitoring. +Preview pricing/terms and access requirements must be checked separately. + +Names are derived from the subscription, environment name, and Foundry region. +Use a different `environment_name` for another environment. Changing those +inputs can replace resources. This configuration creates new infrastructure; +it does not automatically adopt resources provisioned by Bicep. + +### Inputs + +| Input | Required | Meaning | +| --- | --- | --- | +| `subscription_id` | Yes | Target subscription UUID; also supplied explicitly to the provider. | +| `location` | Yes | Foundry, storage, registry, and monitoring region. | +| `gateway_location` | Yes | Region enabled for your AIGateway preview deployment. | +| `storage_public_network_access` | No | `Enabled` by default; set `Disabled` to preserve a policy-required restriction. This sample does not create private storage connectivity. | +| `publisher_email` | Yes | Gateway publisher contact. | +| `model_deployments` | Yes | Map keyed by deployment/catalog name. Each model needs `model_name`, `model_version`, positive integer `capacity`, and `token_limit`. | +| `environment_name` | No | Naming/tagging label; defaults to `aigateway`. | +| `developer_principal_id` | No | Entra object ID for optional developer access; omitted by default. | +| `developer_principal_type` | No | `User`, `Group`, or `ServicePrincipal`; defaults to `User`. | +| `publisher_name`, `project_display_name`, `project_description`, `tags` | No | Display metadata and additional resource tags. | + +`capacity` is the model deployment's SKU-specific quota unit; `token_limit` is +the gateway's per-identity tokens-per-minute limit. They are independent inputs, +not a universal conversion. Each model can also override `model_format`, +`sku_name`, `api_format`, `endpoints`, and `description`; see +[variables.tf](variables.tf). Match endpoints/API format to each model's capabilities. + +### Outputs and credentials + +`terraform output` exposes resource group names, gateway ID/endpoint, the runtime +API key **resource ID**, model deployment IDs, project ID/endpoint, registry login +server, storage name, and monitoring resource IDs. It does not export runtime +key values or telemetry connection strings. + +Foundry backend access, storage, and image pulls use managed identities. The +runtime API key resource is created for applications that authenticate to the +gateway with a key; retrieve and store its value through your approved secret +management process rather than committing it to a `.tfvars` file. + +## State and security + +- This demonstration starts with **local state**. State and saved plans can + contain sensitive resource responses, including telemetry connection strings + and API responses. Restrict filesystem access and do not commit them. + `.gitignore` excludes local state, plans, provider downloads, and user `.tfvars`. + The only committed `.tfvars.json` exception is the non-secret PoC profile; + keep subscription-specific inputs and credentials out of it. Retain + `.terraform.lock.hcl` to pin provider checksums. +- Before shared or production use, configure an access-controlled remote backend + with encryption/locking, such as Azure Blob Storage with Entra authentication, + and migrate local state using `terraform init -migrate-state`. +- Like the upstream sample, the default uses **public network endpoints**. Optional + private gateway/model networking is described below; hosted-agent networking + remains separate. This is not a private-network production baseline. Storage forbids anonymous blobs and shared + keys; ACR admin credentials and Foundry local authentication are disabled. +- If Azure Policy disables storage public access, set + `storage_public_network_access = "Disabled"` rather than overriding the policy. + Gateway/model testing can proceed independently, but hosted-agent storage + connectivity remains unverified until a policy-compliant private network path + is configured separately. +- Project account roles mirror the upstream Bicep, including Cognitive Services + Contributor. Optional developer access grants those account roles and Storage + Blob Data Contributor. Enable it only deliberately and review least privilege. +- Gateway telemetry payload capture is disabled. + +## Private-connectivity PoC profile + +Use [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) +to select the dedicated-SKU configuration proven in the sandbox: + +```text +Private client -> gateway private endpoint -> private model endpoint +``` + +| Setting | Profile value | +| --- | --- | +| `enable_private_networking` | `true` | +| `gateway_public_network_access` | `Enabled` | +| `models_public_network_access` | `Disabled` | + +The profile reuses the resources in this root rather than creating a second +gateway: gateway/model private endpoints and DNS, outbound VNet integration, +gateway managed identity and Foundry User binding, API-key resource, and +Application Insights telemetry with payload capture disabled. Model local +authentication is disabled and its network ACL becomes Deny. + +**Not private-only ingress:** clients can use the gateway private endpoint, +but the public gateway endpoint remains reachable with valid credentials. +The profile does not claim to fix the public-disablement failure documented +below. Hosted-agent networking is unchanged and agent execution is not proven. + +Keep subscription, environment, regions, model choices, policy-compatible storage +settings and other personal inputs in your ignored `terraform.tfvars`. Pass this +profile explicitly with `-var-file`; it is not auto-loaded and does not change +the default public sample. It overrides the three settings above from +`terraform.tfvars` and any `*.auto.tfvars`, including a private-only override. +Review that deliberate gateway-public-access choice before applying. Later +`-var` or `-var-file` arguments can override the profile. + +For an **existing deployment with the private backend already working**, plan +the final PoC configuration directly: + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" +# Review the saved plan, including gateway public access and any resource changes. +terraform apply "private-connectivity.tfplan" +``` + +For a **new deployment or a public-backend deployment**, establish networking +first with temporary model public access, then disable it: + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-var=models_public_network_access=Enabled" "-out=private-connectivity-stage1.tfplan" +# Review, then apply. Model public access is temporarily Enabled in this stage. +terraform apply "private-connectivity-stage1.tfplan" +# Verify approved private endpoints, private DNS, and gateway inference from the VNet. + +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" +# Review, then apply the model public-access Disabled / ACL Deny transition. +terraform apply "private-connectivity.tfplan" +``` + +Use an existing client connected to the VNet, or opt into the test VM by setting +`enable_private_test_client = true` and a real +`private_test_client_ssh_public_key` in your ignored inputs. The profile does not +enable or disable that VM; an already-enabled VM is preserved. See the test-client +instructions below for its no-public-IP design, Run Command probe and deallocation. + +With the optional VM running, repeat the private probe after the final apply: + +```powershell +.\scripts\Test-PrivateGateway.ps1 +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode +``` + +Require an `OK` response from every alias with model public access disabled, +and a final plan exit code of `0`. The probe obtains the gateway key through +managed identity without printing it. A passing pre-disablement probe is not +proof of private backend use. This probe does not independently verify negative +authentication or telemetry ingestion; those require separate runtime checks. +Do not reuse earlier failed private-only saved plans for this PoC. + +## Optional private gateway/model proof + +Set `enable_private_networking = true` to add a VNet, an outbound integration +subnet delegated to `Microsoft.Web/serverFarms`, separate endpoint/client +subnets, two private endpoints, and four private DNS zones. The gateway PE uses +group ID `Gateway`; the model account uses `account`. Gateway networking uses +the working `2025-09-01-preview` contract with `virtualNetworkType = "External"`. +Private Link alone does not provide outbound connectivity. + +The default VNet is `10.247.0.0/16`. Set `private_network_address_prefix` to a +non-overlapping IPv4 /16 before first deployment if needed. Existing corporate +DNS, peering, VPN, forced tunnelling, and custom firewall rules are not configured. +Optional networking additionally requires `Microsoft.Network`; the optional +test VM requires `Microsoft.Compute` and regional VM quota. + +Use staged, reviewed plans, with no resource replacements: + +The profile above stops after stage 2 and intentionally keeps gateway public +access enabled. Stage 3 below is the separate, currently blocked private-only +ingress goal, not a prerequisite for the private-connectivity PoC. + +1. Enable private networking, initially keeping `gateway_public_network_access` + and `models_public_network_access` as `Enabled`. Verify endpoint approval and + private DNS from inside the VNet. +2. Set `models_public_network_access = "Disabled"`, apply, and repeat gateway + inference. Success with model public access disabled proves the backend path. +3. Set `gateway_public_network_access = "Disabled"`, apply, and repeat private + inference. Also prove a previously working valid-key public request now fails. + +**Observed preview result (October 18, 2026, Sweden Central):** private-client +DNS and inference passed for both configured aliases, including with model +public access disabled and its network ACL set to Deny. Disabling gateway public +access failed with `UpdateApiServiceFailed` through both Terraform's service +update and the dedicated CLI's public-access-only PATCH. The approved private +endpoint and outbound integration continued to work, but gateway public access +remained Enabled. A separate fresh minimal gateway in the same subscription and +region reproduced the error without outbound integration or model/backend +configuration, through both the service update and a public-access-only CLI +PATCH. Its approved private endpoint remained reachable; the temporary +reproduction resources were then deleted. This narrows the failure beyond the +original instance but does not establish a root cause or prove that the SKU +never supports private-only ingress. Do not declare private-only operation +until step 3 succeeds and the public negative test passes. No destructive +workaround or automatic re-enablement is included. + +Optionally set `enable_private_test_client = true` and supply +`private_test_client_ssh_public_key` with an OpenSSH **public** key. The Ubuntu +`Standard_B2s_v2` VM has no public IP, denies all inbound traffic, and uses a +managed identity with a custom role granting only gateway API-key retrieval. +Its subnet explicitly enables default outbound internet for the VM agent/ARM: +this PoC client is not an air-gapped production design. Do not place secrets in +the public-key variable or in scripts. + +From this Terraform directory, with the VM running: + +```powershell +.\scripts\Test-PrivateGateway.ps1 +``` + +The script reads non-secret Terraform outputs and uses VM Run Command to execute +the standard-library Python probe. It requires private DNS for both service +hosts and an `OK` inference response from every configured alias. The VM obtains +the runtime key in memory using managed identity; tokens and keys are not +printed or passed in Run Command parameters. A positive report before disabling +model public access is only a partial proof, not evidence of private backend use. + +After testing, deallocate the VM to stop compute billing: + +```powershell +$vmId = terraform output -raw private_test_vm_id +$subscriptionId = ($vmId -split '/')[2] +az vm deallocate --subscription $subscriptionId --ids $vmId +``` + +The managed disk, private endpoints, private DNS, and monitoring can still incur +charges. Start the VM again before rerunning the probe. Removing networking or +turning off its Terraform feature flag can destroy network resources and must +be reviewed separately. Re-enabling public access is an explicit security change, +not an automatic recovery step. + +**Boundary:** this option does not privately network the hosted-agent account, +Storage, or ACR. It does not validate agent execution or promise an SLA. + +## Preview schema limitations + +AzAPI 2.13 does not bundle schemas for the preview connector gateway, telemetry +exporter, model provider/catalog, model alias, and runtime API key resources. Their blocks +set `schema_validation_enabled = false`; all other resource blocks retain the +provider's default schema validation. ARM still validates those resources when +they are provisioned. A successful offline check is **not** proof of preview +access, regional capacity, API acceptance, or runtime connectivity. + +## Offline checks + +```powershell +terraform init -backend=false +terraform fmt -check -recursive +terraform validate +terraform test +``` + +Tests use a mocked AzAPI provider with no Azure authentication or provisioning. +They check model/endpoint/token-limit propagation, independent gateway location, +identity-based backend configuration, opt-in developer roles, and invalid model +inputs. They do not call Azure or prove the hosted-agent application works. +The private-connectivity regression reads the committed JSON profile directly +and checks its mixed gateway-public/model-private posture, endpoint wiring, +managed-identity backend access, API-key resource and telemetry exporter. + +## Runtime verification + +After applying, follow the [gateway model test](../../cli/README.md#5-test-the-gateway) +using the gateway/resource-group outputs, provider `foundry`, and your configured +deployment names. The guide uses the optional AI Gateway CLI extension. +Expect an HTTP 200 response from every model and HTTP 401/403 when the runtime +key is absent or invalid. Model calls can incur inference charges; keep keys out +of terminal output, logs, and source control. + +Confirm the six default RBAC assignments and the Foundry connections exist, and +check the gateway's Application Insights for the requests after allowing for +ingestion delay. Connection metadata alone does not prove that hosted agents can +access storage or pull an image. Finally, run `terraform plan -detailed-exitcode`; +exit code 0 means no drift, 2 means changes remain, and 1 means the plan failed. +Never override a policy-required storage network restriction to make this check +pass. Include the same explicit `-var-file` profile and overrides used for the +deployment when checking drift. + +## 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 tier](https://aka.ms/aigateway/docs) +- [Model and tool management](https://learn.microsoft.com/azure/api-management/ai-gateway-manage-models-tools) diff --git a/samples/terraform/ai-gateway.tf b/samples/terraform/ai-gateway.tf new file mode 100644 index 0000000..baff382 --- /dev/null +++ b/samples/terraform/ai-gateway.tf @@ -0,0 +1,141 @@ +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"] + 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" +} + +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 = "applicationInsights" + payloadCapture = false + applicationInsights = { + connectionString = azapi_resource.insights["gateway"].output.properties.ConnectionString + resourceId = azapi_resource.insights["gateway"].id + } + } + } +} + +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/foundry-agents.tf b/samples/terraform/foundry-agents.tf new file mode 100644 index 0000000..53182b6 --- /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["agents"].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["agents"].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["agents"].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..3ddc929 --- /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["models"].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..4e69d0b --- /dev/null +++ b/samples/terraform/main.tf @@ -0,0 +1,57 @@ +locals { + subscription_scope = "/subscriptions/${var.subscription_id}" + resource_token = substr(sha256("${var.subscription_id}:${var.environment_name}:${var.location}"), 0, 12) + tags = merge(var.tags, { environment = var.environment_name }) + names = { + gateway = "aigw-${local.resource_token}" + models = "aimodels-${local.resource_token}" + agents = "aiagents-${local.resource_token}" + project = "proj-${local.resource_token}" + storage = "st${local.resource_token}" + registry = "acr${local.resource_token}" + } +} + +resource "azapi_resource" "resource_group" { + for_each = toset(["gateway", "models", "agents"]) + 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[each.key].id + location = var.location + tags = local.tags + body = { + properties = { + retentionInDays = 30 + features = { enableLogAccessUsingOnlyResourcePermissions = true } + sku = { name = "PerGB2018" } + } + } +} + +resource "azapi_resource" "insights" { + for_each = toset(["gateway", "agents"]) + type = "Microsoft.Insights/components@2020-02-02" + name = "${local.names[each.key]}-insights" + parent_id = azapi_resource.resource_group[each.key].id + location = var.location + tags = local.tags + body = { + kind = "web" + properties = { + Application_Type = "web" + IngestionMode = "LogAnalytics" + WorkspaceResourceId = azapi_resource.logs[each.key].id + } + } + response_export_values = ["properties.ConnectionString"] +} diff --git a/samples/terraform/outputs.tf b/samples/terraform/outputs.tf new file mode 100644 index 0000000..3102f66 --- /dev/null +++ b/samples/terraform/outputs.tf @@ -0,0 +1,75 @@ +output "resource_groups" { + description = "Resource group names for the three building blocks." + value = { for key, group in azapi_resource.resource_group : key => group.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 "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 "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..d114e6e --- /dev/null +++ b/samples/terraform/private-network.tf @@ -0,0 +1,160 @@ +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"] + } + models = { + resource_id = azapi_resource.models.id + group_id = "account" + dns_keys = ["cognitiveservices", "openai", "services"] + } + } : {} +} + +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.key}-pe" + parent_id = azapi_resource.resource_group["gateway"].id + location = var.gateway_location + tags = local.tags + body = { + properties = { + subnet = { id = azapi_resource.private_subnet["endpoints"].id } + privateLinkServiceConnections = [{ + name = each.key + properties = { + privateLinkServiceId = each.value.resource_id + groupIds = [each.value.group_id] + } + }] + } + } +} + +resource "azapi_resource" "private_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 } + }] + } + } +} 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..53ed1b4 --- /dev/null +++ b/samples/terraform/profiles/private-connectivity.tfvars.json @@ -0,0 +1,5 @@ +{ + "enable_private_networking": 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..0bde24b --- /dev/null +++ b/samples/terraform/rbac.tf @@ -0,0 +1,63 @@ +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" + } + 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}" + } + } +} diff --git a/samples/terraform/scripts/Test-PrivateGateway.ps1 b/samples/terraform/scripts/Test-PrivateGateway.ps1 new file mode 100644 index 0000000..f990e3a --- /dev/null +++ b/samples/terraform/scripts/Test-PrivateGateway.ps1 @@ -0,0 +1,50 @@ +param() + +$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 +$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.' +} +$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 + 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 probe report. VM result: $($result | ConvertTo-Json -Depth 10 -Compress)" + } + $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..2b1d473 --- /dev/null +++ b/samples/terraform/scripts/private-path-probe.py @@ -0,0 +1,89 @@ +"""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 + + +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 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) + 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 = [] + for model in configuration["model_names"]: + status, response = request_json( + opener, + gateway + "/default/models/openai/v1/chat/completions", + {"Content-Type": "application/json", "Api-Key": key}, + { + "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") + results.append({"model": model, "status": status, "reply": text, "usage": response.get("usage")}) + print(json.dumps({"dns": dns, "inference": results, "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/infrastructure.tftest.hcl b/samples/terraform/tests/infrastructure.tftest.hcl new file mode 100644 index 0000000..4911b52 --- /dev/null +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -0,0 +1,301 @@ +mock_provider "azapi" {} + +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 + + 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 = ( + 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) == 6 && + 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) == 13 && + 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 "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 = ( + 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_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.privateLinkServiceConnections[0].properties.privateLinkServiceId == azapi_resource.gateway.id && + 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 == "applicationInsights" && + !azapi_resource.telemetry_exporter.body.properties.payloadCapture + ) + 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_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..436dd8c --- /dev/null +++ b/samples/terraform/tests/test_private_path_probe.py @@ -0,0 +1,59 @@ +import contextlib +import importlib.util +import io +import ipaddress +import json +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"], + } + 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] + 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.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"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/samples/terraform/variables.tf b/samples/terraform/variables.tf new file mode 100644 index 0000000..f90d9bb --- /dev/null +++ b/samples/terraform/variables.tf @@ -0,0 +1,197 @@ +variable "subscription_id" { + description = "Azure subscription in which to provision the three resource groups." + 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 resource group names 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 "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 "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 +} From 8b88bff4e407e2c0b806f92e1d3a852cb1068fd2 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:12:17 +0200 Subject: [PATCH 02/11] docs(terraform): clarify setup paths and preview networking limitations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d152d9d0-aa57-42d9-8fdf-8c24618112cd --- samples/terraform/README.md | 221 +++++++++++++++++++++++++++++------- 1 file changed, 183 insertions(+), 38 deletions(-) diff --git a/samples/terraform/README.md b/samples/terraform/README.md index b209b8e..1215993 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -5,6 +5,54 @@ Provision the three infrastructure building blocks used by the AI Gateway, a dedicated Foundry model account, and a separate Foundry hosted-agent account/project. This is a standalone Terraform sample, not an `azd` application. +## Disclaimer and current limitations + +**Preview PoC, not a production or private-only reference architecture.** +The following distinguishes Microsoft-documented constraints from observations +in this sample's test environment. Documentation checked **October 19, 2026**; +verify the linked Microsoft Learn pages before deploying because preview +features, APIs, availability and restrictions can change. + +| Area | Limitation and deployment implication | Official Microsoft Learn guidance | +| --- | --- | --- | +| AI Gateway preview | Best-effort availability with **no SLA**. APIs, limits, regions and pricing can change. Currently documented gateway regions are East US 2 and Sweden Central; access and capacity can vary by subscription. Connected services remain billable. | [AI Gateway overview and preview FAQ](https://learn.microsoft.com/azure/api-management/ai-gateway-overview) | +| Hosted-agent networking must be designed before account creation | **Hosted-agent VNet injection must be included when the Foundry account is first created; adding it to an existing account is not supported.** This sample does not configure that injection. If private hosted-agent execution is required, select a supported network-secured Foundry deployment before creating the agent account; do not expect to retrofit this sample's account by adding private endpoints. | [Foundry Agent Service networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations) | +| Private endpoints are not the same as VNet injection | The creation-time restriction above is **not a blanket restriction on adding private endpoints**. AI Gateway documentation describes adding an inbound private endpoint to an existing gateway and configuring outbound VNet integration separately. Inbound Private Link alone does not give the gateway or hosted agents private backend access. | [AI Gateway private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) | +| Gateway public-access disablement | Microsoft documents disabling public access after private connectivity is verified. **Our October 18 Sweden Central tests failed that update**, including on a separate minimal gateway. The PoC therefore leaves gateway public access enabled. This is an observed unresolved failure, not a Microsoft-documented universal SKU limitation. | [Documented inbound Private Link sequence](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking#inbound-private-link); [sample findings](#known-blocker-private-only-gateway-ingress) | +| Private hosted-agent image pulls | Current guidance supports private ACR for Foundry projects created after June 25, 2026; older projects require public ACR reachability. This does not remove the account-creation requirement for hosted-agent VNet injection. The sample leaves ACR public and does not prove hosted-agent execution. | [Foundry networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations) | + +**Decide on hosted-agent isolation before running `terraform apply`.** The +optional private profile covers only the gateway/model path, not the agent +account, Storage or ACR. It also uses a temporary-public-model staging step; +if your policies prohibit that step, do not use the walkthrough unchanged. +Infrastructure provisioning, private endpoints and successful gateway model +calls do not establish end-to-end private hosted-agent execution. + +## Choose your deployment + +| Goal | Start here | Network posture | +| --- | --- | --- | +| Deploy the basic infrastructure sample | [Configure and provision](#configure-and-provision) | Gateway and model endpoints are public by default; authentication is required. | +| Try the verified private gateway-to-model path | [Private-connectivity PoC profile](#private-connectivity-poc-profile) | Gateway and model have private endpoints; model public access is disabled, but gateway public access remains enabled. | +| Require private-only gateway ingress | [Known blocker: private-only gateway ingress](#known-blocker-private-only-gateway-ingress) | **Not verified in the tested environment.** Disabling gateway public access failed; do not use this PoC to satisfy a no-public-ingress requirement. | + +**Recommended first run:** provision the basic sample, then +[test a model through the gateway](#test-a-model-through-the-gateway). +If your policies do not permit public model endpoints, do not run that default +deployment: review the private profile's temporary-public-model staging with +your network/security owner first. Neither walkthrough establishes private-only +gateway ingress or a production-ready hosted-agent deployment. +If hosted agents must run inside your VNet, first review the +[creation-time networking constraint](#disclaimer-and-current-limitations) +instead of provisioning the default agent account. + +The sections below cover [prerequisites](#prerequisites), +[deployment inputs](#inputs), [state and security](#state-and-security), +[private-network details and the test VM](#private-network-details-and-test-client), +and [offline checks](#offline-checks). + +### Implementation background + The configuration follows the upstream [Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra). It uses the AzAPI provider to express the preview ARM resources directly. @@ -32,7 +80,9 @@ counter-key format rather than the older scalar `"Identity"` format. definition, start a hosted-agent container, register GitHub MCP tools, configure GitHub credentials, or create a daily-digest schedule. Use the end-to-end sample for application setup after provisioning. It does not replace its `azd` hooks or -produce an `azd` environment. +produce an `azd` environment. It also does not configure the creation-time +hosted-agent VNet injection required for private agent execution; review the +[disclaimer](#disclaimer-and-current-limitations) before creating that account. ## Prerequisites @@ -55,7 +105,18 @@ before applying. Region/model availability is not inferred by this configuration ## Configure and provision -Run from this directory in PowerShell: +This is the **default public-endpoint deployment**. For the private-connectivity +PoC, complete the same sign-in, input-file setup, initialization and validation +below, then use the profile's [staged plan/apply commands](#private-connectivity-poc-profile) +instead of the final two commands here. + +From the repository root, open this directory: + +```powershell +Set-Location .\samples\terraform +``` + +Run the following in PowerShell: ```powershell az login @@ -70,6 +131,9 @@ terraform plan "-out=deployment.tfplan" terraform apply "deployment.tfplan" ``` +After apply succeeds, [test a model through the gateway](#test-a-model-through-the-gateway). +Provisioning infrastructure alone does not start a hosted agent. + Review the plan before applying. These commands create billable resources, including the Foundry deployments, Premium ACR, Storage, and monitoring. Preview pricing/terms and access requirements must be checked separately. @@ -215,7 +279,7 @@ proof of private backend use. This probe does not independently verify negative authentication or telemetry ingestion; those require separate runtime checks. Do not reuse earlier failed private-only saved plans for this PoC. -## Optional private gateway/model proof +## Private-network details and test client Set `enable_private_networking = true` to add a VNet, an outbound integration subnet delegated to `Microsoft.Web/serverFarms`, separate endpoint/client @@ -230,35 +294,12 @@ DNS, peering, VPN, forced tunnelling, and custom firewall rules are not configur Optional networking additionally requires `Microsoft.Network`; the optional test VM requires `Microsoft.Compute` and regional VM quota. -Use staged, reviewed plans, with no resource replacements: - -The profile above stops after stage 2 and intentionally keeps gateway public -access enabled. Stage 3 below is the separate, currently blocked private-only -ingress goal, not a prerequisite for the private-connectivity PoC. - -1. Enable private networking, initially keeping `gateway_public_network_access` - and `models_public_network_access` as `Enabled`. Verify endpoint approval and - private DNS from inside the VNet. -2. Set `models_public_network_access = "Disabled"`, apply, and repeat gateway - inference. Success with model public access disabled proves the backend path. -3. Set `gateway_public_network_access = "Disabled"`, apply, and repeat private - inference. Also prove a previously working valid-key public request now fails. - -**Observed preview result (October 18, 2026, Sweden Central):** private-client -DNS and inference passed for both configured aliases, including with model -public access disabled and its network ACL set to Deny. Disabling gateway public -access failed with `UpdateApiServiceFailed` through both Terraform's service -update and the dedicated CLI's public-access-only PATCH. The approved private -endpoint and outbound integration continued to work, but gateway public access -remained Enabled. A separate fresh minimal gateway in the same subscription and -region reproduced the error without outbound integration or model/backend -configuration, through both the service update and a public-access-only CLI -PATCH. Its approved private endpoint remained reachable; the temporary -reproduction resources were then deleted. This narrows the failure beyond the -original instance but does not establish a root cause or prove that the SKU -never supports private-only ingress. Do not declare private-only operation -until step 3 succeeds and the public negative test passes. No destructive -workaround or automatic re-enablement is included. +Use the [profile walkthrough](#private-connectivity-poc-profile) for staged, +reviewed plans. Its completion point is successful private inference with model +public access disabled and gateway public access still enabled. Review any +unexpected resource replacement before applying. Gateway public disablement is +a [separate blocked goal](#known-blocker-private-only-gateway-ingress), not a +third step required to complete the PoC. Optionally set `enable_private_test_client = true` and supply `private_test_client_ssh_public_key` with an OpenSSH **public** key. The Ubuntu @@ -297,6 +338,39 @@ not an automatic recovery step. **Boundary:** this option does not privately network the hosted-agent account, Storage, or ACR. It does not validate agent execution or promise an SLA. +Adding these gateway/model private endpoints does not retrofit hosted-agent +VNet injection into an existing Foundry account; that is a separate +[creation-time requirement](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). + +## Known blocker: private-only gateway ingress + +**This is a diagnostic finding, not a deployment step.** Do not change +`gateway_public_network_access` to `Disabled` as part of the working PoC. + +**Observed preview result (October 18, 2026, Sweden Central):** private-client +DNS and inference passed for both configured aliases, including with model +public access disabled and its network ACL set to Deny. Disabling gateway public +access failed with `UpdateApiServiceFailed` through both Terraform's service +update and the dedicated CLI's public-access-only PATCH. The approved private +endpoint and outbound integration continued to work, but gateway public access +remained Enabled. A separate fresh minimal gateway in the same subscription and +region reproduced the error without outbound integration or model/backend +configuration, through both the service update and a public-access-only CLI +PATCH. Its approved private endpoint remained reachable; the temporary +reproduction resources were then deleted. + +This narrows the failure beyond the original instance but does not establish a +root cause or prove that the SKU never supports private-only ingress. A future +private-only implementation must meet all of these acceptance criteria: + +- The gateway reports public network access `Disabled`. +- Every configured model alias still succeeds through the gateway private endpoint. +- A previously working public request with a valid key receives a network-access + rejection, not merely an authentication failure. +- A plan using the intended private-only inputs reports no pending changes. + +Until then, treat a no-public-ingress requirement as blocked. No destructive +workaround or automatic re-enablement is included. ## Preview schema limitations @@ -326,12 +400,80 @@ managed-identity backend access, API-key resource and telemetry exporter. ## Runtime verification -After applying, follow the [gateway model test](../../cli/README.md#5-test-the-gateway) -using the gateway/resource-group outputs, provider `foundry`, and your configured -deployment names. The guide uses the optional AI Gateway CLI extension. -Expect an HTTP 200 response from every model and HTTP 401/403 when the runtime -key is absent or invalid. Model calls can incur inference charges; keep keys out -of terminal output, logs, and source control. +### Test a model through the gateway + +Run this in PowerShell from `samples\terraform` after a successful apply, while +signed in to Azure. It uses Terraform outputs and Azure CLI; the optional AI +Gateway CLI extension is **not required**. Your signed-in identity needs +permission to retrieve the gateway API key +(`Microsoft.ApiManagement/service/apiKeys/listSecrets/action`). + +The example calls every configured alias and assumes chat-completions models, +such as the two models in `terraform.tfvars.example`. For other model types, +use their supported API and request body instead. Model calls incur inference +charges. Do not enable CLI debug output, PowerShell tracing or session recording +while retrieving or using keys. + +```powershell +$outputJson = terraform output -json +if ($LASTEXITCODE -ne 0) { throw "Unable to read Terraform outputs." } +$outputs = $outputJson | 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." +} + +$apiKey = $null +$headers = $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 -ErrorAction Stop + if (!$reply.choices -or !$reply.choices[0].message.content) { + throw "No chat-completion text returned for model alias '$model'." + } + "{0}: {1}" -f $model, $reply.choices[0].message.content + } +} +finally { + $headers = $null + $apiKey = $null +} +``` + +Expect a short reply prefixed by each alias, for example `gpt-mini: Hello!`. +The key is held in memory and cleared from the example's variables even if a +request fails; it is not printed or written to a file. Stop on errors rather +than disabling authentication or relaxing network policies. A `401/403` can +indicate authentication, authorization or network restrictions; a `429` can +indicate token limits or capacity. Inspect the service error before retrying. + +**This direct request does not prove private routing.** With the PoC profile, +a public client can still reach the gateway. Use the +[private test-client probe](#private-network-details-and-test-client) from the +VNet to verify private DNS and inference with model public access disabled. + +For CLI-based alternatives, follow the +[gateway model test](../../cli/README.md#5-test-the-gateway), using provider +`foundry` and your configured deployment names. Separately verify that missing +or invalid runtime keys receive HTTP `401/403`; a successful authenticated call +does not test that protection. + +### Check telemetry and deployment consistency Confirm the six default RBAC assignments and the Foundry connections exist, and check the gateway's Application Insights for the requests after allowing for @@ -347,4 +489,7 @@ deployment when checking drift. - [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 tier](https://aka.ms/aigateway/docs) +- [AI Gateway overview, preview availability and SLA](https://learn.microsoft.com/azure/api-management/ai-gateway-overview) +- [Configure private networking for AI Gateway](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) +- [Foundry Agent Service private networking and limitations](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) From 34d7cdc6f4ccf1c6222782e6832e3ce17483a3af Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:42:24 +0200 Subject: [PATCH 03/11] docs(terraform): organize README by component purpose and deployment path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d152d9d0-aa57-42d9-8fdf-8c24618112cd --- samples/terraform/README.md | 721 +++++++++++++++++++++--------------- 1 file changed, 424 insertions(+), 297 deletions(-) diff --git a/samples/terraform/README.md b/samples/terraform/README.md index 1215993..5c38ccb 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -1,88 +1,85 @@ # AI Gateway and Foundry infrastructure with Terraform -Provision the three infrastructure building blocks used by the -[Foundry AI Gateway Python sample](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway): -AI Gateway, a dedicated Foundry model account, and a separate Foundry hosted-agent -account/project. This is a standalone Terraform sample, not an `azd` application. +**Use this sample to deploy an AI Gateway and call Foundry models through it.** +It also prepares a separate Foundry project for a hosted-agent application, but +does not deploy or run that application. -## Disclaimer and current limitations +This is standalone Terraform infrastructure for the +[Foundry AI Gateway Python sample](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway), +not an `azd` application. -**Preview PoC, not a production or private-only reference architecture.** -The following distinguishes Microsoft-documented constraints from observations -in this sample's test environment. Documentation checked **October 19, 2026**; -verify the linked Microsoft Learn pages before deploying because preview -features, APIs, availability and restrictions can change. +**Read in this order:** [understand the components](#what-is-provisioned), +[choose a path](#choose-your-deployment), complete the +[shared setup](#configure-and-provision), then follow **only Path A or Path B**. +The [technical reference](#technical-reference) is for looking up details, +not a second deployment walkthrough. -| Area | Limitation and deployment implication | Official Microsoft Learn guidance | -| --- | --- | --- | -| AI Gateway preview | Best-effort availability with **no SLA**. APIs, limits, regions and pricing can change. Currently documented gateway regions are East US 2 and Sweden Central; access and capacity can vary by subscription. Connected services remain billable. | [AI Gateway overview and preview FAQ](https://learn.microsoft.com/azure/api-management/ai-gateway-overview) | -| Hosted-agent networking must be designed before account creation | **Hosted-agent VNet injection must be included when the Foundry account is first created; adding it to an existing account is not supported.** This sample does not configure that injection. If private hosted-agent execution is required, select a supported network-secured Foundry deployment before creating the agent account; do not expect to retrofit this sample's account by adding private endpoints. | [Foundry Agent Service networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations) | -| Private endpoints are not the same as VNet injection | The creation-time restriction above is **not a blanket restriction on adding private endpoints**. AI Gateway documentation describes adding an inbound private endpoint to an existing gateway and configuring outbound VNet integration separately. Inbound Private Link alone does not give the gateway or hosted agents private backend access. | [AI Gateway private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) | -| Gateway public-access disablement | Microsoft documents disabling public access after private connectivity is verified. **Our October 18 Sweden Central tests failed that update**, including on a separate minimal gateway. The PoC therefore leaves gateway public access enabled. This is an observed unresolved failure, not a Microsoft-documented universal SKU limitation. | [Documented inbound Private Link sequence](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking#inbound-private-link); [sample findings](#known-blocker-private-only-gateway-ingress) | -| Private hosted-agent image pulls | Current guidance supports private ACR for Foundry projects created after June 25, 2026; older projects require public ACR reachability. This does not remove the account-creation requirement for hosted-agent VNet injection. The sample leaves ACR public and does not prove hosted-agent execution. | [Foundry networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations) | - -**Decide on hosted-agent isolation before running `terraform apply`.** The -optional private profile covers only the gateway/model path, not the agent -account, Storage or ACR. It also uses a temporary-public-model staging step; -if your policies prohibit that step, do not use the walkthrough unchanged. -Infrastructure provisioning, private endpoints and successful gateway model -calls do not establish end-to-end private hosted-agent execution. +## What is provisioned -## Choose your deployment +### What each component is for -| Goal | Start here | Network posture | +| Component | Its job | What you get from this sample | | --- | --- | --- | -| Deploy the basic infrastructure sample | [Configure and provision](#configure-and-provision) | Gateway and model endpoints are public by default; authentication is required. | -| Try the verified private gateway-to-model path | [Private-connectivity PoC profile](#private-connectivity-poc-profile) | Gateway and model have private endpoints; model public access is disabled, but gateway public access remains enabled. | -| Require private-only gateway ingress | [Known blocker: private-only gateway ingress](#known-blocker-private-only-gateway-ingress) | **Not verified in the tested environment.** Disabling gateway public access failed; do not use this PoC to satisfy a no-public-ingress requirement. | - -**Recommended first run:** provision the basic sample, then -[test a model through the gateway](#test-a-model-through-the-gateway). -If your policies do not permit public model endpoints, do not run that default -deployment: review the private profile's temporary-public-model staging with -your network/security owner first. Neither walkthrough establishes private-only -gateway ingress or a production-ready hosted-agent deployment. -If hosted agents must run inside your VNet, first review the -[creation-time networking constraint](#disclaimer-and-current-limitations) -instead of provisioning the default agent account. - -The sections below cover [prerequisites](#prerequisites), -[deployment inputs](#inputs), [state and security](#state-and-security), -[private-network details and the test VM](#private-network-details-and-test-client), -and [offline checks](#offline-checks). - -### Implementation background +| **AI Gateway** | The front door for model calls: authenticates clients, applies token limits, routes requests and records telemetry. It does not run the model itself. | A dedicated **AIGateway** preview SKU, model registrations/aliases, a runtime API key resource and monitoring. | +| **Foundry model account** | Runs the deployed models that answer requests forwarded by the gateway. | A separate AI Services account with the model versions and capacity you choose. The gateway uses its managed identity to access it, not a backend API key. | +| **Foundry hosted-agent account and project** | The foundation for a later application that runs an agent. Storage holds project artifacts; Container Registry holds its container image. | Account/project, Storage, Premium Container Registry, access roles, connections and monitoring. **No agent image, definition or running container.** | +| **Optional private network and test VM** | Adds and verifies a private route between a client, the gateway and the models. | Gateway/model private endpoints, private DNS, gateway outbound VNet integration and an optional VM that runs a probe. **Does not privately network the hosted-agent component.** | -The configuration follows the upstream -[Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra). -It uses the AzAPI provider to express the preview ARM resources directly. -The current token-policy shape and runtime model aliases incorporate the -[upstream model-registration fix](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/commit/03fcd3788aea06cf6000af19890f8164303348d5). +**Both deployment paths create all three core components.** Choosing the private +profile changes networking; it does not select a smaller gateway-only deployment. +Hosted-agent infrastructure is provisioned even if you only test model calls. -## What is provisioned +### How a model request flows -| Building block | Resources | -| --- | --- | -| [AI Gateway](ai-gateway.tf) | API Management with the **AIGateway** SKU and system-assigned identity, connector gateway, Foundry model provider, model catalog entries with per-identity token limits, runtime model aliases, runtime API key resource, and Application Insights telemetry exporter. | -| [Foundry Models](foundry-models.tf) | Separate AI Services account with local authentication disabled and explicitly versioned model deployments. The gateway identity receives Foundry User access on this account. | -| [Foundry Hosted Agent infrastructure](foundry-agents.tf) | Project-enabled AI Services account, project identity, Storage, Premium Container Registry, project connections, and agent tracing through Application Insights. | -| [Shared resources](main.tf) and [RBAC](rbac.tf) | Three resource groups, separate gateway/agent Log Analytics workspaces and Application Insights components, project access to its account/storage/registry, and optional developer access. | -| Optional [private-connectivity PoC profile](#private-connectivity-poc-profile) | Gateway/model private endpoints and DNS, gateway outbound VNet integration, private model access, and an optional private test client. Keeps the dedicated AIGateway SKU and explicitly leaves gateway public access enabled. | - -Model operations are serialized per account using AzAPI resource locks. The -gateway's automatically created `default` workspace is referenced rather than -created a second time. -Each catalog entry has a matching workspace alias; the alias is required to route -runtime requests to the model. Token policies use the current `["identity"]` -counter-key format rather than the older scalar `"Identity"` format. - -**Infrastructure only:** this does not build/push an agent image, create an agent -definition, start a hosted-agent container, register GitHub MCP tools, configure -GitHub credentials, or create a daily-digest schedule. Use the end-to-end sample -for application setup after provisioning. It does not replace its `azd` hooks or -produce an `azd` environment. It also does not configure the creation-time -hosted-agent VNet injection required for private agent execution; review the -[disclaimer](#disclaimer-and-current-limitations) before creating that account. +```text +Your application or test client + | + | Runtime API key: authenticates the client to the gateway + v +AI Gateway: validates the key, applies token limits, selects a model alias + | + | Managed identity: authenticates the gateway to the model account + v +Foundry model account: runs the selected model and returns the answer +``` + +A **model alias** is the name your client sends, such as `gpt-mini`; it is not +necessarily the underlying model name, such as `gpt-4.1-mini`. +The hosted-agent project is not needed to execute this test request. Deploying +an actual agent, building/pushing its image, configuring GitHub MCP credentials +and setting a daily-digest schedule are separate application steps in the +end-to-end sample. This Terraform configuration does not replace its `azd` hooks. + +## Choose your deployment + +| | Path A: public-endpoint sample | Path B: private-connectivity PoC | +| --- | --- | --- | +| **Use it to** | Make a first authenticated model call through the gateway. | Demonstrate private client-to-gateway and gateway-to-model connectivity. | +| **Client location** | Your local machine can call the public gateway. | A client inside or connected to the VNet; the included test VM is the walkthrough's example. | +| **Gateway public access** | Enabled. | **Still enabled**; valid-key public calls can still work. | +| **Model public access** | Enabled. | Disabled after a temporary-public setup stage. | +| **Additional resources** | No sample VNet or test VM by default. | VNet, private endpoints/DNS and optional billable test VM. | +| **Instructions after shared setup** | [Path A](#path-a-public-endpoint-sample) | [Path B](#private-connectivity-poc-profile) | +| **Completion means** | Every configured alias returns a model response. | Every alias works from the VNet with model public access disabled; no pending Terraform changes. | + +### Stop and check these constraints first + +**Preview PoC, not a production security baseline.** Review the full +[disclaimer and official Microsoft Learn sources](#disclaimer-and-current-limitations) +before applying. In particular: + +- **No public gateway ingress allowed? Neither path meets that requirement.** + Gateway public-access disablement failed in the tested environment; + [private-only ingress remains blocked](#known-blocker-private-only-gateway-ingress). +- **Need private hosted-agent execution? Decide before creating the account.** + Microsoft requires hosted-agent VNet injection at Foundry account creation; + this sample does not configure it. Adding private endpoints later is not a + substitute. +- **Public model endpoints prohibited, even temporarily?** Do not run Path A or + Path B's staging unchanged. Review a policy-compliant deployment separately. + +The gateway preview has **no SLA**. Both paths create billable resources; +model calls, Premium ACR, Storage and monitoring can incur charges. ## Prerequisites @@ -102,110 +99,85 @@ hosted-agent VNet injection required for private agent execution; review the Confirm preview access, model availability, and organizational network policies before applying. Region/model availability is not inferred by this configuration. +Path B additionally requires `Microsoft.Network`; the test VM requires +`Microsoft.Compute`, regional VM quota and permission to use VM Run Command. ## Configure and provision -This is the **default public-endpoint deployment**. For the private-connectivity -PoC, complete the same sign-in, input-file setup, initialization and validation -below, then use the profile's [staged plan/apply commands](#private-connectivity-poc-profile) -instead of the final two commands here. +**Shared setup for both paths.** These steps prepare your inputs and Terraform; +they do not deploy resources yet. All later commands run from this same directory. + +### Prepare your personal input file -From the repository root, open this directory: +From the repository root, sign in and copy the example **only if you do not +already have `terraform.tfvars`**: ```powershell Set-Location .\samples\terraform +az login +# Fresh checkout only; do not overwrite existing inputs. +Copy-Item terraform.tfvars.example terraform.tfvars ``` -Run the following in PowerShell: +For a fresh checkout, copy the example once. If you already have +`terraform.tfvars`, edit it instead of overwriting it. Before continuing, open +the file and set: + +| Set in `terraform.tfvars` | Why it matters | +| --- | --- | +| `subscription_id` | Determines where resources are created. | +| `environment_name` | Distinguishes this deployment from another environment. | +| `location` and `gateway_location` | Choose supported Foundry/model and gateway regions respectively. | +| `publisher_email` | Gateway publisher contact. | +| `model_deployments` | Choose model versions, deployment capacity and token limits available to your subscription. The example's `gpt-mini` and `gpt-full` keys become the client-facing aliases. | +| `storage_public_network_access`, if policy requires it | Use `Disabled` when required by policy; private hosted-agent storage connectivity is not supplied here. | + +Keep this file local: it is ignored by Git. See the +[input reference](#inputs) for optional fields. Review +[state and security](#state-and-security) before applying; state and saved plans +can contain sensitive data. + +### Initialize Terraform ```powershell -az login -Copy-Item terraform.tfvars.example terraform.tfvars -# Edit terraform.tfvars: subscription, regions, publisher, model versions, and quota. az account set --subscription "" - terraform init terraform fmt -check -recursive terraform validate -terraform plan "-out=deployment.tfplan" -terraform apply "deployment.tfplan" ``` -After apply succeeds, [test a model through the gateway](#test-a-model-through-the-gateway). -Provisioning infrastructure alone does not start a hosted agent. +Stop if a command fails. Choose **one** path below; do not run both sets of +plan/apply commands in sequence. This creates new infrastructure, not an +automatic import of existing Bicep resources. Names depend on subscription, +environment name and Foundry region; changing those inputs can replace resources. -Review the plan before applying. These commands create billable resources, -including the Foundry deployments, Premium ACR, Storage, and monitoring. -Preview pricing/terms and access requirements must be checked separately. +## Path A: public-endpoint sample -Names are derived from the subscription, environment name, and Foundry region. -Use a different `environment_name` for another environment. Changing those -inputs can replace resources. This configuration creates new infrastructure; -it does not automatically adopt resources provisioned by Bicep. +**Purpose:** get a first model response without setting up private networking. +Use this only when your policies permit public gateway and model endpoints. +On a fresh checkout, the example inputs use this posture; do not reuse unrelated +private-network overrides. -### Inputs +```powershell +terraform plan "-out=deployment.tfplan" +``` -| Input | Required | Meaning | -| --- | --- | --- | -| `subscription_id` | Yes | Target subscription UUID; also supplied explicitly to the provider. | -| `location` | Yes | Foundry, storage, registry, and monitoring region. | -| `gateway_location` | Yes | Region enabled for your AIGateway preview deployment. | -| `storage_public_network_access` | No | `Enabled` by default; set `Disabled` to preserve a policy-required restriction. This sample does not create private storage connectivity. | -| `publisher_email` | Yes | Gateway publisher contact. | -| `model_deployments` | Yes | Map keyed by deployment/catalog name. Each model needs `model_name`, `model_version`, positive integer `capacity`, and `token_limit`. | -| `environment_name` | No | Naming/tagging label; defaults to `aigateway`. | -| `developer_principal_id` | No | Entra object ID for optional developer access; omitted by default. | -| `developer_principal_type` | No | `User`, `Group`, or `ServicePrincipal`; defaults to `User`. | -| `publisher_name`, `project_display_name`, `project_description`, `tags` | No | Display metadata and additional resource tags. | - -`capacity` is the model deployment's SKU-specific quota unit; `token_limit` is -the gateway's per-identity tokens-per-minute limit. They are independent inputs, -not a universal conversion. Each model can also override `model_format`, -`sku_name`, `api_format`, `endpoints`, and `description`; see -[variables.tf](variables.tf). Match endpoints/API format to each model's capabilities. +Review the plan, including public access, resource creation and costs. Then apply +that reviewed plan: -### Outputs and credentials +```powershell +terraform apply "deployment.tfplan" +``` -`terraform output` exposes resource group names, gateway ID/endpoint, the runtime -API key **resource ID**, model deployment IDs, project ID/endpoint, registry login -server, storage name, and monitoring resource IDs. It does not export runtime -key values or telemetry connection strings. - -Foundry backend access, storage, and image pulls use managed identities. The -runtime API key resource is created for applications that authenticate to the -gateway with a key; retrieve and store its value through your approved secret -management process rather than committing it to a `.tfvars` file. - -## State and security - -- This demonstration starts with **local state**. State and saved plans can - contain sensitive resource responses, including telemetry connection strings - and API responses. Restrict filesystem access and do not commit them. - `.gitignore` excludes local state, plans, provider downloads, and user `.tfvars`. - The only committed `.tfvars.json` exception is the non-secret PoC profile; - keep subscription-specific inputs and credentials out of it. Retain - `.terraform.lock.hcl` to pin provider checksums. -- Before shared or production use, configure an access-controlled remote backend - with encryption/locking, such as Azure Blob Storage with Entra authentication, - and migrate local state using `terraform init -migrate-state`. -- Like the upstream sample, the default uses **public network endpoints**. Optional - private gateway/model networking is described below; hosted-agent networking - remains separate. This is not a private-network production baseline. Storage forbids anonymous blobs and shared - keys; ACR admin credentials and Foundry local authentication are disabled. -- If Azure Policy disables storage public access, set - `storage_public_network_access = "Disabled"` rather than overriding the policy. - Gateway/model testing can proceed independently, but hosted-agent storage - connectivity remains unverified until a policy-compliant private network path - is configured separately. -- Project account roles mirror the upstream Bicep, including Cognitive Services - Contributor. Optional developer access grants those account roles and Storage - Blob Data Contributor. Enable it only deliberately and review least privilege. -- Gateway telemetry payload capture is disabled. +**Next:** run [Test a model through the gateway](#test-a-model-through-the-gateway), +then [check telemetry and deployment consistency](#check-telemetry-and-deployment-consistency). +You are done with Path A when every alias returns a response and the final +plan has no pending changes. This does not deploy an agent application. ## Private-connectivity PoC profile -Use [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) -to select the dedicated-SKU configuration proven in the sandbox: +**Path B. Purpose:** prove the following private route, using the same dedicated +AIGateway SKU and core infrastructure as Path A: ```text Private client -> gateway private endpoint -> private model endpoint @@ -217,112 +189,106 @@ Private client -> gateway private endpoint -> private model endpoint | `gateway_public_network_access` | `Enabled` | | `models_public_network_access` | `Disabled` | -The profile reuses the resources in this root rather than creating a second -gateway: gateway/model private endpoints and DNS, outbound VNet integration, -gateway managed identity and Foundry User binding, API-key resource, and -Application Insights telemetry with payload capture disabled. Model local -authentication is disabled and its network ACL becomes Deny. +Select [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) +explicitly for this path. It adds gateway/model private endpoints, DNS and +gateway outbound VNet integration to the same deployment, not a second gateway. +Gateway identity-based backend access, API-key authentication and telemetry +remain in place. Model local authentication is disabled and, in the final +configuration, its network ACL becomes Deny. **Not private-only ingress:** clients can use the gateway private endpoint, but the public gateway endpoint remains reachable with valid credentials. The profile does not claim to fix the public-disablement failure documented below. Hosted-agent networking is unchanged and agent execution is not proven. -Keep subscription, environment, regions, model choices, policy-compatible storage -settings and other personal inputs in your ignored `terraform.tfvars`. Pass this -profile explicitly with `-var-file`; it is not auto-loaded and does not change -the default public sample. It overrides the three settings above from -`terraform.tfvars` and any `*.auto.tfvars`, including a private-only override. -Review that deliberate gateway-public-access choice before applying. Later -`-var` or `-var-file` arguments can override the profile. +### B1. Choose the client that will prove private connectivity -For an **existing deployment with the private backend already working**, plan -the final PoC configuration directly: +Your laptop is not automatically connected to the new VNet. The steps below use +the optional test VM so no VPN or inbound SSH is needed. Before the first plan, +add these inputs to your ignored `terraform.tfvars`, replacing the key +placeholder with a real OpenSSH ed25519 or RSA **public** key: -```powershell -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" -# Review the saved plan, including gateway public access and any resource changes. -terraform apply "private-connectivity.tfplan" +```hcl +enable_private_test_client = true +private_test_client_ssh_public_key = "ssh-ed25519 " ``` -For a **new deployment or a public-backend deployment**, establish networking -first with temporary model public access, then disable it: +The key is required to provision the VM; the probe uses Azure VM Run Command, +not SSH. Never paste a private key or password. The VM is billable, has no +public IP and denies inbound traffic. See [networking reference](#networking-reference) +for its outbound-access design and required network settings. + +If you already have a client connected to the VNet, you can omit the VM. +Replace the VM-probe steps below with equivalent private DNS and inference +checks from that client; `Test-PrivateGateway.ps1` specifically requires the VM. + +### B2. Create networking with the model temporarily public + +For a **new deployment or a public-backend deployment**, first make the private +route available. This stage is not the final security posture. ```powershell terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-var=models_public_network_access=Enabled" "-out=private-connectivity-stage1.tfplan" -# Review, then apply. Model public access is temporarily Enabled in this stage. -terraform apply "private-connectivity-stage1.tfplan" -# Verify approved private endpoints, private DNS, and gateway inference from the VNet. +``` -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" -# Review, then apply the model public-access Disabled / ACL Deny transition. -terraform apply "private-connectivity.tfplan" +Review the plan: both gateway and model public access are temporarily enabled, +and networking plus the selected test VM are created. Do not apply unexpected +replacements. Then: + +```powershell +terraform apply "private-connectivity-stage1.tfplan" ``` -Use an existing client connected to the VNet, or opt into the test VM by setting -`enable_private_test_client = true` and a real -`private_test_client_ssh_public_key` in your ignored inputs. The profile does not -enable or disable that VM; an already-enabled VM is preserved. See the test-client -instructions below for its no-public-IP design, Run Command probe and deallocation. +### B3. Check the private route before disabling model public access -With the optional VM running, repeat the private probe after the final apply: +With the test VM running: ```powershell .\scripts\Test-PrivateGateway.ps1 -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode ``` -Require an `OK` response from every alias with model public access disabled, -and a final plan exit code of `0`. The probe obtains the gateway key through -managed identity without printing it. A passing pre-disablement probe is not -proof of private backend use. This probe does not independently verify negative -authentication or telemetry ingestion; those require separate runtime checks. -Do not reuse earlier failed private-only saved plans for this PoC. - -## Private-network details and test client - -Set `enable_private_networking = true` to add a VNet, an outbound integration -subnet delegated to `Microsoft.Web/serverFarms`, separate endpoint/client -subnets, two private endpoints, and four private DNS zones. The gateway PE uses -group ID `Gateway`; the model account uses `account`. Gateway networking uses -the working `2025-09-01-preview` contract with `virtualNetworkType = "External"`. -Private Link alone does not provide outbound connectivity. - -The default VNet is `10.247.0.0/16`. Set `private_network_address_prefix` to a -non-overlapping IPv4 /16 before first deployment if needed. Existing corporate -DNS, peering, VPN, forced tunnelling, and custom firewall rules are not configured. -Optional networking additionally requires `Microsoft.Network`; the optional -test VM requires `Microsoft.Compute` and regional VM quota. - -Use the [profile walkthrough](#private-connectivity-poc-profile) for staged, -reviewed plans. Its completion point is successful private inference with model -public access disabled and gateway public access still enabled. Review any -unexpected resource replacement before applying. Gateway public disablement is -a [separate blocked goal](#known-blocker-private-only-gateway-ingress), not a -third step required to complete the PoC. - -Optionally set `enable_private_test_client = true` and supply -`private_test_client_ssh_public_key` with an OpenSSH **public** key. The Ubuntu -`Standard_B2s_v2` VM has no public IP, denies all inbound traffic, and uses a -managed identity with a custom role granting only gateway API-key retrieval. -Its subnet explicitly enables default outbound internet for the VM agent/ARM: -this PoC client is not an air-gapped production design. Do not place secrets in -the public-key variable or in scripts. - -From this Terraform directory, with the VM running: +The script runs inside the VM through Run Command. It checks that the gateway +and model hostnames resolve into the private endpoint subnet and every alias +answers `OK`. The VM retrieves the gateway key using managed identity; no key +is printed or passed as a Run Command argument. + +**Stop if the probe fails.** Check endpoint approval, private DNS, permissions +and outbound integration before continuing. A pass here is only an initial +connectivity check: model public access is still enabled. + +### B4. Disable model public access + +Plan again **without** the temporary `Enabled` override: + +```powershell +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" +``` + +Review the transition: model public access becomes `Disabled` / ACL Deny; +gateway public access stays `Enabled`. Then: + +```powershell +terraform apply "private-connectivity.tfplan" +``` + +### B5. Prove the final private path ```powershell .\scripts\Test-PrivateGateway.ps1 +terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode ``` -The script reads non-secret Terraform outputs and uses VM Run Command to execute -the standard-library Python probe. It requires private DNS for both service -hosts and an `OK` inference response from every configured alias. The VM obtains -the runtime key in memory using managed identity; tokens and keys are not -printed or passed in Run Command parameters. A positive report before disabling -model public access is only a partial proof, not evidence of private backend use. +**Path B is complete only when** the probe reports `"passed": true` with `OK` +for every alias while model public access is disabled, and the final plan exits +with code `0`. This proves the gateway/model private path, not private-only +gateway ingress or hosted-agent execution. + +Also perform the [authentication and telemetry checks](#check-telemetry-and-deployment-consistency): +the private probe does not independently test invalid keys or telemetry ingestion. -After testing, deallocate the VM to stop compute billing: +### B6. Stop test VM compute billing + +After testing, **if you created the optional VM** (otherwise skip this step): ```powershell $vmId = terraform output -raw private_test_vm_id @@ -330,76 +296,37 @@ $subscriptionId = ($vmId -split '/')[2] az vm deallocate --subscription $subscriptionId --ids $vmId ``` -The managed disk, private endpoints, private DNS, and monitoring can still incur -charges. Start the VM again before rerunning the probe. Removing networking or -turning off its Terraform feature flag can destroy network resources and must -be reviewed separately. Re-enabling public access is an explicit security change, -not an automatic recovery step. - -**Boundary:** this option does not privately network the hosted-agent account, -Storage, or ACR. It does not validate agent execution or promise an SLA. -Adding these gateway/model private endpoints does not retrofit hosted-agent -VNet injection into an existing Foundry account; that is a separate -[creation-time requirement](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). - -## Known blocker: private-only gateway ingress - -**This is a diagnostic finding, not a deployment step.** Do not change -`gateway_public_network_access` to `Disabled` as part of the working PoC. - -**Observed preview result (October 18, 2026, Sweden Central):** private-client -DNS and inference passed for both configured aliases, including with model -public access disabled and its network ACL set to Deny. Disabling gateway public -access failed with `UpdateApiServiceFailed` through both Terraform's service -update and the dedicated CLI's public-access-only PATCH. The approved private -endpoint and outbound integration continued to work, but gateway public access -remained Enabled. A separate fresh minimal gateway in the same subscription and -region reproduced the error without outbound integration or model/backend -configuration, through both the service update and a public-access-only CLI -PATCH. Its approved private endpoint remained reachable; the temporary -reproduction resources were then deleted. - -This narrows the failure beyond the original instance but does not establish a -root cause or prove that the SKU never supports private-only ingress. A future -private-only implementation must meet all of these acceptance criteria: - -- The gateway reports public network access `Disabled`. -- Every configured model alias still succeeds through the gateway private endpoint. -- A previously working public request with a valid key receives a network-access - rejection, not merely an authentication failure. -- A plan using the intended private-only inputs reports no pending changes. - -Until then, treat a no-public-ingress requirement as blocked. No destructive -workaround or automatic re-enablement is included. - -## Preview schema limitations - -AzAPI 2.13 does not bundle schemas for the preview connector gateway, telemetry -exporter, model provider/catalog, model alias, and runtime API key resources. Their blocks -set `schema_validation_enabled = false`; all other resource blocks retain the -provider's default schema validation. ARM still validates those resources when -they are provisioned. A successful offline check is **not** proof of preview -access, regional capacity, API acceptance, or runtime connectivity. - -## Offline checks +Disks, private endpoints, DNS, model deployments and monitoring can still incur +charges. To use the VM again: ```powershell -terraform init -backend=false -terraform fmt -check -recursive -terraform validate -terraform test +az vm start --subscription $subscriptionId --ids $vmId ``` -Tests use a mocked AzAPI provider with no Azure authentication or provisioning. -They check model/endpoint/token-limit propagation, independent gateway location, -identity-based backend configuration, opt-in developer roles, and invalid model -inputs. They do not call Azure or prove the hosted-agent application works. -The private-connectivity regression reads the committed JSON profile directly -and checks its mixed gateway-public/model-private posture, endpoint wiring, -managed-identity backend access, API-key resource and telemetry exporter. +### Existing deployments and profile precedence + +If your deployment already has a working private backend with model public +access disabled, you can start at B4, review the final profile plan, then run B5. +Keep your existing client choice; the profile does not turn the test VM on or off. + +The profile is not auto-loaded: include `-var-file` on every plan for Path B, +including drift checks. Its three values override `terraform.tfvars` and +`*.auto.tfvars`, even an old private-only override. Later `-var` or `-var-file` +arguments can override it again. Keep personal settings in the ignored inputs, +not the committed profile. Do not apply stale private-only plans or re-enable +public access as an automatic recovery step. ## Runtime verification +### Which check proves what? + +| Check | Where it runs | What it proves | +| --- | --- | --- | +| Model request below | Your current machine, against the gateway endpoint. | Authentication and a successful model response. **Not private routing.** Use for Path A or an additional Path B smoke check. | +| [Path B private probe](#b3-check-the-private-route-before-disabling-model-public-access) | Inside the optional VM via Azure Run Command. | Private DNS and inference. Repeat after disabling model public access to establish private backend use. | +| `terraform plan -detailed-exitcode` | Your Terraform working directory. | Configuration consistency, not successful runtime calls. | +| [Offline checks](#offline-checks) | Locally with a mocked provider. | Configuration assertions, not Azure access or runtime behavior. | + ### Test a model through the gateway Run this in PowerShell from `samples\terraform` after a successful apply, while @@ -464,17 +391,18 @@ indicate token limits or capacity. Inspect the service error before retrying. **This direct request does not prove private routing.** With the PoC profile, a public client can still reach the gateway. Use the -[private test-client probe](#private-network-details-and-test-client) from the +[private test-client probe](#b3-check-the-private-route-before-disabling-model-public-access) from the VNet to verify private DNS and inference with model public access disabled. For CLI-based alternatives, follow the [gateway model test](../../cli/README.md#5-test-the-gateway), using provider -`foundry` and your configured deployment names. Separately verify that missing -or invalid runtime keys receive HTTP `401/403`; a successful authenticated call -does not test that protection. +`foundry` and your configured deployment names. ### Check telemetry and deployment consistency +For either path, separately verify that missing or invalid runtime keys receive +HTTP `401/403`; a successful authenticated call does not test that protection. + Confirm the six default RBAC assignments and the Foundry connections exist, and check the gateway's Application Insights for the requests after allowing for ingestion delay. Connection metadata alone does not prove that hosted agents can @@ -484,6 +412,205 @@ Never override a policy-required storage network restriction to make this check pass. Include the same explicit `-var-file` profile and overrides used for the deployment when checking drift. +## Technical reference + +**Look up details here as needed.** You do not need to execute another deployment +path or modify every file below to use the sample. + +### File guide + +| File | What it controls | When you need it | +| --- | --- | --- | +| [terraform.tfvars.example](terraform.tfvars.example) | Template for subscription, region, model and publisher inputs. | Copy once to your ignored `terraform.tfvars`; edit your copy, not the template. | +| [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) | Three networking settings for Path B. No subscription or credentials. | Select explicitly on Path B plans; not needed for Path A. | +| [variables.tf](variables.tf) / [outputs.tf](outputs.tf) | Supported inputs, validation and exported resource information. | Look up an optional setting or find an endpoint/resource ID. | +| [ai-gateway.tf](ai-gateway.tf) | AIGateway service, connector gateway, provider, catalog, aliases, runtime key resource and telemetry exporter. | Understand gateway routing and policies. | +| [foundry-models.tf](foundry-models.tf) | Model account and versioned deployments. | Understand backend provisioning; choose models through inputs. | +| [foundry-agents.tf](foundry-agents.tf) | Agent account/project, Storage, Premium ACR and connections. | Understand what is prepared for the separate agent application. | +| [main.tf](main.tf) / [rbac.tf](rbac.tf) | Naming, three resource groups, monitoring and access roles. | Understand shared infrastructure and identity permissions. | +| [private-network.tf](private-network.tf) / [private-test-client.tf](private-test-client.tf) | Optional VNet, private endpoints/DNS, gateway integration and probe VM. | Path B networking implementation. | +| [scripts\Test-PrivateGateway.ps1](scripts/Test-PrivateGateway.ps1) / [Python probe](scripts/private-path-probe.py) | Read non-secret outputs and run DNS/inference checks inside the VM. | Execute Path B's probe with the PowerShell wrapper; it sends the Python code to the VM. | +| [tests](tests) | Mocked Terraform assertions and Python probe unit tests. | Develop or check the sample offline; no live deployment proof. | +| [versions.tf](versions.tf) / [.terraform.lock.hcl](.terraform.lock.hcl) | Provider requirements and locked provider checksums. | Reproduce dependency selection; keep the lock file committed. | + +### Inputs + +The [shared setup](#configure-and-provision) covers the minimum values to edit. +These optional settings refine that configuration: + +| Input | Purpose and default | +| --- | --- | +| `environment_name` | Naming/tagging label; defaults to `aigateway`. | +| `storage_public_network_access` | `Enabled` by default; use `Disabled` when policy requires it. No private storage path is created here. | +| `developer_principal_id` | Optional Entra object ID for developer access; empty means no developer role assignments. | +| `developer_principal_type` | `User`, `Group` or `ServicePrincipal`; defaults to `User`. | +| `publisher_name`, `project_display_name`, `project_description`, `tags` | Display metadata and additional tags. | +| `private_network_address_prefix` | Path B VNet IPv4 /16; defaults to `10.247.0.0/16`. Choose a non-overlapping range before first deployment. | +| `enable_private_test_client` | Adds the Path B VM; defaults to `false`. Requires private networking. | +| `private_test_client_ssh_public_key` | Real OpenSSH public key required when creating the VM. | +| `private_test_client_vm_size` | Defaults to `Standard_B2s_v2`; check availability and quota. | + +Each `model_deployments` entry requires `model_name`, `model_version`, +`capacity` and `token_limit`. `capacity` is a model deployment SKU-specific +quota unit; `token_limit` is the gateway's per-identity tokens-per-minute limit. +They are independent, not a universal conversion. Optional model fields are +`model_format`, `sku_name`, `api_format`, `endpoints` and `description`; match +API format/endpoints to the model's capabilities. See [variables.tf](variables.tf) +for complete types, defaults and validation. + +### Outputs and credentials + +| Output | Use it for | +| --- | --- | +| `gateway_endpoint` | Base URL for runtime calls through the gateway. | +| `gateway_api_key_id` | ARM ID used to retrieve the runtime key through an approved process. **Not the key value.** | +| `model_deployment_ids` | Model IDs keyed by the client-facing aliases. | +| `project_endpoint`, `project_id` | Separate hosted-agent application setup. Not a gateway model-call URL. | +| `container_registry_endpoint`, `storage_account_name` | Image publishing and project artifact setup for that application. | +| `resource_groups`, `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource management and telemetry lookup. | +| `private_endpoint_ids`, `private_test_vm_id`, `private_probe_configuration` | Path B resource lookup and VM probe inputs. VM outputs are only available when the test client is enabled. | + +`terraform output` does not export runtime key values or telemetry connection +strings. Backend calls, storage and image pulls use managed identities. Retrieve +runtime key values through your approved secret-management process; do not put +them in `.tfvars`, terminal output or source control. + +### Networking reference + +Path B adds a VNet, an outbound integration subnet delegated to +`Microsoft.Web/serverFarms`, separate endpoint/client subnets, two private +endpoints and four private DNS zones. Gateway PE group ID is `Gateway`; model +PE group ID is `account`. The sample's tested gateway contract is +`2025-09-01-preview` with `virtualNetworkType = "External"`. Inbound Private +Link and outbound integration solve different problems; one does not replace +the other. + +The optional Ubuntu VM has no public IP, denies inbound traffic and has only a +narrow custom role for gateway API-key retrieval. Its subnet explicitly allows +default outbound internet for VM agent/ARM operations; it is not air-gapped. +The probe reads non-secret outputs and uses standard-library Python inside the +VM; your local machine does not need a private route to run the wrapper. + +Existing corporate DNS, peering, VPN, forced tunnelling and custom firewall +rules are not configured. Review same-region outbound VNet requirements, +dedicated subnets, DNS and NSG dependencies in the +[official gateway networking guide](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking). +Removing networking or disabling its feature flag can destroy network resources; +review those changes separately. This network does not isolate the agent +account, Storage or ACR. + +### State and security + +- This demonstration uses **local state**. State and saved plans can contain + sensitive responses, including telemetry connection strings. Restrict access + and do not commit them. `.gitignore` excludes state, plans, provider downloads + and user `.tfvars`; the only `.tfvars.json` exception is the non-secret profile. +- Before shared or production use, configure an access-controlled remote backend + with encryption/locking, such as Azure Blob Storage with Entra authentication; + migrate local state using `terraform init -migrate-state`. +- Storage forbids anonymous blobs and shared keys. ACR admin credentials and + Foundry local authentication are disabled. Gateway telemetry payload capture + is disabled. +- Preserve policy-required storage restrictions. Gateway/model testing can work + independently, but private hosted-agent storage connectivity remains unverified + until configured separately. +- Project roles mirror the upstream Bicep, including Cognitive Services + Contributor. Optional developer access also grants Storage Blob Data Contributor. + Enable it deliberately and review least privilege. + +### Implementation background and preview schemas + +The configuration follows the upstream +[Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra) +and [model-registration fix](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/commit/03fcd3788aea06cf6000af19890f8164303348d5). +AzAPI expresses the preview ARM resources directly. Model operations are +serialized per account using resource locks. The automatically created gateway +`default` workspace is referenced, not created again. Each catalog entry needs +a workspace alias for runtime routing; token policies use `["identity"]` +instead of the older scalar `"Identity"` counter key. + +AzAPI 2.13 lacks bundled schemas for the preview connector gateway, telemetry +exporter, model provider/catalog, alias and runtime API key resources. Those +blocks set `schema_validation_enabled = false`; other blocks retain default +schema validation. ARM still validates the resources at provisioning time. +Offline success does not establish preview access, capacity or API acceptance. + +### Offline checks + +For configuration checks without provisioning Azure resources: + +```powershell +terraform init -backend=false +terraform fmt -check -recursive +terraform validate +terraform test +``` + +The Terraform tests mock AzAPI and require no Azure authentication. They check +model/endpoint/token-limit propagation, independent gateway location, +identity-based backend configuration, opt-in developer roles and invalid inputs. +The private regression reads the committed JSON profile and asserts its mixed +gateway-public/model-private posture, endpoint wiring, backend identity, +API-key resource and telemetry exporter. These are not runtime tests. + +## Disclaimer and current limitations + +**Preview PoC, not a production or private-only reference architecture.** +Documentation checked **October 19, 2026**. The following separates +Microsoft-documented constraints from local test findings; revisit the official +sources before deploying because preview features and restrictions can change. + +### Microsoft-documented constraints + +**AI Gateway preview:** best-effort availability with **no SLA**; APIs, limits, +regions and pricing can change. Current documented gateway regions are East US 2 +and Sweden Central, with access/capacity varying by subscription. Connected +services remain billable. See [AI Gateway overview and FAQ](https://learn.microsoft.com/azure/api-management/ai-gateway-overview). + +**Hosted-agent isolation is a creation-time decision:** hosted-agent VNet +injection must be included when the Foundry account is first created; it cannot +be added to an existing account. This sample does not configure it. If private +hosted-agent execution is required, choose a supported network-secured Foundry +deployment before creating the account. See +[Foundry Agent Service networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). + +**This is not a general ban on adding private endpoints later.** Microsoft +documents adding an inbound private endpoint to an existing gateway and configuring +outbound integration separately. Private endpoints alone do not provide gateway +or hosted-agent outbound access. See +[AI Gateway private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking). + +**Private hosted-agent image pulls:** current guidance supports private ACR for +Foundry projects created after June 25, 2026; older projects require public ACR +reachability. This does not remove the account-creation requirement for injection. +The sample leaves ACR public and does not prove agent execution. See +[Foundry networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). + +### Known blocker: private-only gateway ingress + +**Observed test finding, not a deployment step or a universal SKU limitation.** +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 configured aliases passed private DNS/inference checks with model public + access disabled and its network ACL set to Deny. +- Disabling gateway public access failed with `UpdateApiServiceFailed` through + Terraform's service update and the dedicated CLI's public-access-only PATCH. + Private connectivity kept working, but gateway public access stayed enabled. +- A separate minimal gateway in the same subscription/region reproduced the + failure without outbound integration or model/backend configuration, using + both update methods. Its private endpoint remained reachable; temporary + reproduction resources were deleted. + +Root cause remains unknown. Do not set gateway public access to `Disabled` as +part of this working PoC or present it as private-only. A future private-only +implementation must show the gateway reports `Disabled`, every alias still +works privately, a previously working valid-key public call receives a +network-access rejection (not just an authentication error), and the intended +private-only plan has no pending changes. No destructive workaround or automatic +public-access recovery is included. + ## References - [Upstream Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra) From d4f1c457f89f5009fa998c446e86f53904b92463 Mon Sep 17 00:00:00 2001 From: annawiewer <138025423+annawiewer@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:10:41 +0000 Subject: [PATCH 04/11] docs(terraform): split README into quick start, private connectivity, and reference - Shorten samples/terraform/README.md to overview, prerequisites, and a public quick start - Move the optional private-connectivity steps to docs/private-connectivity.md - Move file guide, inputs, networking, security, and limitations to docs/reference.md - Move the alias test snippet to scripts/Test-ModelAliases.ps1 - Update samples/README.md link to the new private-connectivity doc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/README.md | 2 +- samples/terraform/README.md | 641 ++---------------- .../terraform/docs/private-connectivity.md | 106 +++ samples/terraform/docs/reference.md | 149 ++++ .../terraform/scripts/Test-ModelAliases.ps1 | 45 ++ 5 files changed, 367 insertions(+), 576 deletions(-) create mode 100644 samples/terraform/docs/private-connectivity.md create mode 100644 samples/terraform/docs/reference.md create mode 100644 samples/terraform/scripts/Test-ModelAliases.ps1 diff --git a/samples/README.md b/samples/README.md index 4dc7917..cd21659 100644 --- a/samples/README.md +++ b/samples/README.md @@ -5,4 +5,4 @@ A curated catalog of samples that build on the [AI Gateway tier in Azure API Man | 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) / [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/README.md#private-connectivity-poc-profile) (gateway public access remains enabled). | +| 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/README.md b/samples/terraform/README.md index 5c38ccb..2e1bbe5 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -1,622 +1,113 @@ -# AI Gateway and Foundry infrastructure with Terraform +# AI Gateway and Foundry with Terraform -**Use this sample to deploy an AI Gateway and call Foundry models through it.** -It also prepares a separate Foundry project for a hosted-agent application, but -does not deploy or run that application. +Deploy 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. -This is standalone Terraform infrastructure for the -[Foundry AI Gateway Python sample](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway), -not an `azd` application. +> **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. -**Read in this order:** [understand the components](#what-is-provisioned), -[choose a path](#choose-your-deployment), complete the -[shared setup](#configure-and-provision), then follow **only Path A or Path B**. -The [technical reference](#technical-reference) is for looking up details, -not a second deployment walkthrough. +## Before you start -## What is provisioned +**Check these first:** -### What each component is for +- [ ] Terraform 1.11+ (below 2.0) and Azure CLI installed +- [ ] Subscription enabled for the **preview AIGateway tier** and Foundry hosted agents in your regions +- [ ] Permission to create resources and role assignments (for example, Contributor plus Role Based Access Control Administrator) +- [ ] Providers registered: `Microsoft.ApiManagement`, `Microsoft.Web`, `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.ContainerRegistry`, `Microsoft.OperationalInsights`, `Microsoft.Insights` (this sample does not register them for you) +- [ ] Quota for the model and SKU in your region -| Component | Its job | What you get from this sample | -| --- | --- | --- | -| **AI Gateway** | The front door for model calls: authenticates clients, applies token limits, routes requests and records telemetry. It does not run the model itself. | A dedicated **AIGateway** preview SKU, model registrations/aliases, a runtime API key resource and monitoring. | -| **Foundry model account** | Runs the deployed models that answer requests forwarded by the gateway. | A separate AI Services account with the model versions and capacity you choose. The gateway uses its managed identity to access it, not a backend API key. | -| **Foundry hosted-agent account and project** | The foundation for a later application that runs an agent. Storage holds project artifacts; Container Registry holds its container image. | Account/project, Storage, Premium Container Registry, access roles, connections and monitoring. **No agent image, definition or running container.** | -| **Optional private network and test VM** | Adds and verifies a private route between a client, the gateway and the models. | Gateway/model private endpoints, private DNS, gateway outbound VNet integration and an optional VM that runs a probe. **Does not privately network the hosted-agent component.** | +**Decide before you start:** -**Both deployment paths create all three core components.** Choosing the private -profile changes networking; it does not select a smaller gateway-only deployment. -Hosted-agent infrastructure is provisioned even if you only test model calls. +- **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?** Not achievable with this sample. See the [known blocker](docs/reference.md#known-blocker-private-only-gateway-ingress). -### How a model request flows +## 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.** | +| **Optional private network and test VM** | Private endpoints and DNS for the gateway and models, plus a probe VM. See [private connectivity](docs/private-connectivity.md). | + +Both paths create all three core components. Hosted-agent infrastructure is +created even if you only test model calls. + +**Request flow:** ```text -Your application or test client - | - | Runtime API key: authenticates the client to the gateway - v -AI Gateway: validates the key, applies token limits, selects a model alias - | - | Managed identity: authenticates the gateway to the model account - v -Foundry model account: runs the selected model and returns the answer +Client --(runtime API key)--> AI Gateway --(managed identity)--> Foundry model account ``` -A **model alias** is the name your client sends, such as `gpt-mini`; it is not -necessarily the underlying model name, such as `gpt-4.1-mini`. -The hosted-agent project is not needed to execute this test request. Deploying -an actual agent, building/pushing its image, configuring GitHub MCP credentials -and setting a daily-digest schedule are separate application steps in the -end-to-end sample. This Terraform configuration does not replace its `azd` hooks. - -## Choose your deployment - -| | Path A: public-endpoint sample | Path B: private-connectivity PoC | -| --- | --- | --- | -| **Use it to** | Make a first authenticated model call through the gateway. | Demonstrate private client-to-gateway and gateway-to-model connectivity. | -| **Client location** | Your local machine can call the public gateway. | A client inside or connected to the VNet; the included test VM is the walkthrough's example. | -| **Gateway public access** | Enabled. | **Still enabled**; valid-key public calls can still work. | -| **Model public access** | Enabled. | Disabled after a temporary-public setup stage. | -| **Additional resources** | No sample VNet or test VM by default. | VNet, private endpoints/DNS and optional billable test VM. | -| **Instructions after shared setup** | [Path A](#path-a-public-endpoint-sample) | [Path B](#private-connectivity-poc-profile) | -| **Completion means** | Every configured alias returns a model response. | Every alias works from the VNet with model public access disabled; no pending Terraform changes. | - -### Stop and check these constraints first - -**Preview PoC, not a production security baseline.** Review the full -[disclaimer and official Microsoft Learn sources](#disclaimer-and-current-limitations) -before applying. In particular: - -- **No public gateway ingress allowed? Neither path meets that requirement.** - Gateway public-access disablement failed in the tested environment; - [private-only ingress remains blocked](#known-blocker-private-only-gateway-ingress). -- **Need private hosted-agent execution? Decide before creating the account.** - Microsoft requires hosted-agent VNet injection at Foundry account creation; - this sample does not configure it. Adding private endpoints later is not a - substitute. -- **Public model endpoints prohibited, even temporarily?** Do not run Path A or - Path B's staging unchanged. Review a policy-compliant deployment separately. - -The gateway preview has **no SLA**. Both paths create billable resources; -model calls, Premium ACR, Storage and monitoring can incur charges. - -## Prerequisites - -- Terraform **1.11 or later**, below 2.0, and Azure CLI. -- An Azure subscription enabled for the **preview AIGateway tier** and Foundry - hosted agents in your selected regions. The gateway region is independent from - the Foundry region. -- Permission to create resource groups/resources and role assignments at their - scopes, such as Contributor plus Role Based Access Control Administrator, - subject to your organization's policies. -- Registered providers: `Microsoft.ApiManagement`, `Microsoft.Web`, - `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.ContainerRegistry`, - `Microsoft.OperationalInsights`, and `Microsoft.Insights`. This sample does not - register providers on your behalf. -- Available model versions and sufficient deployment quota for the selected - model SKU/region. Example values are illustrative, not an availability promise. - -Confirm preview access, model availability, and organizational network policies -before applying. Region/model availability is not inferred by this configuration. -Path B additionally requires `Microsoft.Network`; the test VM requires -`Microsoft.Compute`, regional VM quota and permission to use VM Run Command. - -## Configure and provision - -**Shared setup for both paths.** These steps prepare your inputs and Terraform; -they do not deploy resources yet. All later commands run from this same directory. - -### Prepare your personal input file - -From the repository root, sign in and copy the example **only if you do not -already have `terraform.tfvars`**: +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`). + +## Quick start (public endpoints) + +Run from `samples/terraform`. + +**1. Sign in and create your input file** (only on a fresh checkout; never overwrite an existing `terraform.tfvars`): ```powershell -Set-Location .\samples\terraform az login -# Fresh checkout only; do not overwrite existing inputs. Copy-Item terraform.tfvars.example terraform.tfvars ``` -For a fresh checkout, copy the example once. If you already have -`terraform.tfvars`, edit it instead of overwriting it. Before continuing, open -the file and set: +**2. Edit `terraform.tfvars`:** -| Set in `terraform.tfvars` | Why it matters | +| Setting | What to set | | --- | --- | -| `subscription_id` | Determines where resources are created. | -| `environment_name` | Distinguishes this deployment from another environment. | -| `location` and `gateway_location` | Choose supported Foundry/model and gateway regions respectively. | -| `publisher_email` | Gateway publisher contact. | -| `model_deployments` | Choose model versions, deployment capacity and token limits available to your subscription. The example's `gpt-mini` and `gpt-full` keys become the client-facing aliases. | -| `storage_public_network_access`, if policy requires it | Use `Disabled` when required by policy; private hosted-agent storage connectivity is not supplied here. | +| `subscription_id` | Where resources are created | +| `environment_name` | Distinguishes this deployment | +| `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 | -Keep this file local: it is ignored by Git. See the -[input reference](#inputs) for optional fields. Review -[state and security](#state-and-security) before applying; state and saved plans -can contain sensitive data. +This file is git-ignored. Optional settings are in the [input reference](docs/reference.md#inputs). -### Initialize Terraform +**3. Initialize and check:** ```powershell -az account set --subscription "" +az account set --subscription "" terraform init terraform fmt -check -recursive terraform validate ``` -Stop if a command fails. Choose **one** path below; do not run both sets of -plan/apply commands in sequence. This creates new infrastructure, not an -automatic import of existing Bicep resources. Names depend on subscription, -environment name and Foundry region; changing those inputs can replace resources. - -## Path A: public-endpoint sample - -**Purpose:** get a first model response without setting up private networking. -Use this only when your policies permit public gateway and model endpoints. -On a fresh checkout, the example inputs use this posture; do not reuse unrelated -private-network overrides. +**4. Plan and apply the reviewed plan:** ```powershell terraform plan "-out=deployment.tfplan" -``` - -Review the plan, including public access, resource creation and costs. Then apply -that reviewed plan: - -```powershell terraform apply "deployment.tfplan" ``` -**Next:** run [Test a model through the gateway](#test-a-model-through-the-gateway), -then [check telemetry and deployment consistency](#check-telemetry-and-deployment-consistency). -You are done with Path A when every alias returns a response and the final -plan has no pending changes. This does not deploy an agent application. - -## Private-connectivity PoC profile - -**Path B. Purpose:** prove the following private route, using the same dedicated -AIGateway SKU and core infrastructure as Path A: - -```text -Private client -> gateway private endpoint -> private model endpoint -``` - -| Setting | Profile value | -| --- | --- | -| `enable_private_networking` | `true` | -| `gateway_public_network_access` | `Enabled` | -| `models_public_network_access` | `Disabled` | - -Select [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) -explicitly for this path. It adds gateway/model private endpoints, DNS and -gateway outbound VNet integration to the same deployment, not a second gateway. -Gateway identity-based backend access, API-key authentication and telemetry -remain in place. Model local authentication is disabled and, in the final -configuration, its network ACL becomes Deny. - -**Not private-only ingress:** clients can use the gateway private endpoint, -but the public gateway endpoint remains reachable with valid credentials. -The profile does not claim to fix the public-disablement failure documented -below. Hosted-agent networking is unchanged and agent execution is not proven. - -### B1. Choose the client that will prove private connectivity - -Your laptop is not automatically connected to the new VNet. The steps below use -the optional test VM so no VPN or inbound SSH is needed. Before the first plan, -add these inputs to your ignored `terraform.tfvars`, replacing the key -placeholder with a real OpenSSH ed25519 or RSA **public** key: - -```hcl -enable_private_test_client = true -private_test_client_ssh_public_key = "ssh-ed25519 " -``` - -The key is required to provision the VM; the probe uses Azure VM Run Command, -not SSH. Never paste a private key or password. The VM is billable, has no -public IP and denies inbound traffic. See [networking reference](#networking-reference) -for its outbound-access design and required network settings. - -If you already have a client connected to the VNet, you can omit the VM. -Replace the VM-probe steps below with equivalent private DNS and inference -checks from that client; `Test-PrivateGateway.ps1` specifically requires the VM. - -### B2. Create networking with the model temporarily public - -For a **new deployment or a public-backend deployment**, first make the private -route available. This stage is not the final security posture. - -```powershell -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-var=models_public_network_access=Enabled" "-out=private-connectivity-stage1.tfplan" -``` - -Review the plan: both gateway and model public access are temporarily enabled, -and networking plus the selected test VM are created. Do not apply unexpected -replacements. Then: - -```powershell -terraform apply "private-connectivity-stage1.tfplan" -``` - -### B3. Check the private route before disabling model public access - -With the test VM running: - -```powershell -.\scripts\Test-PrivateGateway.ps1 -``` - -The script runs inside the VM through Run Command. It checks that the gateway -and model hostnames resolve into the private endpoint subnet and every alias -answers `OK`. The VM retrieves the gateway key using managed identity; no key -is printed or passed as a Run Command argument. - -**Stop if the probe fails.** Check endpoint approval, private DNS, permissions -and outbound integration before continuing. A pass here is only an initial -connectivity check: model public access is still enabled. - -### B4. Disable model public access - -Plan again **without** the temporary `Enabled` override: - -```powershell -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" "-out=private-connectivity.tfplan" -``` - -Review the transition: model public access becomes `Disabled` / ACL Deny; -gateway public access stays `Enabled`. Then: - -```powershell -terraform apply "private-connectivity.tfplan" -``` - -### B5. Prove the final private path - -```powershell -.\scripts\Test-PrivateGateway.ps1 -terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-exitcode -``` - -**Path B is complete only when** the probe reports `"passed": true` with `OK` -for every alias while model public access is disabled, and the final plan exits -with code `0`. This proves the gateway/model private path, not private-only -gateway ingress or hosted-agent execution. - -Also perform the [authentication and telemetry checks](#check-telemetry-and-deployment-consistency): -the private probe does not independently test invalid keys or telemetry ingestion. +Review the plan for public access, resource creation and cost before applying. -### B6. Stop test VM compute billing - -After testing, **if you created the optional VM** (otherwise skip this step): - -```powershell -$vmId = terraform output -raw private_test_vm_id -$subscriptionId = ($vmId -split '/')[2] -az vm deallocate --subscription $subscriptionId --ids $vmId -``` - -Disks, private endpoints, DNS, model deployments and monitoring can still incur -charges. To use the VM again: +**5. Test every alias:** ```powershell -az vm start --subscription $subscriptionId --ids $vmId +.\scripts\Test-ModelAliases.ps1 ``` -### Existing deployments and profile precedence - -If your deployment already has a working private backend with model public -access disabled, you can start at B4, review the final profile plan, then run B5. -Keep your existing client choice; the profile does not turn the test VM on or off. - -The profile is not auto-loaded: include `-var-file` on every plan for Path B, -including drift checks. Its three values override `terraform.tfvars` and -`*.auto.tfvars`, even an old private-only override. Later `-var` or `-var-file` -arguments can override it again. Keep personal settings in the ignored inputs, -not the committed profile. Do not apply stale private-only plans or re-enable -public access as an automatic recovery step. - -## Runtime verification - -### Which check proves what? +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. -| Check | Where it runs | What it proves | -| --- | --- | --- | -| Model request below | Your current machine, against the gateway endpoint. | Authentication and a successful model response. **Not private routing.** Use for Path A or an additional Path B smoke check. | -| [Path B private probe](#b3-check-the-private-route-before-disabling-model-public-access) | Inside the optional VM via Azure Run Command. | Private DNS and inference. Repeat after disabling model public access to establish private backend use. | -| `terraform plan -detailed-exitcode` | Your Terraform working directory. | Configuration consistency, not successful runtime calls. | -| [Offline checks](#offline-checks) | Locally with a mocked provider. | Configuration assertions, not Azure access or runtime behavior. | +**Done when:** every alias returns a response and `terraform plan -detailed-exitcode` exits `0`. -### Test a model through the gateway +**Also verify:** -Run this in PowerShell from `samples\terraform` after a successful apply, while -signed in to Azure. It uses Terraform outputs and Azure CLI; the optional AI -Gateway CLI extension is **not required**. Your signed-in identity needs -permission to retrieve the gateway API key -(`Microsoft.ApiManagement/service/apiKeys/listSecrets/action`). +- Missing or invalid API keys get HTTP `401/403`. A successful call does not prove this. +- The six default RBAC assignments and Foundry connections exist. +- Requests appear in the gateway's Application Insights after ingestion delay. -The example calls every configured alias and assumes chat-completions models, -such as the two models in `terraform.tfvars.example`. For other model types, -use their supported API and request body instead. Model calls incur inference -charges. Do not enable CLI debug output, PowerShell tracing or session recording -while retrieving or using keys. +## Private connectivity (optional) -```powershell -$outputJson = terraform output -json -if ($LASTEXITCODE -ne 0) { throw "Unable to read Terraform outputs." } -$outputs = $outputJson | 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." -} - -$apiKey = $null -$headers = $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 -ErrorAction Stop - if (!$reply.choices -or !$reply.choices[0].message.content) { - throw "No chat-completion text returned for model alias '$model'." - } - "{0}: {1}" -f $model, $reply.choices[0].message.content - } -} -finally { - $headers = $null - $apiKey = $null -} -``` - -Expect a short reply prefixed by each alias, for example `gpt-mini: Hello!`. -The key is held in memory and cleared from the example's variables even if a -request fails; it is not printed or written to a file. Stop on errors rather -than disabling authentication or relaxing network policies. A `401/403` can -indicate authentication, authorization or network restrictions; a `429` can -indicate token limits or capacity. Inspect the service error before retrying. - -**This direct request does not prove private routing.** With the PoC profile, -a public client can still reach the gateway. Use the -[private test-client probe](#b3-check-the-private-route-before-disabling-model-public-access) from the -VNet to verify private DNS and inference with model public access disabled. - -For CLI-based alternatives, follow the -[gateway model test](../../cli/README.md#5-test-the-gateway), using provider -`foundry` and your configured deployment names. - -### Check telemetry and deployment consistency - -For either path, separately verify that missing or invalid runtime keys receive -HTTP `401/403`; a successful authenticated call does not test that protection. - -Confirm the six default RBAC assignments and the Foundry connections exist, and -check the gateway's Application Insights for the requests after allowing for -ingestion delay. Connection metadata alone does not prove that hosted agents can -access storage or pull an image. Finally, run `terraform plan -detailed-exitcode`; -exit code 0 means no drift, 2 means changes remain, and 1 means the plan failed. -Never override a policy-required storage network restriction to make this check -pass. Include the same explicit `-var-file` profile and overrides used for the -deployment when checking drift. - -## Technical reference - -**Look up details here as needed.** You do not need to execute another deployment -path or modify every file below to use the sample. - -### File guide - -| File | What it controls | When you need it | -| --- | --- | --- | -| [terraform.tfvars.example](terraform.tfvars.example) | Template for subscription, region, model and publisher inputs. | Copy once to your ignored `terraform.tfvars`; edit your copy, not the template. | -| [profiles/private-connectivity.tfvars.json](profiles/private-connectivity.tfvars.json) | Three networking settings for Path B. No subscription or credentials. | Select explicitly on Path B plans; not needed for Path A. | -| [variables.tf](variables.tf) / [outputs.tf](outputs.tf) | Supported inputs, validation and exported resource information. | Look up an optional setting or find an endpoint/resource ID. | -| [ai-gateway.tf](ai-gateway.tf) | AIGateway service, connector gateway, provider, catalog, aliases, runtime key resource and telemetry exporter. | Understand gateway routing and policies. | -| [foundry-models.tf](foundry-models.tf) | Model account and versioned deployments. | Understand backend provisioning; choose models through inputs. | -| [foundry-agents.tf](foundry-agents.tf) | Agent account/project, Storage, Premium ACR and connections. | Understand what is prepared for the separate agent application. | -| [main.tf](main.tf) / [rbac.tf](rbac.tf) | Naming, three resource groups, monitoring and access roles. | Understand shared infrastructure and identity permissions. | -| [private-network.tf](private-network.tf) / [private-test-client.tf](private-test-client.tf) | Optional VNet, private endpoints/DNS, gateway integration and probe VM. | Path B networking implementation. | -| [scripts\Test-PrivateGateway.ps1](scripts/Test-PrivateGateway.ps1) / [Python probe](scripts/private-path-probe.py) | Read non-secret outputs and run DNS/inference checks inside the VM. | Execute Path B's probe with the PowerShell wrapper; it sends the Python code to the VM. | -| [tests](tests) | Mocked Terraform assertions and Python probe unit tests. | Develop or check the sample offline; no live deployment proof. | -| [versions.tf](versions.tf) / [.terraform.lock.hcl](.terraform.lock.hcl) | Provider requirements and locked provider checksums. | Reproduce dependency selection; keep the lock file committed. | - -### Inputs - -The [shared setup](#configure-and-provision) covers the minimum values to edit. -These optional settings refine that configuration: - -| Input | Purpose and default | -| --- | --- | -| `environment_name` | Naming/tagging label; defaults to `aigateway`. | -| `storage_public_network_access` | `Enabled` by default; use `Disabled` when policy requires it. No private storage path is created here. | -| `developer_principal_id` | Optional Entra object ID for developer access; empty means no developer role assignments. | -| `developer_principal_type` | `User`, `Group` or `ServicePrincipal`; defaults to `User`. | -| `publisher_name`, `project_display_name`, `project_description`, `tags` | Display metadata and additional tags. | -| `private_network_address_prefix` | Path B VNet IPv4 /16; defaults to `10.247.0.0/16`. Choose a non-overlapping range before first deployment. | -| `enable_private_test_client` | Adds the Path B VM; defaults to `false`. Requires private networking. | -| `private_test_client_ssh_public_key` | Real OpenSSH public key required when creating the VM. | -| `private_test_client_vm_size` | Defaults to `Standard_B2s_v2`; check availability and quota. | - -Each `model_deployments` entry requires `model_name`, `model_version`, -`capacity` and `token_limit`. `capacity` is a model deployment SKU-specific -quota unit; `token_limit` is the gateway's per-identity tokens-per-minute limit. -They are independent, not a universal conversion. Optional model fields are -`model_format`, `sku_name`, `api_format`, `endpoints` and `description`; match -API format/endpoints to the model's capabilities. See [variables.tf](variables.tf) -for complete types, defaults and validation. - -### Outputs and credentials - -| Output | Use it for | -| --- | --- | -| `gateway_endpoint` | Base URL for runtime calls through the gateway. | -| `gateway_api_key_id` | ARM ID used to retrieve the runtime key through an approved process. **Not the key value.** | -| `model_deployment_ids` | Model IDs keyed by the client-facing aliases. | -| `project_endpoint`, `project_id` | Separate hosted-agent application setup. Not a gateway model-call URL. | -| `container_registry_endpoint`, `storage_account_name` | Image publishing and project artifact setup for that application. | -| `resource_groups`, `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource management and telemetry lookup. | -| `private_endpoint_ids`, `private_test_vm_id`, `private_probe_configuration` | Path B resource lookup and VM probe inputs. VM outputs are only available when the test client is enabled. | - -`terraform output` does not export runtime key values or telemetry connection -strings. Backend calls, storage and image pulls use managed identities. Retrieve -runtime key values through your approved secret-management process; do not put -them in `.tfvars`, terminal output or source control. - -### Networking reference - -Path B adds a VNet, an outbound integration subnet delegated to -`Microsoft.Web/serverFarms`, separate endpoint/client subnets, two private -endpoints and four private DNS zones. Gateway PE group ID is `Gateway`; model -PE group ID is `account`. The sample's tested gateway contract is -`2025-09-01-preview` with `virtualNetworkType = "External"`. Inbound Private -Link and outbound integration solve different problems; one does not replace -the other. - -The optional Ubuntu VM has no public IP, denies inbound traffic and has only a -narrow custom role for gateway API-key retrieval. Its subnet explicitly allows -default outbound internet for VM agent/ARM operations; it is not air-gapped. -The probe reads non-secret outputs and uses standard-library Python inside the -VM; your local machine does not need a private route to run the wrapper. - -Existing corporate DNS, peering, VPN, forced tunnelling and custom firewall -rules are not configured. Review same-region outbound VNet requirements, -dedicated subnets, DNS and NSG dependencies in the -[official gateway networking guide](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking). -Removing networking or disabling its feature flag can destroy network resources; -review those changes separately. This network does not isolate the agent -account, Storage or ACR. - -### State and security - -- This demonstration uses **local state**. State and saved plans can contain - sensitive responses, including telemetry connection strings. Restrict access - and do not commit them. `.gitignore` excludes state, plans, provider downloads - and user `.tfvars`; the only `.tfvars.json` exception is the non-secret profile. -- Before shared or production use, configure an access-controlled remote backend - with encryption/locking, such as Azure Blob Storage with Entra authentication; - migrate local state using `terraform init -migrate-state`. -- Storage forbids anonymous blobs and shared keys. ACR admin credentials and - Foundry local authentication are disabled. Gateway telemetry payload capture - is disabled. -- Preserve policy-required storage restrictions. Gateway/model testing can work - independently, but private hosted-agent storage connectivity remains unverified - until configured separately. -- Project roles mirror the upstream Bicep, including Cognitive Services - Contributor. Optional developer access also grants Storage Blob Data Contributor. - Enable it deliberately and review least privilege. - -### Implementation background and preview schemas - -The configuration follows the upstream -[Bicep infrastructure](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/c572db847e4bff5278bc5f8630c2acb4b6991119/infra) -and [model-registration fix](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/commit/03fcd3788aea06cf6000af19890f8164303348d5). -AzAPI expresses the preview ARM resources directly. Model operations are -serialized per account using resource locks. The automatically created gateway -`default` workspace is referenced, not created again. Each catalog entry needs -a workspace alias for runtime routing; token policies use `["identity"]` -instead of the older scalar `"Identity"` counter key. - -AzAPI 2.13 lacks bundled schemas for the preview connector gateway, telemetry -exporter, model provider/catalog, alias and runtime API key resources. Those -blocks set `schema_validation_enabled = false`; other blocks retain default -schema validation. ARM still validates the resources at provisioning time. -Offline success does not establish preview access, capacity or API acceptance. - -### Offline checks - -For configuration checks without provisioning Azure resources: +Use this only if you need private client-to-gateway and gateway-to-model traffic. +Steps are in [docs/private-connectivity.md](docs/private-connectivity.md). -```powershell -terraform init -backend=false -terraform fmt -check -recursive -terraform validate -terraform test -``` +## Going further -The Terraform tests mock AzAPI and require no Azure authentication. They check -model/endpoint/token-limit propagation, independent gateway location, -identity-based backend configuration, opt-in developer roles and invalid inputs. -The private regression reads the committed JSON profile and asserts its mixed -gateway-public/model-private posture, endpoint wiring, backend identity, -API-key resource and telemetry exporter. These are not runtime tests. - -## Disclaimer and current limitations - -**Preview PoC, not a production or private-only reference architecture.** -Documentation checked **October 19, 2026**. The following separates -Microsoft-documented constraints from local test findings; revisit the official -sources before deploying because preview features and restrictions can change. - -### Microsoft-documented constraints - -**AI Gateway preview:** best-effort availability with **no SLA**; APIs, limits, -regions and pricing can change. Current documented gateway regions are East US 2 -and Sweden Central, with access/capacity varying by subscription. Connected -services remain billable. See [AI Gateway overview and FAQ](https://learn.microsoft.com/azure/api-management/ai-gateway-overview). - -**Hosted-agent isolation is a creation-time decision:** hosted-agent VNet -injection must be included when the Foundry account is first created; it cannot -be added to an existing account. This sample does not configure it. If private -hosted-agent execution is required, choose a supported network-secured Foundry -deployment before creating the account. See -[Foundry Agent Service networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). - -**This is not a general ban on adding private endpoints later.** Microsoft -documents adding an inbound private endpoint to an existing gateway and configuring -outbound integration separately. Private endpoints alone do not provide gateway -or hosted-agent outbound access. See -[AI Gateway private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking). - -**Private hosted-agent image pulls:** current guidance supports private ACR for -Foundry projects created after June 25, 2026; older projects require public ACR -reachability. This does not remove the account-creation requirement for injection. -The sample leaves ACR public and does not prove agent execution. See -[Foundry networking limitations](https://learn.microsoft.com/azure/foundry/agents/how-to/virtual-networks#limitations). - -### Known blocker: private-only gateway ingress - -**Observed test finding, not a deployment step or a universal SKU limitation.** -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 configured aliases passed private DNS/inference checks with model public - access disabled and its network ACL set to Deny. -- Disabling gateway public access failed with `UpdateApiServiceFailed` through - Terraform's service update and the dedicated CLI's public-access-only PATCH. - Private connectivity kept working, but gateway public access stayed enabled. -- A separate minimal gateway in the same subscription/region reproduced the - failure without outbound integration or model/backend configuration, using - both update methods. Its private endpoint remained reachable; temporary - reproduction resources were deleted. - -Root cause remains unknown. Do not set gateway public access to `Disabled` as -part of this working PoC or present it as private-only. A future private-only -implementation must show the gateway reports `Disabled`, every alias still -works privately, a previously working valid-key public call receives a -network-access rejection (not just an authentication error), and the intended -private-only plan has no pending changes. No destructive workaround or automatic -public-access recovery is included. - -## 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 tier](https://aka.ms/aigateway/docs) -- [AI Gateway overview, preview availability and SLA](https://learn.microsoft.com/azure/api-management/ai-gateway-overview) -- [Configure private networking for AI Gateway](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking) -- [Foundry Agent Service private networking and limitations](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) +- [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/docs/private-connectivity.md b/samples/terraform/docs/private-connectivity.md new file mode 100644 index 0000000..4783ba2 --- /dev/null +++ b/samples/terraform/docs/private-connectivity.md @@ -0,0 +1,106 @@ +# Private connectivity (optional) + +**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_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 profile does **not** make the gateway private-only, and it does not secure +> hosted-agent networking or prove agent execution. + +## Prerequisites + +Everything in the [main prerequisites](../README.md#before-you-start), plus: + +- `Microsoft.Network` registered +- For the test VM: `Microsoft.Compute`, regional VM quota, and permission to use VM Run Command + +## 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 for a new deployment, or any deployment whose backend is still public. It is not the final posture. + +```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 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 script runs inside the VM. 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. + +**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 does not test invalid keys or telemetry. Run the checks in the [main README](../README.md#quick-start-public-endpoints). + +## 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..98e4219 --- /dev/null +++ b/samples/terraform/docs/reference.md @@ -0,0 +1,149 @@ +# Reference + +Look up details here as needed. You do not need to read this to deploy. + +## 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) | Three private-connectivity settings (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, resource groups, 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 | +| [tests](../tests) | Offline Terraform and Python tests | +| [versions.tf](../versions.tf) / [.terraform.lock.hcl](../.terraform.lock.hcl) | Provider versions and checksums (commit the lock file) | + +## Inputs + +Required values are in the [quick start](../README.md#quick-start-public-endpoints). + +| 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` | Path B VNet IPv4 /16. Default `10.247.0.0/16`. Pick a non-overlapping range before the first deploy. | +| `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 | +| `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_groups`, `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource and telemetry lookup | +| `private_endpoint_ids`, `private_test_vm_id`, `private_probe_configuration` | Path B 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. + +## Networking + +Path B 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. + +## Offline checks + +No Azure resources or authentication needed: + +```powershell +terraform init -backend=false +terraform fmt -check -recursive +terraform validate +terraform test +``` + +These use mocked AzAPI. They check model, endpoint and token-limit wiring, gateway location, identity-based backends, opt-in developer roles, invalid inputs, and the private profile. They are **not** runtime tests. + +## 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 + +Documentation checked **October 19, 2026**. Preview features can change. Recheck the official sources before deploying. + +### 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 gateway, with outbound integration configured separately. Private endpoints alone do not give the gateway or agents outbound access. ([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 + +**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. + +A future 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. To tear down, run `terraform destroy` after reviewing the plan. Stop the optional VM as described in [private connectivity](private-connectivity.md#step-6-stop-the-vm-when-you-are-done) if you want to keep the rest. + +## 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/scripts/Test-ModelAliases.ps1 b/samples/terraform/scripts/Test-ModelAliases.ps1 new file mode 100644 index 0000000..f96efdf --- /dev/null +++ b/samples/terraform/scripts/Test-ModelAliases.ps1 @@ -0,0 +1,45 @@ +# 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" + +$outputs = terraform output -json | 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." +} + +$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 ([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 +} From e27e12ebe87e0cff66e8eb9e3e57361962c96cfb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:18:30 +0000 Subject: [PATCH 05/11] docs(terraform): address review feedback Co-authored-by: annawiewer <138025423+annawiewer@users.noreply.github.com> --- samples/terraform/README.md | 17 +++++++++++++---- samples/terraform/docs/private-connectivity.md | 6 +++++- samples/terraform/docs/reference.md | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/samples/terraform/README.md b/samples/terraform/README.md index 2e1bbe5..e1c8400 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -76,16 +76,22 @@ terraform fmt -check -recursive terraform validate ``` -**4. Plan and apply the reviewed plan:** +**4. Plan and review:** ```powershell terraform plan "-out=deployment.tfplan" -terraform apply "deployment.tfplan" ``` -Review the plan for public access, resource creation and cost before applying. +Stop if planning fails. Review the plan for public access, resource creation and +cost. Do not apply unexpected changes. + +**5. Apply the reviewed plan:** + +```powershell +terraform apply "deployment.tfplan" +``` -**5. Test every alias:** +**6. Test every alias:** ```powershell .\scripts\Test-ModelAliases.ps1 @@ -93,6 +99,9 @@ Review the plan for public access, resource creation and cost before applying. 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`. 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`. diff --git a/samples/terraform/docs/private-connectivity.md b/samples/terraform/docs/private-connectivity.md index 4783ba2..26fbf64 100644 --- a/samples/terraform/docs/private-connectivity.md +++ b/samples/terraform/docs/private-connectivity.md @@ -22,7 +22,11 @@ Always pass the profile on every plan, including drift checks: ## Prerequisites -Everything in the [main prerequisites](../README.md#before-you-start), plus: +Complete the [main prerequisites](../README.md#before-you-start), then steps +1–3 of the [quick start](../README.md#quick-start-public-endpoints): sign in, +create and configure `terraform.tfvars`, and initialize and validate Terraform. +Stop before its public plan/apply and test steps. Then complete these additional +requirements: - `Microsoft.Network` registered - For the test VM: `Microsoft.Compute`, regional VM quota, and permission to use VM Run Command diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md index 98e4219..da931d1 100644 --- a/samples/terraform/docs/reference.md +++ b/samples/terraform/docs/reference.md @@ -137,7 +137,7 @@ 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. To tear down, run `terraform destroy` after reviewing the plan. Stop the optional VM as described in [private connectivity](private-connectivity.md#step-6-stop-the-vm-when-you-are-done) if you want to keep the rest. +Both paths create billable resources. Model calls, Premium ACR, Storage, and monitoring all cost money. To tear down, run `terraform destroy` after reviewing the plan. For a private deployment, include its profile: `terraform destroy "-var-file=profiles\private-connectivity.tfvars.json"`. Stop the optional VM as described in [private connectivity](private-connectivity.md#step-6-stop-the-vm-when-you-are-done) if you want to keep the rest. ## References From 4e75e4b865b7359aabd10a3ca53659d69b82d013 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:32:42 +0200 Subject: [PATCH 06/11] docs(terraform): clarify public and private networking paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7982bda-e8a3-49b6-a668-6a999f427adf --- samples/terraform/README.md | 93 ++++++++++++++----- .../terraform/docs/private-connectivity.md | 34 +++++-- samples/terraform/docs/reference.md | 35 +++++-- 3 files changed, 123 insertions(+), 39 deletions(-) diff --git a/samples/terraform/README.md b/samples/terraform/README.md index e1c8400..4f016ed 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -1,18 +1,42 @@ # AI Gateway and Foundry with Terraform -Deploy an Azure AI Gateway that routes calls to Foundry models. This is standalone +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 | + +**Private connectivity is not private-only gateway access.** The private profile +provides a private route to the gateway and models, but the gateway's public +endpoint remains enabled because of the [observed public-disable blocker](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 -- [ ] Subscription enabled for the **preview AIGateway tier** and Foundry hosted agents in your regions +- [ ] 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) - [ ] Providers registered: `Microsoft.ApiManagement`, `Microsoft.Web`, `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.ContainerRegistry`, `Microsoft.OperationalInsights`, `Microsoft.Insights` (this sample does not register them for you) - [ ] Quota for the model and SKU in your region @@ -30,12 +54,13 @@ Terraform for the [Foundry AI Gateway Python sample](https://github.com/Azure-Sa | **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.** | -| **Optional private network and test VM** | Private endpoints and DNS for the gateway and models, plus a probe VM. See [private connectivity](docs/private-connectivity.md). | +| **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 create all three core components. Hosted-agent infrastructure is -created even if you only test model calls. +Both paths manage all three core components. Hosted-agent infrastructure is +included even if you only test model calls. -**Request flow:** +**Authentication in both paths** (independent of public or private routing): ```text Client --(runtime API key)--> AI Gateway --(managed identity)--> Foundry model account @@ -43,29 +68,32 @@ Client --(runtime API key)--> AI Gateway --(managed identity)--> Foundry model a 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`). -## Quick start (public endpoints) +## Shared setup -Run from `samples/terraform`. +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 and create your input file** (only on a fresh checkout; never overwrite an existing `terraform.tfvars`): +**1. Sign in:** ```powershell az login -Copy-Item terraform.tfvars.example terraform.tfvars ``` -**2. Edit `terraform.tfvars`:** +**2. Review the existing `terraform.tfvars`:** | Setting | What to set | | --- | --- | | `subscription_id` | Where resources are created | -| `environment_name` | Distinguishes this deployment | +| `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. Optional settings are in the [input reference](docs/reference.md#inputs). +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:** @@ -76,7 +104,31 @@ terraform fmt -check -recursive terraform validate ``` -**4. Plan and review:** +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" @@ -85,13 +137,13 @@ terraform plan "-out=deployment.tfplan" Stop if planning fails. Review the plan for public access, resource creation and cost. Do not apply unexpected changes. -**5. Apply the reviewed plan:** +**2. Apply the reviewed plan:** ```powershell terraform apply "deployment.tfplan" ``` -**6. Test every alias:** +**3. Test every alias:** ```powershell .\scripts\Test-ModelAliases.ps1 @@ -105,17 +157,14 @@ use their supported API and request body instead. **Done when:** every alias returns a response and `terraform plan -detailed-exitcode` exits `0`. -**Also verify:** +## Additional runtime checks + +For either path, also verify: - Missing or invalid API keys get HTTP `401/403`. A successful call does not prove this. - The six default RBAC assignments and Foundry connections exist. - Requests appear in the gateway's Application Insights after ingestion delay. -## Private connectivity (optional) - -Use this only if you need private client-to-gateway and gateway-to-model traffic. -Steps are in [docs/private-connectivity.md](docs/private-connectivity.md). - ## Going further - [Reference](docs/reference.md): file guide, all inputs, outputs, networking, state and security, offline tests, limitations diff --git a/samples/terraform/docs/private-connectivity.md b/samples/terraform/docs/private-connectivity.md index 26fbf64..18bb7cf 100644 --- a/samples/terraform/docs/private-connectivity.md +++ b/samples/terraform/docs/private-connectivity.md @@ -1,4 +1,8 @@ -# Private connectivity (optional) +# 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: @@ -20,13 +24,15 @@ Always pass the profile on every plan, including drift checks: > The profile does **not** make the gateway private-only, and it does not secure > hosted-agent networking or prove agent execution. +**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), then steps -1–3 of the [quick start](../README.md#quick-start-public-endpoints): sign in, -create and configure `terraform.tfvars`, and initialize and validate Terraform. -Stop before its public plan/apply and test steps. Then complete these additional -requirements: +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: - `Microsoft.Network` registered - For the test VM: `Microsoft.Compute`, regional VM quota, and permission to use VM Run Command @@ -46,13 +52,16 @@ private_test_client_ssh_public_key = "ssh-ed25519 " ## Step 2: Create networking with models temporarily public -Use this stage for a new deployment, or any deployment whose backend is still public. It is not the final posture. +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 are created. Do not apply unexpected replacements. +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" @@ -64,7 +73,10 @@ terraform apply "private-connectivity-stage1.tfplan" .\scripts\Test-PrivateGateway.ps1 ``` -The script runs inside the VM. 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. +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. **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. @@ -91,7 +103,9 @@ terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-e **Done when:** the probe reports `"passed": true` with `OK` for every alias, and the plan exits `0`. -The probe does not test invalid keys or telemetry. Run the checks in the [main README](../README.md#quick-start-public-endpoints). +The probe does not test invalid keys or telemetry. Follow the +[additional runtime checks](../README.md#additional-runtime-checks), not the +public-path plan/apply. ## Step 6: Stop the VM when you are done diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md index da931d1..a83e46b 100644 --- a/samples/terraform/docs/reference.md +++ b/samples/terraform/docs/reference.md @@ -1,6 +1,7 @@ # Reference -Look up details here as needed. You do not need to read this to deploy. +Details for both [networking paths](../README.md#choose-your-networking-path). +Review the limitations before changing an existing deployment. ## File guide @@ -21,7 +22,7 @@ Look up details here as needed. You do not need to read this to deploy. ## Inputs -Required values are in the [quick start](../README.md#quick-start-public-endpoints). +Required values are in the [shared setup](../README.md#shared-setup). | Input | Purpose and default | | --- | --- | @@ -30,7 +31,7 @@ Required values are in the [quick start](../README.md#quick-start-public-endpoin | `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` | Path B VNet IPv4 /16. Default `10.247.0.0/16`. Pick a non-overlapping range before the first deploy. | +| `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. | @@ -53,7 +54,7 @@ Optional fields: `model_format`, `sku_name`, `api_format`, `endpoints`, `descrip | `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_groups`, `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource and telemetry lookup | -| `private_endpoint_ids`, `private_test_vm_id`, `private_probe_configuration` | Path B lookup (VM outputs only when the test client is enabled) | +| `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. @@ -61,7 +62,7 @@ Never put keys in `.tfvars`, terminal output or source control. ## Networking -Path B adds: +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 @@ -107,13 +108,33 @@ These use mocked AzAPI. They check model, endpoint and token-limit wiring, gatew ## Limitations -Documentation checked **October 19, 2026**. Preview features can change. Recheck the official sources before deploying. +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 gateway, with outbound integration configured separately. Private endpoints alone do not give the gateway or agents outbound access. ([private networking](https://learn.microsoft.com/azure/api-management/ai-gateway-configure-private-networking)) +- **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 From 3509f73b32bf58aada17dd7673112d501a5bd841 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:49:05 +0200 Subject: [PATCH 07/11] Consolidate Terraform resources and add customer acceptance gates Preserve the legacy AIGateway/private architecture in one application group. Add guarded backend verification, same-route auth and correlated telemetry checks, credential-free CI, and operator-attested portal acceptance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41b2279f-0f7d-4bf0-b9b0-721eb55bea64 --- .github/workflows/terraform-offline.yml | 37 ++ samples/terraform/.gitignore | 2 + samples/terraform/README.md | 42 +- samples/terraform/ai-gateway.tf | 3 + samples/terraform/docs/acceptance.md | 320 ++++++++++++++ samples/terraform/docs/end-to-end.md | 196 ++++++++ .../terraform/docs/private-connectivity.md | 51 ++- samples/terraform/docs/reference.md | 135 +++++- samples/terraform/foundry-agents.tf | 6 +- samples/terraform/foundry-models.tf | 2 +- samples/terraform/main.tf | 18 +- samples/terraform/outputs.tf | 17 +- samples/terraform/private-network.tf | 2 + .../scripts/Complete-PortalAcceptance.ps1 | 69 +++ .../scripts/Test-CustomerAcceptance.ps1 | 418 ++++++++++++++++++ .../terraform/scripts/Test-ModelAliases.ps1 | 18 +- samples/terraform/scripts/Test-Offline.ps1 | 24 + .../terraform/scripts/Test-PrivateGateway.ps1 | 17 +- .../terraform/scripts/private-path-probe.py | 80 +++- .../tests/Test-CustomerAcceptance.ps1 | 242 ++++++++++ samples/terraform/tests/Test-ModelScripts.ps1 | 116 +++++ .../terraform/tests/Test-PortalAcceptance.ps1 | 59 +++ .../terraform/tests/infrastructure.tftest.hcl | 97 ++++ .../tests/test_private_path_probe.py | 57 ++- samples/terraform/variables.tf | 20 +- 25 files changed, 1996 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/terraform-offline.yml create mode 100644 samples/terraform/docs/acceptance.md create mode 100644 samples/terraform/docs/end-to-end.md create mode 100644 samples/terraform/scripts/Complete-PortalAcceptance.ps1 create mode 100644 samples/terraform/scripts/Test-CustomerAcceptance.ps1 create mode 100644 samples/terraform/scripts/Test-Offline.ps1 create mode 100644 samples/terraform/tests/Test-CustomerAcceptance.ps1 create mode 100644 samples/terraform/tests/Test-ModelScripts.ps1 create mode 100644 samples/terraform/tests/Test-PortalAcceptance.ps1 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/terraform/.gitignore b/samples/terraform/.gitignore index cdc798b..5178412 100644 --- a/samples/terraform/.gitignore +++ b/samples/terraform/.gitignore @@ -11,3 +11,5 @@ override.tf override.tf.json *_override.tf *_override.tf.json +__pycache__/ +acceptance-results/ diff --git a/samples/terraform/README.md b/samples/terraform/README.md index 4f016ed..8025a0d 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -23,9 +23,13 @@ the Terraform default; the private profile adds private endpoints and routing. | [Private connectivity](#private-connectivity-path) | Gateway and model account | **Enabled** | **Disabled** after staging | | [Public endpoints](#public-endpoint-path) | None | Enabled | Enabled | -**Private connectivity is not private-only gateway access.** The private profile -provides a private route to the gateway and models, but the gateway's public -endpoint remains enabled because of the [observed public-disable blocker](docs/reference.md#known-blocker-private-only-gateway-ingress). +**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 @@ -38,6 +42,7 @@ path or repeat the temporary-public staging step for an already-private backend. - [ ] 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` (this sample does not register them for you) - [ ] Quota for the model and SKU in your region @@ -45,7 +50,7 @@ path or repeat the temporary-public staging step for an already-private backend. - **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?** Not achievable with this sample. See the [known blocker](docs/reference.md#known-blocker-private-only-gateway-ingress). +- **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 @@ -60,6 +65,16 @@ path or repeat the temporary-public staging step for an already-private backend. Both paths manage all three core components. Hosted-agent infrastructure is included even if you only test model calls. +All resource-group-scoped 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 +separate gateway/agent monitoring stacks are unchanged. + +**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 @@ -152,18 +167,25 @@ terraform apply "deployment.tfplan" 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`. For other model types, -use their supported API and request body instead. +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 either path, also verify: +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. -- Missing or invalid API keys get HTTP `401/403`. A successful call does not prove this. -- The six default RBAC assignments and Foundry connections exist. -- Requests appear in the gateway's Application Insights after ingestion delay. +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 scoped RBAC assignments, Foundry connections, telemetry, +and the final drift check. A successful model call alone is not full acceptance. ## Going further diff --git a/samples/terraform/ai-gateway.tf b/samples/terraform/ai-gateway.tf index baff382..c0dd47a 100644 --- a/samples/terraform/ai-gateway.tf +++ b/samples/terraform/ai-gateway.tf @@ -18,6 +18,9 @@ resource "azapi_resource" "gateway" { } } response_export_values = ["properties.gatewayUrl"] + lifecycle { + prevent_destroy = true + } timeouts { create = "60m" update = "60m" diff --git a/samples/terraform/docs/acceptance.md b/samples/terraform/docs/acceptance.md new file mode 100644 index 0000000..f693f2b --- /dev/null +++ b/samples/terraform/docs/acceptance.md @@ -0,0 +1,320 @@ +# 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 +Application Insights. 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) + $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 + +```powershell +$exporter = Get-Arm "$gatewayId/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' +if ($exporter.properties.kind -ne 'applicationInsights' -or + $exporter.properties.payloadCapture -ne $false -or + $exporter.properties.applicationInsights.resourceId -ine $o.application_insights_ids.value.gateway) { + throw 'Incorrect telemetry exporter target or payload-capture setting.' +} +$o.application_insights_ids.value.gateway +$testStartUtc.ToString('o') +$testEndUtc.ToString('o') +``` + +Open **that gateway Application Insights resource > Logs** (not agent insights). +Allow several minutes for ingestion; use Application Insights table names and +replace the time bounds below with your test window, widened by a few minutes: + +```kusto +union withsource=TelemetryTable isfuzzy=true requests, dependencies, traces, customEvents +| where timestamp between (datetime(2026-01-01T12:00:00Z) .. datetime(2026-01-01T12:15:00Z)) +| project timestamp, TelemetryTable, operation_Id, name, resultCode, success +| order by timestamp asc +``` + +**Expected:** new gateway request/related telemetry attributable to the test +window and model requests, including successful calls. Inspect operation 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. + +## 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 binding results, 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..25b1686 --- /dev/null +++ b/samples/terraform/docs/end-to-end.md @@ -0,0 +1,196 @@ +# 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. +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 Application Insights 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, monitoring/exporter, 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. + +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 +gateway's Application Insights for those exact `operation_Id` values and +successful HTTP 200 requests in the recorded UTC window, polling for at most 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 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/private-connectivity.md b/samples/terraform/docs/private-connectivity.md index 18bb7cf..8c754e5 100644 --- a/samples/terraform/docs/private-connectivity.md +++ b/samples/terraform/docs/private-connectivity.md @@ -21,8 +21,12 @@ Private client -> gateway private endpoint -> private model endpoint Always pass the profile on every plan, including drift checks: `-var-file=profiles\private-connectivity.tfvars.json`. It overrides `terraform.tfvars`. -> The profile does **not** make the gateway private-only, and it does not secure -> hosted-agent networking or prove agent execution. +> **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 @@ -37,6 +41,32 @@ state/backend. Do not run the public-path plan/apply. Additional requirements: - `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 at their original resource scopes. | +| 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 role assignments. + ## 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`: @@ -78,6 +108,13 @@ 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 @@ -103,9 +140,13 @@ terraform plan "-var-file=profiles\private-connectivity.tfvars.json" -detailed-e **Done when:** the probe reports `"passed": true` with `OK` for every alias, and the plan exits `0`. -The probe does not test invalid keys or telemetry. Follow the -[additional runtime checks](../README.md#additional-runtime-checks), not the -public-path plan/apply. +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 diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md index a83e46b..3a76ef3 100644 --- a/samples/terraform/docs/reference.md +++ b/samples/terraform/docs/reference.md @@ -13,11 +13,12 @@ Review the limitations before changing an existing deployment. | [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, resource groups, monitoring, role assignments | +| [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 | | [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 @@ -51,15 +52,49 @@ Optional fields: `model_format`, `sku_name`, `api_format`, `endpoints`, `descrip | `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_groups`, `gateway_id`, `foundry_models_id`, `application_insights_ids` | Resource and telemetry lookup | +| `resource_group_name` | The single shared resource group | +| `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 | | `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: @@ -86,18 +121,78 @@ Disabling the feature flag can destroy network resources. Review that change sep - 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. + +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: +No Azure resources or authentication needed. Use a clean checkout without +deployment tfvars/state. The local/CI entrypoint includes every regression suite: ```powershell -terraform init -backend=false -terraform fmt -check -recursive -terraform validate -terraform test +.\scripts\Test-Offline.ps1 ``` -These use mocked AzAPI. They check model, endpoint and token-limit wiring, gateway location, identity-based backends, opt-in developer roles, invalid inputs, and the private profile. They are **not** runtime tests. +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 @@ -139,6 +234,12 @@ deployments. ### 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). @@ -147,7 +248,7 @@ deployments. Root cause is unknown. Do not set gateway public access to `Disabled` in this PoC or describe it as private-only. -A future private-only version must show: +Before this pending item can be closed, a private-only version must show: 1. The gateway reports `Disabled`. 2. Every alias works privately. @@ -158,7 +259,21 @@ 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. To tear down, run `terraform destroy` after reviewing the plan. For a private deployment, include its profile: `terraform destroy "-var-file=profiles\private-connectivity.tfvars.json"`. Stop the optional VM as described in [private connectivity](private-connectivity.md#step-6-stop-the-vm-when-you-are-done) if you want to keep the rest. +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 diff --git a/samples/terraform/foundry-agents.tf b/samples/terraform/foundry-agents.tf index 53182b6..60613fe 100644 --- a/samples/terraform/foundry-agents.tf +++ b/samples/terraform/foundry-agents.tf @@ -1,7 +1,7 @@ resource "azapi_resource" "agents" { type = "Microsoft.CognitiveServices/accounts@2025-04-01-preview" name = local.names.agents - parent_id = azapi_resource.resource_group["agents"].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags identity { type = "SystemAssigned" } @@ -25,7 +25,7 @@ resource "azapi_resource" "agents" { resource "azapi_resource" "storage" { type = "Microsoft.Storage/storageAccounts@2024-01-01" name = local.names.storage - parent_id = azapi_resource.resource_group["agents"].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags body = { @@ -49,7 +49,7 @@ resource "azapi_resource" "storage" { resource "azapi_resource" "registry" { type = "Microsoft.ContainerRegistry/registries@2023-07-01" name = local.names.registry - parent_id = azapi_resource.resource_group["agents"].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags identity { type = "SystemAssigned" } diff --git a/samples/terraform/foundry-models.tf b/samples/terraform/foundry-models.tf index 3ddc929..1f56eff 100644 --- a/samples/terraform/foundry-models.tf +++ b/samples/terraform/foundry-models.tf @@ -1,7 +1,7 @@ resource "azapi_resource" "models" { type = "Microsoft.CognitiveServices/accounts@2025-04-01-preview" name = local.names.models - parent_id = azapi_resource.resource_group["models"].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags identity { type = "SystemAssigned" } diff --git a/samples/terraform/main.tf b/samples/terraform/main.tf index 4e69d0b..bcc47e8 100644 --- a/samples/terraform/main.tf +++ b/samples/terraform/main.tf @@ -1,19 +1,21 @@ 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.resource_token}" - agents = "aiagents-${local.resource_token}" - project = "proj-${local.resource_token}" - storage = "st${local.resource_token}" - registry = "acr${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" { - for_each = toset(["gateway", "models", "agents"]) + # 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 @@ -26,7 +28,7 @@ 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[each.key].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags body = { @@ -42,7 +44,7 @@ resource "azapi_resource" "insights" { for_each = toset(["gateway", "agents"]) type = "Microsoft.Insights/components@2020-02-02" name = "${local.names[each.key]}-insights" - parent_id = azapi_resource.resource_group[each.key].id + parent_id = azapi_resource.resource_group["gateway"].id location = var.location tags = local.tags body = { diff --git a/samples/terraform/outputs.tf b/samples/terraform/outputs.tf index 3102f66..5a0af79 100644 --- a/samples/terraform/outputs.tf +++ b/samples/terraform/outputs.tf @@ -1,6 +1,11 @@ +output "resource_group_name" { + description = "Single shared resource group for gateway, Foundry, monitoring and optional networking." + value = azapi_resource.resource_group["gateway"].name +} + output "resource_groups" { - description = "Resource group names for the three building blocks." - value = { for key, group in azapi_resource.resource_group : key => group.name } + 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" { @@ -23,6 +28,14 @@ output "model_deployment_ids" { 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 diff --git a/samples/terraform/private-network.tf b/samples/terraform/private-network.tf index d114e6e..670f5bc 100644 --- a/samples/terraform/private-network.tf +++ b/samples/terraform/private-network.tf @@ -130,6 +130,8 @@ resource "azapi_resource" "private_endpoint" { 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.privateLinkServiceConnections"] body = { properties = { subnet = { id = azapi_resource.private_subnet["endpoints"].id } 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/Test-CustomerAcceptance.ps1 b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 new file mode 100644 index 0000000..5c07ae4 --- /dev/null +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -0,0 +1,418 @@ +#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) { + 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$groupId/", [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 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.StartsWith('ACCEPTANCE: ')) { + $message.Substring(12) + } 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)) { + 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.' } + } + 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 + $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' } +} + +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) { + $roleId = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/$($binding[3])" + $assignments = @(Read-ArmItems "$($binding[2])/providers/Microsoft.Authorization/roleAssignments" '2022-04-01') + $match = @($assignments | Where-Object { + $_.properties.scope -ieq $binding[2] -and $_.properties.principalId -eq $binding[1] -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: $($binding[0])" } + } + @{ 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' @('context') { + 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') + if ($name -eq 'gateway') { $context.insightsAppId = $insights.properties.AppId } + } + if ($context.insightsAppId -notmatch '^[a-fA-F0-9-]{36}$') { Stop-Check 'Missing gateway Insights app ID.' } + $exporter = Read-Arm "$($context.gatewayId)/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' + if ($exporter.properties.kind -ne 'applicationInsights' -or $exporter.properties.payloadCapture -ne $false -or + $exporter.properties.applicationInsights.resourceId -ine $context.outputs.application_insights_ids.value.gateway) { + Stop-Check 'Telemetry exporter target or payload-capture mismatch.' + } + @{ readyMonitoringStacks = 2; delivery = 'Requires telemetry check' } +} + +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) + $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') + $query = "requests | where timestamp between (datetime($start) .. datetime($end)) | where operation_Id in ($traceFilter) | where success == true and resultCode == '200' | distinct operation_Id" + $url = "https://api.applicationinsights.io/v1/apps/$($context.insightsAppId)/query?query=$([uri]::EscapeDataString($query))" + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TelemetryWaitSeconds) + do { + $response = Invoke-AzureJson @('rest', '--method', 'get', '--resource', 'https://api.applicationinsights.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 1 -or $table.columns[0].name -ne 'operation_Id') { + Stop-Check 'Telemetry correlation schema unsupported; delivery not proven.' + } + $found = @($table.rows | ForEach-Object { $_[0] }) + if (@($traces | Where-Object { $_ -notin $found }).Count -eq 0) { + return @{ matchedTraceIds = $traces; query = $query; 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 'No successful request telemetry for every probe trace ID within the ingestion window; not a delivery pass.' +} + +$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 index f96efdf..91622ce 100644 --- a/samples/terraform/scripts/Test-ModelAliases.ps1 +++ b/samples/terraform/scripts/Test-ModelAliases.ps1 @@ -5,20 +5,34 @@ $ErrorActionPreference = "Stop" -$outputs = terraform output -json | ConvertFrom-Json +$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 ([string]::IsNullOrWhiteSpace($apiKey)) { + 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 } 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 index f990e3a..b1cf3b2 100644 --- a/samples/terraform/scripts/Test-PrivateGateway.ps1 +++ b/samples/terraform/scripts/Test-PrivateGateway.ps1 @@ -1,8 +1,11 @@ -param() +param( + [ValidatePattern('^[a-f0-9]{32}$')] + [string]$RunId +) $ErrorActionPreference = 'Stop' $root = Split-Path $PSScriptRoot -Parent -$outputsJson = & terraform "-chdir=$root" output -json +$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 @@ -10,6 +13,12 @@ $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)) @@ -27,7 +36,7 @@ $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 + --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 = @( @@ -40,7 +49,7 @@ try { } ) if ($reports.Count -ne 1) { - throw "Expected one probe report. VM result: $($result | ConvertTo-Json -Depth 10 -Compress)" + 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.' } diff --git a/samples/terraform/scripts/private-path-probe.py b/samples/terraform/scripts/private-path-probe.py index 2b1d473..ac2350e 100644 --- a/samples/terraform/scripts/private-path-probe.py +++ b/samples/terraform/scripts/private-path-probe.py @@ -9,6 +9,7 @@ import urllib.error import urllib.parse import urllib.request +import uuid def request_json(opener, url, headers=None, body=None): @@ -25,11 +26,52 @@ def private_dns(host, subnet): 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("/") @@ -60,11 +102,17 @@ def main(): 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, - gateway + "/default/models/openai/v1/chat/completions", - {"Content-Type": "application/json", "Api-Key": key}, + 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."}], @@ -75,8 +123,32 @@ def main(): text = response["choices"][0]["message"]["content"].strip() if status != 200 or text != "OK": raise RuntimeError(f"Inference failed for {model}: HTTP {status}, unexpected reply") - results.append({"model": model, "status": status, "reply": text, "usage": response.get("usage")}) - print(json.dumps({"dns": dns, "inference": results, "passed": True})) + 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__": diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 new file mode 100644 index 0000000..de30183 --- /dev/null +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -0,0 +1,242 @@ +$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" +$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 = @{} } + 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' } } + } + 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 = 'applicationInsights'; payloadCapture = $false; applicationInsights = @{ resourceId = $f.outputs.application_insights_ids.value.gateway } + } } + foreach ($binding in @( + @($models, 'gateway-principal', '53ca6127-db72-4b80-b1b0-d745d6d5456d'), + @($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' } } + ) + if ($f.scenario -eq 'extra-group') { $resources += @{ mode = 'managed'; values = @{ id = "$group-old"; type = 'Microsoft.Resources/resourceGroups@2021-04-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.applicationinsights.io/')) { + $rows = if ($f.scenario -eq 'missing-telemetry') { @() } else { @(@($f.traces[0]), @($f.traces[1])) } + return (@{ tables = @(@{ columns = @(@{ name = 'operation_Id' }); rows = $rows }) } | 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') + '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') + drift = @('FAIL', 'drift'); 'plan-error' = @('FAIL', 'drift') + 'runtime-error' = @('FAIL', 'runtime'); 'missing-telemetry' = @('FAIL', 'telemetry') + 'cleanup-error' = @('FAIL', 'vm-cleanup'); 'running-vm' = @('FAIL', 'runtime') +} +try { + foreach ($case in $cases.Keys) { + New-Fixture + $f = $global:acceptanceTest + $f.scenario = $case + 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' } + '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 + ($case -eq 'pass' -and $caught) -or ($case -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 ($case -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 -notin @('pass', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and + @($f.calls | Where-Object { $_ -match '^vm (start|deallocate|run-command)' }).Count) { throw "Unexpected VM mutation in $case." } + if ($case -in @('pass', 'runtime-error', 'missing-telemetry') -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..78447b8 --- /dev/null +++ b/samples/terraform/tests/Test-PortalAcceptance.ps1 @@ -0,0 +1,59 @@ +$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', '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' } + '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 index 4911b52..9dc0df1 100644 --- a/samples/terraform/tests/infrastructure.tftest.hcl +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -1,5 +1,13 @@ mock_provider "azapi" {} +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" @@ -23,6 +31,30 @@ variables { 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 @@ -44,6 +76,14 @@ run "models_and_access_without_developer" { 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) && @@ -127,6 +167,46 @@ run "reject_invalid_capacity" { 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 { @@ -153,6 +233,23 @@ run "private_gateway_and_model_paths" { } } + 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" && diff --git a/samples/terraform/tests/test_private_path_probe.py b/samples/terraform/tests/test_private_path_probe.py index 436dd8c..e75f08b 100644 --- a/samples/terraform/tests/test_private_path_probe.py +++ b/samples/terraform/tests/test_private_path_probe.py @@ -3,6 +3,7 @@ import io import ipaddress import json +import urllib.error from pathlib import Path import unittest from unittest.mock import patch @@ -30,11 +31,21 @@ def run_probe(self, replies): "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] + ] + [(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))]), \ @@ -47,6 +58,9 @@ def test_checks_all_aliases_without_emitting_credentials(self): 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) @@ -54,6 +68,47 @@ 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 index f90d9bb..1d0bb86 100644 --- a/samples/terraform/variables.tf +++ b/samples/terraform/variables.tf @@ -1,5 +1,5 @@ variable "subscription_id" { - description = "Azure subscription in which to provision the three resource groups." + 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)) @@ -8,7 +8,7 @@ variable "subscription_id" { } variable "environment_name" { - description = "Short environment label used in resource group names and tags." + description = "Short environment label used in the shared resource group name and tags." type = string default = "aigateway" validation { @@ -17,6 +17,22 @@ variable "environment_name" { } } +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 From 694f0f82e296bf5be8988dd32ea4dcfe0046ba29 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:52:56 +0200 Subject: [PATCH 08/11] Handle documented preview GET omissions in acceptance checks Retain live endpoint, alias, telemetry target and payload checks while explicitly reporting apiFormat/kind fields omitted by the legacy service. Cover omitted and contradictory responses with regression cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41b2279f-0f7d-4bf0-b9b0-721eb55bea64 --- samples/terraform/docs/end-to-end.md | 7 +++++++ .../scripts/Test-CustomerAcceptance.ps1 | 15 +++++++++++---- .../terraform/tests/Test-CustomerAcceptance.ps1 | 16 ++++++++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/samples/terraform/docs/end-to-end.md b/samples/terraform/docs/end-to-end.md index 25b1686..28734b2 100644 --- a/samples/terraform/docs/end-to-end.md +++ b/samples/terraform/docs/end-to-end.md @@ -87,6 +87,13 @@ 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`, and its exporter GET can +omit `kind`, even though those fields were sent on creation. The runner records +this verification boundary: it requires compatible output metadata and live +supported endpoints/alias targets, plus the explicit live Application Insights +target and `payloadCapture = false`. If the optional fields are returned they +must match. Actual API behavior and telemetry delivery 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 diff --git a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 index 5c07ae4..c6f2219 100644 --- a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -187,13 +187,16 @@ Invoke-Check 'resources' @('context') { $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 - $catalog.properties.apiFormat -ne $metadata.api_format -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' } + @{ + 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') { @@ -255,11 +258,15 @@ Invoke-Check 'monitoring' @('context') { } if ($context.insightsAppId -notmatch '^[a-fA-F0-9-]{36}$') { Stop-Check 'Missing gateway Insights app ID.' } $exporter = Read-Arm "$($context.gatewayId)/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' - if ($exporter.properties.kind -ne 'applicationInsights' -or $exporter.properties.payloadCapture -ne $false -or + if (('kind' -in $exporter.properties.PSObject.Properties.Name -and $exporter.properties.kind -ne 'applicationInsights') -or + $exporter.properties.payloadCapture -ne $false -or $exporter.properties.applicationInsights.resourceId -ine $context.outputs.application_insights_ids.value.gateway) { Stop-Check 'Telemetry exporter target or payload-capture mismatch.' } - @{ readyMonitoringStacks = 2; delivery = 'Requires telemetry check' } + @{ + readyMonitoringStacks = 2; delivery = 'Requires telemetry check' + exporterVerification = 'Application Insights target and payloadCapture checked; kind checked when returned (preview GET can omit it).' + } } Invoke-Check 'private-network' @('resources') { diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 index de30183..04ed1fa 100644 --- a/samples/terraform/tests/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -192,11 +192,13 @@ function az { $cases = [ordered]@{ pass = @('PASS', 'runtime'); readonly = @('BLOCKED', 'runtime') + 'omitted-preview-fields' = @('PASS', 'runtime') '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') + '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') @@ -211,6 +213,12 @@ try { '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' } + 'omitted-preview-fields' { + foreach ($name in @('full', 'mini')) { $f.resources["$gateway/workspaces/default/modelProviders/foundry/models/$name"].properties.Remove('apiFormat') } + $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.Remove('kind') + } 'running-vm' { $f.power = 'running' } } $destination = Join-Path $testDirectory $case @@ -225,15 +233,15 @@ try { $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 - ($case -eq 'pass' -and $caught) -or ($case -ne 'pass' -and -not $caught)) { + ($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 ($case -eq 'pass') { 'PASS' } elseif ($case -eq 'readonly') { 'NOT RUN' } else { 'FAIL' } + $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 -notin @('pass', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and + if ($case -notin @('pass', 'omitted-preview-fields', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and @($f.calls | Where-Object { $_ -match '^vm (start|deallocate|run-command)' }).Count) { throw "Unexpected VM mutation in $case." } - if ($case -in @('pass', 'runtime-error', 'missing-telemetry') -and $f.power -ne 'deallocated') { throw "VM left running in $case." } + if ($case -in @('pass', 'omitted-preview-fields', 'runtime-error', 'missing-telemetry') -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." } } From d200b20c739786f26242311b5b6200c29d37044e Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:14:40 +0200 Subject: [PATCH 09/11] Require portal-recognizable telemetry exporter metadata Fail monitoring acceptance when the legacy API omits the exporter kind. Document observed portal recognition and private endpoint state limitations, and cover missing/null and Pascal-case kinds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41b2279f-0f7d-4bf0-b9b0-721eb55bea64 --- samples/terraform/docs/end-to-end.md | 24 ++++++++++++++----- .../scripts/Test-CustomerAcceptance.ps1 | 9 ++++--- .../tests/Test-CustomerAcceptance.ps1 | 15 ++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/samples/terraform/docs/end-to-end.md b/samples/terraform/docs/end-to-end.md index 28734b2..a9b3edb 100644 --- a/samples/terraform/docs/end-to-end.md +++ b/samples/terraform/docs/end-to-end.md @@ -87,12 +87,19 @@ 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`, and its exporter GET can -omit `kind`, even though those fields were sent on creation. The runner records -this verification boundary: it requires compatible output metadata and live -supported endpoints/alias targets, plus the explicit live Application Insights -target and `payloadCapture = false`. If the optional fields are returned they -must match. Actual API behavior and telemetry delivery still need runtime proof. +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 = applicationInsights` (case-insensitive), the +expected Application Insights resource ID and `payloadCapture = false`. A missing +or empty `kind` is a **failure**, even if the target exists: the current portal +uses this discriminator to recognize telemetry configuration. Live diagnosis +observed a legacy exporter omitting it while Monitoring offered "Configure" +instead of metrics. Do not create a second monitoring stack through that wizard +or assume waiting for ingestion will fix this configuration-recognition problem. +Resolve the API/portal compatibility issue before final acceptance. Actual API +behavior, telemetry 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 @@ -146,6 +153,11 @@ 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 diff --git a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 index c6f2219..7fe1574 100644 --- a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -258,14 +258,17 @@ Invoke-Check 'monitoring' @('context') { } if ($context.insightsAppId -notmatch '^[a-fA-F0-9-]{36}$') { Stop-Check 'Missing gateway Insights app ID.' } $exporter = Read-Arm "$($context.gatewayId)/workspaces/default/telemetryExporters/appinsights" '2025-09-01-preview' - if (('kind' -in $exporter.properties.PSObject.Properties.Name -and $exporter.properties.kind -ne 'applicationInsights') -or + if ([string]::IsNullOrWhiteSpace([string]$exporter.properties.kind)) { + Stop-Check 'Telemetry exporter kind is missing; the portal cannot recognize the Application Insights configuration.' + } + if ($exporter.properties.kind -ne 'applicationInsights' -or $exporter.properties.payloadCapture -ne $false -or $exporter.properties.applicationInsights.resourceId -ine $context.outputs.application_insights_ids.value.gateway) { - Stop-Check 'Telemetry exporter target or payload-capture mismatch.' + Stop-Check 'Telemetry exporter kind, target or payload-capture mismatch.' } @{ readyMonitoringStacks = 2; delivery = 'Requires telemetry check' - exporterVerification = 'Application Insights target and payloadCapture checked; kind checked when returned (preview GET can omit it).' + exporterVerification = 'Returned Application Insights kind, target and payloadCapture checked; portal metrics still require functional review.' } } diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 index 04ed1fa..b6645f3 100644 --- a/samples/terraform/tests/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -192,7 +192,8 @@ function az { $cases = [ordered]@{ pass = @('PASS', 'runtime'); readonly = @('BLOCKED', 'runtime') - 'omitted-preview-fields' = @('PASS', 'runtime') + 'omitted-api-format' = @('PASS', 'runtime'); 'pascal-exporter-kind' = @('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') @@ -215,9 +216,11 @@ try { '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' } - 'omitted-preview-fields' { + 'pascal-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = 'ApplicationInsights' } + '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') } - $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.Remove('kind') } 'running-vm' { $f.power = 'running' } } @@ -239,9 +242,11 @@ try { $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 -notin @('pass', 'omitted-preview-fields', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and + 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 @('pass', 'omitted-api-format', 'pascal-exporter-kind', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and @($f.calls | Where-Object { $_ -match '^vm (start|deallocate|run-command)' }).Count) { throw "Unexpected VM mutation in $case." } - if ($case -in @('pass', 'omitted-preview-fields', 'runtime-error', 'missing-telemetry') -and $f.power -ne 'deallocated') { throw "VM left running in $case." } + if ($case -in @('pass', 'omitted-api-format', 'pascal-exporter-kind', 'runtime-error', 'missing-telemetry') -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." } } From cd91f16da1837943b7a2d441f0acd733cabc4687 Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:18:24 +0200 Subject: [PATCH 10/11] Configure validated gateway OTLP monitoring and acceptance Discover real Insights-linked endpoints and DCR, publish with the gateway identity, verify all signal paths, and document controlled immutable exporter replacement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41b2279f-0f7d-4bf0-b9b0-721eb55bea64 --- samples/terraform/README.md | 19 +- samples/terraform/ai-gateway.tf | 35 +++- samples/terraform/docs/acceptance.md | 51 ++++-- samples/terraform/docs/end-to-end.md | 37 ++-- samples/terraform/docs/monitoring.md | 163 ++++++++++++++++++ .../terraform/docs/private-connectivity.md | 7 +- samples/terraform/docs/reference.md | 10 +- samples/terraform/main.tf | 26 ++- samples/terraform/outputs.tf | 10 +- samples/terraform/rbac.tf | 15 ++ .../scripts/Get-GatewayMonitoring.ps1 | 117 +++++++++++++ .../scripts/Test-CustomerAcceptance.ps1 | 145 ++++++++++++---- .../tests/Test-CustomerAcceptance.ps1 | 136 +++++++++++++-- .../terraform/tests/Test-PortalAcceptance.ps1 | 4 +- .../terraform/tests/infrastructure.tftest.hcl | 71 +++++++- 15 files changed, 753 insertions(+), 93 deletions(-) create mode 100644 samples/terraform/docs/monitoring.md create mode 100644 samples/terraform/scripts/Get-GatewayMonitoring.ps1 diff --git a/samples/terraform/README.md b/samples/terraform/README.md index 8025a0d..a6e2898 100644 --- a/samples/terraform/README.md +++ b/samples/terraform/README.md @@ -43,7 +43,7 @@ path or repeat the temporary-public staging step for an already-private backend. - [ ] 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` (this sample does not register them for you) +- [ ] 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:** @@ -65,10 +65,13 @@ path or repeat the temporary-public staging step for an already-private backend. Both paths manage all three core components. Hosted-agent infrastructure is included even if you only test model calls. -All resource-group-scoped resources use **one shared resource group**: +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 -separate gateway/agent monitoring stacks are unchanged. +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 @@ -119,7 +122,13 @@ terraform fmt -check -recursive terraform validate ``` -Continue with **one** of the networking paths below. +**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 @@ -184,7 +193,7 @@ 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 scoped RBAC assignments, Foundry connections, telemetry, +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 diff --git a/samples/terraform/ai-gateway.tf b/samples/terraform/ai-gateway.tf index c0dd47a..ecb7c84 100644 --- a/samples/terraform/ai-gateway.tf +++ b/samples/terraform/ai-gateway.tf @@ -43,6 +43,15 @@ resource "azapi_resource" "connector_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" { @@ -53,14 +62,34 @@ resource "azapi_resource" "telemetry_exporter" { parent_id = local.gateway_workspace_id body = { properties = { - kind = "applicationInsights" + kind = "OpenTelemetry" payloadCapture = false applicationInsights = { - connectionString = azapi_resource.insights["gateway"].output.properties.ConnectionString - resourceId = azapi_resource.insights["gateway"].id + 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" { diff --git a/samples/terraform/docs/acceptance.md b/samples/terraform/docs/acceptance.md index f693f2b..cea20d3 100644 --- a/samples/terraform/docs/acceptance.md +++ b/samples/terraform/docs/acceptance.md @@ -20,7 +20,7 @@ These checks do not grant permissions or repair resources. Inference is billable Replace both placeholders with the approved customer context. The operator needs resource/role-assignment read access and permission to query the gateway's -Application Insights. Running the private probe also requires the +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 @@ -257,31 +257,51 @@ 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 'applicationInsights' -or +if ($exporter.properties.kind -ne 'OpenTelemetry' -or $exporter.properties.payloadCapture -ne $false -or - $exporter.properties.applicationInsights.resourceId -ine $o.application_insights_ids.value.gateway) { + $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.' } -$o.application_insights_ids.value.gateway +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 Application Insights resource > Logs** (not agent insights). -Allow several minutes for ingestion; use Application Insights table names and -replace the time bounds below with your test window, widened by a few minutes: +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 isfuzzy=true requests, dependencies, traces, customEvents -| where timestamp between (datetime(2026-01-01T12:00:00Z) .. datetime(2026-01-01T12:15:00Z)) -| project timestamp, TelemetryTable, operation_Id, name, resultCode, success -| order by timestamp asc +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 operation IDs and +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 @@ -290,6 +310,11 @@ alone is **not** proof of delivery. Do not enable payload capture to troubleshoo 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: @@ -309,7 +334,7 @@ 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 binding results, connection metadata, authentication +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. diff --git a/samples/terraform/docs/end-to-end.md b/samples/terraform/docs/end-to-end.md index a9b3edb..80c1294 100644 --- a/samples/terraform/docs/end-to-end.md +++ b/samples/terraform/docs/end-to-end.md @@ -10,6 +10,9 @@ Keep the legacy `AIGateway` SKU and private-endpoint architecture. All applicati 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. @@ -63,7 +66,7 @@ Provider installation needs internet access; "offline" means no Azure operations 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 Application Insights query access. +also needs Log Analytics and AMW Prometheus query access. ```powershell $parameters = @{ @@ -82,7 +85,7 @@ 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, monitoring/exporter, endpoint provisioning **and** approval, DNS +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. @@ -91,15 +94,15 @@ 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 = applicationInsights` (case-insensitive), the -expected Application Insights resource ID and `payloadCapture = false`. A missing -or empty `kind` is a **failure**, even if the target exists: the current portal -uses this discriminator to recognize telemetry configuration. Live diagnosis -observed a legacy exporter omitting it while Monitoring offered "Configure" -instead of metrics. Do not create a second monitoring stack through that wizard -or assume waiting for ingestion will fix this configuration-recognition problem. -Resolve the API/portal compatibility issue before final acceptance. Actual API -behavior, telemetry delivery and visible portal metrics still need runtime proof. +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 @@ -133,9 +136,15 @@ 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 -gateway's Application Insights for those exact `operation_Id` values and -successful HTTP 200 requests in the recorded UTC window, polling for at most the -configured ingestion wait (0-900 seconds, excluding individual CLI call duration). +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. diff --git a/samples/terraform/docs/monitoring.md b/samples/terraform/docs/monitoring.md new file mode 100644 index 0000000..1fcc8dc --- /dev/null +++ b/samples/terraform/docs/monitoring.md @@ -0,0 +1,163 @@ +# 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. + +## 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) diff --git a/samples/terraform/docs/private-connectivity.md b/samples/terraform/docs/private-connectivity.md index 8c754e5..3eeb062 100644 --- a/samples/terraform/docs/private-connectivity.md +++ b/samples/terraform/docs/private-connectivity.md @@ -38,6 +38,9 @@ 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 @@ -50,7 +53,7 @@ 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 at their original resource scopes. | +| 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. | @@ -65,7 +68,7 @@ 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 role assignments. +This avoids the custom test-VM role, not the six core service roles or DCR publishing role. ## Step 1: Choose your test client diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md index 3a76ef3..8e3fb65 100644 --- a/samples/terraform/docs/reference.md +++ b/samples/terraform/docs/reference.md @@ -17,6 +17,7 @@ Review the limitations before changing an existing deployment. | [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) | @@ -55,9 +56,10 @@ Optional fields: `model_format`, `sku_name`, `api_format`, `endpoints`, `descrip | `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 single shared resource group | +| `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. @@ -131,6 +133,12 @@ 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 diff --git a/samples/terraform/main.tf b/samples/terraform/main.tf index bcc47e8..169f865 100644 --- a/samples/terraform/main.tf +++ b/samples/terraform/main.tf @@ -41,19 +41,27 @@ resource "azapi_resource" "logs" { } resource "azapi_resource" "insights" { - 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 + # 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 = { + properties = merge({ Application_Type = "web" IngestionMode = "LogAnalytics" WorkspaceResourceId = azapi_resource.logs[each.key].id - } + }, each.key == "gateway" ? { AzureMonitorWorkspaceIngestionMode = "Enabled" } : {}) } - response_export_values = ["properties.ConnectionString"] + 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 index 5a0af79..3ac20eb 100644 --- a/samples/terraform/outputs.tf +++ b/samples/terraform/outputs.tf @@ -1,5 +1,5 @@ output "resource_group_name" { - description = "Single shared resource group for gateway, Foundry, monitoring and optional networking." + description = "Shared application group; excludes linked Azure-managed OTLP helper groups and the DCR-scoped publisher assignment." value = azapi_resource.resource_group["gateway"].name } @@ -66,6 +66,14 @@ output "application_insights_ids" { 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 } diff --git a/samples/terraform/rbac.tf b/samples/terraform/rbac.tf index 0bde24b..722c94a 100644 --- a/samples/terraform/rbac.tf +++ b/samples/terraform/rbac.tf @@ -12,6 +12,12 @@ locals { 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 @@ -60,4 +66,13 @@ resource "azapi_resource" "role_assignment" { 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/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 index 7fe1574..f9a2e43 100644 --- a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -60,12 +60,17 @@ function Read-Arm([string]$Id, [string]$Version) { Invoke-AzureJson @('rest', '--method', 'get', '--url', "https://management.azure.com${Id}?api-version=$Version") } -function Read-ArmItems([string]$Id, [string]$Version) { - Assert-GroupId $Id +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$groupId/", [StringComparison]::OrdinalIgnoreCase) -or + 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 @@ -73,6 +78,26 @@ function Read-ArmItems([string]$Id, [string]$Version) { } } +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)" @@ -99,8 +124,8 @@ function Invoke-Check([string]$Name, [string[]]$Needs, [scriptblock]$Action) { catch { $checks[$Name].status = 'FAIL' $message = $_.Exception.Message - $checks[$Name].detail = if ($message.StartsWith('ACCEPTANCE: ')) { - $message.Substring(12) + $checks[$Name].detail = if ($message -match '^(?:ACCEPTANCE|MONITORING): (.*)$') { + $Matches[1] } else { 'Unexpected check error; raw error omitted to protect credentials.' } } } @@ -128,7 +153,8 @@ Invoke-Check 'context' @() { $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.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 @@ -156,6 +182,21 @@ Invoke-Check 'placement' @('context') { $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 } @@ -209,14 +250,7 @@ Invoke-Check 'roles' @('resources') { @('project-user', $context.project.identity.principalId, $context.agentsId, 'a97b65f3-24c7-4388-baec-2e87135dc908') ) foreach ($binding in $bindings) { - $roleId = "/subscriptions/$subscription/providers/Microsoft.Authorization/roleDefinitions/$($binding[3])" - $assignments = @(Read-ArmItems "$($binding[2])/providers/Microsoft.Authorization/roleAssignments" '2022-04-01') - $match = @($assignments | Where-Object { - $_.properties.scope -ieq $binding[2] -and $_.properties.principalId -eq $binding[1] -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: $($binding[0])" } + Assert-RoleBinding $binding[0] $binding[1] $binding[2] $binding[3] } @{ verifiedBindings = 6; additionalDeveloperRoles = 'Not certified by this check' } } @@ -249,26 +283,37 @@ Invoke-Check 'connections' @('resources') { @{ verifiedConnections = 3; backendAuthentication = 'ManagedIdentity' } } -Invoke-Check 'monitoring' @('context') { +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') - if ($name -eq 'gateway') { $context.insightsAppId = $insights.properties.AppId } } - if ($context.insightsAppId -notmatch '^[a-fA-F0-9-]{36}$') { Stop-Check 'Missing gateway Insights app ID.' } + $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 Application Insights configuration.' + Stop-Check 'Telemetry exporter kind is missing; the portal cannot recognize the OpenTelemetry configuration.' } - if ($exporter.properties.kind -ne 'applicationInsights' -or + if ($exporter.properties.kind -ne 'OpenTelemetry' -or $exporter.properties.payloadCapture -ne $false -or - $exporter.properties.applicationInsights.resourceId -ine $context.outputs.application_insights_ids.value.gateway) { + $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' - exporterVerification = 'Returned Application Insights kind, target and payloadCapture checked; portal metrics still require functional review.' + verifiedPublisherBindings = 1; destination = $topology + exporterVerification = 'OpenTelemetry, all endpoints, system identity audience, linked destination and exact DCR role checked; delivery/UI are separate gates.' } } @@ -387,25 +432,67 @@ Invoke-Check 'telemetry' @('runtime', 'monitoring') { $traceFilter = ($traces | ForEach-Object { "'$_'" }) -join ',' $start = $context.testStartUtc.AddMinutes(-1).UtcDateTime.ToString('o') $end = $context.testEndUtc.AddMinutes(1).UtcDateTime.ToString('o') - $query = "requests | where timestamp between (datetime($start) .. datetime($end)) | where operation_Id in ($traceFilter) | where success == true and resultCode == '200' | distinct operation_Id" - $url = "https://api.applicationinsights.io/v1/apps/$($context.insightsAppId)/query?query=$([uri]::EscapeDataString($query))" + $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.applicationinsights.io', '--url', $url) + $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 1 -or $table.columns[0].name -ne 'operation_Id') { + 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 | ForEach-Object { $_[0] }) - if (@($traces | Where-Object { $_ -notin $found }).Count -eq 0) { - return @{ matchedTraceIds = $traces; query = $query; windowStartUtc = $start; windowEndUtc = $end } + $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 'No successful request telemetry for every probe trace ID within the ingestion window; not a delivery pass.' + 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 }) diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 index b6645f3..322f7ac 100644 --- a/samples/terraform/tests/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -11,6 +11,11 @@ $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 @@ -25,6 +30,7 @@ function New-Fixture { 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' @@ -49,7 +55,42 @@ function New-Fixture { $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' } } + $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" @@ -93,10 +134,12 @@ function New-Fixture { resourceIds = @($models); endpoint = 'https://models.example/' } } } $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"] = @{ properties = @{ - kind = 'applicationInsights'; payloadCapture = $false; applicationInsights = @{ resourceId = $f.outputs.application_insights_ids.value.gateway } + 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'), @@ -135,9 +178,13 @@ function terraform { 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' } } + @{ 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.' @@ -180,9 +227,37 @@ function az { throw 'Unexpected VM operation.' } $url = [string]$args[[Array]::IndexOf($args, '--url') + 1] - if ($url.StartsWith('https://api.applicationinsights.io/')) { - $rows = if ($f.scenario -eq 'missing-telemetry') { @() } else { @(@($f.traces[0]), @($f.traces[1])) } - return (@{ tables = @(@{ columns = @(@{ name = 'operation_Id' }); rows = $rows }) } | ConvertTo-Json -Depth 10) + 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' } @@ -192,7 +267,8 @@ function az { $cases = [ordered]@{ pass = @('PASS', 'runtime'); readonly = @('BLOCKED', 'runtime') - 'omitted-api-format' = @('PASS', 'runtime'); 'pascal-exporter-kind' = @('PASS', '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') @@ -203,7 +279,23 @@ $cases = [ordered]@{ 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', '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 @@ -216,7 +308,31 @@ try { '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' } - 'pascal-exporter-kind' { $f.resources["$gateway/workspaces/default/telemetryExporters/appinsights"].properties.kind = 'ApplicationInsights' } + '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' { @@ -244,9 +360,9 @@ try { 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 @('pass', 'omitted-api-format', 'pascal-exporter-kind', 'runtime-error', 'missing-telemetry', 'cleanup-error') -and + 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 @('pass', 'omitted-api-format', 'pascal-exporter-kind', 'runtime-error', 'missing-telemetry') -and $f.power -ne 'deallocated') { throw "VM left running 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." } } diff --git a/samples/terraform/tests/Test-PortalAcceptance.ps1 b/samples/terraform/tests/Test-PortalAcceptance.ps1 index 78447b8..10699da 100644 --- a/samples/terraform/tests/Test-PortalAcceptance.ps1 +++ b/samples/terraform/tests/Test-PortalAcceptance.ps1 @@ -2,7 +2,7 @@ $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', 'missing-backend-check', 'context', 'not-run', 'duplicate', 'no-evidence', 'old-review', 'old-metrics', 'different-metrics', 'zero-metrics', 'boolean-metrics') +$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 @@ -31,6 +31,8 @@ try { } 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' } diff --git a/samples/terraform/tests/infrastructure.tftest.hcl b/samples/terraform/tests/infrastructure.tftest.hcl index 9dc0df1..7b7810e 100644 --- a/samples/terraform/tests/infrastructure.tftest.hcl +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -1,5 +1,21 @@ 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 @@ -104,7 +120,7 @@ run "models_and_access_without_developer" { assert { condition = ( - length(azapi_resource.role_assignment) == 6 && + 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-")]) @@ -135,7 +151,7 @@ run "explicit_developer_service_principal" { assert { condition = ( - length(azapi_resource.role_assignment) == 13 && + length(azapi_resource.role_assignment) == 14 && alltrue([ for key, binding in azapi_resource.role_assignment : binding.body.properties.principalId == var.developer_principal_id && @@ -224,7 +240,6 @@ run "private_gateway_and_model_paths" { 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 @@ -372,8 +387,19 @@ run "private_connectivity_poc_profile" { 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 == "applicationInsights" && - !azapi_resource.telemetry_exporter.body.properties.payloadCapture + 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." } @@ -388,6 +414,41 @@ run "reject_private_client_without_network" { expect_failures = [var.enable_private_test_client] } +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 { From 3b326f75b51058fe6d645933d4705827e38f0bdf Mon Sep 17 00:00:00 2001 From: Anna Wiewer <138025423+annawiewer@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:44:59 +0100 Subject: [PATCH 11/11] Fix legacy gateway private endpoint approval and DNS integration Use explicit approval for manual gateway connections, preserve strict acceptance checks, and document migration and cleanup limitations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 41b2279f-0f7d-4bf0-b9b0-721eb55bea64 --- samples/terraform/docs/acceptance.md | 6 +- samples/terraform/docs/monitoring.md | 36 ++++++++++ .../terraform/docs/private-connectivity.md | 37 +++++++++++ samples/terraform/docs/reference.md | 2 +- samples/terraform/private-network.tf | 65 ++++++++++++++++--- .../profiles/private-connectivity.tfvars.json | 1 + .../scripts/Test-CustomerAcceptance.ps1 | 6 +- .../tests/Test-CustomerAcceptance.ps1 | 15 ++++- .../terraform/tests/infrastructure.tftest.hcl | 26 ++++++-- samples/terraform/variables.tf | 10 +++ 10 files changed, 186 insertions(+), 18 deletions(-) diff --git a/samples/terraform/docs/acceptance.md b/samples/terraform/docs/acceptance.md index cea20d3..b2c8724 100644 --- a/samples/terraform/docs/acceptance.md +++ b/samples/terraform/docs/acceptance.md @@ -188,7 +188,11 @@ if ($models.properties.publicNetworkAccess -ne 'Disabled' -or } foreach ($name in @('gateway', 'models')) { $endpoint = Get-Arm $o.private_endpoint_ids.value.$name '2024-05-01' - $connections = @($endpoint.properties.privateLinkServiceConnections) + $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 diff --git a/samples/terraform/docs/monitoring.md b/samples/terraform/docs/monitoring.md index 1fcc8dc..bc32051 100644 --- a/samples/terraform/docs/monitoring.md +++ b/samples/terraform/docs/monitoring.md @@ -154,6 +154,39 @@ 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) @@ -161,3 +194,6 @@ offline fixtures alone do not prove the preview works in a subscription. - [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 index 3eeb062..7427c45 100644 --- a/samples/terraform/docs/private-connectivity.md +++ b/samples/terraform/docs/private-connectivity.md @@ -15,12 +15,49 @@ Private client -> gateway private endpoint -> private model endpoint | 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 diff --git a/samples/terraform/docs/reference.md b/samples/terraform/docs/reference.md index 8e3fb65..333111f 100644 --- a/samples/terraform/docs/reference.md +++ b/samples/terraform/docs/reference.md @@ -8,7 +8,7 @@ Review the limitations before changing an existing deployment. | 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) | Three private-connectivity settings (no secrets) | +| [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 | diff --git a/samples/terraform/private-network.tf b/samples/terraform/private-network.tf index 670f5bc..daa4268 100644 --- a/samples/terraform/private-network.tf +++ b/samples/terraform/private-network.tf @@ -12,14 +12,18 @@ locals { } : {} private_endpoints = var.enable_private_networking ? { gateway = { - resource_id = azapi_resource.gateway.id - group_id = "Gateway" - dns_keys = ["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"] + resource_id = azapi_resource.models.id + group_id = "account" + dns_keys = ["cognitiveservices", "openai", "services"] + connection_name = "models" + connection_property = "privateLinkServiceConnections" } } : {} } @@ -126,17 +130,17 @@ resource "azapi_resource" "private_dns_link" { resource "azapi_resource" "private_endpoint" { for_each = local.private_endpoints type = "Microsoft.Network/privateEndpoints@2024-05-01" - name = "${local.names.gateway}-${each.key}-pe" + 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.privateLinkServiceConnections"] + replace_triggers_refs = ["properties.${each.value.connection_property}"] body = { properties = { subnet = { id = azapi_resource.private_subnet["endpoints"].id } - privateLinkServiceConnections = [{ - name = each.key + (each.value.connection_property) = [{ + name = each.value.connection_name properties = { privateLinkServiceId = each.value.resource_id groupIds = [each.value.group_id] @@ -146,6 +150,46 @@ resource "azapi_resource" "private_endpoint" { } } +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" @@ -159,4 +203,5 @@ resource "azapi_resource" "private_dns_zone_group" { }] } } + depends_on = [azapi_resource_action.gateway_private_endpoint_approval] } diff --git a/samples/terraform/profiles/private-connectivity.tfvars.json b/samples/terraform/profiles/private-connectivity.tfvars.json index 53ed1b4..c32899a 100644 --- a/samples/terraform/profiles/private-connectivity.tfvars.json +++ b/samples/terraform/profiles/private-connectivity.tfvars.json @@ -1,5 +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/scripts/Test-CustomerAcceptance.ps1 b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 index f9a2e43..43cb921 100644 --- a/samples/terraform/scripts/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/scripts/Test-CustomerAcceptance.ps1 @@ -334,7 +334,11 @@ Invoke-Check 'private-network' @('resources') { $endpointId = $context.outputs.private_endpoint_ids.value.$name $endpoint = Read-Arm $endpointId '2024-05-01' Assert-Ready $endpoint - $connections = @($endpoint.properties.privateLinkServiceConnections) + $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 diff --git a/samples/terraform/tests/Test-CustomerAcceptance.ps1 b/samples/terraform/tests/Test-CustomerAcceptance.ps1 index 322f7ac..d841e0a 100644 --- a/samples/terraform/tests/Test-CustomerAcceptance.ps1 +++ b/samples/terraform/tests/Test-CustomerAcceptance.ps1 @@ -275,6 +275,8 @@ $cases = [ordered]@{ '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') @@ -293,7 +295,7 @@ $cases = [ordered]@{ 'nonfinite-metrics' = @('FAIL', 'telemetry'); 'relinked-law' = @('FAIL', 'monitoring') 'wrong-dce-endpoint' = @('FAIL', 'monitoring'); 'unsafe-query-endpoint' = @('FAIL', 'monitoring') } -$runtimeCases = @('pass', 'omitted-api-format', 'lowercase-exporter-kind', 'arm-dcr-fallback', 'runtime-error', +$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 { @@ -301,6 +303,17 @@ try { 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') } diff --git a/samples/terraform/tests/infrastructure.tftest.hcl b/samples/terraform/tests/infrastructure.tftest.hcl index 7b7810e..243f5b7 100644 --- a/samples/terraform/tests/infrastructure.tftest.hcl +++ b/samples/terraform/tests/infrastructure.tftest.hcl @@ -306,9 +306,10 @@ run "private_gateway_and_model_paths" { run "private_connectivity_poc_profile" { command = plan variables { - enable_private_networking = jsondecode(file("profiles/private-connectivity.tfvars.json")).enable_private_networking - 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 + 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 { @@ -362,7 +363,15 @@ run "private_connectivity_poc_profile" { condition = ( length(azapi_resource.private_endpoint) == 2 && length(azapi_resource.private_dns_zone) == 4 && - azapi_resource.private_endpoint["gateway"].body.properties.privateLinkServiceConnections[0].properties.privateLinkServiceId == azapi_resource.gateway.id && + 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" && @@ -411,9 +420,18 @@ run "reject_private_client_without_network" { 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 { diff --git a/samples/terraform/variables.tf b/samples/terraform/variables.tf index 1d0bb86..487dc4f 100644 --- a/samples/terraform/variables.tf +++ b/samples/terraform/variables.tf @@ -103,6 +103,16 @@ variable "models_public_network_access" { } } +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