diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 0573957e075..9031eac6efc 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -13,6 +13,14 @@ Use `--no-inspector` to run only the local agent process: azd ai agent run --no-inspector ``` +## Private networking for `host: microsoft.foundry` + +Foundry services can be provisioned as network-secured, VNet-bound accounts by +adding a `network:` block to `azure.yaml`. See +[Private networking for `host: microsoft.foundry`](docs/private-networking.md) +for the schema reference, BYO-image requirements, and VNet deployment +cheatsheet. + ## Local Development ### Prerequisites diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md new file mode 100644 index 00000000000..91e58c7f13d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -0,0 +1,314 @@ +# Private networking for `host: azure.ai.agent` + +A Foundry service can be provisioned as a **network-secured (VNet-bound)** +account by adding a `network:` block to the service body in `azure.yaml`. When +`network:` is omitted the account uses public networking (unchanged behavior). + +When `network:` is present, azd always provisions an **account private +endpoint** and disables public network access — the data plane is never left +public. Dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. + +The block models two orthogonal axes: + +- **Egress** (agent runtime network) — set `agentSubnet` to inject the agent + into your subnet (BYO VNet), or omit it to use the Microsoft-managed network. + `isolationMode` tunes the managed network's outbound posture and is valid only + when `agentSubnet` is omitted. +- **Ingress** (account data plane) — `peSubnet` is **required** and always + yields an account private endpoint, so callers (`azd deploy`, + `azd ai agent invoke`) must reach the account from inside the VNet, a peered + VNet, or VPN. + +```yaml +services: + my-project: + host: azure.ai.agent + network: + # ----- Egress: agent runtime network (pick ONE) ----- + # + # (a) Managed egress (shown live below): omit agentSubnet so the agent + # runs in the Microsoft-managed network. isolationMode is valid only + # in this mode. + isolationMode: AllowOnlyApprovedOutbound # or AllowInternetOutbound (default) + # + # (b) BYO egress: inject the agent into your subnet instead. Replace the + # isolationMode line above with an agentSubnet block (same VNet as + # peSubnet in v1): + # agentSubnet: + # vnet: ${AZURE_VNET_ID} + # name: agent-subnet + # prefix: 192.168.10.0/24 # omit prefix to reference an existing subnet + + # ----- Ingress: account private endpoint (REQUIRED) ----- + peSubnet: + vnet: ${AZURE_VNET_ID} # ARM id of the VNet (must already exist) + name: pe-subnet + prefix: 192.168.11.0/24 # omit prefix to reference an existing subnet + + # ----- Private DNS (optional) ----- + dns: + resourceGroup: rg-private-dns # omit to let azd create + link the zones + subscription: ${AZURE_DNS_SUBSCRIPTION_ID} # optional; defaults to the deployment subscription + agents: + - name: my-agent + kind: hosted + project: src/my-agent + image: myprivacr.azurecr.io/agents/my-agent:v1 # BYO image required +``` + +> You do not hand-author the `agents:` entry above. Run +> `azd ai agent init --no-prompt --agent-name my-agent --image ` +> to scaffold it (it writes `agent.yaml`); then add the `network:` block to the +> generated service. + +> The example above uses **managed egress** so every field — including +> `isolationMode` — is shown as valid YAML. For **BYO egress**, swap the +> `isolationMode` line for an `agentSubnet` block (see comment `(b)` and +> Scenario 2 below); `isolationMode` is then invalid and must be removed. + +### Field reference + +| Field | Rule | +| --- | --- | +| `agentSubnet` | Optional. Present: the agent is injected into this customer subnet (BYO egress). Absent: the agent uses the Microsoft-managed network (managed egress). | +| `peSubnet` | **Required.** Subnet for the account private endpoint. Establishes the private data plane (public access disabled). | +| `isolationMode` | Optional. `AllowInternetOutbound` or `AllowOnlyApprovedOutbound`. Valid **only** when `agentSubnet` is omitted (managed egress). | +| subnet `vnet` | Required. ARM id of the VNet that holds (or will hold) the subnet. Supports `${VAR}`. When `agentSubnet` is present, it must reference the same VNet as `peSubnet`. | +| subnet `name` | Required. Subnet name. | +| subnet `prefix` | Optional. Omit to reference an existing subnet; set to create the subnet with that CIDR. | +| `dns.resourceGroup` | Omitted: azd creates and links the AI private DNS zones. Set: azd references existing zones in that resource group. Requires `peSubnet`. | +| `dns.subscription` | Optional. Defaults to the deployment subscription. Accepts a bare GUID or `${VAR}`. | + +### Environment variables + +Network fields support `${VAR}` references resolved client-side from the azd +environment (run `azd env set `). The variable names are +user-chosen; the example above uses: + +| Variable | Format | Used by | +| --- | --- | --- | +| `AZURE_VNET_ID` | ARM resource id of an existing `Microsoft.Network/virtualNetworks` | subnet `vnet` | +| `AZURE_DNS_SUBSCRIPTION_ID` | bare GUID or `/subscriptions/` | `network.dns.subscription` | + +### Limitations + +- **Single VNet (v1).** When `agentSubnet` is present it must live in the same + VNet as `peSubnet`; azd errors otherwise. Cross-VNet topologies (agent and + account private endpoint in different VNets) are deferred — they need + customer-managed peering plus DNS-zone links to both VNets, which azd does not + provision. Managed egress is unaffected (it needs only the `peSubnet` VNet). +- **BYO container image required.** Secured agents must reference a pre-built + image via `agents[].image`; local build into a private ACR is not supported in + v1. The developer owns the registry's SKU, private endpoint, DNS, and firewall. +- **Brownfield (`endpoint:`) ignores `network:`.** When `endpoint:` is set the + account's network posture is fixed by whoever created it; azd warns and does + not reconcile `network:`. +- **One default-DNS account per VNet.** Without a `dns:` block azd links the + three `privatelink.*` AI zones to your VNet, and a VNet may hold only one link + per namespace. A second account (or a brownfield hub that pre-links the zones) + must use `dns:` **reference** mode to bind the private endpoint without + re-linking. +- **Terraform IaC is not supported for private networking (v1).** Bicep-only + today; `azd ai agent init --infra=terraform` is refused when `network:` is + declared. Eject Bicep instead (see *Scenario 3 — Eject and customize the + Bicep*). + +### Scenario 1 — Managed egress: private account, agent on Microsoft's network + +Omit `agentSubnet` so the hosted-agent runtime uses a Microsoft-managed network +instead of your VNet. `peSubnet` is still required: the account data plane stays +private behind an account private endpoint in your VNet, reachable from inside +the VNet / VPN. + +Scaffold the agent with a pre-built (BYO) image (writes `azure.yaml` and +`agent.yaml`): + +```bash +azd ai agent init --no-prompt --agent-name my-agent \ + --image myprivacr.azurecr.io/agents/my-agent:v1 +``` + +Then add a `network:` block to the generated service in `azure.yaml` (omit +`agentSubnet` for managed egress; `isolationMode` is valid only in this mode): + +```yaml +name: my-agent +infra: + provider: microsoft.foundry + +services: + my-agent: + host: azure.ai.agent + deployments: [] + network: + isolationMode: AllowInternetOutbound # managed-egress outbound posture + peSubnet: + vnet: ${AZURE_VNET_ID} + name: pe-subnet + prefix: 192.168.11.0/24 +``` + +`azd ai agent init --image` already created and selected an azd environment and +set `AZD_AGENT_SKIP_ACR=true` (BYO image → no ACR build). Set the deployment +inputs on that environment and provision: + +```bash +azd env set AZURE_SUBSCRIPTION_ID "" +azd env set AZURE_LOCATION westus +azd env set AZURE_RESOURCE_GROUP "" +azd env set AZURE_VNET_ID "" +azd provision --no-prompt +``` + +Grant the Foundry project MI ACR pull permission, then run deploy/invoke from a +host that can reach the account private endpoint: + +```bash +azd deploy --no-prompt +azd ai agent invoke --new-session "hello" +``` + +> **`isolationMode` note.** When set, azd provisions the account's V2 +> managed network (`managednetworks/default`) with the chosen isolation mode. +> `AllowOnlyApprovedOutbound` additionally requires approved outbound rules for +> the agent to reach dependent resources; for the platform-managed stores used +> here those are managed by the Foundry platform. + +### Scenario 2 — BYO egress: agent injected into your VNet subnet + +ACR requirements: + +- The BYO image must be pullable by the Foundry **project managed identity**. +- For ABAC-enabled ACR, grant the project MI `Container Registry Repository Reader`. +- For private-only ACR, use Premium SKU, an ACR private endpoint, and a + `privatelink.azurecr.io` DNS zone linked to the VNet. Disable public access + only after the image is pushed. + +Scaffold the agent with a pre-built (BYO) image — this writes `azure.yaml` and +`agent.yaml` for you, so there is no hand-edited manifest to keep in sync: + +```bash +azd ai agent init --no-prompt --agent-name my-agent \ + --image myprivacr.azurecr.io/agents/my-agent:v1 +``` + +Then add a `network:` block to the generated service in `azure.yaml`: + +```yaml +services: + my-agent: + host: azure.ai.agent + network: + agentSubnet: # omit the whole block for managed egress + vnet: ${AZURE_VNET_ID} + name: agent-subnet + prefix: 192.168.10.0/24 # omit prefix to reference an existing subnet + peSubnet: # required: makes the data plane private + vnet: ${AZURE_VNET_ID} + name: pe-subnet + prefix: 192.168.11.0/24 +``` + +Configure and provision (`init --image` already created/selected the env and set +`AZD_AGENT_SKIP_ACR=true`): + +```bash +azd env set AZURE_SUBSCRIPTION_ID "" +azd env set AZURE_LOCATION westus +azd env set AZURE_RESOURCE_GROUP "" +azd env set AZURE_VNET_ID "" +azd provision --no-prompt +``` + +Deploy and invoke from a host that can reach the Foundry private endpoint: + +```bash +azd deploy --no-prompt +azd ai agent invoke --new-session "hello" +``` + +Common failures: + +- `403 Public access is disabled`: the data plane is private in every + network-bound mode — run deploy/invoke from inside the VNet, a peered VNet, or + VPN. +- `ImageError: registry authentication failed`: grant ACR pull permission to the Foundry project MI. + +### Scenario 3 — Eject and customize the Bicep (advanced) + +The synthesized template covers the common private-networking shapes. When you +need something it doesn't express — an extra subnet, a private endpoint for a BYO +dependent store, custom DNS wiring, a non-default account property, additional +`networkInjections` rules — eject the Bicep, edit it directly, and let azd +provision your edited tree. + +```bash +# 1. Scaffold + declare a network: block in azure.yaml (see Scenarios 1–2 +# above), then eject the infrastructure: +azd ai agent init --infra # writes ./infra/ from azure.yaml +``` + +Eject writes the **full** Bicep tree from your `network:` block — +`infra/main.bicep`, `infra/modules/{resources,network,subnet,private-endpoint-dns,acr}.bicep`, +and `infra/main.parameters.json` — and **preserves `${VAR}` placeholders** +(resolved from the azd environment at provision time). `azure.yaml` is left +unchanged: `infra.provider` stays `microsoft.foundry`. + +```bash +# 2. Edit the ejected Bicep to taste. Two worked examples: + +# (a) infra/modules/network.bicep — add a subnet the network: schema can't +# express (e.g. for a future dependent-store private endpoint). Pick a CIDR +# free in your VNet space. Use the '/' name form (vnet is an +# existing resource in a possibly different RG): +# +# resource extraStoreSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { +# name: '${vnetName}/byo-store-pe-subnet' +# properties: { +# addressPrefix: '192.168.30.0/24' +# privateEndpointNetworkPolicies: 'Disabled' +# } +# dependsOn: [ peSubnet ] +# } + +# (b) infra/modules/resources.bicep — set an extra account property directly on +# the foundryAccount resource, e.g. merge a tag: +# +# tags: union(tags, { editedByPowerUser: 'true' }) +``` + +```bash +# 3. Provision the edited tree. azd detects ./infra/main.bicep and compiles it +# instead of synthesizing from azure.yaml: +azd env set AZURE_SUBSCRIPTION_ID "" +azd env set AZURE_LOCATION westus +azd env set AZURE_RESOURCE_GROUP "" +azd env set AZURE_VNET_ID "" +azd provision --no-prompt + +# 4. Deploy and invoke from a host with line-of-sight to the account private +# endpoint (inside the VNet, a peered VNet, or VPN): +azd deploy --no-prompt +azd ai agent invoke --new-session "hello" +``` + +**How it works.** Once `./infra/main.bicep` exists, azd provisions it directly +and **stops synthesizing from `azure.yaml`** — your edited Bicep is now the +source of truth. Your `main.parameters.json` values are layered over azd's +host-derived parameters (subscription, location, resource group, project name, +`principalId`); you win on keys you set, and azd fills in the rest. + +**Notes.** + +- Re-running `azd ai agent init --infra` is **refused** while `./infra/` exists, + so your edits are never overwritten — delete `./infra/` to regenerate from + `azure.yaml`. +- After ejecting, further `network:` edits in `azure.yaml` have **no effect**; + change the Bicep directly. +- `infra/main.arm.json` is intentionally not ejected (it would go stale the + moment you edit `main.bicep`); azd compiles `main.bicep` on each provision. +- **Terraform is not supported for private networking.** + `azd ai agent init --infra=terraform` is refused for a service that declares + `network:` (the Terraform module has no VNet / private-endpoint / DNS + resources, so ejecting it would silently provision a public account). Use + `--infra` (Bicep) and customize as above. diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index ee9aa4a3c23..b7a6b685f00 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -35,7 +35,11 @@ require ( require github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 -require golang.org/x/term v0.44.0 +require ( + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/term v0.44.0 +) require ( dario.cat/mergo v1.0.2 // indirect @@ -107,10 +111,8 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.53.0 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index cb08cd0bae1..309b25a2d58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -122,6 +122,10 @@ func ejectInfra(projectRoot, provider string) error { RawAzureYAML: rawYAML, ServiceName: svcName, AcceptedHosts: project.FoundryServiceHosts, + // Eject writes a static infra/ tree. Keep ${VAR} references verbatim so + // the ejected main.parameters.json stays environment-portable; the + // on-disk provision flow resolves them from the azd environment. + PreserveVarRefs: true, }) if err != nil { // Reuse the provider's vocabulary so eject and provision report @@ -134,6 +138,19 @@ func ejectInfra(projectRoot, provider string) error { } if provider == project.TerraformProviderName { + // Private networking is Bicep-only today: the Terraform module has no + // VNet / private-endpoint / DNS / networkInjections resources, so ejecting + // it for a network: service would silently drop the config and provision a + // public account — the exact silent public fallback the network: contract + // forbids. Fail fast instead of ejecting an insecure template. + if res.NetworkMode != synthesis.NetworkModeNone { + return exterrors.Validation( + exterrors.CodeInfraEjectNetworkUnsupported, + "private networking (the service's network: block) is not yet supported with Terraform", + "eject Bicep instead with `azd ai agent init --infra` (or `--infra=bicep`), "+ + "or remove the network: block to provision a public account with Terraform", + ) + } return ejectTerraform(projectRoot, infraDir, res.Parameters) } return ejectBicep(infraDir, res.Parameters) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index 37e02f9b10d..3f95752dcab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go @@ -167,6 +167,9 @@ func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { filepath.Join("infra", "main.bicep"), filepath.Join("infra", "abbreviations.json"), filepath.Join("infra", "modules", "acr.bicep"), + filepath.Join("infra", "modules", "network.bicep"), + filepath.Join("infra", "modules", "subnet.bicep"), + filepath.Join("infra", "modules", "private-endpoint-dns.bicep"), filepath.Join("infra", "main.parameters.json"), } for _, rel := range expected { @@ -281,6 +284,125 @@ services: assert.Equal(t, false, doc.Parameters["includeAcr"].Value) } +func TestEjectInfra_PreservesNetworkVarRefs(t *testing.T) { + // See TestEjectInfra_HappyPath_WritesExpectedFiles for why this is not parallel. + // Eject must keep ${VAR} references verbatim in main.parameters.json so the + // ejected tree stays environment-portable; the on-disk provision flow + // resolves them from the azd environment at provision time. + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + my-foundry: + host: azure.ai.agent + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} + dns: + resourceGroup: rg-dns + subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" + deployments: [] + agents: + - name: my-agent + image: registry.io/myorg/myagent:latest +`) + + withCapturedStdout(t, func() { + require.NoError(t, ejectInfra(dir, "bicep")) + }) + + raw, err := os.ReadFile(filepath.Join(dir, "infra", "main.parameters.json")) //nolint:gosec // G304: test file path from t.TempDir() + require.NoError(t, err) + var doc struct { + Parameters map[string]struct { + Value any `json:"value"` + } `json:"parameters"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + + assert.Equal(t, "${AZURE_VNET_ID}", doc.Parameters["vnetId"].Value, + "vnet id ${VAR} must be preserved for provision-time resolution") + assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", doc.Parameters["dnsZonesSubscription"].Value, + "dns subscription ${VAR} must be preserved for provision-time resolution") + assert.Equal(t, true, doc.Parameters["enableNetworkIsolation"].Value) + + // Managed egress (no agentSubnet): the full param set must thread through. + assert.Equal(t, true, doc.Parameters["useManagedEgress"].Value, + "omitting agentSubnet selects managed egress") + assert.Equal(t, false, doc.Parameters["createAgentSubnet"].Value, + "managed egress creates no agent subnet") + assert.Equal(t, "pe-subnet", doc.Parameters["peSubnetName"].Value) + assert.Equal(t, false, doc.Parameters["createPESubnet"].Value, + "peSubnet without prefix references an existing subnet") + assert.Equal(t, "rg-dns", doc.Parameters["dnsZonesResourceGroup"].Value, + "dns.resourceGroup selects reference mode") +} + +// TestEjectInfra_Bicep_NetworkParamsComplete_Byo ejects a BYO-egress service +// (agentSubnet + peSubnet, both with prefixes) and asserts the complete network +// parameter set lands in main.parameters.json. This is the Bicep eject path's +// end-to-end contract: every value the synthesizer derives from network: must +// reach the ejected parameters file so a later `azd provision` reproduces the +// declared topology. +func TestEjectInfra_Bicep_NetworkParamsComplete_Byo(t *testing.T) { + // Not parallel: shares the stdout-capture rationale of the other eject tests. + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: microsoft.foundry +services: + my-foundry: + host: azure.ai.agent + network: + agentSubnet: + vnet: "${AZURE_VNET_ID}" + name: agent-subnet + prefix: 192.168.10.0/24 + peSubnet: + vnet: "${AZURE_VNET_ID}" + name: pe-subnet + prefix: 192.168.11.0/24 + deployments: [] + agents: + - name: my-agent + image: registry.io/myorg/myagent:latest +`) + + withCapturedStdout(t, func() { + require.NoError(t, ejectInfra(dir, "bicep")) + }) + + raw, err := os.ReadFile(filepath.Join(dir, "infra", "main.parameters.json")) //nolint:gosec // G304: test file path from t.TempDir() + require.NoError(t, err) + var doc struct { + Parameters map[string]struct { + Value any `json:"value"` + } `json:"parameters"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + + // Ingress + egress are both private; agentSubnet present => BYO egress. + assert.Equal(t, true, doc.Parameters["enableNetworkIsolation"].Value) + assert.Equal(t, false, doc.Parameters["useManagedEgress"].Value, + "agentSubnet present selects BYO egress") + assert.Equal(t, "${AZURE_VNET_ID}", doc.Parameters["vnetId"].Value, + "vnet id ${VAR} must be preserved for provision-time resolution") + + // Agent (egress) subnet: prefix set => create. + assert.Equal(t, "agent-subnet", doc.Parameters["agentSubnetName"].Value) + assert.Equal(t, "192.168.10.0/24", doc.Parameters["agentSubnetPrefix"].Value) + assert.Equal(t, true, doc.Parameters["createAgentSubnet"].Value, + "agentSubnet with a prefix is created") + + // PE (ingress) subnet: prefix set => create. + assert.Equal(t, "pe-subnet", doc.Parameters["peSubnetName"].Value) + assert.Equal(t, "192.168.11.0/24", doc.Parameters["peSubnetPrefix"].Value) + assert.Equal(t, true, doc.Parameters["createPESubnet"].Value, + "peSubnet with a prefix is created") + + // BYO egress has no managed-network knobs. + assert.Equal(t, "", doc.Parameters["managedIsolationMode"].Value, + "isolationMode is managed-egress only") +} + func TestEjectInfra_RefusesWhenInfraIsAFile(t *testing.T) { t.Parallel() // Pre-existing `infra` as a regular file (not a directory) hits the @@ -576,3 +698,39 @@ func TestEjectInfra_Terraform_RefusesWhenInfraExists(t *testing.T) { assert.Contains(t, string(raw), "provider: microsoft.foundry", "azure.yaml must not be stamped when eject refuses") } + +func TestEjectInfra_Terraform_RefusesWhenNetworkDeclared(t *testing.T) { + t.Parallel() + // Private networking is Bicep-only: the Terraform module has no VNet / PE / + // DNS / networkInjections resources. Ejecting it for a network: service would + // silently drop the config and provision a public account. Eject must refuse + // rather than emit an insecure template. + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: microsoft.foundry +services: + my-foundry: + host: azure.ai.agent + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} + deployments: [] + agents: + - name: my-agent + image: registry.io/myorg/myagent:latest +`) + + err := ejectInfra(dir, "terraform") + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected structured azdext.LocalError, got %T", err) + assert.Equal(t, exterrors.CodeInfraEjectNetworkUnsupported, localErr.Code) + + // The refusal must fire before any files land or azure.yaml is stamped. + _, statErr := os.Stat(filepath.Join(dir, "infra")) + assert.True(t, os.IsNotExist(statErr), "infra/ must not be written when eject refuses") + raw, err := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec // G304: test file path from t.TempDir() + require.NoError(t, err) + assert.Contains(t, string(raw), "provider: microsoft.foundry", + "azure.yaml must not be stamped when eject refuses") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index dc4b3123f35..c7afa23d25b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -216,6 +216,7 @@ const ( CodeInfraEjectAzureYamlMissing = "infra_eject_azure_yaml_missing" CodeInfraEjectWriteFailed = "infra_eject_write_failed" CodeInfraEjectConflictingArguments = "infra_eject_conflicting_arguments" + CodeInfraEjectNetworkUnsupported = "infra_eject_network_unsupported" ) // Operation names for the microsoft.foundry provisioning provider. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go index 4cda4411750..74b0b3cf137 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go @@ -28,6 +28,8 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.yaml.in/yaml/v3" ) @@ -135,9 +137,12 @@ func (p *FoundryProvisioningProvider) Initialize( RawAzureYAML: rawYAML, ServiceName: svcName, AcceptedHosts: FoundryServiceHosts, + Env: p.networkEnvMap(ctx), }) switch { case errors.Is(err, synthesis.ErrEndpointBrownfield): + // network: has no effect in brownfield mode; warn if both are present. + warnNetworkIgnoredInBrownfield(rawYAML, svcName) return exterrors.Validation( exterrors.CodeBrownfieldNotSupported, "endpoint: is set on the foundry service; existing-project (brownfield) "+ @@ -178,7 +183,58 @@ func (p *FoundryProvisioningProvider) Initialize( return p.resolveEnv(ctx) } -// onDiskTemplatePresent returns true when either infra/main.bicepparam +// networkEnvMap returns a best-effort name -> value map of the azd environment +// for ${VAR} substitution in network fields during synthesis. It does not +// require resolveEnv to have run; on any failure it returns nil and the +// synthesizer falls back to the process environment. +func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[string]string { + if p.azdClient == nil { + return nil + } + envClient := p.azdClient.Environment() + if envClient == nil { + return nil + } + curr, err := envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || curr.GetEnvironment() == nil { + return nil + } + resp, err := envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{Name: curr.GetEnvironment().GetName()}) + if err != nil { + log.Printf("[debug] foundry provider: GetValues failed (%s); network ${VAR} uses process env only", err) + return nil + } + out := make(map[string]string, len(resp.GetKeyValues())) + for _, kv := range resp.GetKeyValues() { + if kv != nil { + out[kv.Key] = kv.Value + } + } + return out +} + +// warnNetworkIgnoredInBrownfield logs a warning when a service declares both +// endpoint: (brownfield) and network:. The account's network posture is fixed +// by whoever created it, so the network: block has no effect. +func warnNetworkIgnoredInBrownfield(rawYAML []byte, svcName string) { + type svc struct { + Endpoint string `yaml:"endpoint,omitempty"` + Network yaml.Node `yaml:"network,omitempty"` + } + type root struct { + Services map[string]svc `yaml:"services"` + } + var r root + if err := yaml.Unmarshal(rawYAML, &r); err != nil { + return + } + s := r.Services[svcName] + if s.Endpoint != "" && !s.Network.IsZero() { + log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ + "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) + } +} + // or infra/main.bicep exists under p.projectPath. Stat-only. func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { infraDir := filepath.Join(p.projectPath, onDiskInfraDir) @@ -356,6 +412,15 @@ func (p *FoundryProvisioningProvider) Deploy( ) (*azdext.ProvisioningDeployResult, error) { progress("Preparing Foundry provisioning template...") + // provision.network_mode telemetry: none | byo | managed. Lets us measure + // secured-agent adoption and the BYO-vs-managed split. + networkMode := synthesis.NetworkModeNone + if p.synthResult != nil && p.synthResult.NetworkMode != "" { + networkMode = p.synthResult.NetworkMode + } + trace.SpanFromContext(ctx).SetAttributes( + attribute.String("provision.network_mode", networkMode)) + src, err := p.resolveTemplate(ctx, progress) if err != nil { return nil, err @@ -900,6 +965,8 @@ var canonicalOutputNames = []string{ "AZURE_CONTAINER_REGISTRY_ENDPOINT", "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", + "AZURE_FOUNDRY_NETWORK_MODE", + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", } // --- helpers --- diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go index 9ad4a5c19b3..42fd31b4633 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go @@ -191,6 +191,16 @@ func TestArmOutputsToProto_RepairsMangledKeyCase(t *testing.T) { inKey: "foundrY_PROJECT_ENDPOINT", wantKey: "FOUNDRY_PROJECT_ENDPOINT", }, + { + name: "ARM-mangled AZURE_FOUNDRY_NETWORK_MODE -> canonical", + inKey: "azurE_FOUNDRY_NETWORK_MODE", + wantKey: "AZURE_FOUNDRY_NETWORK_MODE", + }, + { + name: "ARM-mangled AZURE_FOUNDRY_MANAGED_ISOLATION_MODE -> canonical", + inKey: "azurE_FOUNDRY_MANAGED_ISOLATION_MODE", + wantKey: "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", + }, { name: "already-canonical key passes through unchanged", inKey: "AZURE_AI_ACCOUNT_NAME", diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go new file mode 100644 index 00000000000..de80ce720a2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// schemaPath is the editor-tooling JSON schema for the Foundry service body, +// resolved from this package directory. +const schemaPath = "../../schemas/microsoft.foundry.json" + +// TestSchema_NetworkStructuralInvariants guards the network surface of the +// hand-maintained JSON schema against drift from the synthesizer's contract: +// peSubnet is mandatory, the old mode/byo/managed shape is gone, and every +// subnet requires an explicit vnet + name. +func TestSchema_NetworkStructuralInvariants(t *testing.T) { + raw, err := os.ReadFile(schemaPath) + require.NoError(t, err) + + var doc struct { + Properties struct { + Network struct { + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } `json:"network"` + } `json:"properties"` + Definitions struct { + Subnet struct { + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } `json:"subnet"` + } `json:"definitions"` + } + require.NoError(t, json.Unmarshal(raw, &doc), "schema must be valid JSON") + + net := doc.Properties.Network + assert.Contains(t, net.Required, "peSubnet", + "network must require peSubnet (no public data-plane fallback)") + assert.Contains(t, net.Properties, "agentSubnet", "network must expose agentSubnet") + assert.Contains(t, net.Properties, "isolationMode", "network must expose isolationMode") + assert.Contains(t, net.Properties, "peSubnet", "network must expose peSubnet") + + // The retired mode-enum shape must not reappear. + assert.NotContains(t, net.Properties, "mode", "network.mode was removed") + assert.NotContains(t, net.Properties, "byo", "network.byo was removed") + assert.NotContains(t, net.Properties, "managed", "network.managed was removed") + + sub := doc.Definitions.Subnet + assert.ElementsMatch(t, []string{"vnet", "name"}, sub.Required, + "a subnet must require exactly vnet + name") + assert.Contains(t, sub.Properties, "prefix", "subnet must expose prefix (create vs reference)") +} + +// TestARMTemplate_MatchesBicepBuild fails if templates/main.arm.json is stale +// relative to main.bicep. AGENTS guidance forbids hand-editing the ARM JSON; +// this catches a forgotten `bicep build`. Skipped when the bicep CLI is not on +// PATH (e.g. minimal CI images) so it never produces a phantom failure. +func TestARMTemplate_MatchesBicepBuild(t *testing.T) { + bicep := lookupBicep() + if bicep == "" { + t.Skip("bicep CLI not found on PATH; skipping ARM drift check") + } + + templatesDir := "templates" + committed, err := os.ReadFile(filepath.Join(templatesDir, "main.arm.json")) + require.NoError(t, err) + + out := filepath.Join(t.TempDir(), "main.arm.json") + cmd := exec.CommandContext(t.Context(), bicep, "build", + filepath.Join(templatesDir, "main.bicep"), "--outfile", out) + var stderr bytes.Buffer + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) + + rebuilt, err := os.ReadFile(out) + require.NoError(t, err) + + committedNormalized := normalizeArmTemplate(t, committed) + rebuiltNormalized := normalizeArmTemplate(t, rebuilt) + + assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), + "templates/main.arm.json is stale; regenerate with `bicep build main.bicep "+ + "--outfile main.arm.json` from the templates directory") +} + +// normalizeArmTemplate returns a stable JSON representation of an ARM template +// for drift comparison. Bicep generator metadata includes the local Bicep CLI +// version/hash and can differ between developer machines and CI images without +// changing the template semantics. +func normalizeArmTemplate(t *testing.T, raw []byte) []byte { + t.Helper() + + var doc any + require.NoError(t, json.Unmarshal(raw, &doc)) + stripBicepGeneratorMetadata(doc) + + normalized, err := json.Marshal(doc) + require.NoError(t, err) + return normalized +} + +// stripBicepGeneratorMetadata recursively removes Bicep's generator metadata +// from a decoded ARM template. Bicep emits this metadata for the top-level +// template and nested module templates. +func stripBicepGeneratorMetadata(value any) { + switch v := value.(type) { + case map[string]any: + delete(v, "_generator") + for _, child := range v { + stripBicepGeneratorMetadata(child) + } + case []any: + for _, child := range v { + stripBicepGeneratorMetadata(child) + } + } +} + +// lookupBicep returns a usable bicep binary path, preferring PATH and falling +// back to the az-bundled location. +func lookupBicep() string { + if p, err := exec.LookPath("bicep"); err == nil { + return p + } + if home, err := os.UserHomeDir(); err == nil { + azBicep := filepath.Join(home, ".azure", "bin", "bicep") + if _, err := os.Stat(azBicep); err == nil { + return azBicep + } + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index fea490956cc..789dc51a1f5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -15,7 +15,11 @@ package synthesis import ( "errors" "fmt" + "net" + "os" + "regexp" "slices" + "strings" "go.yaml.in/yaml/v3" ) @@ -45,6 +49,19 @@ type Input struct { // caller treats as a Foundry service. If empty, the service's host // value is not checked (only existence and endpoint: are). AcceptedHosts []string + + // Env maps azd environment variable names to values. Used to resolve + // ${VAR} references in network fields (subnet vnet ids, dns.subscription). + // When a referenced variable is absent here, the synthesizer falls back + // to the process environment before failing. May be nil. + Env map[string]string + + // PreserveVarRefs keeps ${VAR} references verbatim instead of resolving + // them. Used by the eject path, where the synthesized main.parameters.json + // must stay environment-portable: the on-disk provision flow resolves + // ${VAR} from the azd environment at provision time. When false (the + // provision path), ${VAR} is resolved here and a missing variable fails. + PreserveVarRefs bool } // Result bundles the bicep sources and the parameter values derived @@ -55,6 +72,10 @@ type Result struct { // Parameters maps bicep param names to plain Go values. Callers wrap // these in ARM's {"value": ...} envelope when serializing. Parameters map[string]any + + // NetworkMode is "none", "byo", or "managed" — derived from the + // network: block (or its absence). Exposed for telemetry. + NetworkMode string } // Deployment mirrors the deploymentType in main.bicep. @@ -94,10 +115,45 @@ type agentBlock struct { // reads. Unknown fields (connections, tools, agents[].tools, etc.) are // intentionally ignored: they are reconciled in azd deploy, not provision. type foundryService struct { - Host string `yaml:"host"` - Endpoint string `yaml:"endpoint,omitempty"` - Deployments []Deployment `yaml:"deployments,omitempty"` - Agents []agentBlock `yaml:"agents,omitempty"` + Host string `yaml:"host"` + Endpoint string `yaml:"endpoint,omitempty"` + Deployments []Deployment `yaml:"deployments,omitempty"` + Agents []agentBlock `yaml:"agents,omitempty"` + Network *networkBlock `yaml:"network,omitempty"` +} + +// networkBlock mirrors the network: sub-tree on the service body. +// +// The block models two orthogonal axes: +// +// - Egress (agent runtime network): agentSubnet present injects the agent into +// that customer subnet; agentSubnet absent uses the Microsoft-managed +// network. isolationMode tunes the managed network's outbound posture and is +// valid only when agentSubnet is absent. +// - Ingress (account data plane): peSubnet is required and always yields an +// account private endpoint, so a network-bound account is never public. +type networkBlock struct { + AgentSubnet *subnetSpec `yaml:"agentSubnet,omitempty"` + IsolationMode string `yaml:"isolationMode,omitempty"` + PESubnet *subnetSpec `yaml:"peSubnet,omitempty"` + DNS *dnsBlock `yaml:"dns,omitempty"` +} + +// subnetSpec is a self-contained subnet descriptor: vnet + name identify the +// subnet, and the optional prefix toggles create-vs-reference. +// +// vnet + name -> reference the existing subnet +// vnet + name + prefix -> create the subnet with that CIDR +type subnetSpec struct { + VNet string `yaml:"vnet,omitempty"` + Name string `yaml:"name,omitempty"` + Prefix string `yaml:"prefix,omitempty"` +} + +// dnsBlock mirrors network.dns (private DNS zone references). +type dnsBlock struct { + ResourceGroup string `yaml:"resourceGroup,omitempty"` + Subscription string `yaml:"subscription,omitempty"` } // projectFile is the root of azure.yaml as we care about it: only services. @@ -150,10 +206,294 @@ func Synthesize(in Input) (*Result, error) { deployments = []Deployment{} } + netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + + params := map[string]any{ + "deployments": deployments, + "includeAcr": includeAcr, + } + for k, v := range netParams { + params[k] = v + } + return &Result{ - Parameters: map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - }, + Parameters: params, + NetworkMode: netMode, }, nil } + +// Network mode values surfaced for telemetry and emitted as bicep params. +const ( + NetworkModeNone = "none" + NetworkModeByo = "byo" + NetworkModeManaged = "managed" +) + +// Default subnet names used when a subnet descriptor is omitted. +const ( + defaultAgentSubnetName = "agent-subnet" + defaultPESubnetName = "pe-subnet" +) + +// vnetIDPattern matches a Microsoft.Network/virtualNetworks ARM resource id. +var vnetIDPattern = regexp.MustCompile( + `(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+$`, +) + +// guidPattern matches a bare GUID. +var guidPattern = regexp.MustCompile( + `(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, +) + +// rgNamePattern matches a valid Azure resource group name. +var rgNamePattern = regexp.MustCompile(`^[-\w._()]{1,90}$`) + +// varRefPattern matches a ${VAR} reference. +var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +// synthesizeNetwork validates the network: block and returns the bicep +// parameter set plus the telemetry mode. When net is nil the returned +// params disable network isolation and the output is byte-identical to the +// pre-network behavior. +// +// When resolve is true, ${VAR} references in byo.vnet.id / dns.subscription +// are expanded from env (provision path) and an unresolved variable fails. +// When resolve is false (eject path), ${VAR} references are kept verbatim so +// the synthesized parameters file stays environment-portable; the format +// checks that cannot run against an unexpanded placeholder are skipped. +func synthesizeNetwork( + net *networkBlock, + svcName string, + env map[string]string, + resolve bool, +) (map[string]any, string, error) { + // Public account: every network param defaults off. + params := map[string]any{ + "enableNetworkIsolation": false, + "useManagedEgress": false, + "vnetId": "", + "agentSubnetName": defaultAgentSubnetName, + "agentSubnetPrefix": "", + "createAgentSubnet": false, + "peSubnetName": defaultPESubnetName, + "peSubnetPrefix": "", + "createPESubnet": false, + "managedIsolationMode": "", + "dnsZonesResourceGroup": "", + "dnsZonesSubscription": "", + } + if net == nil { + return params, NetworkModeNone, nil + } + + fp := func(suffix string) string { + return fmt.Sprintf("services.%s.network%s", svcName, suffix) + } + + // Ingress: a network-bound account always gets an account private endpoint, + // so peSubnet is mandatory. There is no public data-plane fallback. + if net.PESubnet == nil { + return nil, "", fmt.Errorf("%s: private networking requires peSubnet", fp("")) + } + + // Egress: agentSubnet present injects the agent into the customer subnet; + // absent uses the Microsoft-managed network. + useManagedEgress := net.AgentSubnet == nil + + // isolationMode governs the Microsoft-managed network only. + isoMode := strings.TrimSpace(net.IsolationMode) + if isoMode != "" { + if !useManagedEgress { + return nil, "", fmt.Errorf( + "%s.isolationMode: only valid for managed egress (omit agentSubnet)", fp("")) + } + if isoMode != "AllowInternetOutbound" && isoMode != "AllowOnlyApprovedOutbound" { + return nil, "", fmt.Errorf( + "%s.isolationMode: %q is not one of AllowInternetOutbound, AllowOnlyApprovedOutbound", + fp(""), isoMode) + } + } + + // Ingress subnet (account private endpoint). + peVnet, peName, pePrefix, createPE, err := resolveSubnet(net.PESubnet, fp(".peSubnet"), env, resolve) + if err != nil { + return nil, "", err + } + vnetID := peVnet + + // Egress subnet (byo only). v1 keeps both subnets in one VNet so a single + // vnetId drives injection, the PE, and DNS linking. + if !useManagedEgress { + agentVnet, agentName, agentPrefix, createAgent, aerr := resolveSubnet( + net.AgentSubnet, fp(".agentSubnet"), env, resolve) + if aerr != nil { + return nil, "", aerr + } + if !sameVNet(agentVnet, peVnet) { + return nil, "", fmt.Errorf( + "%s: agentSubnet.vnet and peSubnet.vnet must reference the same virtual network", fp("")) + } + // The agent and PE subnets share one VNet, so their names must differ. + // Identical names would point the account private endpoint at the + // Microsoft.App/environments-delegated agent subnet (PEs cannot live in a + // delegated subnet), surfacing as a confusing deploy-time failure. + if strings.EqualFold(agentName, peName) { + return nil, "", fmt.Errorf( + "%s: agentSubnet.name and peSubnet.name must differ (both subnets share one VNet)", fp("")) + } + params["agentSubnetName"] = agentName + params["agentSubnetPrefix"] = agentPrefix + params["createAgentSubnet"] = createAgent + vnetID = agentVnet + } + + params["enableNetworkIsolation"] = true + params["useManagedEgress"] = useManagedEgress + params["vnetId"] = vnetID + params["peSubnetName"] = peName + params["peSubnetPrefix"] = pePrefix + params["createPESubnet"] = createPE + params["managedIsolationMode"] = isoMode + + if net.DNS != nil { + if rg := strings.TrimSpace(net.DNS.ResourceGroup); rg != "" { + if !rgNamePattern.MatchString(rg) { + return nil, "", fmt.Errorf("%s.dns.resourceGroup: %q is not a valid resource group name", fp(""), rg) + } + params["dnsZonesResourceGroup"] = rg + } + if sub := strings.TrimSpace(net.DNS.Subscription); sub != "" { + if resolve { + resolved, err := resolveVars(sub, env) + if err != nil { + return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) + } + sub = resolved + } + // Normalize to a bare GUID only when concrete; an unexpanded ${VAR} + // (eject path) is normalized at provision time. + if containsVarRef(sub) { + params["dnsZonesSubscription"] = sub + } else { + guid, err := normalizeSubscription(sub) + if err != nil { + return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) + } + params["dnsZonesSubscription"] = guid + } + } + } + + mode := NetworkModeByo + if useManagedEgress { + mode = NetworkModeManaged + } + return params, mode, nil +} + +// resolveSubnet validates a subnet descriptor and returns the VNet id, subnet +// name, prefix, and whether azd should create the subnet. +// +// vnet + name -> reference existing subnet (create=false) +// vnet + name + prefix -> create subnet with that CIDR (create=true) +// +// vnet and name are required; ${VAR} references in vnet are expanded when +// resolve is true and validated as a Microsoft.Network/virtualNetworks id only +// when fully concrete. +func resolveSubnet( + s *subnetSpec, fieldPath string, env map[string]string, resolve bool, +) (vnetID, name, prefix string, create bool, err error) { + if s == nil { + return "", "", "", false, fmt.Errorf("%s: required", fieldPath) + } + vnetID = strings.TrimSpace(s.VNet) + name = strings.TrimSpace(s.Name) + prefix = strings.TrimSpace(s.Prefix) + + if vnetID == "" { + return "", "", "", false, fmt.Errorf("%s.vnet: required", fieldPath) + } + if name == "" { + return "", "", "", false, fmt.Errorf("%s.name: required", fieldPath) + } + if resolve { + resolved, rerr := resolveVars(vnetID, env) + if rerr != nil { + return "", "", "", false, fmt.Errorf("%s.vnet: %w", fieldPath, rerr) + } + vnetID = resolved + } + // Validate the ARM id shape only when fully concrete; an unexpanded ${VAR} + // (eject path) is validated at provision time. + if !containsVarRef(vnetID) && !vnetIDPattern.MatchString(vnetID) { + return "", "", "", false, fmt.Errorf( + "%s.vnet: %q is not a well-formed Microsoft.Network/virtualNetworks id", fieldPath, vnetID) + } + if prefix != "" { + if _, _, perr := net.ParseCIDR(prefix); perr != nil { + return "", "", "", false, fmt.Errorf("%s.prefix: %q is not a valid CIDR", fieldPath, prefix) + } + create = true + } + return vnetID, name, prefix, create, nil +} + +// sameVNet reports whether two VNet references point at the same VNet. Concrete +// ids compare case-insensitively (ARM ids are case-insensitive); unresolved +// ${VAR} references compare verbatim. +func sameVNet(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if containsVarRef(a) || containsVarRef(b) { + return a == b + } + return strings.EqualFold(a, b) +} + +// containsVarRef reports whether s still contains a ${VAR} reference. +func containsVarRef(s string) bool { + return varRefPattern.MatchString(s) +} + +// resolveVars expands ${VAR} references in s using env first, then the +// process environment. An unresolved reference is an error naming the +// variable. +func resolveVars(s string, env map[string]string) (string, error) { + var unresolved string + out := varRefPattern.ReplaceAllStringFunc(s, func(match string) string { + name := varRefPattern.FindStringSubmatch(match)[1] + if v, ok := env[name]; ok { + return v + } + if v, ok := os.LookupEnv(name); ok { + return v + } + if unresolved == "" { + unresolved = name + } + return match + }) + if unresolved != "" { + return "", fmt.Errorf("unresolved environment variable ${%s}", unresolved) + } + return out, nil +} + +// normalizeSubscription accepts a bare GUID or a /subscriptions/[/...] +// path and returns the bare GUID. +func normalizeSubscription(s string) (string, error) { + s = strings.TrimSpace(s) + if guidPattern.MatchString(s) { + return s, nil + } + if strings.HasPrefix(strings.ToLower(s), "/subscriptions/") { + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) >= 2 && guidPattern.MatchString(parts[1]) { + return parts[1], nil + } + } + return "", fmt.Errorf("%q is not a subscription GUID or /subscriptions/ id", s) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index ba87b61634b..379083fb060 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -194,6 +194,19 @@ services: - name: gpt-4.1-mini model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} sku: {capacity: 10, name: GlobalStandard} +`, + serviceName: "my-project", + wantErr: ErrEndpointBrownfield, + }, + { + name: "brownfield: endpoint + network => network ignored, still ErrEndpointBrownfield", + yaml: ` +services: + my-project: + host: azure.ai.agent + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + network: + peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} `, serviceName: "my-project", wantErr: ErrEndpointBrownfield, @@ -253,6 +266,53 @@ services: } } +func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { + // Eject path: ${VAR} references must pass through verbatim (and skip the + // format checks that cannot run on an unexpanded placeholder), so the + // ejected main.parameters.json stays environment-portable. + yaml := ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} + dns: + resourceGroup: rg-dns + subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.agent"}, + PreserveVarRefs: true, + }) + require.NoError(t, err, "unset ${VAR} must not fail on the eject path") + require.NotNil(t, res) + assert.Equal(t, "${AZURE_VNET_ID}", res.Parameters["vnetId"]) + assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", res.Parameters["dnsZonesSubscription"]) + assert.Equal(t, "rg-dns", res.Parameters["dnsZonesResourceGroup"]) +} + +func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) { + // PreserveVarRefs only skips checks for unexpanded placeholders; a + // concrete-but-malformed value still fails on the eject path. + yaml := ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: not-an-arm-id, name: pe-subnet} +` + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.agent"}, + PreserveVarRefs: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a well-formed") +} + func TestSynthesize_InputValidation(t *testing.T) { tests := []struct { name string @@ -295,6 +355,9 @@ func TestTemplatesFS_Embedded(t *testing.T) { "templates/main.arm.json", "templates/abbreviations.json", "templates/modules/acr.bicep", + "templates/modules/network.bicep", + "templates/modules/subnet.bicep", + "templates/modules/private-endpoint-dns.bicep", } for _, p := range wantFiles { t.Run(p, func(t *testing.T) { @@ -388,4 +451,369 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { params, ok := arm["parameters"].(map[string]any) require.True(t, ok, "parameters must be an object") assert.Contains(t, params, "resourceGroupName") + + // Network isolation parameters must exist so the synthesizer's network + // param set is accepted by ARM (extra params would fail the deployment). + for _, p := range []string{ + "enableNetworkIsolation", "useManagedEgress", "vnetId", + "agentSubnetName", "agentSubnetPrefix", "createAgentSubnet", + "peSubnetName", "peSubnetPrefix", "createPESubnet", + "managedIsolationMode", "dnsZonesResourceGroup", "dnsZonesSubscription", + } { + assert.Contains(t, params, p, "network param %q must be declared in the ARM template", p) + } + + // The old mode-enum param must be gone; egress is driven by useManagedEgress. + assert.NotContains(t, params, "networkMode", + "networkMode param was replaced by useManagedEgress") + + // Secure-by-default lock: the account data plane must be private whenever + // network isolation is on. The compiled template must gate public access on + // enableNetworkIsolation (not on egress mode), so a network-bound account is + // never left public. This is the regression guard for the data-plane fix. + text := string(data) + wantDisable := `"disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]"` + wantPublic := `"publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]"` + assert.Contains(t, text, wantDisable, + "public data-plane access must be disabled for every network-isolated account") + assert.Contains(t, text, wantPublic, + "account publicNetworkAccess must follow disablePublicDataPlaneAccess") + + // Egress injection shape: byo injects into the customer subnet + // (useMicrosoftManagedNetwork=false), managed uses the Microsoft-managed + // network (useMicrosoftManagedNetwork=true). Both branches must survive + // compilation so the account gets the right networkInjections per mode. + assert.Contains(t, text, "'useMicrosoftManagedNetwork', false()", + "byo egress must inject the agent subnet (useMicrosoftManagedNetwork=false)") + assert.Contains(t, text, "'useMicrosoftManagedNetwork', true()", + "managed egress must use the Microsoft-managed network (useMicrosoftManagedNetwork=true)") + assert.Contains(t, text, `"networkInjections": "[variables('agentNetworkInjections')]"`, + "account must carry the computed networkInjections") + + // isolationMode must be wired to the V2 managed network child resource + // (regression guard: it was previously a no-op echoed only to output). + assert.Contains(t, text, `"type": "Microsoft.CognitiveServices/accounts/managedNetworks"`, + "managed isolationMode must provision a managedNetworks child resource") + assert.Contains(t, text, `"isolationMode": "[parameters('managedIsolationMode')]"`, + "managedNetworks isolationMode must come from the managedIsolationMode param") +} + +func TestSynthesize_Network(t *testing.T) { + t.Setenv("AZURE_VNET_ID", + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ + "providers/Microsoft.Network/virtualNetworks/my-vnet") + + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + + tests := []struct { + name string + yaml string + wantMode string + check func(t *testing.T, p map[string]any) + }{ + { + name: "no network block => public account, isolation off", + yaml: ` +services: + my-project: + host: azure.ai.agent + deployments: + - name: gpt-4.1-mini + model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} + sku: {capacity: 10, name: GlobalStandard} +`, + wantMode: NetworkModeNone, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, false, p["enableNetworkIsolation"]) + assert.Equal(t, false, p["useManagedEgress"]) + }, + }, + { + name: "byo egress (agentSubnet present) with explicit subnets => create both", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + agentSubnet: {vnet: ` + validVNet + `, name: agent-subnet, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} + dns: + resourceGroup: rg-private-dns + subscription: 22222222-2222-2222-2222-222222222222 +`, + wantMode: NetworkModeByo, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["enableNetworkIsolation"]) + assert.Equal(t, false, p["useManagedEgress"]) + assert.Equal(t, validVNet, p["vnetId"]) + assert.Equal(t, "agent-subnet", p["agentSubnetName"]) + assert.Equal(t, "192.168.0.0/24", p["agentSubnetPrefix"]) + assert.Equal(t, true, p["createAgentSubnet"]) + assert.Equal(t, true, p["createPESubnet"]) + assert.Equal(t, "rg-private-dns", p["dnsZonesResourceGroup"]) + assert.Equal(t, "22222222-2222-2222-2222-222222222222", p["dnsZonesSubscription"]) + }, + }, + { + name: "subnet without prefix => reference (create=false)", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + agentSubnet: {vnet: ` + validVNet + `, name: existing-agent} + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeByo, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, "existing-agent", p["agentSubnetName"]) + assert.Equal(t, false, p["createAgentSubnet"]) + assert.Equal(t, "pe-subnet", p["peSubnetName"]) + assert.Equal(t, true, p["createPESubnet"]) + }, + }, + { + name: "subnet vnet from ${VAR}", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Contains(t, p["vnetId"], "/virtualNetworks/my-vnet") + }, + }, + { + name: "managed egress (agentSubnet absent) with isolation", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + isolationMode: AllowOnlyApprovedOutbound + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["enableNetworkIsolation"]) + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, false, p["createAgentSubnet"]) + assert.Equal(t, "AllowOnlyApprovedOutbound", p["managedIsolationMode"]) + }, + }, + { + name: "dns subscription normalized from /subscriptions/", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} + dns: + resourceGroup: rg-dns + subscription: /subscriptions/33333333-3333-3333-3333-333333333333 +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, "33333333-3333-3333-3333-333333333333", p["dnsZonesSubscription"]) + }, + }, + { + name: "managed egress, isolationMode unset => empty managedIsolationMode", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, "", p["managedIsolationMode"]) + assert.Equal(t, true, p["createPESubnet"]) + }, + }, + { + name: "managed egress, AllowInternetOutbound with referenced peSubnet", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + isolationMode: AllowInternetOutbound + peSubnet: {vnet: ` + validVNet + `, name: existing-pe} +`, + wantMode: NetworkModeManaged, + check: func(t *testing.T, p map[string]any) { + assert.Equal(t, true, p["useManagedEgress"]) + assert.Equal(t, "AllowInternetOutbound", p["managedIsolationMode"]) + assert.Equal(t, "existing-pe", p["peSubnetName"]) + assert.Equal(t, false, p["createPESubnet"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: []byte(tt.yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.agent"}, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, tt.wantMode, res.NetworkMode) + if tt.check != nil { + tt.check(t, res.Parameters) + } + }) + } +} + +func TestSynthesize_NetworkValidationErrors(t *testing.T) { + const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/my-vnet" + const validVNet2 = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + + "providers/Microsoft.Network/virtualNetworks/other-vnet" + + tests := []struct { + name string + yaml string + wantSub string + }{ + { + name: "network present but peSubnet missing", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + isolationMode: AllowInternetOutbound +`, + wantSub: "private networking requires peSubnet", + }, + { + name: "isolationMode with agentSubnet present", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + isolationMode: AllowInternetOutbound + agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: 192.168.1.0/24} +`, + wantSub: "only valid for managed egress", + }, + { + name: "agentSubnet and peSubnet in different vnets", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet2 + `, name: pe, prefix: 192.168.1.0/24} +`, + wantSub: "same virtual network", + }, + { + name: "agentSubnet and peSubnet share a name in one vnet", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + agentSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.0.0/24} + peSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.1.0/24} +`, + wantSub: "agentSubnet.name and peSubnet.name must differ", + }, + { + name: "subnet missing vnet", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {name: pe} +`, + wantSub: "peSubnet.vnet: required", + }, + { + name: "subnet missing name", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: ` + validVNet + `} +`, + wantSub: "peSubnet.name: required", + }, + { + name: "malformed vnet id", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: not-an-arm-id, name: pe} +`, + wantSub: "not a well-formed", + }, + { + name: "subnet invalid cidr", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: not-a-cidr} +`, + wantSub: "not a valid CIDR", + }, + { + name: "unresolved var", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + peSubnet: {vnet: "${DEFINITELY_NOT_SET_VAR_XYZ}", name: pe} +`, + wantSub: "unresolved environment variable", + }, + { + name: "bad managed isolation mode", + yaml: ` +services: + my-project: + host: azure.ai.agent + network: + isolationMode: Wide + peSubnet: {vnet: ` + validVNet + `, name: pe} +`, + wantSub: "isolationMode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Synthesize(Input{ + RawAzureYAML: []byte(tt.yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.agent"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantSub) + // Errors carry the service-scoped field path. + assert.Contains(t, err.Error(), "services.my-project.network") + }) + } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json index 0f0dd210464..bb33d48a719 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json @@ -5,8 +5,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "10248453623902523275" + "version": "0.44.1.10279", + "templateHash": "15130149524391891581" } }, "definitions": { @@ -120,6 +120,90 @@ "metadata": { "description": "Principal type used in the developer role assignment." } + }, + "enableNetworkIsolation": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Master switch: when true the account is VNet-bound (private)." + } + }, + "useManagedEgress": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "ARM id of the existing customer VNet (byo mode)." + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "Agent (delegated) subnet name." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "Private-endpoint subnet name." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + }, + "managedIsolationMode": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Managed-network isolation mode (managed mode)." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } } }, "resources": { @@ -164,6 +248,42 @@ }, "principalType": { "value": "[parameters('principalType')]" + }, + "enableNetworkIsolation": { + "value": "[parameters('enableNetworkIsolation')]" + }, + "useManagedEgress": { + "value": "[parameters('useManagedEgress')]" + }, + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "createAgentSubnet": { + "value": "[parameters('createAgentSubnet')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "createPESubnet": { + "value": "[parameters('createPESubnet')]" + }, + "managedIsolationMode": { + "value": "[parameters('managedIsolationMode')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" } }, "template": { @@ -173,8 +293,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2049009886480371322" + "version": "0.44.1.10279", + "templateHash": "769018913626752462" } }, "definitions": { @@ -281,6 +401,90 @@ "metadata": { "description": "Principal type used in the developer role assignment." } + }, + "enableNetworkIsolation": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Master switch: when true the account is VNet-bound (private)." + } + }, + "useManagedEgress": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "ARM id of the existing customer VNet (byo mode)." + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "Agent (delegated) subnet name." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "Private-endpoint subnet name." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + }, + "managedIsolationMode": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } } }, "variables": { @@ -291,7 +495,12 @@ "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", - "cognitiveServicesUserRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]" + "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", + "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", + "disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]", + "cognitiveServicesUserRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "agentSubnetArmId": "[format('{0}/subnets/{1}', parameters('vnetId'), parameters('agentSubnetName'))]", + "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]" }, "resources": { "foundryAccount::modelDeployments": { @@ -345,20 +554,39 @@ "properties": { "allowProjectManagement": true, "customSubDomainName": "[variables('foundryAccountName')]", - "publicNetworkAccess": "Enabled", + "publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]", "disableLocalAuth": true, "networkAcls": { - "defaultAction": "Allow", + "defaultAction": "[if(variables('disablePublicDataPlaneAccess'), 'Deny', 'Allow')]", + "bypass": "[if(variables('disablePublicDataPlaneAccess'), 'AzureServices', null())]", "virtualNetworkRules": [], "ipRules": [] + }, + "networkInjections": "[variables('agentNetworkInjections')]" + }, + "dependsOn": [ + "network" + ] + }, + "foundryManagedNetwork": { + "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/{1}', variables('foundryAccountName'), 'default')]", + "properties": { + "managedNetwork": { + "isolationMode": "[parameters('managedIsolationMode')]" } - } + }, + "dependsOn": [ + "foundryAccount" + ] }, "developerCognitiveServicesUser": { "condition": "[not(empty(parameters('principalId')))]", "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.CognitiveServices/accounts/{0}/projects/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]", "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName')), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", "properties": { "principalId": "[parameters('principalId')]", @@ -369,6 +597,316 @@ "foundryAccount::project" ] }, + "network": { + "condition": "[parameters('enableNetworkIsolation')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-network", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "createAgentSubnet": { + "value": "[parameters('createAgentSubnet')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "createPESubnet": { + "value": "[parameters('createPESubnet')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "11653429655583605398" + } + }, + "parameters": { + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the existing customer VNet." + } + }, + "agentSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the agent (delegated) subnet." + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "CIDR for the agent subnet. Empty derives a /24 from the VNet space." + } + }, + "createAgentSubnet": { + "type": "bool", + "metadata": { + "description": "When true, create the agent subnet; when false, reference it." + } + }, + "peSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the private-endpoint subnet." + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space." + } + }, + "createPESubnet": { + "type": "bool", + "metadata": { + "description": "When true, create the PE subnet; when false, reference it." + } + } + }, + "variables": { + "vnetParts": "[split(parameters('vnetId'), '/')]", + "vnetSubscriptionId": "[variables('vnetParts')[2]]", + "vnetResourceGroupName": "[variables('vnetParts')[4]]", + "vnetName": "[last(variables('vnetParts'))]" + }, + "resources": [ + { + "condition": "[parameters('createAgentSubnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", + "subscriptionId": "[variables('vnetSubscriptionId')]", + "resourceGroup": "[variables('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[variables('vnetName')]" + }, + "subnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", + "delegations": { + "value": [ + { + "name": "Microsoft.App/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "9706203844896299767" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network the subnet belongs to." + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet to create." + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "CIDR for the subnet." + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + }, + { + "condition": "[parameters('createPESubnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", + "subscriptionId": "[variables('vnetSubscriptionId')]", + "resourceGroup": "[variables('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[variables('vnetName')]" + }, + "subnetName": { + "value": "[parameters('peSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", + "delegations": { + "value": [] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "9706203844896299767" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network the subnet belongs to." + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet to create." + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "CIDR for the subnet." + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName'))))]" + ] + } + ], + "outputs": { + "vnetId": { + "type": "string", + "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName'))]" + }, + "vnetName": { + "type": "string", + "value": "[variables('vnetName')]" + }, + "vnetSubscriptionId": { + "type": "string", + "value": "[variables('vnetSubscriptionId')]" + }, + "vnetResourceGroupName": { + "type": "string", + "value": "[variables('vnetResourceGroupName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('peSubnetName'))]" + }, + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + } + } + } + } + }, "acr": { "condition": "[parameters('includeAcr')]", "type": "Microsoft.Resources/deployments", @@ -405,8 +943,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "1861506930511297752" + "version": "0.44.1.10279", + "templateHash": "5205255504807716842" } }, "parameters": { @@ -497,7 +1035,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('name'))]", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId'))]", "properties": { "principalId": "[parameters('foundryProjectPrincipalId')]", @@ -529,6 +1067,232 @@ "foundryAccount", "foundryAccount::project" ] + }, + "privateEndpointDns": { + "condition": "[parameters('enableNetworkIsolation')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-private-endpoint-dns", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aiAccountName": { + "value": "[variables('foundryAccountName')]" + }, + "vnetId": { + "value": "[reference('network').outputs.vnetId.value]" + }, + "peSubnetId": { + "value": "[reference('network').outputs.peSubnetId.value]" + }, + "suffix": { + "value": "[variables('resourceToken')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "17066190331540845310" + } + }, + "parameters": { + "aiAccountName": { + "type": "string", + "metadata": { + "description": "Name of the Foundry (AIServices) account to bind the private endpoint to." + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the customer VNet." + } + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the private-endpoint subnet." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource/link names." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "variables": { + "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", + "openAiDnsZoneName": "privatelink.openai.azure.com", + "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "aiServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", + "openAiZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", + "cognitiveServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]" + }, + "resources": [ + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", + "location": "[resourceGroup().location]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", + "groupIds": [ + "account" + ] + } + } + ] + } + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('aiServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('openAiDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('cognitiveServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" + ] + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + }, + { + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('aiServicesZoneId')]" + } + }, + { + "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('openAiZoneId')]" + } + }, + { + "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('cognitiveServicesZoneId')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + } + ] + } + }, + "dependsOn": [ + "foundryAccount", + "network" + ] } }, "outputs": { @@ -563,6 +1327,14 @@ "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { "type": "string", "value": "[if(parameters('includeAcr'), reference('acr').outputs.connectionName.value, '')]" + }, + "AZURE_FOUNDRY_NETWORK_MODE": { + "type": "string", + "value": "[if(not(parameters('enableNetworkIsolation')), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" + }, + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { + "type": "string", + "value": "[if(variables('useManagedNetwork'), parameters('managedIsolationMode'), '')]" } } } @@ -608,6 +1380,14 @@ "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { "type": "string", "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME.value]" + }, + "AZURE_FOUNDRY_NETWORK_MODE": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_FOUNDRY_NETWORK_MODE.value]" + }, + "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { + "type": "string", + "value": "[reference('resources').outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE.value]" } } } \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep index 21411315904..dec15004123 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep @@ -63,6 +63,45 @@ param principalId string = '' @description('Principal type used in the developer role assignment.') param principalType string = 'User' +// Network isolation parameters (see modules/resources.bicep for semantics). +// All default off so an absent network: block yields a public account. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode).') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + // Resources resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { @@ -83,6 +122,18 @@ module resources 'modules/resources.bicep' = { includeAcr: includeAcr principalId: principalId principalType: principalType + enableNetworkIsolation: enableNetworkIsolation + useManagedEgress: useManagedEgress + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + managedIsolationMode: managedIsolationMode + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription } } @@ -97,3 +148,5 @@ output FOUNDRY_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPO output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME +output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE +output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep new file mode 100644 index 00000000000..68f414e6da5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep @@ -0,0 +1,94 @@ +// Virtual network wiring for a network-secured (VNet-injected) Foundry account. +// +// Bring-your-own VNet only (network.mode: byo). The VNet must already exist; +// v1 references it by the ARM id supplied in azure.yaml. Each subnet follows +// the tri-state rule from the synthesizer: +// +// create=true, prefix set -> create the subnet with that prefix +// create=true, prefix empty -> create the subnet with a derived /24 prefix +// create=false -> reference an existing subnet as-is +// +// All subnet ids are deterministic ('/subnets/'), so outputs are +// valid whether the subnet was created here or already existed. + +targetScope = 'resourceGroup' + +@description('ARM resource id of the existing customer VNet.') +param vnetId string + +@description('Name of the agent (delegated) subnet.') +param agentSubnetName string + +@description('CIDR for the agent subnet. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool + +@description('Name of the private-endpoint subnet.') +param peSubnetName string + +@description('CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool + +// The VNet may live in a different resource group than the deployment RG. +var vnetParts = split(vnetId, '/') +var vnetSubscriptionId = vnetParts[2] +var vnetResourceGroupName = vnetParts[4] +var vnetName = last(vnetParts) + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) +} + +var vnetAddressSpace = vnet.properties.addressSpace.addressPrefixes[0] +var agentPrefix = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix +var pePrefix = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix + +// Create the agent subnet, delegated to Microsoft.App/environments so the +// hosted agent's container app environment can be injected into it. +module agentSubnet 'subnet.bicep' = if (createAgentSubnet) { + name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: agentSubnetName + addressPrefix: agentPrefix + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } +} + +// Create the private-endpoint subnet. Depends on the agent subnet so the two +// subnet PUTs against the same VNet do not race (ARM serializes subnet writes). +module peSubnet 'subnet.bicep' = if (createPESubnet) { + name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: peSubnetName + addressPrefix: pePrefix + delegations: [] + } + dependsOn: [ + agentSubnet + ] +} + +output vnetId string = vnet.id +output vnetName string = vnetName +output vnetSubscriptionId string = vnetSubscriptionId +output vnetResourceGroupName string = vnetResourceGroupName +output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' +output peSubnetId string = '${vnet.id}/subnets/${peSubnetName}' +output peSubnetName string = peSubnetName diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep new file mode 100644 index 00000000000..a8af939b859 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep @@ -0,0 +1,165 @@ +// Account private endpoint + the three AI private DNS zones for a +// network-secured Foundry account. Dependent stores stay platform-managed, so +// only the account itself gets a private endpoint here (no Search / Storage / +// Cosmos endpoints). +// +// DNS zones are created and linked to the VNet by default. When +// dnsZonesResourceGroup is set, the zones are referenced from that resource +// group (in dnsZonesSubscription, defaulting to this subscription) instead of +// being created. + +targetScope = 'resourceGroup' + +@description('Name of the Foundry (AIServices) account to bind the private endpoint to.') +param aiAccountName string + +@description('ARM resource id of the customer VNet.') +param vnetId string + +@description('ARM resource id of the private-endpoint subnet.') +param peSubnetId string + +@description('Suffix for unique resource/link names.') +param suffix string + +@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +var aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' +var openAiDnsZoneName = 'privatelink.openai.azure.com' +var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription + +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiAccountName + scope: resourceGroup() +} + +// Account private endpoint in the PE subnet, targeting the 'account' group. +resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${aiAccountName}-private-endpoint' + location: resourceGroup().location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${aiAccountName}-private-link-service-connection' + properties: { + privateLinkServiceId: aiAccount.id + groupIds: [ + 'account' + ] + } + } + ] + } +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource aiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: aiServicesDnsZoneName + location: 'global' +} +resource existingAiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: aiServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var aiServicesZoneId = useExistingZones ? existingAiServicesZone.id : aiServicesZone.id + +resource openAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: openAiDnsZoneName + location: 'global' +} +resource existingOpenAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: openAiDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var openAiZoneId = useExistingZones ? existingOpenAiZone.id : openAiZone.id + +resource cognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: cognitiveServicesDnsZoneName + location: 'global' +} +resource existingCognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: cognitiveServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var cognitiveServicesZoneId = useExistingZones ? existingCognitiveServicesZone.id : cognitiveServicesZone.id + +// ---- VNet links (only when we create the zones) ---- + +resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: aiServicesZone + name: 'aiServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: openAiZone + name: 'aiServicesOpenAI-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: cognitiveServicesZone + name: 'aiServicesCognitiveServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} + +// ---- DNS zone group binds the three zones to the account endpoint ---- + +resource aiAccountDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: aiAccountPrivateEndpoint + name: '${aiAccountName}-dns-group' + properties: { + privateDnsZoneConfigs: [ + { + name: '${aiAccountName}-dns-aiserv-config' + properties: { + privateDnsZoneId: aiServicesZoneId + } + } + { + name: '${aiAccountName}-dns-openai-config' + properties: { + privateDnsZoneId: openAiZoneId + } + } + { + name: '${aiAccountName}-dns-cogserv-config' + properties: { + privateDnsZoneId: cognitiveServicesZoneId + } + } + ] + } + dependsOn: [ + aiServicesLink + openAiLink + cognitiveServicesLink + ] +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index 42e1991b4ed..32985dbdfe5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -55,6 +55,45 @@ param principalId string = '' @description('Principal type used in the developer role assignment.') param principalType string = 'User' +// Network isolation parameters. All default off so an absent network: block in +// azure.yaml yields a public account identical to the pre-network template. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound.') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + // Variables var resourceToken = empty(resourceTokenSalt) @@ -65,6 +104,13 @@ var abbrs = loadJsonContent('../abbreviations.json') var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' +// Egress: byo injects the agent into a customer subnet; managed uses the +// Microsoft-managed network. Ingress: an account private endpoint is always +// provisioned when isolation is on, so the data plane is never left public. +var useByoNetwork = enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = enableNetworkIsolation + // Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles var cognitiveServicesUserRoleId = subscriptionResourceId( 'Microsoft.Authorization/roleDefinitions', @@ -73,6 +119,50 @@ var cognitiveServicesUserRoleId = subscriptionResourceId( // Resources +// Customer VNet wiring: reference the VNet and create or reference the agent +// (byo egress only) + private-endpoint subnets. Runs whenever isolation is on +// because the account private endpoint is always provisioned. +module network 'network.bicep' = if (enableNetworkIsolation) { + name: 'foundry-network' + params: { + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + } +} + +// networkInjections wires the account into the agent subnet (byo) or the +// Microsoft-managed network (managed). Null when isolation is off. +// +// subnetArmId is built as a concrete string from the (concrete) vnetId param +// rather than network!.outputs.agentSubnetId. The account and the network +// module deploy in the same template, so an inter-module reference() here is +// unresolved at the CognitiveServices RP preflight, which then fails to convert +// networkInjections to its typed contract (ARM what-if does not catch this). +// The deterministic id avoids the unresolved reference; an explicit dependsOn +// on the network module preserves ordering (the subnet must exist first). +var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' +var agentNetworkInjections = useByoNetwork + ? [ + { + scenario: 'agent' + subnetArmId: agentSubnetArmId + useMicrosoftManagedNetwork: false + } + ] + : (useManagedNetwork + ? [ + { + scenario: 'agent' + useMicrosoftManagedNetwork: true + } + ] + : null) + resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { name: foundryAccountName location: location @@ -87,15 +177,22 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { properties: { allowProjectManagement: true customSubDomainName: foundryAccountName - publicNetworkAccess: 'Enabled' + publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' disableLocalAuth: true networkAcls: { - defaultAction: 'Allow' + defaultAction: disablePublicDataPlaneAccess ? 'Deny' : 'Allow' + bypass: disablePublicDataPlaneAccess ? 'AzureServices' : null virtualNetworkRules: [] ipRules: [] } + networkInjections: agentNetworkInjections } + // The account injects into the agent subnet via a deterministic id (above), + // so Bicep cannot infer the dependency on the network module that creates + // that subnet. Declare it explicitly so the subnet exists before injection. + dependsOn: useByoNetwork ? [network] : [] + // Sequential model deployment creation; ARM throttles concurrent // deployments on the same account. @batchSize(1) @@ -128,6 +225,23 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } } +// Managed-network isolation (managed egress only). Applies the chosen outbound +// isolation mode to the Microsoft-managed VNet that hosts the agent runtime. +// Only deployed when an explicit isolationMode is requested; otherwise the +// platform default applies. Note: AllowOnlyApprovedOutbound additionally +// requires approved outbound rules for the agent to reach dependent resources; +// for the platform-managed stores used here those are managed by the platform. +resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = + if (useManagedNetwork && !empty(managedIsolationMode)) { + parent: foundryAccount + name: 'default' + properties: { + managedNetwork: { + isolationMode: managedIsolationMode + } + } + } + module acr 'acr.bicep' = if (includeAcr) { name: 'acr' params: { @@ -140,6 +254,21 @@ module acr 'acr.bicep' = if (includeAcr) { } } +// Account private endpoint + AI private DNS zones. The account is always given a +// private endpoint when isolation is on (byo or managed egress); dependent +// stores stay platform-managed, so only the account gets an endpoint. +module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { + name: 'foundry-private-endpoint-dns' + params: { + aiAccountName: foundryAccount.name + vnetId: network!.outputs.vnetId + peSubnetId: network!.outputs.peSubnetId + suffix: resourceToken + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + // Grant the developer Cognitive Services User on the project so they can call // the Foundry data-plane (chat/completions, agents API) from their machine. resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { @@ -162,3 +291,5 @@ output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.service output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/subnet.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/subnet.bicep new file mode 100644 index 00000000000..27abadcb813 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/subnet.bicep @@ -0,0 +1,28 @@ +// Single subnet on an existing VNet. Kept as its own module so the parent can +// place subnets in the VNet's resource group (which may differ from the +// deployment RG) and serialize subnet writes via module dependsOn. + +targetScope = 'resourceGroup' + +@description('Name of the virtual network the subnet belongs to.') +param vnetName string + +@description('Name of the subnet to create.') +param subnetName string + +@description('CIDR for the subnet.') +param addressPrefix string + +@description('Subnet delegations (e.g. Microsoft.App/environments for the agent subnet).') +param delegations array = [] + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + name: '${vnetName}/${subnetName}' + properties: { + addressPrefix: addressPrefix + delegations: delegations + } +} + +output subnetId string = subnet.id +output subnetName string = subnetName diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 63878d29ffc..89ad8b2380d 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml @@ -12,6 +12,24 @@ metadata: services: ai: host: microsoft.foundry + # Private networking: provision a VNet-bound (network-secured) account. + # Omit this block for a public account. + network: + # Egress: inject the agent into a customer subnet (BYO VNet). Omit + # agentSubnet to use the Microsoft-managed network instead. + agentSubnet: + vnet: ${AZURE_VNET_ID} + name: agent-subnet + prefix: 192.168.0.0/24 + # Ingress: the account private endpoint (required). Establishes the + # private data plane (public network access disabled). + peSubnet: + vnet: ${AZURE_VNET_ID} + name: pe-subnet + prefix: 192.168.1.0/24 + dns: + resourceGroup: rg-private-dns + subscription: ${AZURE_DNS_SUBSCRIPTION_ID} deployments: - name: gpt-4o model: diff --git a/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json b/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json index 34a5d502a2c..d4ba81234bc 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json @@ -39,6 +39,64 @@ "type": "array", "description": "All agent definitions (hosted and prompt).", "items": { "$ref": "Agent.json" } + }, + "network": { + "type": "object", + "description": "Private networking for the Foundry account. When omitted, the account uses public networking. When present, azd always provisions an account private endpoint (the data plane is never left public) and uses platform-managed dependent stores. Ignored when 'endpoint' is set (brownfield).", + "additionalProperties": false, + "required": ["peSubnet"], + "properties": { + "agentSubnet": { + "description": "Egress: when set, the agent runtime is injected into this customer subnet (BYO VNet). When omitted, the agent uses the Microsoft-managed network.", + "$ref": "#/definitions/subnet" + }, + "isolationMode": { + "type": "string", + "description": "Outbound posture of the Microsoft-managed network. Valid only when 'agentSubnet' is omitted (managed egress).", + "enum": ["AllowInternetOutbound", "AllowOnlyApprovedOutbound"] + }, + "peSubnet": { + "description": "Ingress: subnet for the account private endpoint. Required. Establishes the private data plane (public network access disabled).", + "$ref": "#/definitions/subnet" + }, + "dns": { + "type": "object", + "description": "Private DNS zones for the account private endpoint. When omitted (or resourceGroup omitted), azd creates and links the required AI private DNS zones. When resourceGroup is set, azd references existing zones in that resource group.", + "additionalProperties": false, + "properties": { + "resourceGroup": { + "type": "string", + "description": "Resource group that holds existing private DNS zones to reference." + }, + "subscription": { + "type": "string", + "description": "Subscription that holds the existing private DNS zones. Defaults to the deployment subscription. Accepts a bare GUID or ${VAR}." + } + } + } + } + } + }, + "definitions": { + "subnet": { + "type": "object", + "description": "Subnet descriptor. vnet and name are required. Omit prefix to reference an existing subnet; set prefix to create the subnet with that CIDR.", + "additionalProperties": false, + "required": ["vnet", "name"], + "properties": { + "vnet": { + "type": "string", + "description": "ARM resource id of the virtual network that holds (or will hold) the subnet. Supports ${VAR} resolved from the azd environment." + }, + "name": { + "type": "string", + "description": "Subnet name." + }, + "prefix": { + "type": "string", + "description": "Subnet CIDR. When set, azd creates the subnet; when omitted, azd references the existing subnet." + } + } } } } diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 148b450a0cf..2e5d025721f 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -390,6 +390,16 @@ Emitted on `azd provision` / `azd up` to measure adoption and safety of `infra.l | `provision.layer.explicit_dependson_count` | measurement | Layers using the explicit `infra.layers[].dependsOn` override | +
+Foundry Private Networking + +Emitted at provision start by the `microsoft.foundry` provisioning provider (the `azure.ai.agents` extension) to measure secured-agent adoption and the BYO-vs-managed split. + +| Field Key | Type | Description | +|-----------|------|-------------| +| `provision.network_mode` | string | `none` (public account, no `network:` block), `byo` (customer VNet), or `managed` (Foundry-managed VNet) | +
+
Environment Management