Skip to content
Open
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
17 changes: 16 additions & 1 deletion .github/workflows/test-ext-azure-ai-agents.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- "cli/azd/extensions/azure.ai.agents/**"
- "cli/azd/extensions/azure.ai.inspector/**"
- "cli/azd/extensions/azure.ai.projects/**"
- "cli/azd/test/functional/ai_agents/**"
- ".github/workflows/test-ext-azure-ai-agents.yml"
branches: [main]
Expand Down Expand Up @@ -121,6 +122,20 @@ jobs:
azd x publish
azd extension install azure.ai.inspector --source local --force --no-prompt

- name: Build & install projects dependency from source
working-directory: cli/azd/extensions/azure.ai.projects
run: |
pwsh -File ci-build.ps1 -OutputFileName azure-ai-projects-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')"
Comment thread
huimiu marked this conversation as resolved.
mkdir -p bin
mv azure-ai-projects-linux-amd64 bin/
azd x pack
azd x publish
azd extension install azure.ai.projects --source local --force --no-prompt
Comment thread
huimiu marked this conversation as resolved.

- name: Run projects unit tests
working-directory: cli/azd/extensions/azure.ai.projects
run: go test ./... -count=1

- name: Build extension (ci-build.ps1)
working-directory: cli/azd/extensions/azure.ai.agents
run: |
Expand All @@ -137,7 +152,7 @@ jobs:
run: |
azd x pack
azd x publish
azd extension install azure.ai.agents --source local --force --no-prompt
azd extension install azure.ai.agents --source local --force --no-dependencies --no-prompt

- name: Run Tier 0 tests (offline, production extension)
working-directory: cli/azd
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/extensions/azure.ai.agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ Define new codes in `internal/exterrors/codes.go`.

A new extension release ships in two PRs:

### Provider handoff release

The release that removes `microsoft.foundry` from this extension must be coordinated with the provider-bearing `azure.ai.projects` release. Publish both artifacts before updating either registry entry, then update both entries and the `microsoft.foundry` meta-package together.

### PR 1 — Version bump

Bumps the extension to the new version. Touches only:
Expand Down
6 changes: 1 addition & 5 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,7 @@ Details:

