diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 8b2ca4a64d9..89c874857af 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- `azd ai agent init` now writes each Foundry resource as its own `azure.yaml` service entry instead of bundling everything into the agent service. Model deployments become a single `azure.ai.project` service, each connection becomes an `azure.ai.connection` service, and each toolbox becomes an `azure.ai.toolbox` service, all wired to the agent through `uses:`. The agents extension registers the `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` service-target hosts itself as no-ops (the resources are created by Bicep at provision time), so only this extension needs to be installed for `azd up`/`azd deploy` to walk the new service entries. Provisioning behavior is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets, falling back to a pre-split `azure.yaml` that still bundles them on the agent service so existing projects keep provisioning without re-running `init`. +- `azd ai agent init` now writes each Foundry resource as its own `azure.yaml` service entry instead of bundling everything into the agent service. Model deployments become a single `azure.ai.project` service, each connection becomes an `azure.ai.connection` service, and each toolbox becomes an `azure.ai.toolbox` service, all wired to the agent through `uses:`. The agents extension registers the `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` service-target hosts itself as no-ops (the resources are created by Bicep at provision time), so only this extension needs to be installed for `azd up`/`azd deploy` to walk the new service entries. Provisioning behavior is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets, falling back to a pre-split non-network `azure.yaml` that still bundles them on the agent service so existing projects keep provisioning without re-running `init`. - The `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` hosts are now owned by their sibling extensions (`azure.ai.projects`, `azure.ai.connections`, `azure.ai.toolboxes`) as real deploy-time service targets. The agents extension no longer registers them as no-op hosts, and toolboxes are reconciled at `azd deploy` by the `azure.ai.toolbox` target rather than created during `azd provision`. - `azd provision` now connects to an existing Foundry project when the `azure.ai.project` service sets `endpoint:` (bring-your-own) instead of failing with a brownfield error, and `azd down` leaves a bring-your-own project in place because azd did not create it. diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 42155d8b3db..a5979b86524 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -48,11 +48,11 @@ services: description: My hosted agent ``` -## Private networking for `host: microsoft.foundry` +## Private networking for `host: azure.ai.project` -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) +Foundry project services can be provisioned as network-secured, VNet-bound +accounts by adding a `network:` block to the `host: azure.ai.project` service in +`azure.yaml`. See [Private networking for `host: azure.ai.project`](docs/private-networking.md) for the schema reference, BYO-image requirements, and VNet deployment cheatsheet. diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 91e58c7f13d..ac7a75eb63d 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -1,35 +1,36 @@ -# Private networking for `host: azure.ai.agent` +# Private networking for `host: azure.ai.project` -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). +A Foundry project service can be provisioned as a **network-secured (VNet-bound)** account by adding a `network:` block to the `host: azure.ai.project` service in `azure.yaml`. When `network:` is omitted, the account uses public networking. -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. +Do **not** place `network:` on `host: azure.ai.agent`. Agent services describe deployable agents and depend on the project through `uses:`; the project service owns account-level provisioning inputs such as `endpoint:`, `deployments:`, and `network:`. -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. +When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. ```yaml +infra: + provider: microsoft.foundry + services: - my-project: + my-agent: host: azure.ai.agent + project: src/my-agent + uses: + - ai-project + image: myprivacr.azurecr.io/agents/my-agent:v1 + + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: { format: OpenAI, name: gpt-4o-mini, version: "2024-07-18" } + sku: { name: GlobalStandard, capacity: 10 } 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) + isolationMode: AllowOnlyApprovedOutbound # or AllowInternetOutbound # # (b) BYO egress: inject the agent into your subnet instead. Replace the # isolationMode line above with an agentSubnet block (same VNet as @@ -41,32 +42,19 @@ services: # ----- Ingress: account private endpoint (REQUIRED) ----- peSubnet: - vnet: ${AZURE_VNET_ID} # ARM id of the VNet (must already exist) + vnet: ${AZURE_VNET_ID} 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 + subscription: ${AZURE_DNS_SUBSCRIPTION_ID} # optional; defaults to deployment subscription ``` -> 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. +Run `azd ai agent init --no-prompt --agent-name my-agent --image ` to scaffold the agent. The generated `azure.yaml` contains an `azure.ai.agent` service and an `azure.ai.project` service named `ai-project`; add the `network:` block to the `ai-project` service. -### Field reference +## Field reference | Field | Rule | | --- | --- | @@ -79,78 +67,59 @@ services: | `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 +## 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: +Network fields support `${VAR}` references resolved client-side from the azd environment. The variable names are user-chosen; the examples use: | 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`): +Configure them with `azd env set`, for example: ```bash -azd ai agent init --no-prompt --agent-name my-agent \ - --image myprivacr.azurecr.io/agents/my-agent:v1 +azd env set AZURE_VNET_ID "" +azd env set AZURE_DNS_SUBSCRIPTION_ID "" ``` -Then add a `network:` block to the generated service in `azure.yaml` (omit -`agentSubnet` for managed egress; `isolationMode` is valid only in this mode): +## Limitations + +- **Project-owned network settings.** `network:` is supported only on the `host: azure.ai.project` service. Multiple agents may depend on the same project; they do not carry separate VNet settings. +- **Single VNet (v1).** When `agentSubnet` is present it must live in the same VNet as `peSubnet`. Managed egress is unaffected because it needs only the `peSubnet` VNet. +- **BYO container image required.** Secured agents should use a pre-built image. The image belongs to the `azure.ai.agent` service; the VNet configuration belongs to the `azure.ai.project` service. The developer owns the registry's SKU, private endpoint, DNS, and firewall. +- **Brownfield (`endpoint:`) ignores `network:`.** When `endpoint:` is set on the project service, 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. + +## Scenario 1 — Managed egress: private account, agent on Microsoft's network + +Omit `agentSubnet` so the hosted-agent runtime uses a Microsoft-managed network. `peSubnet` is still required: the account data plane stays private behind an account private endpoint in your VNet, reachable from inside the VNet, a peered VNet, or VPN. ```yaml -name: my-agent infra: provider: microsoft.foundry services: my-agent: host: azure.ai.agent + project: src/my-agent + uses: + - ai-project + image: myprivacr.azurecr.io/agents/my-agent:v1 + + ai-project: + host: azure.ai.project deployments: [] network: - isolationMode: AllowInternetOutbound # managed-egress outbound posture + isolationMode: AllowInternetOutbound 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: +Provision and deploy: ```bash azd env set AZURE_SUBSCRIPTION_ID "" @@ -158,157 +127,57 @@ azd env set AZURE_LOCATION westus azd env set AZURE_RESOURCE_GROUP "" azd env set AZURE_VNET_ID "" azd provision --no-prompt +azd deploy --no-prompt ``` -Grant the Foundry project MI ACR pull permission, then run deploy/invoke from a -host that can reach the account private endpoint: +Run deploy and 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 -``` +## Scenario 2 — BYO egress: agent injected into your VNet subnet -Then add a `network:` block to the generated service in `azure.yaml`: +Set `agentSubnet` to inject the hosted-agent runtime into your customer subnet. `agentSubnet` and `peSubnet` must reference the same VNet in v1. ```yaml services: my-agent: host: azure.ai.agent + project: src/my-agent + uses: + - ai-project + image: myprivacr.azurecr.io/agents/my-agent:v1 + + ai-project: + host: azure.ai.project network: - agentSubnet: # omit the whole block for managed egress + agentSubnet: 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 + prefix: 192.168.10.0/24 + peSubnet: 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. +- `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 managed identity. -### Scenario 3 — Eject and customize the Bicep (advanced) +## 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. +The synthesized template covers the common private-networking shapes. When you need something it does not express — an extra subnet, a private endpoint for a BYO dependent store, custom DNS wiring, a non-default account property, or 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 +# Scaffold first, declare network: on the azure.ai.project service, then eject: +azd ai agent init --infra ``` -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' }) -``` +Eject reads `network:` from the `host: azure.ai.project` service and writes the full Bicep tree: `infra/main.bicep`, `infra/modules/{resources,network,subnet,private-endpoint-dns,acr}.bicep`, and `infra/main.parameters.json`. `${VAR}` placeholders are preserved in the generated parameters file and resolved from the azd environment at provision time. `azure.yaml` is left unchanged: `infra.provider` stays `microsoft.foundry`. ```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/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 309b25a2d58..c8547132fcc 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 @@ -121,7 +121,7 @@ func ejectInfra(projectRoot, provider string) error { res, err := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: rawYAML, ServiceName: svcName, - AcceptedHosts: project.FoundryServiceHosts, + AcceptedHosts: project.FoundryProvisioningServiceHosts, // 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. @@ -132,8 +132,8 @@ func ejectInfra(projectRoot, provider string) error { // consistent codes for the same azure.yaml problems. return exterrors.Validation( exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("synthesize foundry service %q: %s", svcName, err), - "check the deployments/agents fields under your foundry service", + fmt.Sprintf("synthesize foundry project service %q: %s", svcName, err), + "check the endpoint, deployments, and network fields under your azure.ai.project service", ) } @@ -224,12 +224,13 @@ func ejectTerraform(projectRoot, infraDir string, params map[string]any) error { return nil } -// findFoundryServiceForEject scans azure.yaml for a service whose host is in -// project.FoundryServiceHosts and returns its name, using eject-specific error -// codes so telemetry can distinguish init-time eject from provision failures. +// findFoundryServiceForEject scans azure.yaml for the azure.ai.project service +// and returns its name, using eject-specific error codes so telemetry can +// distinguish init-time eject from provision failures. func findFoundryServiceForEject(raw []byte) (string, error) { type svc struct { - Host string `yaml:"host"` + Host string `yaml:"host"` + Network yaml.Node `yaml:"network,omitempty"` } type root struct { Services map[string]svc `yaml:"services"` @@ -245,31 +246,65 @@ func findFoundryServiceForEject(raw []byte) (string, error) { } var matches []string + var misplacedNetwork []string for name, s := range r.Services { - if slices.Contains(project.FoundryServiceHosts, s.Host) { + if slices.Contains(project.FoundryProjectServiceHosts, s.Host) { matches = append(matches, name) + continue + } + if project.IsFoundryNetworkHost(s.Host) && !s.Network.IsZero() { + misplacedNetwork = append(misplacedNetwork, name) } } - switch len(matches) { - case 0: - return "", exterrors.Dependency( - exterrors.CodeInfraEjectNoFoundryService, - fmt.Sprintf("no azure.ai.* services found in azure.yaml (looking for host in %v); "+ - "nothing to eject", project.FoundryServiceHosts), - fmt.Sprintf("add a service with `host: %s` to azure.yaml, "+ - "or remove --infra to run init normally", project.FoundryServiceHosts[0]), + if len(misplacedNetwork) > 0 { + slices.Sort(misplacedNetwork) + return "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("network: is only supported on services with host: %s (found on %v)", + project.FoundryProjectHost, misplacedNetwork), + "move the network: block to the azure.ai.project service (for example, services.ai-project)", ) + } + + switch len(matches) { case 1: return matches[0], nil + case 0: + var legacyMatches []string + for name, s := range r.Services { + if slices.Contains(project.FoundryLegacyProvisioningHosts, s.Host) { + legacyMatches = append(legacyMatches, name) + } + } + switch len(legacyMatches) { + case 1: + return legacyMatches[0], nil + case 0: + return "", exterrors.Dependency( + exterrors.CodeInfraEjectNoFoundryService, + fmt.Sprintf("no foundry provisioning service found in azure.yaml (looking for host in %v); "+ + "nothing to eject", project.FoundryProvisioningServiceHosts), + fmt.Sprintf("add a service with `host: %s` to azure.yaml, "+ + "or remove --infra to run init normally", project.FoundryProjectHost), + ) + default: + slices.Sort(legacyMatches) + return "", exterrors.Dependency( + exterrors.CodeInfraEjectMultipleFoundryServices, + fmt.Sprintf("multiple legacy services declare a foundry provisioning host %v (%v); only one is supported", + project.FoundryLegacyProvisioningHosts, legacyMatches), + "keep a single azure.ai.project service per project, or a single pre-split foundry service", + ) + } default: // Sort for deterministic error message; map iteration order is // randomized and would otherwise produce flaky tests. slices.Sort(matches) return "", exterrors.Dependency( exterrors.CodeInfraEjectMultipleFoundryServices, - fmt.Sprintf("multiple services declare a foundry host %v (%v); only one is supported", - project.FoundryServiceHosts, matches), - "keep a single foundry service per project", + fmt.Sprintf("multiple services declare a foundry project host %v (%v); only one is supported", + project.FoundryProjectServiceHosts, matches), + "keep a single azure.ai.project service per project", ) } } 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 3f95752dcab..98731ed61ec 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 @@ -28,7 +28,7 @@ infra: provider: microsoft.foundry services: my-foundry: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4-1-mini model: @@ -133,9 +133,9 @@ func TestEjectInfra_RefusesWhenMultipleFoundryServices(t *testing.T) { mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: agent-a: - host: azure.ai.agent + host: azure.ai.project agent-b: - host: azure.ai.agent + host: azure.ai.project `) err := ejectInfra(dir, "bicep") @@ -258,7 +258,7 @@ func TestEjectInfra_HappyPath_NoDockerOmitsAcrParam(t *testing.T) { mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: my-foundry: - host: azure.ai.agent + host: azure.ai.project deployments: [] agents: - name: my-agent @@ -293,7 +293,7 @@ func TestEjectInfra_PreservesNetworkVarRefs(t *testing.T) { mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: my-foundry: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} dns: @@ -350,7 +350,7 @@ infra: provider: microsoft.foundry services: my-foundry: - host: azure.ai.agent + host: azure.ai.project network: agentSubnet: vnet: "${AZURE_VNET_ID}" @@ -635,7 +635,7 @@ func TestEjectInfra_Terraform_NoDockerOmitsAcr(t *testing.T) { mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: my-foundry: - host: azure.ai.agent + host: azure.ai.project deployments: [] agents: - name: my-agent @@ -711,7 +711,7 @@ infra: provider: microsoft.foundry services: my-foundry: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} deployments: [] 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 0e859980da3..a43fc0c6301 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 @@ -51,7 +51,7 @@ const ( const deploymentNamePrefix = "azd-foundry-" // FoundryProvisioningProvider implements azdext.ProvisioningProvider for -// services whose host is one of FoundryServiceHosts. By default it deploys +// the service whose host is FoundryProjectHost. By default it deploys // the extension's pre-compiled ARM template (no bicep CLI required). When // ./infra/main.bicep or ./infra/main.bicepparam exists on disk (e.g. after // `azd ai agent init --infra`), it compiles that Bicep at runtime instead @@ -123,7 +123,7 @@ func (p *FoundryProvisioningProvider) Initialize( ) } - svcName, err := findFoundryService(rawYAML) + svcName, err := findFoundryProjectService(rawYAML) if err != nil { return err } @@ -145,7 +145,7 @@ func (p *FoundryProvisioningProvider) Initialize( res, err := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: rawYAML, ServiceName: svcName, - AcceptedHosts: FoundryServiceHosts, + AcceptedHosts: FoundryProvisioningServiceHosts, Env: p.networkEnvMap(ctx), }) switch { @@ -158,14 +158,14 @@ func (p *FoundryProvisioningProvider) Initialize( case errors.Is(err, synthesis.ErrServiceNotFound): return exterrors.Dependency( exterrors.CodeProvisioningServiceNotFound, - fmt.Sprintf("no service in azure.yaml has host in %v", FoundryServiceHosts), - fmt.Sprintf("add a service with `host: %s` to azure.yaml", FoundryServiceHosts[0]), + fmt.Sprintf("no service in azure.yaml has host in %v", FoundryProjectServiceHosts), + fmt.Sprintf("add a service with `host: %s` to azure.yaml", FoundryProjectHost), ) case err != nil: return exterrors.Validation( exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("synthesize foundry service %q: %s", svcName, err), - "check the deployments/agents fields under your foundry service", + fmt.Sprintf("synthesize foundry project service %q: %s", svcName, err), + "check the endpoint, deployments, and network fields under your azure.ai.project service", ) } p.synthResult = res @@ -1091,11 +1091,11 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { return out } -// findFoundryService scans azure.yaml for a single service whose host -// matches one of FoundryServiceHosts and returns its name. -func findFoundryService(raw []byte) (string, error) { +// findFoundryProjectService scans azure.yaml for a single azure.ai.project service and returns its name. +func findFoundryProjectService(raw []byte) (string, error) { type svc struct { - Host string `yaml:"host"` + Host string `yaml:"host"` + Network yaml.Node `yaml:"network,omitempty"` } type root struct { Services map[string]svc `yaml:"services"` @@ -1110,26 +1110,61 @@ func findFoundryService(raw []byte) (string, error) { } var matches []string + var misplacedNetwork []string for name, s := range r.Services { - if slices.Contains(FoundryServiceHosts, s.Host) { + if slices.Contains(FoundryProjectServiceHosts, s.Host) { matches = append(matches, name) + continue + } + if IsFoundryNetworkHost(s.Host) && !s.Network.IsZero() { + misplacedNetwork = append(misplacedNetwork, name) } } - switch len(matches) { - case 0: - return "", exterrors.Dependency( - exterrors.CodeProvisioningServiceNotFound, - fmt.Sprintf("no service in azure.yaml has host in %v", FoundryServiceHosts), - fmt.Sprintf("add a service with `host: %s` to azure.yaml", FoundryServiceHosts[0]), + if len(misplacedNetwork) > 0 { + slices.Sort(misplacedNetwork) + return "", exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("network: is only supported on services with host: %s (found on %v)", + FoundryProjectHost, misplacedNetwork), + "move the network: block to the azure.ai.project service (for example, services.ai-project)", ) + } + + switch len(matches) { case 1: return matches[0], nil + case 0: + var legacyMatches []string + for name, s := range r.Services { + if slices.Contains(FoundryLegacyProvisioningHosts, s.Host) { + legacyMatches = append(legacyMatches, name) + } + } + switch len(legacyMatches) { + case 1: + return legacyMatches[0], nil + case 0: + return "", exterrors.Dependency( + exterrors.CodeProvisioningServiceNotFound, + fmt.Sprintf("no service in azure.yaml has host in %v", FoundryProvisioningServiceHosts), + fmt.Sprintf("add a service with `host: %s` to azure.yaml", FoundryProjectHost), + ) + default: + slices.Sort(legacyMatches) + return "", exterrors.Dependency( + exterrors.CodeProvisioningServiceNotFound, + fmt.Sprintf("multiple legacy services declare a foundry provisioning host %v (%v); only one is supported", + FoundryLegacyProvisioningHosts, legacyMatches), + "keep a single azure.ai.project service per project, or a single pre-split foundry service", + ) + } default: + slices.Sort(matches) return "", exterrors.Dependency( exterrors.CodeProvisioningServiceNotFound, - fmt.Sprintf("multiple services declare a foundry host %v (%v); only one is supported", - FoundryServiceHosts, matches), - "keep a single foundry service per project", + fmt.Sprintf("multiple services declare a foundry project host %v (%v); only one is supported", + FoundryProjectServiceHosts, matches), + "keep a single azure.ai.project service per project", ) } } 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 f3fe159e9ee..7fd52ad7b00 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 @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestFindFoundryService(t *testing.T) { +func TestFindFoundryProjectService(t *testing.T) { tests := []struct { name string yaml string @@ -30,46 +30,59 @@ func TestFindFoundryService(t *testing.T) { wantErr bool }{ { - name: "single foundry service", + name: "single project service", yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project `, want: "my-project", }, { - name: "legacy microsoft.foundry service host", + name: "project service alongside agent services", yaml: ` services: - my-project: - host: microsoft.foundry + agent-a: + host: azure.ai.agent + uses: [ai-project] + agent-b: + host: azure.ai.agent + uses: [ai-project] + ai-project: + host: azure.ai.project `, - want: "my-project", + want: "ai-project", }, { - name: "foundry service alongside other hosts", + name: "pre-split agent service fallback", yaml: ` services: - webapp: - host: containerapp - project: src/web - my-foundry: + agent: host: azure.ai.agent `, - want: "my-foundry", + want: "agent", }, { - name: "no foundry service", + name: "legacy microsoft.foundry service fallback", yaml: ` services: - webapp: + legacy: + host: microsoft.foundry +`, + want: "legacy", + }, + { + name: "no project or legacy service", + yaml: ` +services: + web: host: containerapp + project: src/web `, wantErr: true, }, { - name: "multiple foundry services rejected", + name: "multiple pre-split agent services rejected", yaml: ` services: a: @@ -80,13 +93,50 @@ services: wantErr: true, }, { - name: "new and legacy foundry services rejected as ambiguous", + name: "project service wins over legacy fallback", yaml: ` services: - a: + agent: host: azure.ai.agent + ai-project: + host: azure.ai.project +`, + want: "ai-project", + }, + { + name: "multiple project services rejected", + yaml: ` +services: + a: + host: azure.ai.project b: + host: azure.ai.project +`, + wantErr: true, + }, + { + name: "network on agent service rejected", + yaml: ` +services: + agent: + host: azure.ai.agent + network: + peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} + ai-project: + host: azure.ai.project +`, + wantErr: true, + }, + { + name: "network on legacy foundry service rejected", + yaml: ` +services: + legacy: host: microsoft.foundry + network: + peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} + ai-project: + host: azure.ai.project `, wantErr: true, }, @@ -94,7 +144,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := findFoundryService([]byte(tt.yaml)) + got, err := findFoundryProjectService([]byte(tt.yaml)) if tt.wantErr { assert.Error(t, err) return @@ -558,17 +608,17 @@ func TestDestroy_RefusesWithoutForce(t *testing.T) { assert.Contains(t, local.Suggestion, "--force") } -func TestFindFoundryService_DependencyCategory(t *testing.T) { +func TestFindFoundryProjectService_DependencyCategory(t *testing.T) { // Missing service in azure.yaml is a missing-dependency error, not // a validation error (the yaml parses fine). Telemetry classifiers // differentiate these; the wrong category buckets misconfigurations // alongside actual malformed yaml. - _, err := findFoundryService([]byte("name: x\nservices:\n web:\n host: containerapp\n")) + _, err := findFoundryProjectService([]byte("name: x\nservices:\n web:\n host: containerapp\n")) require.Error(t, err) var local *azdext.LocalError require.True(t, errors.As(err, &local)) assert.Equal(t, azdext.LocalErrorCategoryDependency, local.Category, - "missing foundry service is a Dependency, not a Validation") + "missing foundry project service is a Dependency, not a Validation") } func TestOnDiskTemplatePresent(t *testing.T) { @@ -752,7 +802,7 @@ func TestFoundryServiceEndpoint(t *testing.T) { yaml: `name: x services: foundry: - host: azure.ai.agent`, + host: azure.ai.project`, svcName: "foundry", wantEndpoint: "", }, @@ -761,7 +811,7 @@ services: yaml: `name: x services: foundry: - host: azure.ai.agent + host: azure.ai.project endpoint: https://example.foundry.example.com`, svcName: "foundry", wantEndpoint: "https://example.foundry.example.com", @@ -771,7 +821,7 @@ services: yaml: `name: x services: foundry: - host: azure.ai.agent + host: azure.ai.project endpoint: " "`, svcName: "foundry", wantEndpoint: "", diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go index cce5f750a7b..2560af144ad 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go @@ -3,6 +3,8 @@ package project +import "slices" + // FoundryProviderName is the value written to `infra.provider` in // azure.yaml by `azd ai agent init` and looked up by azd's provider // resolver to dispatch provisioning to this extension. @@ -17,10 +19,28 @@ const ( TerraformProviderName = "terraform" ) -// FoundryServiceHosts lists the values of `services..host` that this -// extension's provisioning provider treats as Foundry services. Keep -// "azure.ai.agent" first so suggestions point users at the unified host while -// "microsoft.foundry" remains accepted for existing projects during migration. -// Must stay in sync with cmd.AiAgentHost ("azure.ai.agent") — kept here to avoid -// a cmd -> project import cycle. -var FoundryServiceHosts = []string{"azure.ai.agent", "microsoft.foundry"} +// FoundryProjectHost is the `services..host` value whose service body +// owns Foundry account/project provisioning inputs such as endpoint:, deployments:, and network:. +const FoundryProjectHost = "azure.ai.project" + +// FoundryProjectServiceHosts lists the values that the provisioning provider +// treats as Foundry project services. Keep this project-scoped: agent services +// depend on the project service, but do not own account-level provisioning settings. +var FoundryProjectServiceHosts = []string{FoundryProjectHost} + +// FoundryLegacyProvisioningHosts lists pre-split service hosts that can still drive +// provisioning when no azure.ai.project service exists. network: remains unsupported +// on these hosts; this compatibility path is only for existing non-network projects. +var FoundryLegacyProvisioningHosts = []string{"azure.ai.agent", "microsoft.foundry"} + +// FoundryProvisioningServiceHosts lists every service host accepted by the synthesizer. +var FoundryProvisioningServiceHosts = append( + slices.Clone(FoundryProjectServiceHosts), FoundryLegacyProvisioningHosts...) + +// IsFoundryNetworkHost reports whether a host belongs to a Foundry service shape +// where network: would be a likely user mistake. The shipped network contract is +// project-scoped, so callers use this to reject misplaced network: blocks with +// actionable guidance instead of silently ignoring them. +func IsFoundryNetworkHost(host string) bool { + return host == "azure.ai.agent" || host == "microsoft.foundry" +} 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 index de80ce720a2..e20ecb376fa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go @@ -6,18 +6,20 @@ package synthesis import ( "bytes" "encoding/json" + "errors" "os" "os/exec" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// schemaPath is the editor-tooling JSON schema for the Foundry service body, +// schemaPath is the editor-tooling JSON schema for the Foundry project service body, // resolved from this package directory. -const schemaPath = "../../schemas/microsoft.foundry.json" +const schemaPath = "../../../azure.ai.projects/schemas/azure.ai.project.json" // TestSchema_NetworkStructuralInvariants guards the network surface of the // hand-maintained JSON schema against drift from the synthesizer's contract: @@ -25,12 +27,16 @@ const schemaPath = "../../schemas/microsoft.foundry.json" // subnet requires an explicit vnet + name. func TestSchema_NetworkStructuralInvariants(t *testing.T) { raw, err := os.ReadFile(schemaPath) + if errors.Is(err, os.ErrNotExist) { + t.Skipf("project schema not found at %s; skipping cross-extension schema invariant test", schemaPath) + } require.NoError(t, err) var doc struct { Properties struct { Network struct { Required []string `json:"required"` + AllOf []json.RawMessage `json:"allOf"` Properties map[string]json.RawMessage `json:"properties"` } `json:"network"` } `json:"properties"` @@ -38,7 +44,7 @@ func TestSchema_NetworkStructuralInvariants(t *testing.T) { Subnet struct { Required []string `json:"required"` Properties map[string]json.RawMessage `json:"properties"` - } `json:"subnet"` + } `json:"Subnet"` } `json:"definitions"` } require.NoError(t, json.Unmarshal(raw, &doc), "schema must be valid JSON") @@ -50,6 +56,8 @@ func TestSchema_NetworkStructuralInvariants(t *testing.T) { assert.Contains(t, net.Properties, "isolationMode", "network must expose isolationMode") assert.Contains(t, net.Properties, "peSubnet", "network must expose peSubnet") + assertNetworkRejectsAgentSubnetWithIsolationMode(t, net.AllOf) + // 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") @@ -61,6 +69,25 @@ func TestSchema_NetworkStructuralInvariants(t *testing.T) { assert.Contains(t, sub.Properties, "prefix", "subnet must expose prefix (create vs reference)") } +func assertNetworkRejectsAgentSubnetWithIsolationMode(t *testing.T, allOf []json.RawMessage) { + t.Helper() + + for _, rule := range allOf { + var candidate struct { + Not struct { + Required []string `json:"required"` + } `json:"not"` + } + require.NoError(t, json.Unmarshal(rule, &candidate), "network allOf rule must be valid JSON") + if slices.Contains(candidate.Not.Required, "agentSubnet") && + slices.Contains(candidate.Not.Required, "isolationMode") { + return + } + } + + assert.Fail(t, "network schema must reject agentSubnet and isolationMode together") +} + // 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 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 387e8bb793b..d9b6876114f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -98,23 +98,29 @@ type DeploymentSku struct { Capacity int `yaml:"capacity" json:"capacity"` } -// dockerBlock is the subset of an agent's docker: object we read to -// decide whether a registry is needed. +// dockerBlock is the subset of a docker: object we read to decide whether a registry is needed. type dockerBlock struct { Path string `yaml:"path"` } -// agentBlock is the subset of an agent entry we inspect. +// agentBlock is the subset of a legacy inline agent entry we inspect. type agentBlock struct { Name string `yaml:"name"` Docker *dockerBlock `yaml:"docker,omitempty"` Image string `yaml:"image,omitempty"` } -// foundryService is the subset of a services. body the synthesizer -// reads. Unknown fields (connections, tools, agents[].tools, etc.) are -// intentionally ignored: they are reconciled in azd deploy, not provision. -type foundryService struct { +// serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. +type serviceBlock struct { + Host string `yaml:"host"` + Docker *dockerBlock `yaml:"docker,omitempty"` + Image string `yaml:"image,omitempty"` + Agents []agentBlock `yaml:"agents,omitempty"` +} + +// projectService is the subset of a host: azure.ai.project service body the synthesizer reads. +// Unknown fields are intentionally ignored: they are reconciled in deploy-time service targets. +type projectService struct { Host string `yaml:"host"` Endpoint string `yaml:"endpoint,omitempty"` Deployments []Deployment `yaml:"deployments,omitempty"` @@ -162,7 +168,7 @@ type projectFile struct { } // Synthesize derives the parameter values needed by main.bicep from one -// Foundry service in azure.yaml. +// Foundry project service in azure.yaml. func Synthesize(in Input) (*Result, error) { if len(in.RawAzureYAML) == 0 { return nil, errors.New("synthesis: RawAzureYAML is empty") @@ -181,7 +187,7 @@ func Synthesize(in Input) (*Result, error) { return nil, ErrServiceNotFound } - var svc foundryService + var svc projectService if err := node.Decode(&svc); err != nil { return nil, fmt.Errorf("decode service %q: %w", in.ServiceName, err) } @@ -193,13 +199,7 @@ func Synthesize(in Input) (*Result, error) { return nil, ErrEndpointBrownfield } - includeAcr := false - for _, a := range svc.Agents { - if a.Docker != nil { - includeAcr = true - break - } - } + includeAcr := deriveIncludeAcr(root.Services, svc) deployments := svc.Deployments if deployments == nil { @@ -225,6 +225,31 @@ func Synthesize(in Input) (*Result, error) { }, nil } +// deriveIncludeAcr reports whether provisioning should create an ACR. In the split +// azure.yaml shape, project provisioning reads the azure.ai.project service while +// Docker build settings live on sibling azure.ai.agent services. Until ACR gets a +// first-class project-level switch, any Docker-backed agent in the single-project +// file requires ACR. The legacy inline agents[] scan is kept for hand-authored +// transitional files and tests. +func deriveIncludeAcr(services map[string]yaml.Node, svc projectService) bool { + for _, a := range svc.Agents { + if a.Docker != nil && strings.TrimSpace(a.Image) == "" { + return true + } + } + + for _, node := range services { + var service serviceBlock + if err := node.Decode(&service); err != nil { + continue + } + if service.Host == "azure.ai.agent" && service.Docker != nil && strings.TrimSpace(service.Image) == "" { + return true + } + } + return false +} + // Network mode values surfaced for telemetry and emitted as bicep params. const ( NetworkModeNone = "none" 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 4fac8bec6bb..c11cc482797 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 @@ -30,7 +30,7 @@ func TestSynthesize(t *testing.T) { name: my-foundry-agent services: my-project: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4.1-mini model: @@ -54,12 +54,83 @@ services: wantDeployName0: "gpt-4.1-mini", }, { - name: "greenfield hosted agent runtime-only (no docker)", + name: "split project with sibling docker agent => ACR on", yaml: ` name: my-foundry-agent services: + my-agent: + host: azure.ai.agent + project: src/my-agent + uses: + - my-project + docker: + path: Dockerfile + remoteBuild: true my-project: + host: azure.ai.project + deployments: + - 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", + wantDeployLen: 1, + wantIncludeAcr: true, + wantDeployName0: "gpt-4.1-mini", + }, + { + name: "split project with sibling docker agent and image => no ACR", + yaml: ` +services: + my-agent: host: azure.ai.agent + project: src/my-agent + uses: + - my-project + image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + path: Dockerfile + remoteBuild: true + my-project: + host: azure.ai.project + deployments: + - 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", + wantDeployLen: 1, + wantIncludeAcr: false, + }, + { + name: "legacy inline docker agent with image => no ACR", + yaml: ` +services: + my-project: + host: azure.ai.project + agents: + - name: my-agent + kind: hosted + image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + path: Dockerfile +`, + serviceName: "my-project", + wantDeployLen: 0, + wantIncludeAcr: false, + }, + { + name: "greenfield hosted agent runtime-only (no docker)", + yaml: ` +name: my-foundry-agent +services: + my-project: + host: azure.ai.project deployments: - name: gpt-4.1-mini model: @@ -86,7 +157,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4.1-mini model: @@ -110,7 +181,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4.1 model: @@ -139,7 +210,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project agents: - name: prompt-agent kind: prompt @@ -154,7 +225,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4.1-mini model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} @@ -188,7 +259,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 deployments: - name: gpt-4.1-mini @@ -203,7 +274,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 network: peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} @@ -216,7 +287,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project endpoint: " " `, serviceName: "my-project", @@ -226,7 +297,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project `, serviceName: "nope", wantErr: ErrServiceNotFound, @@ -249,7 +320,7 @@ services: res, err := Synthesize(Input{ RawAzureYAML: []byte(tt.yaml), ServiceName: tt.serviceName, - AcceptedHosts: []string{"azure.ai.agent"}, + AcceptedHosts: []string{"azure.ai.project"}, }) if tt.wantErr != nil { @@ -283,7 +354,7 @@ func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { yaml := ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} dns: @@ -293,7 +364,7 @@ services: res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.agent"}, + AcceptedHosts: []string{"azure.ai.project"}, PreserveVarRefs: true, }) require.NoError(t, err, "unset ${VAR} must not fail on the eject path") @@ -309,14 +380,14 @@ func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) yaml := ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: not-an-arm-id, name: pe-subnet} ` _, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.agent"}, + AcceptedHosts: []string{"azure.ai.project"}, PreserveVarRefs: true, }) require.Error(t, err) @@ -336,7 +407,7 @@ func TestSynthesize_InputValidation(t *testing.T) { }, { name: "empty service name", - in: Input{RawAzureYAML: []byte("services:\n x:\n host: azure.ai.agent\n")}, + in: Input{RawAzureYAML: []byte("services:\n x:\n host: azure.ai.project\n")}, want: "ServiceName is empty", }, { @@ -527,7 +598,7 @@ func TestSynthesize_Network(t *testing.T) { yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project deployments: - name: gpt-4.1-mini model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} @@ -544,7 +615,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project 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} @@ -570,7 +641,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: agentSubnet: {vnet: ` + validVNet + `, name: existing-agent} peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} @@ -588,7 +659,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} `, @@ -602,7 +673,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: isolationMode: AllowOnlyApprovedOutbound peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} @@ -620,7 +691,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} dns: @@ -637,7 +708,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} `, @@ -653,7 +724,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: isolationMode: AllowInternetOutbound peSubnet: {vnet: ` + validVNet + `, name: existing-pe} @@ -673,7 +744,7 @@ services: res, err := Synthesize(Input{ RawAzureYAML: []byte(tt.yaml), ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.agent"}, + AcceptedHosts: []string{"azure.ai.project"}, }) require.NoError(t, err) require.NotNil(t, res) @@ -701,7 +772,7 @@ func TestSynthesize_NetworkValidationErrors(t *testing.T) { yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: isolationMode: AllowInternetOutbound `, @@ -712,7 +783,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: isolationMode: AllowInternetOutbound agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} @@ -725,7 +796,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} peSubnet: {vnet: ` + validVNet2 + `, name: pe, prefix: 192.168.1.0/24} @@ -737,7 +808,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: agentSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.0.0/24} peSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.1.0/24} @@ -749,7 +820,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {name: pe} `, @@ -760,7 +831,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: ` + validVNet + `} `, @@ -771,7 +842,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: not-an-arm-id, name: pe} `, @@ -782,7 +853,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: not-a-cidr} `, @@ -793,7 +864,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: peSubnet: {vnet: "${DEFINITELY_NOT_SET_VAR_XYZ}", name: pe} `, @@ -804,7 +875,7 @@ services: yaml: ` services: my-project: - host: azure.ai.agent + host: azure.ai.project network: isolationMode: Wide peSubnet: {vnet: ` + validVNet + `, name: pe} @@ -818,7 +889,7 @@ services: _, err := Synthesize(Input{ RawAzureYAML: []byte(tt.yaml), ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.agent"}, + AcceptedHosts: []string{"azure.ai.project"}, }) require.Error(t, err) assert.Contains(t, err.Error(), tt.wantSub) 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 dec15004123..185ccc18f3b 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 @@ -1,8 +1,8 @@ -// Provisioning template for a microsoft.foundry service. +// Provisioning template for a Foundry project service. // -// Inputs are derived from the host: microsoft.foundry service body in +// Inputs are derived from the host: azure.ai.project service body in // azure.yaml by internal/synthesis. Greenfield only (no endpoint:); a -// brownfield path is a future addition. +// brownfield path is handled by the provider before synthesis. // // Subscription-scoped so the resource group is part of the deployment. This // keeps `azd provision --preview` side-effect free: the resource group shows 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 5e86330f023..1f299dd05bb 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 @@ -1,17 +1,18 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -# Exercises the full microsoft.foundry surface: deployments, connections, -# toolboxes, skills, routines, hosted + prompt agents, inline tools, and -# external file references ($ref). Schema-validation fixture for the -# integration branch (see ../README.md). File-ref paths are illustrative. +# Exercises the split Foundry resource surface: project deployments + network, +# connection services, toolbox services, skill services, routine services, hosted +# + prompt agent services, inline tools, and external file references ($ref). +# Schema-validation fixture for the integration branch (see ../README.md). +# File-ref paths are illustrative. name: foundry-complex metadata: template: foundry-complex@0.0.1 services: - ai: - host: microsoft.foundry + ai-project: + host: azure.ai.project # Private networking: provision a VNet-bound (network-secured) account. # Omit this block for a public account. network: @@ -40,75 +41,130 @@ services: name: GlobalStandard capacity: 50 - $ref: ./deployments/embeddings.yaml - connections: - - name: search-conn - category: CognitiveSearch - target: https://my-search.search.windows.net - authType: ApiKey - credentials: - key: ${SEARCH_API_KEY} - - name: bing-conn - category: ApiKey - target: https://api.bing.microsoft.com - authType: ApiKey + + search-conn: + host: azure.ai.connection + uses: + - ai-project + category: CognitiveSearch + target: https://my-search.search.windows.net + authType: ApiKey + credentials: + key: ${SEARCH_API_KEY} + + bing-conn: + host: azure.ai.connection + uses: + - ai-project + category: ApiKey + target: https://api.bing.microsoft.com + authType: ApiKey + + research-tools: + host: azure.ai.toolbox + uses: + - ai-project + - search-conn + - bing-conn + description: Tools used by research agents. + tools: + - type: bing_grounding + connection: bing-conn + - type: azure_ai_search + connection: search-conn + - type: code_interpreter + + summarize: + host: azure.ai.skill + uses: + - research-tools + description: Summarize long documents. + instructions: ./skills/summarize.md + tools: + - code_interpreter + + translate: + host: azure.ai.skill + uses: + - research-tools + $ref: ./skills/translate.yaml + + researcher: + host: azure.ai.agent + project: ./agents/researcher + uses: + - ai-project + - search-conn + - bing-conn + - research-tools + kind: hosted + name: researcher + description: Hosted research agent built from source. + runtime: + stack: python + version: "3.12" + startupCommand: python main.py toolboxes: - - name: research-tools - description: Tools used by research agents. - tools: - - type: bing_grounding - connection: bing-conn - - type: azure_ai_search - connection: search-conn - - type: code_interpreter - skills: - - name: summarize - description: Summarize long documents. - instructions: ./skills/summarize.md - tools: - - code_interpreter - - $ref: ./skills/translate.yaml - agents: - - name: researcher - kind: hosted - description: Hosted research agent built from source. - project: ./agents/researcher - runtime: - stack: python - version: "3.12" - startupCommand: python main.py - toolboxes: - - research-tools - env: - LOG_LEVEL: info - MODEL_ENDPOINT: ${{project.endpoint}} - protocols: - - protocol: a2a - version: "0.2" - container: - resources: - cpu: "1.0" - memory: 2Gi - - name: writer - kind: prompt - description: Prompt agent backed by the summarize skill. - skill: summarize - toolboxes: - - research-tools - - $ref: ./agents/triage.yaml - routines: - - name: nightly-digest - description: Summarize the day's documents every night. - trigger: - type: schedule - cron: "0 2 * * *" - agent: researcher - input: - topic: ${DIGEST_TOPIC} - - name: on-upload - description: React to newly uploaded blobs. - trigger: - type: event - filter: - source: blob - eventType: Microsoft.Storage.BlobCreated - agent: writer + - research-tools + env: + LOG_LEVEL: info + MODEL_ENDPOINT: ${{project.endpoint}} + protocols: + - protocol: a2a + version: "0.2" + container: + resources: + cpu: "1.0" + memory: 2Gi + + writer: + host: azure.ai.agent + project: ./agents/writer + uses: + - ai-project + - research-tools + - summarize + kind: hosted + name: writer + description: Prompt agent backed by the summarize skill. + skill: summarize + toolboxes: + - research-tools + + triage: + host: azure.ai.agent + project: ./agents/triage + uses: + - ai-project + - research-tools + $ref: ./agents/triage.yaml + + nightly-digest: + host: azure.ai.routine + uses: + - researcher + description: Summarize the day's documents every night. + triggers: + default: + type: schedule + cron_expression: "0 2 * * *" + action: + type: invoke_agent_responses_api + agent_name: researcher + input: + topic: ${DIGEST_TOPIC} + + on-upload: + host: azure.ai.routine + uses: + - writer + description: React to newly uploaded blobs. + triggers: + default: + type: event + filter: + source: blob + eventType: Microsoft.Storage.BlobCreated + action: + type: invoke_agent_responses_api + agent_name: writer diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml index d834134b42e..104dd2ccf62 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml @@ -1,12 +1,12 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -# Minimal microsoft.foundry service: one model deployment and one prompt agent. -# Schema-validation fixture for the integration branch (see ../README.md). +# Minimal split Foundry project: one project service with a model deployment and +# one agent service that depends on it. name: foundry-simple services: - ai: - host: microsoft.foundry + ai-project: + host: azure.ai.project deployments: - name: gpt-4o-mini model: @@ -16,8 +16,12 @@ services: sku: name: GlobalStandard capacity: 10 - agents: - - name: assistant - kind: prompt - description: A simple prompt-based assistant. - instructions: You are a helpful assistant. Answer concisely. + + assistant: + host: azure.ai.agent + project: ./agents/assistant + uses: + - ai-project + kind: hosted + name: assistant + description: A simple assistant. 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 d4ba81234bc..362478cbcc7 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json", "title": "Microsoft Foundry project (services entry with host: microsoft.foundry)", "description": "Schema for a Foundry project as a service in azure.yaml. Composes per-resource sub-schemas via $ref, modeled on microsoft/AgentSchema's split-file pattern.", "type": "object", @@ -39,64 +39,6 @@ "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/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json index 93d5fa9e5ce..9d2ec486285 100644 --- a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json +++ b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json @@ -19,6 +19,49 @@ { "$ref": "#/definitions/FileRef" } ] } + }, + "network": { + "type": "object", + "description": "Private networking for the Foundry account that backs this project. When omitted, the account uses public networking. When present, azd provisions an account private endpoint and disables public data-plane access.", + "additionalProperties": false, + "required": ["peSubnet"], + "allOf": [ + { + "not": { + "required": ["agentSubnet", "isolationMode"] + } + } + ], + "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": { @@ -62,6 +105,26 @@ "description": "Path to a YAML or JSON file containing the definition. Relative paths resolve from the file containing this $ref. Absolute paths are also accepted; remote URLs are not supported." } } + }, + "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/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 631227c0864..13a53e5241b 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -430,7 +430,8 @@ "properties": { "config": false, "k8s": false, - "apiVersion": false + "apiVersion": false, + "network": false } } }, @@ -535,7 +536,7 @@ } }, { - "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", + "comment": "Legacy Microsoft Foundry host - compatibility for old non-network files; new provisioning uses azure.ai.project", "if": { "properties": { "host": { "const": "microsoft.foundry" } @@ -550,7 +551,8 @@ "runtime": false, "docker": false, "image": false, - "config": false + "config": false, + "network": false } } }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 5c9a5394ccf..928cdf3adcc 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -390,7 +390,8 @@ "properties": { "config": false, "k8s": false, - "apiVersion": false + "apiVersion": false, + "network": false } } }, @@ -495,7 +496,7 @@ } }, { - "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", + "comment": "Legacy Microsoft Foundry host - compatibility for old non-network files; new provisioning uses azure.ai.project", "if": { "properties": { "host": { "const": "microsoft.foundry" } @@ -510,7 +511,8 @@ "runtime": false, "docker": false, "image": false, - "config": false + "config": false, + "network": false } } },