Skip to content

Commit 702f8a3

Browse files
authored
feat: transfer Foundry provisioning to projects (#9133)
1 parent 0fcf14b commit 702f8a3

80 files changed

Lines changed: 8203 additions & 315 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-ext-azure-ai-agents.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
paths:
66
- "cli/azd/extensions/azure.ai.agents/**"
77
- "cli/azd/extensions/azure.ai.inspector/**"
8+
- "cli/azd/extensions/azure.ai.projects/**"
89
- "cli/azd/test/functional/ai_agents/**"
910
- ".github/workflows/test-ext-azure-ai-agents.yml"
1011
branches: [main]
@@ -121,6 +122,20 @@ jobs:
121122
azd x publish
122123
azd extension install azure.ai.inspector --source local --force --no-prompt
123124
125+
- name: Build & install projects dependency from source
126+
working-directory: cli/azd/extensions/azure.ai.projects
127+
run: |
128+
pwsh -File ci-build.ps1 -OutputFileName azure-ai-projects-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')"
129+
mkdir -p bin
130+
mv azure-ai-projects-linux-amd64 bin/
131+
azd x pack
132+
azd x publish
133+
azd extension install azure.ai.projects --source local --force --no-prompt
134+
135+
- name: Run projects unit tests
136+
working-directory: cli/azd/extensions/azure.ai.projects
137+
run: go test ./... -count=1
138+
124139
- name: Build extension (ci-build.ps1)
125140
working-directory: cli/azd/extensions/azure.ai.agents
126141
run: |
@@ -137,7 +152,7 @@ jobs:
137152
run: |
138153
azd x pack
139154
azd x publish
140-
azd extension install azure.ai.agents --source local --force --no-prompt
155+
azd extension install azure.ai.agents --source local --force --no-dependencies --no-prompt
141156
142157
- name: Run Tier 0 tests (offline, production extension)
143158
working-directory: cli/azd

cli/azd/extensions/azure.ai.agents/AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ Define new codes in `internal/exterrors/codes.go`.
124124

125125
A new extension release ships in two PRs:
126126

127+
### Provider handoff release
128+
129+
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.
130+
127131
### PR 1 — Version bump
128132

129133
Bumps the extension to the new version. Touches only:

cli/azd/extensions/azure.ai.agents/README.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ Details:
8080

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

83-
Foundry project services can be provisioned as network-secured, VNet-bound
84-
accounts by adding a `network:` block to the `host: azure.ai.project` service in
85-
`azure.yaml`. See [Private networking for `host: azure.ai.project`](docs/private-networking.md)
86-
for the schema reference, BYO-image requirements, and VNet deployment
87-
cheatsheet.
83+
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.
8884

8985
## Local Development
9086

cli/azd/extensions/azure.ai.agents/docs/private-networking.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ A Foundry project service can be provisioned as a **network-secured (VNet-bound)
44

55
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:`.
66

7+
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.
8+
79
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.
810

911
```yaml

cli/azd/extensions/azure.ai.agents/extension.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,19 @@ requiredAzdVersion: ">=1.27.1"
1010
dependencies:
1111
- id: azure.ai.inspector
1212
version: "~1.0.0-beta.1"
13+
- id: azure.ai.projects
14+
version: "~1.0.0-beta.3"
1315
language: go
1416
capabilities:
1517
- custom-commands
1618
- lifecycle-events
1719
- mcp-server
1820
- service-target-provider
19-
- provisioning-provider
20-
- validation-provider
2121
- metadata
2222
providers:
2323
- name: azure.ai.agent
2424
type: service-target
2525
description: Deploys agents to the Foundry Agent Service
26-
- name: microsoft.foundry
27-
type: provisioning-provider
28-
description: Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory
2926
examples:
3027
- name: init
3128
description: Initialize a new AI agent project.

cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,9 +614,27 @@ func writeTfvarsFile(infraDir string, params map[string]any) (ejectArtifact, err
614614
if v, ok := params["deployments"]; ok {
615615
doc["deployments"] = v
616616
}
617-
if v, ok := params["connections"]; ok {
618-
doc["connections"] = v
617+
connections, ok := params["connections"].([]synthesis.Connection)
618+
if !ok {
619+
return ejectArtifact{}, exterrors.Internal(
620+
exterrors.CodeInfraEjectWriteFailed,
621+
fmt.Sprintf("connections parameter has unexpected type %T", params["connections"]),
622+
)
623+
}
624+
connectionCredentials, ok := params["connectionCredentials"].(map[string]map[string]any)
625+
if !ok {
626+
return ejectArtifact{}, exterrors.Internal(
627+
exterrors.CodeInfraEjectWriteFailed,
628+
fmt.Sprintf(
629+
"connectionCredentials parameter has unexpected type %T",
630+
params["connectionCredentials"],
631+
),
632+
)
619633
}
634+
doc["connections"] = synthesis.JoinConnectionCredentials(
635+
connections,
636+
connectionCredentials,
637+
)
620638

621639
data, err := json.MarshalIndent(doc, "", " ")
622640
if err != nil {

cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,8 @@ services:
318318

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

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

382378
// Nested CustomKeys credentials must remain an object so Terraform's
383379
// optional(any) value can preserve mixed connection credential shapes.
384380
mcpConn, ok := conns[0].(map[string]any)
385381
require.True(t, ok, "connection entry should be an object, got %T", conns[0])
386382
assert.Equal(t, "mcp-conn", mcpConn["name"])
387-
mcpCreds := mcpConn["credentials"].(map[string]any)
383+
assert.NotContains(t, mcpConn, "credentials")
384+
385+
secureCreds, ok := doc.Parameters["connectionCredentials"].Value.(map[string]any)
386+
require.True(t, ok, "connectionCredentials should be an object")
387+
searchCreds := secureCreds["search-conn"].(map[string]any)
388+
assert.Equal(t, "${SEARCH_API_KEY}", searchCreds["key"])
389+
mcpCreds := secureCreds["mcp-conn"].(map[string]any)
388390
keys := mcpCreds["keys"].(map[string]any)
389391
assert.Equal(t, "${MCP_KEY}", keys["x-api-key"])
390392
}
@@ -742,6 +744,7 @@ func TestEjectInfra_Terraform_TfvarsShape(t *testing.T) {
742744
conns, ok := doc["connections"].([]any)
743745
require.True(t, ok, "connections should be an array, got %T", doc["connections"])
744746
assert.Empty(t, conns)
747+
assert.NotContains(t, doc, "connectionCredentials")
745748
}
746749

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

797801
// outputs.tf always carries the connection-names output, unconditional on
798802
// includeAcr (unlike the ACR outputs).

cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,6 @@ func configureExtensionHost(host *azdext.ExtensionHost) {
3636
WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider {
3737
return project.NewAgentServiceTargetProvider(azdClient)
3838
}).
39-
WithProvisioningProvider(project.FoundryProviderName, func() azdext.ProvisioningProvider {
40-
return project.NewFoundryProvisioningProvider(azdClient)
41-
}).
42-
WithValidationCheck(azdext.ValidationCheckRegistration{
43-
// Provider-agnostic check: runs before provisioning regardless of the
44-
// provisioning provider. This matters because the azure.ai.agents
45-
// extension provisions through its own microsoft.foundry provider,
46-
// which never triggers the Bicep-only "arm-provision" checks.
47-
CheckType: azdext.ValidationCheckTypeProvision,
48-
RuleID: project.ResourceGroupLocationRuleID,
49-
Factory: func() azdext.ValidationCheckProvider {
50-
return project.NewResourceGroupLocationCheck(azdClient)
51-
},
52-
}).
5339
WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error {
5440
return preprovisionHandler(ctx, azdClient, args)
5541
}).
@@ -68,8 +54,11 @@ func configureExtensionHost(host *azdext.ExtensionHost) {
6854
}
6955

7056
func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error {
71-
deployments, err := collectProjectDeployments(args.Project.Services)
72-
if err != nil {
57+
if err := updateLegacyProjectDeployments(
58+
ctx,
59+
azdClient,
60+
args.Project.Services,
61+
); err != nil {
7362
return err
7463
}
7564
connections, err := collectConnections(args.Project.Services)
@@ -83,7 +72,13 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args
8372
if err := populateContainerSettings(ctx, azdClient, svc); err != nil {
8473
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
8574
}
86-
if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil {
75+
if err := envUpdate(
76+
ctx,
77+
azdClient,
78+
args.Project,
79+
svc,
80+
connections,
81+
); err != nil {
8782
return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err)
8883
}
8984
}
@@ -168,6 +163,34 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e
168163
return resp.Environment.Name, nil
169164
}
170165

166+
func updateLegacyProjectDeployments(
167+
ctx context.Context,
168+
azdClient *azdext.AzdClient,
169+
services map[string]*azdext.ServiceConfig,
170+
) error {
171+
deployments, err := collectLegacyProjectDeployments(services)
172+
if err != nil {
173+
return err
174+
}
175+
if len(deployments) == 0 {
176+
return nil
177+
}
178+
179+
envName, err := currentEnvName(ctx, azdClient)
180+
if err != nil {
181+
return fmt.Errorf(
182+
"resolving environment for legacy deployments: %w",
183+
err,
184+
)
185+
}
186+
return deploymentEnvUpdate(
187+
ctx,
188+
deployments,
189+
azdClient,
190+
envName,
191+
)
192+
}
193+
171194
// developerRBACOnce ensures CheckDeveloperRBAC runs at most once per extension
172195
// process lifetime. Service-level predeploy handlers fire per-service, but the
173196
// RBAC pre-flight check is project-scoped and idempotent — running it once is
@@ -194,8 +217,11 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
194217
warnDuplicateAgentNames(args.Project)
195218
})
196219

