Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/terraform-offline.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion samples/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Samples

A curated catalog of samples that build on the [AI Gateway tier in Azure API Management](https://aka.ms/aigateway/docs). This folder catalogs samples that live in their own repositories; it does not host sample code.
A curated catalog of samples that build on the [AI Gateway tier in Azure API Management](https://aka.ms/aigateway/docs), including external examples and a repository-local Terraform reference.

| Sample | Language | Description |
| ------ | -------- | ----------- |
| Foundry AI Gateway E2E | [Python](https://aka.ms/foundry-aigateway-python) / [.NET (C#)](https://aka.ms/foundry-aigateway-dotnet) | A Daily Digest Agent deployed as a Foundry Hosted Agent whose model and GitHub MCP traffic is routed through AI Gateway. |
| IaC only | [Bicep](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/ai-gateway) | Infrastructure as code for provisioning infrastructure using Bicep, scripts, or azd. Direct links to the building blocks: [AI Gateway](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/ai-gateway), [Foundry Models](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/foundry-models), and [Foundry Hosted Agent](https://github.com/Azure-Samples/simple-foundry-hosted-agent-python-aigateway/tree/main/infra/foundry-agents). |
| Terraform reference (legacy preview) | [Terraform](terraform/README.md) | AI Gateway and Foundry infrastructure with Public or Private-only networking, API-key or optional Entra access, and optional private hosted-agent infrastructure. Uses the time-limited legacy SKU; see the sample's migration notice before deployment. |
23 changes: 23 additions & 0 deletions samples/terraform/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions samples/terraform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# AI Gateway and Foundry with Terraform

Deploy AI Gateway, Foundry model deployments and infrastructure for a separately
deployed hosted agent. The gateway handles access and policy; the model generates
the answer. This is standalone Terraform, not an `azd` application.

> [!NOTE]
> **Legacy SKU and migration deadline:** This sample uses
> `Microsoft.ApiManagement/service` with SKU `AIGateway`. Microsoft requires
> migration **before January 15, 2027**. This SKU is retained here for the
> private-endpoint scenario: as of October 25, 2026, the newer
> `Microsoft.ApiManagement/aigateways` resource does **not** support private
> endpoints. This is a time-limited preview reference, not a long-term deployment
> target. Plan migration without assuming private-endpoint feature parity;
> this sample does not perform that migration. See Microsoft's
> [migration deadline and limitations](https://learn.microsoft.com/azure/api-management/ai-gateway-resource).

> [!IMPORTANT]
> **Terraform Registry disclaimer (checked October 25, 2026):** No dedicated
> Microsoft-published Registry module was identified for the standalone
> `AIGateway` preview used here. General API Management and community gateway
> modules do exist; this directory is a repository-specific Terraform
> configuration, not an official Registry module. Instead, it uses Microsoft's
> **`Azure/azapi` provider** to manage explicit ARM resource types and preview
> API versions. See [how this is implemented](deployment.md#terraform-implementation)
> for the distinction and validation limits.

## Two networking options

| Configuration | Gateway public access | Model public access |
| --- | --- | --- |
| **Public** (default) | Enabled | Enabled |
| **Private-only** | Disabled | Disabled |

### Public

![Public application authenticates with Entra ID or an API key; AI Gateway uses its separate managed identity for Foundry Models](docs/images/architecture-public.svg)

Clients use an API key or optional Entra token; the gateway authorizes to Foundry
Models using its managed identity. Agent hosting resources are provisioned, but the
container image and agent application are deployed separately.

### Private-only

![Private application authenticates with Entra ID or an API key through the gateway private endpoint; gateway managed identity reaches the private model backend](docs/images/architecture-private-only.svg)

Private endpoints (PE), DNS and outbound VNet integration connect the private
request path. The optional private agent requires creation-time network injection
and a later application deployment/runtime-role binding. It is part of
Private-only, not a third networking option.

In both diagrams, the **Application** is the caller: it sends an **Entra token
with an invocation role, or an API key** to the gateway. Its hosting location is
your choice; Private-only additionally requires private DNS and a network route.
The optional hosted agent is a separate application, not a required model-call hop.

These are logical flows, not subnet-placement diagrams. The Foundry inbound PE,
agent outbound injection, gateway inbound PE and gateway outbound integration are
separate controls. Identity, control-plane and telemetry traffic are not claimed
to be fully private. Registry/Storage and monitoring are supporting resources,
not extra model-serving hops; registry public access is unchanged by the agent extension.

## Access

Grant identity-based access with **Microsoft Entra ID**, or manage **API keys**.
Access is independent of Public/Private-only.

| Option | Configuration |
| --- | --- |
| Microsoft Entra ID | Add caller object IDs to `gateway_access_principals`; grants **AI Gateway Models User** for all models in this gateway. |
| API keys (existing default) | Leave the map empty. Existing keys remain available even when Entra callers are added. |

The existing agent and private test VM keep their API-key flow. This is additive,
**not an Entra-only switch**. See [Access setup](deployment.md#access) for
post-deployment configuration and `Test-ModelAliases.ps1 -Authentication EntraId`.

**Live-verified over the private route:** both sample models returned **200** with
managed-identity Entra access, **403** without the invocation role, and **401**
for missing/invalid credentials. API-key calls also passed; gateway/backend
traces matched. The temporary grant was removed afterward. This verifies the
managed-identity caller flow, not custom delegated-user applications.

## Deploy

Follow the **[deployment guide](deployment.md)**, next to this README, for prerequisites, inputs,
monitoring bootstrap, the chosen network path, private-agent setup and acceptance.
Keep the selected private profile on all later plans. The retained
`private-connectivity` profile is only an intermediate provisioning stage.

Review preview support, policies, regional availability and costs before applying.
The sample retains the `Microsoft.ApiManagement/service` / `AIGateway` contract;
check its compatibility with your environment. Supporting resources and model
calls incur charges; the gateway preview has no SLA.

| File | Purpose |
| --- | --- |
| [terraform.tfvars.example](terraform.tfvars.example) | Starting inputs for a new environment |
| [variables.tf](variables.tf) / [outputs.tf](outputs.tf) | Complete input and output contract |
| [profiles/private-only.tfvars.json](profiles/private-only.tfvars.json) | Final private gateway/model posture |
| [scripts](scripts) / [tests](tests) | Deployment checks and offline regression tests |

Diagrams generated with Azure Architecture Diagram Builder MCP; specialized
service icons corrected in the SVG export using the
[official Azure icon set](https://learn.microsoft.com/azure/architecture/icons/).
173 changes: 173 additions & 0 deletions samples/terraform/ai-gateway.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
resource "azapi_resource" "gateway" {
type = "Microsoft.ApiManagement/service@2025-09-01-preview"
name = local.names.gateway
parent_id = azapi_resource.resource_group["gateway"].id
location = var.gateway_location
tags = local.tags
identity { type = "SystemAssigned" }
body = {
sku = { name = "AIGateway", capacity = 1 }
properties = {
publisherEmail = var.publisher_email
publisherName = var.publisher_name
publicNetworkAccess = var.gateway_public_network_access
virtualNetworkType = var.enable_private_networking ? "External" : "None"
virtualNetworkConfiguration = var.enable_private_networking ? {
subnetResourceId = azapi_resource.private_subnet["gateway"].id
} : null
}
}
response_export_values = ["properties.gatewayUrl"]
lifecycle {
prevent_destroy = true
}
timeouts {
create = "60m"
update = "60m"
delete = "60m"
}
depends_on = [azapi_resource.private_dns_link]
}

resource "azapi_resource" "connector_gateway" {
# This preview type is not yet present in AzAPI's bundled schema.
schema_validation_enabled = false
type = "Microsoft.Web/connectorGateways@2026-05-01-preview"
name = local.names.gateway
parent_id = azapi_resource.resource_group["gateway"].id
location = var.gateway_location
body = { properties = {} }
depends_on = [azapi_resource.gateway]
}

locals {
# The AIGateway service creates its default workspace; do not manage it twice.
gateway_workspace_id = "${azapi_resource.gateway.id}/workspaces/default"
gateway_monitoring = {
dcr_id = try(coalesce(
try(azapi_resource.insights["gateway"].output.properties.DataCollectionRuleResourceId, null),
try(azapi_resource.insights["gateway"].output.properties.DataCollectionRuleId, null)
), "")
metrics_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPMetricsEndpoint), "")
logs_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPLogsEndpoint), "")
traces_endpoint = try(coalesce(azapi_resource.insights["gateway"].output.properties.OTLPTracesEndpoint), "")
}
}

resource "azapi_resource" "telemetry_exporter" {
# This preview type is not yet present in AzAPI's bundled schema.
schema_validation_enabled = false
type = "Microsoft.ApiManagement/service/workspaces/telemetryExporters@2025-09-01-preview"
name = "appinsights"
parent_id = local.gateway_workspace_id
body = {
properties = {
kind = "OpenTelemetry"
payloadCapture = false
applicationInsights = {
resourceId = azapi_resource.insights["gateway"].id
}
openTelemetry = {
metricsEndpoint = local.gateway_monitoring.metrics_endpoint
logsEndpoint = local.gateway_monitoring.logs_endpoint
tracesEndpoint = local.gateway_monitoring.traces_endpoint
credentials = {
managedIdentity = { resource = "https://monitor.azure.com" }
}
}
}
}
lifecycle {
precondition {
condition = alltrue([
for endpoint in [
local.gateway_monitoring.metrics_endpoint,
local.gateway_monitoring.logs_endpoint,
local.gateway_monitoring.traces_endpoint
] : can(regex("^https://[^/?#]+/[^?#]+$", endpoint))
])
error_message = "Gateway OTLP endpoints are incomplete. Enable gateway Insights first, run scripts/Get-GatewayMonitoring.ps1, then create a fresh plan; see deployment.md#monitoring-setup."
}
}
depends_on = [azapi_resource.role_assignment["gateway-monitoring-publisher"]]
}

resource "azapi_resource" "foundry_provider" {
# This preview type is not yet present in AzAPI's bundled schema.
schema_validation_enabled = false
type = "Microsoft.ApiManagement/service/workspaces/modelProviders@2025-09-01-preview"
name = "foundry"
parent_id = local.gateway_workspace_id
body = {
properties = {
kind = "Foundry"
displayName = "Foundry"
description = "Managed-identity access to the dedicated Foundry model account."
foundry = {
endpoint = "${trimsuffix(azapi_resource.models.output.properties.endpoint, "/")}/"
resourceIds = [azapi_resource.models.id]
authentication = {
kind = "ManagedIdentity"
managedIdentity = { resource = "https://cognitiveservices.azure.com/" }
}
}
}
}
depends_on = [azapi_resource.role_assignment["gateway-foundry-user"]]
}

resource "azapi_resource" "catalog_model" {
# This preview type is not yet present in AzAPI's bundled schema.
schema_validation_enabled = false
for_each = var.model_deployments
type = "Microsoft.ApiManagement/service/workspaces/modelProviders/models@2025-09-01-preview"
name = each.key
parent_id = azapi_resource.foundry_provider.id
body = {
properties = {
description = each.value.description
displayName = each.value.model_name
apiFormat = each.value.api_format
supportedEndpoints = each.value.endpoints
deployment = {
resourceId = azapi_resource.model_deployment[each.key].id
modelName = each.key
modelVersion = each.value.model_version
}
policies = [{
type = "tokenLimit"
period = "minute"
count = each.value.token_limit
counterKey = ["identity"]
}]
}
}
}

resource "azapi_resource" "model_alias" {
# Runtime routing requires an alias in addition to the provider's catalog entry.
schema_validation_enabled = false
for_each = var.model_deployments
type = "Microsoft.ApiManagement/service/workspaces/aliases@2025-09-01-preview"
name = each.key
parent_id = local.gateway_workspace_id
body = {
properties = {
aliasName = each.key
description = each.value.description
backendModels = [{
resourceId = azapi_resource.catalog_model[each.key].id
}]
}
}
}

resource "azapi_resource" "runtime_api_key" {
# This preview type is not yet present in AzAPI's bundled schema.
schema_validation_enabled = false
type = "Microsoft.ApiManagement/service/apiKeys@2025-09-01-preview"
name = "default"
parent_id = azapi_resource.gateway.id
body = { properties = { displayName = "All APIs subscription" } }
response_export_values = []
}
Loading