## Private networking for `host: azure.ai.project`

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.
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`. The `azure.ai.projects` extension owns that service and the `microsoft.foundry` provider; this extension still authors the block during agent init. See [Private networking for `host: azure.ai.project`](docs/private-networking.md) for the schema reference, BYO-image requirements, and VNet deployment cheatsheet.

## Local Development

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ A Foundry project service can be provisioned as a **network-secured (VNet-bound)

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 `azure.ai.projects` extension owns the project service and the `microsoft.foundry` provider. `azd ai agent init` continues to author the block and eject its IaC during the staged ownership migration.

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
Expand Down
7 changes: 2 additions & 5 deletions cli/azd/extensions/azure.ai.agents/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,19 @@ requiredAzdVersion: ">=1.27.1"
dependencies:
- id: azure.ai.inspector
version: "~1.0.0-beta.1"
- id: azure.ai.projects
version: "~1.0.0-beta.3"
Comment thread
huimiu marked this conversation as resolved.
Comment thread
huimiu marked this conversation as resolved.
Comment thread
huimiu marked this conversation as resolved.
language: go
capabilities:
- custom-commands
- lifecycle-events
- mcp-server
- service-target-provider
- provisioning-provider
- validation-provider
- metadata
providers:
- name: azure.ai.agent
type: service-target
description: Deploys agents to the Foundry Agent Service
- name: microsoft.foundry
type: provisioning-provider
description: Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory
examples:
- name: init
description: Initialize a new AI agent project.
Expand Down
22 changes: 20 additions & 2 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,9 +614,27 @@ func writeTfvarsFile(infraDir string, params map[string]any) (ejectArtifact, err
if v, ok := params["deployments"]; ok {
doc["deployments"] = v
}
if v, ok := params["connections"]; ok {
doc["connections"] = v
connections, ok := params["connections"].([]synthesis.Connection)
if !ok {
return ejectArtifact{}, exterrors.Internal(
exterrors.CodeInfraEjectWriteFailed,
fmt.Sprintf("connections parameter has unexpected type %T", params["connections"]),
)
}
connectionCredentials, ok := params["connectionCredentials"].(map[string]map[string]any)
if !ok {
return ejectArtifact{}, exterrors.Internal(
exterrors.CodeInfraEjectWriteFailed,
fmt.Sprintf(
"connectionCredentials parameter has unexpected type %T",
params["connectionCredentials"],
),
)
}
doc["connections"] = synthesis.JoinConnectionCredentials(
connections,
connectionCredentials,
)

data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,8 @@ services:

func TestEjectInfra_EjectsConnectionServices(t *testing.T) {
// See TestEjectInfra_HappyPath_WritesExpectedFiles for why this is not parallel.
// A host: azure.ai.connection service must be synthesized into the
// connections param, its module copied into the ejected tree, and any
// ${VAR} in credentials kept verbatim (environment-portable).
// Connection metadata and credentials are ejected separately.
// Bicep keeps credential values in a secure object parameter.
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project
services:
Expand Down Expand Up @@ -374,17 +373,20 @@ services:
assert.Equal(t, "CognitiveSearch", conn["category"])
assert.Equal(t, "ApiKey", conn["authType"])

// ${VAR} in credentials must be preserved verbatim on the eject path.
creds, ok := conn["credentials"].(map[string]any)
require.True(t, ok, "credentials should be an object, got %T", conn["credentials"])
assert.Equal(t, "${SEARCH_API_KEY}", creds["key"])
assert.NotContains(t, conn, "credentials")

// Nested CustomKeys credentials must remain an object so Terraform's
// optional(any) value can preserve mixed connection credential shapes.
mcpConn, ok := conns[0].(map[string]any)
require.True(t, ok, "connection entry should be an object, got %T", conns[0])
assert.Equal(t, "mcp-conn", mcpConn["name"])
mcpCreds := mcpConn["credentials"].(map[string]any)
assert.NotContains(t, mcpConn, "credentials")

secureCreds, ok := doc.Parameters["connectionCredentials"].Value.(map[string]any)
require.True(t, ok, "connectionCredentials should be an object")
searchCreds := secureCreds["search-conn"].(map[string]any)
assert.Equal(t, "${SEARCH_API_KEY}", searchCreds["key"])
mcpCreds := secureCreds["mcp-conn"].(map[string]any)
keys := mcpCreds["keys"].(map[string]any)
assert.Equal(t, "${MCP_KEY}", keys["x-api-key"])
}
Expand Down Expand Up @@ -742,6 +744,7 @@ func TestEjectInfra_Terraform_TfvarsShape(t *testing.T) {
conns, ok := doc["connections"].([]any)
require.True(t, ok, "connections should be an array, got %T", doc["connections"])
assert.Empty(t, conns)
assert.NotContains(t, doc, "connectionCredentials")
}

func TestEjectInfra_Terraform_EjectsConnectionServices(t *testing.T) {
Expand Down Expand Up @@ -793,6 +796,7 @@ services:
creds, ok := conn["credentials"].(map[string]any)
require.True(t, ok, "credentials should be an object, got %T", conn["credentials"])
assert.Equal(t, "${SEARCH_API_KEY}", creds["key"])
assert.NotContains(t, doc, "connectionCredentials")

// outputs.tf always carries the connection-names output, unconditional on
// includeAcr (unlike the ACR outputs).
Expand Down
82 changes: 52 additions & 30 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,6 @@ func configureExtensionHost(host *azdext.ExtensionHost) {
WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider {
return project.NewAgentServiceTargetProvider(azdClient)
}).
WithProvisioningProvider(project.FoundryProviderName, func() azdext.ProvisioningProvider {
return project.NewFoundryProvisioningProvider(azdClient)
}).
WithValidationCheck(azdext.ValidationCheckRegistration{
// Provider-agnostic check: runs before provisioning regardless of the
// provisioning provider. This matters because the azure.ai.agents
// extension provisions through its own microsoft.foundry provider,
// which never triggers the Bicep-only "arm-provision" checks.
CheckType: azdext.ValidationCheckTypeProvision,
RuleID: project.ResourceGroupLocationRuleID,
Factory: func() azdext.ValidationCheckProvider {
return project.NewResourceGroupLocationCheck(azdClient)
},
}).
WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error {
return preprovisionHandler(ctx, azdClient, args)
}).
Expand All @@ -68,8 +54,11 @@ func configureExtensionHost(host *azdext.ExtensionHost) {
}

func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error {
deployments, err := collectProjectDeployments(args.Project.Services)
if err != nil {
if err := updateLegacyProjectDeployments(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are "legacy projects"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By “legacy projects,” I mean existing azure.yaml projects using the older azure.ai.agent or microsoft.foundry service shapes instead of azure.ai.project. We’re keeping this path for backward compatibility while provisioning moves to azure.ai.projects, and we recommend using azure.ai.project for new projects.

ctx,
azdClient,
args.Project.Services,
); err != nil {
return err
}
connections, err := collectConnections(args.Project.Services)
Expand All @@ -83,7 +72,13 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args
if err := populateContainerSettings(ctx, azdClient, svc); err != nil {
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
}
if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil {
if err := envUpdate(
ctx,
azdClient,
args.Project,
svc,
connections,
); err != nil {
return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err)
}
}
Expand Down Expand Up @@ -168,6 +163,34 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e
return resp.Environment.Name, nil
}

func updateLegacyProjectDeployments(
ctx context.Context,
azdClient *azdext.AzdClient,
services map[string]*azdext.ServiceConfig,
) error {
deployments, err := collectLegacyProjectDeployments(services)
if err != nil {
return err
}
if len(deployments) == 0 {
return nil
}

envName, err := currentEnvName(ctx, azdClient)
if err != nil {
return fmt.Errorf(
"resolving environment for legacy deployments: %w",
err,
)
}
return deploymentEnvUpdate(
ctx,
deployments,
azdClient,
envName,
)
}

// developerRBACOnce ensures CheckDeveloperRBAC runs at most once per extension
// process lifetime. Service-level predeploy handlers fire per-service, but the
// RBAC pre-flight check is project-scoped and idempotent — running it once is
Expand All @@ -194,8 +217,11 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
warnDuplicateAgentNames(args.Project)
})

deployments, err := collectProjectDeployments(args.Project.Services)
if err != nil {
if err := updateLegacyProjectDeployments(
ctx,
azdClient,
args.Project.Services,
); err != nil {
return err
}
connections, err := collectConnections(args.Project.Services)
Expand All @@ -206,7 +232,13 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
if err := populateContainerSettings(ctx, azdClient, svc); err != nil {
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
}
if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil {
if err := envUpdate(
ctx,
azdClient,
args.Project,
svc,
connections,
); err != nil {
return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err)
}

Expand Down Expand Up @@ -477,7 +509,6 @@ func envUpdate(
azdClient *azdext.AzdClient,
azdProject *azdext.ProjectConfig,
svc *azdext.ServiceConfig,
deployments []project.Deployment,
connections []project.Connection,
) error {

Expand All @@ -495,15 +526,6 @@ func envUpdate(
return err
}

// Deployments and connections are sourced from the sibling
// azure.ai.project and azure.ai.connection services. Resources and tool
// connections stay on the agent service.
if len(deployments) > 0 {
if err := deploymentEnvUpdate(ctx, deployments, azdClient, currentEnvResponse.Environment.Name); err != nil {
return err
}
}

if foundryAgentConfig != nil && len(foundryAgentConfig.Resources) > 0 {
if err := resourcesEnvUpdate(ctx, foundryAgentConfig.Resources, azdClient, currentEnvResponse.Environment.Name); err != nil {
return err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,36 +346,23 @@ func reserveServiceName(used map[string]string, name, source string) error {
return nil
}

// collectProjectDeployments gathers the model deployments declared across all
// azure.ai.project services so provisioning handlers can source them from the
// sibling project service instead of the agent service config. Services are
// visited in sorted name order so serialized env-var output stays stable.
//
// Falls back to the deployments bundled on the agent service when no project
// service carries any, so an azure.yaml written before the per-resource split
// still provisions without re-running init.
func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) {
var out []project.Deployment
for _, svc := range sortedServices(services) {
props := project.ServiceConfigProps(svc)
if svc.Host != AiProjectHost || props == nil {
continue
}
var cfg *project.ServiceTargetAgentConfig
if err := project.UnmarshalStruct(props, &cfg); err != nil {
return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err)
}
if cfg != nil {
out = append(out, cfg.Deployments...)
// collectLegacyProjectDeployments reads only pre-split agent config.
// A split project disables this compatibility path because projects
// owns that service's runtime projection.
func collectLegacyProjectDeployments(
services map[string]*azdext.ServiceConfig,
) ([]project.Deployment, error) {
for _, svc := range services {
if svc.GetHost() == AiProjectHost {
return nil, nil
}
}
if len(out) > 0 {
return out, nil
}

legacy, err := collectLegacyAgentConfigs(services)
if err != nil {
return nil, err
}
var out []project.Deployment
for _, cfg := range legacy {
out = append(out, cfg.Deployments...)
}
Expand Down
Loading
Loading