197-
deployments, err := collectProjectDeployments(args.Project.Services)
198-
if err != nil {
220+
if err := updateLegacyProjectDeployments(
221+
ctx,
222+
azdClient,
223+
args.Project.Services,
224+
); err != nil {
199225
return err
200226
}
201227
connections, err := collectConnections(args.Project.Services)
@@ -206,7 +232,13 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
206232
if err := populateContainerSettings(ctx, azdClient, svc); err != nil {
207233
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
208234
}
209-
if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil {
235+
if err := envUpdate(
236+
ctx,
237+
azdClient,
238+
args.Project,
239+
svc,
240+
connections,
241+
); err != nil {
210242
return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err)
211243
}
212244

@@ -477,7 +509,6 @@ func envUpdate(
477509
azdClient *azdext.AzdClient,
478510
azdProject *azdext.ProjectConfig,
479511
svc *azdext.ServiceConfig,
480-
deployments []project.Deployment,
481512
connections []project.Connection,
482513
) error {
483514

@@ -495,15 +526,6 @@ func envUpdate(
495526
return err
496527
}
497528

498-
// Deployments and connections are sourced from the sibling
499-
// azure.ai.project and azure.ai.connection services. Resources and tool
500-
// connections stay on the agent service.
501-
if len(deployments) > 0 {
502-
if err := deploymentEnvUpdate(ctx, deployments, azdClient, currentEnvResponse.Environment.Name); err != nil {
503-
return err
504-
}
505-
}
506-
507529
if foundryAgentConfig != nil && len(foundryAgentConfig.Resources) > 0 {
508530
if err := resourcesEnvUpdate(ctx, foundryAgentConfig.Resources, azdClient, currentEnvResponse.Environment.Name); err != nil {
509531
return err

cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -346,36 +346,23 @@ func reserveServiceName(used map[string]string, name, source string) error {
346346
return nil
347347
}
348348

349-
// collectProjectDeployments gathers the model deployments declared across all
350-
// azure.ai.project services so provisioning handlers can source them from the
351-
// sibling project service instead of the agent service config. Services are
352-
// visited in sorted name order so serialized env-var output stays stable.
353-
//
354-
// Falls back to the deployments bundled on the agent service when no project
355-
// service carries any, so an azure.yaml written before the per-resource split
356-
// still provisions without re-running init.
357-
func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) {
358-
var out []project.Deployment
359-
for _, svc := range sortedServices(services) {
360-
props := project.ServiceConfigProps(svc)
361-
if svc.Host != AiProjectHost || props == nil {
362-
continue
363-
}
364-
var cfg *project.ServiceTargetAgentConfig
365-
if err := project.UnmarshalStruct(props, &cfg); err != nil {
366-
return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err)
367-
}
368-
if cfg != nil {
369-
out = append(out, cfg.Deployments...)
349+
// collectLegacyProjectDeployments reads only pre-split agent config.
350+
// A split project disables this compatibility path because projects
351+
// owns that service's runtime projection.
352+
func collectLegacyProjectDeployments(
353+
services map[string]*azdext.ServiceConfig,
354+
) ([]project.Deployment, error) {
355+
for _, svc := range services {
356+
if svc.GetHost() == AiProjectHost {
357+
return nil, nil
370358
}
371359
}
372-
if len(out) > 0 {
373-
return out, nil
374-
}
360+
375361
legacy, err := collectLegacyAgentConfigs(services)
376362
if err != nil {
377363
return nil, err
378364
}
365+
var out []project.Deployment
379366
for _, cfg := range legacy {
380367
out = append(out, cfg.Deployments...)
381368
}

0 commit comments

Comments
 (0)