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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
273 changes: 71 additions & 202 deletions cli/azd/extensions/azure.ai.agents/docs/private-networking.md

Large diffs are not rendered by default.

73 changes: 54 additions & 19 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
)
}

Expand Down Expand Up @@ -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"`
Expand All @@ -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",
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -123,7 +123,7 @@ func (p *FoundryProvisioningProvider) Initialize(
)
}

svcName, err := findFoundryService(rawYAML)
svcName, err := findFoundryProjectService(rawYAML)
if err != nil {
return err
}
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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"`
Expand All @@ -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",
)
}
}
Expand Down
Loading