From 117ccfafab2fc98379a748835f49862860438a70 Mon Sep 17 00:00:00 2001 From: hund030 Date: Tue, 28 Jul 2026 16:51:25 +0800 Subject: [PATCH 1/3] refactor(foundry): unify brownfield synthesis --- .../internal/cmd/init_infra.go | 35 ++- .../internal/cmd/init_infra_test.go | 23 ++ .../internal/synthesis/synthesizer.go | 167 +++++------ .../internal/synthesis/synthesizer_test.go | 178 ++++++----- .../internal/synthesis/templates_embed.go | 5 +- .../foundry_provisioning_provider.go | 276 ++++-------------- ...ovisioning_provider_brownfield_acr_test.go | 66 +++-- .../foundry_provisioning_provider_test.go | 123 +------- .../resource_group_location_check.go | 7 +- .../internal/synthesis/synthesizer.go | 167 +++++------ .../internal/synthesis/synthesizer_test.go | 155 ++++++---- .../internal/synthesis/templates_embed.go | 5 +- 12 files changed, 481 insertions(+), 726 deletions(-) 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 1fd07aeafe0..ae8b68d81c4 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 @@ -139,6 +139,23 @@ func ejectInfra(projectRoot, provider string) error { if err != nil { return err } + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("resolve existing Foundry project endpoint: %s", err), + "fix the project service configuration in azure.yaml", + ) + } + if endpoint != "" { + return exterrors.Validation( + exterrors.CodeInfraEjectBrownfieldUnsupported, + "`azd ai agent init --infra` is not supported for a project that reuses an existing "+ + "Foundry resource (the azure.ai.project service sets endpoint:)", + "remove --infra: the extension provisions the existing project (and any required "+ + "container registry) directly with `azd provision`", + ) + } infraDir := filepath.Join(projectRoot, "infra") if _, err := os.Stat(infraDir); err == nil { @@ -162,19 +179,6 @@ func ejectInfra(projectRoot, provider string) error { PreserveVarRefs: true, }) if err != nil { - // A brownfield (endpoint:) project provisions through the extension's - // brownfield path, which never compiles ./infra/. Ejecting IaC for it - // would be misleading, so refuse with a clear message instead of the - // raw synthesizer error. - if errors.Is(err, synthesis.ErrEndpointBrownfield) { - return exterrors.Validation( - exterrors.CodeInfraEjectBrownfieldUnsupported, - "`azd ai agent init --infra` is not supported for a project that reuses an existing "+ - "Foundry resource (the azure.ai.project service sets endpoint:)", - "remove --infra: the extension provisions the existing project (and any required "+ - "container registry) directly with `azd provision`", - ) - } // Reuse the provider's vocabulary so eject and provision report // consistent codes for the same azure.yaml problems. return exterrors.Validation( @@ -183,7 +187,6 @@ func ejectInfra(projectRoot, provider string) error { "check the endpoint, deployments, and network fields under your azure.ai.project service", ) } - if provider == project.TerraformProviderName { // Private networking is Bicep-only today: the Terraform module has no // VNet / private-endpoint / DNS / networkInjections resources, so ejecting @@ -206,9 +209,9 @@ func ejectInfra(projectRoot, provider string) error { // retryable without manual cleanup. var ejectErr error if provider == project.TerraformProviderName { - ejectErr = ejectTerraform(projectRoot, infraDir, res.Parameters) + ejectErr = ejectTerraform(projectRoot, infraDir, res.Parameters()) } else { - ejectErr = ejectBicep(infraDir, res.Parameters) + ejectErr = ejectBicep(infraDir, res.Parameters()) } if ejectErr != nil { _ = os.RemoveAll(infraDir) 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 7ed897af679..d2dd70820eb 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 @@ -169,6 +169,29 @@ services: assert.Contains(t, localErr.Message, "endpoint:") } +func TestEjectInfra_BrownfieldRefusalPrecedesSiblingRefValidation(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + $ref: ./missing-agent.yaml + connection: + host: azure.ai.connection + $ref: ./missing-connection.yaml +`) + + err := ejectInfra(dir, "bicep") + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, exterrors.CodeInfraEjectBrownfieldUnsupported, localErr.Code) +} + func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { // Intentionally NOT parallel: this test captures os.Stdout, and running // it concurrently with other stdout-capturing tests in the same package 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 8c4e7d7baea..0648beea834 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -8,8 +8,9 @@ // for the bicep compiler // - a Parameters map of the values the template's params consume // -// Greenfield only: if the service has an endpoint: field, ErrEndpointBrownfield -// is returned so callers can short-circuit the provision path. +// Both greenfield and brownfield services produce a Result. Brownfield services +// reference an existing project through endpoint: while still synthesizing the +// deployments and connections that azd manages on that project. package synthesis import ( @@ -26,16 +27,17 @@ import ( "go.yaml.in/yaml/v3" ) -// Sentinel errors returned by Synthesize. -var ( - // ErrEndpointBrownfield indicates the service points at an existing - // Foundry project via endpoint:. The provider should skip ARM - // provisioning and connect to the endpoint directly. - ErrEndpointBrownfield = errors.New("synthesis: service has endpoint: (brownfield)") +// ErrServiceNotFound indicates the requested service does not exist in +// azure.yaml or its host: value is not in AcceptedHosts. +var ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") - // ErrServiceNotFound indicates the requested service does not exist - // in azure.yaml or its host: value is not in AcceptedHosts. - ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") +// Mode identifies whether synthesis creates a Foundry project or reconciles an +// existing project. +type Mode string + +const ( + ModeGreenfield Mode = "greenfield" + ModeBrownfield Mode = "brownfield" ) // Input is the synthesizer's view of azure.yaml. @@ -72,20 +74,41 @@ type Input struct { ProjectRoot string } -// Result bundles the bicep sources and the parameter values derived -// from the service body. Callers stage Templates on disk, compile -// main.bicep, and pass Parameters when invoking the resulting ARM -// deployment. +// Result contains the typed resources and settings derived from one Foundry +// project service. type Result struct { - // Parameters maps bicep param names to plain Go values. Callers wrap - // these in ARM's {"value": ...} envelope when serializing. - Parameters map[string]any + // Mode selects the greenfield or brownfield provisioning template. + Mode Mode + + // Endpoint is the existing Foundry project endpoint in brownfield mode. + // It is empty in greenfield mode. + Endpoint string + + Deployments []Deployment + Connections []Connection + ConnectionCredentials map[string]map[string]any + RequiresAcr bool + NetworkConfigured bool + networkParameters map[string]any // NetworkMode is "none", "byo", or "managed" — derived from the // network: block (or its absence). Exposed for telemetry. NetworkMode string } +// Parameters returns the Bicep parameter contract derived from the typed +// synthesis result. The external parameter remains includeAcr for compatibility. +func (r *Result) Parameters() map[string]any { + params := map[string]any{ + "deployments": r.Deployments, + "includeAcr": r.RequiresAcr, + "connections": r.Connections, + "connectionCredentials": r.ConnectionCredentials, + } + maps.Copy(params, r.networkParameters) + return params +} + // Deployment mirrors the deploymentType in main.bicep. type Deployment struct { Name string `yaml:"name" json:"name"` @@ -270,18 +293,6 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { - return nil, ErrEndpointBrownfield - } - - includeAcr, err := deriveIncludeAcr( - root.Services, - svc, - in.ProjectRoot, - ) - if err != nil { - return nil, err - } deployments := svc.Deployments if deployments == nil { @@ -298,84 +309,46 @@ func Synthesize(in Input) (*Result, error) { return nil, err } connections, connectionCredentials := SplitConnectionCredentials(connections) + + endpoint := strings.TrimSpace(svc.Endpoint) + if endpoint != "" { + return &Result{ + Mode: ModeBrownfield, + Endpoint: endpoint, + Deployments: deployments, + Connections: connections, + ConnectionCredentials: connectionCredentials, + NetworkConfigured: svc.Network != nil, + }, nil + } + + requiresAcr, err := deriveRequiresAcr(root.Services, svc, in.ProjectRoot) + if err != nil { + return nil, err + } + netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err } - if includeAcr && netMode != NetworkModeNone { + if requiresAcr && netMode != NetworkModeNone { return nil, errors.New( "synthesis: private networking does not support an auto-created Azure Container Registry; specify an image instead", ) } - params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - "connections": connections, - "connectionCredentials": connectionCredentials, - } - maps.Copy(params, netParams) - return &Result{ - Parameters: params, - NetworkMode: netMode, + Mode: ModeGreenfield, + Deployments: deployments, + Connections: connections, + ConnectionCredentials: connectionCredentials, + RequiresAcr: requiresAcr, + NetworkConfigured: svc.Network != nil, + networkParameters: netParams, + NetworkMode: netMode, }, nil } -// BrownfieldDeployments returns the model deployments declared on a brownfield -// (endpoint:) Foundry project service. Synthesize short-circuits with -// ErrEndpointBrownfield before reading deployments:, so the provider uses this -// to learn which model deployments to create on the existing account. Returns -// nil (not an error) when the service declares no deployments. -func BrownfieldDeployments( - raw []byte, - serviceName string, - projectRoot string, -) ([]Deployment, error) { - if len(raw) == 0 { - return nil, errors.New("synthesis: raw azure.yaml is empty") - } - if serviceName == "" { - return nil, errors.New("synthesis: serviceName is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - svc, err := loadProjectService(root.Services, serviceName, projectRoot) - if err != nil { - return nil, err - } - - return svc.Deployments, nil -} - -// BrownfieldConnections returns the host: azure.ai.connection services declared -// in azure.yaml, for a brownfield (endpoint:) project. Synthesize short-circuits -// with ErrEndpointBrownfield before collecting connections, so the provider uses -// this to create the same connections on the existing account. ${VAR} is -// resolved from env since brownfield provisions immediately; Foundry ${{...}} -// expressions pass through. Returns an empty slice (not an error) when none -// are declared. -func BrownfieldConnections( - raw []byte, - env map[string]string, - projectRoot string, -) ([]Connection, error) { - if len(raw) == 0 { - return nil, errors.New("synthesis: raw azure.yaml is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - return collectConnections(root.Services, env, true, projectRoot) -} - // ProjectEndpoint returns the endpoint configured on a Foundry project service. // It resolves $ref includes before decoding the service body. func ProjectEndpoint( @@ -542,7 +515,7 @@ func validateAgentRootRefCoreFields( return nil } -// deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is +// deriveRequiresAcr reports whether the project needs an ACR. An ACR is // needed when any agent is a hosted, build-from-source agent: azd builds its // image and pushes it to the registry. Agents live on sibling azure.ai.agent // services in the split shape, or under agents[] in the legacy inline shape; @@ -555,7 +528,7 @@ func validateAgentRootRefCoreFields( // Keying on image/codeConfiguration rather than the optional docker: block is // deliberate: docker: is not in the agent schema and is dropped by omitempty // when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr( +func deriveRequiresAcr( services map[string]yaml.Node, svc projectService, projectRoot string, 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 d505c04da2e..22f78a325cf 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 @@ -21,6 +21,8 @@ func TestSynthesize(t *testing.T) { serviceName string wantErr error + wantMode Mode + wantEndpoint string wantDeployLen int wantIncludeAcr bool // wantDeployName0, if non-empty, asserts the name of the first deployment. @@ -443,7 +445,7 @@ services: wantConnectionNames: []string{}, }, { - name: "brownfield: endpoint set => ErrEndpointBrownfield", + name: "brownfield: endpoint returns managed resources", yaml: ` services: my-project: @@ -454,11 +456,13 @@ services: model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} sku: {capacity: 10, name: GlobalStandard} `, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, + serviceName: "my-project", + wantMode: ModeBrownfield, + wantEndpoint: "https://existing.services.ai.azure.com/api/projects/p1", + wantDeployLen: 1, }, { - name: "brownfield: endpoint + network => network ignored, still ErrEndpointBrownfield", + name: "brownfield: endpoint ignores network", yaml: ` services: my-project: @@ -467,8 +471,9 @@ services: network: peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} `, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, + serviceName: "my-project", + wantMode: ModeBrownfield, + wantEndpoint: "https://existing.services.ai.azure.com/api/projects/p1", }, { name: "blank endpoint is treated as greenfield", @@ -519,18 +524,20 @@ services: require.NoError(t, err) require.NotNil(t, res) + if tt.wantMode != "" { + assert.Equal(t, tt.wantMode, res.Mode) + } else { + assert.Equal(t, ModeGreenfield, res.Mode) + } + assert.Equal(t, tt.wantEndpoint, res.Endpoint) - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - assert.Len(t, deployments, tt.wantDeployLen) + assert.Len(t, res.Deployments, tt.wantDeployLen) if tt.wantDeployName0 != "" { - require.NotEmpty(t, deployments) - assert.Equal(t, tt.wantDeployName0, deployments[0].Name) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantDeployName0, res.Deployments[0].Name) } - includeAcr, ok := res.Parameters["includeAcr"].(bool) - require.True(t, ok, "includeAcr param should be bool") - assert.Equal(t, tt.wantIncludeAcr, includeAcr) + assert.Equal(t, tt.wantIncludeAcr, res.RequiresAcr) connections := resultConnections(t, res) if tt.wantConnectionNames != nil { @@ -592,10 +599,8 @@ services: assert.Equal(t, "secret-value", keys["x-api-key"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) - publicConnections := res.Parameters["connections"].([]Connection) - assert.Nil(t, publicConnections[0].Credentials) - secureCredentials := res.Parameters["connectionCredentials"].(map[string]map[string]any) - assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) + assert.Nil(t, res.Connections[0].Credentials) + assert.Equal(t, "secret-value", res.ConnectionCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) }) t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { @@ -707,11 +712,7 @@ credentials: func resultConnections(t *testing.T, result *Result) []Connection { t.Helper() - connections, ok := result.Parameters["connections"].([]Connection) - require.True(t, ok, "connections param should be []Connection") - credentials, ok := result.Parameters["connectionCredentials"].(map[string]map[string]any) - require.True(t, ok, "connectionCredentials param should be a credential map") - return JoinConnectionCredentials(connections, credentials) + return JoinConnectionCredentials(result.Connections, result.ConnectionCredentials) } // TestBrownfieldConnections verifies connection services are collected for a @@ -740,17 +741,15 @@ services: ` t.Run("collects and resolves connections (sorted)", func(t *testing.T) { - conns, err := BrownfieldConnections( - []byte(yaml), - map[string]string{"SEARCH_API_KEY": "secret"}, - "", - ) + res, err := Synthesize(Input{RawAzureYAML: []byte(yaml), ServiceName: "my-project", Env: map[string]string{ + "SEARCH_API_KEY": "secret", + }}) require.NoError(t, err) - require.Len(t, conns, 2) - assert.Equal(t, "bing-conn", conns[0].Name) - assert.Equal(t, "search-conn", conns[1].Name) - assert.Equal(t, "CognitiveSearch", conns[1].Category) - assert.Equal(t, "secret", conns[1].Credentials["key"]) + require.Len(t, res.Connections, 2) + assert.Equal(t, "bing-conn", res.Connections[0].Name) + assert.Equal(t, "search-conn", res.Connections[1].Name) + assert.Equal(t, "CognitiveSearch", res.Connections[1].Category) + assert.Equal(t, "secret", res.ConnectionCredentials["search-conn"]["key"]) }) t.Run("no connection services yields empty slice", func(t *testing.T) { @@ -760,13 +759,13 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil, "") + res, err := Synthesize(Input{RawAzureYAML: []byte(noConns), ServiceName: "my-project"}) require.NoError(t, err) - assert.Empty(t, conns) + assert.Empty(t, res.Connections) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil, "") + _, err := Synthesize(Input{ServiceName: "my-project"}) require.Error(t, err) }) @@ -793,19 +792,51 @@ credentials: $ref: ./connection.yaml `) - connections, err := BrownfieldConnections( - raw, - map[string]string{"SEARCH_KEY": "secret"}, - root, - ) + res, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", Env: map[string]string{ + "SEARCH_KEY": "secret", + }, ProjectRoot: root}) require.NoError(t, err) - require.Len(t, connections, 1) - assert.Equal(t, "CognitiveSearch", connections[0].Category) - assert.Equal(t, "secret", connections[0].Credentials["key"]) + require.Len(t, res.Connections, 1) + assert.Equal(t, "CognitiveSearch", res.Connections[0].Category) + assert.Equal(t, "secret", res.ConnectionCredentials["search"]["key"]) }) } +func TestSynthesize_BrownfieldSkipsAgentReferences(t *testing.T) { + t.Parallel() + raw := []byte(`services: + project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + $ref: ./missing-agent.yaml +`) + + res, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: t.TempDir()}) + + require.NoError(t, err) + assert.Equal(t, ModeBrownfield, res.Mode) + assert.False(t, res.RequiresAcr) +} + +func TestSynthesize_BrownfieldValidatesManagedConnectionReferences(t *testing.T) { + t.Parallel() + raw := []byte(`services: + project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + connection: + host: azure.ai.connection + $ref: ./missing-connection.yaml +`) + + _, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: t.TempDir()}) + + require.ErrorContains(t, err, "missing-connection.yaml") +} + func TestBrownfieldDeployments(t *testing.T) { tests := []struct { name string @@ -885,11 +916,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BrownfieldDeployments( - []byte(tt.yaml), - tt.serviceName, - "", - ) + res, err := Synthesize(Input{RawAzureYAML: []byte(tt.yaml), ServiceName: tt.serviceName}) if tt.serviceName == "" { require.Error(t, err) @@ -902,21 +929,21 @@ services: } require.NoError(t, err) - assert.Len(t, got, tt.wantLen) + assert.Len(t, res.Deployments, tt.wantLen) if tt.wantName0 != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantName0, got[0].Name) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantName0, res.Deployments[0].Name) } if tt.wantVersion != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantVersion, got[0].Model.Version) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantVersion, res.Deployments[0].Model.Version) } }) } } func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { - _, err := BrownfieldDeployments(nil, "my-project", "") + _, err := Synthesize(Input{ServiceName: "my-project"}) require.Error(t, err) } @@ -948,15 +975,11 @@ func TestBrownfieldDeployments_ResolvesFileRef( - $ref: ./deployment.yaml `) - deployments, err := BrownfieldDeployments( - raw, - "my-project", - root, - ) + res, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "my-project", ProjectRoot: root}) require.NoError(t, err) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) + require.Len(t, res.Deployments, 1) + assert.Equal(t, "gpt-4o", res.Deployments[0].Name) } func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { @@ -981,9 +1004,10 @@ services: }) require.NoError(t, err, "unset ${VAR} must not fail on the eject path") require.NotNil(t, res) - assert.Equal(t, "${AZURE_VNET_ID}", res.Parameters["vnetId"]) - assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", res.Parameters["dnsZonesSubscription"]) - assert.Equal(t, "rg-dns", res.Parameters["dnsZonesResourceGroup"]) + params := res.Parameters() + assert.Equal(t, "${AZURE_VNET_ID}", params["vnetId"]) + assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", params["dnsZonesSubscription"]) + assert.Equal(t, "rg-dns", params["dnsZonesResourceGroup"]) } func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) { @@ -1029,12 +1053,10 @@ services: ProjectRoot: root, }) require.NoError(t, err) - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) - assert.Equal(t, "gpt-4o", deployments[0].Model.Name) - assert.Equal(t, 10, deployments[0].Sku.Capacity) + require.Len(t, res.Deployments, 1) + assert.Equal(t, "gpt-4o", res.Deployments[0].Name) + assert.Equal(t, "gpt-4o", res.Deployments[0].Model.Name) + assert.Equal(t, 10, res.Deployments[0].Sku.Capacity) } func TestSynthesize_RequiresAgentImageInline(t *testing.T) { @@ -1066,9 +1088,7 @@ func TestSynthesize_RequiresAgentImageInline(t *testing.T) { }) require.NoError(t, err) - includeAcr, ok := result.Parameters["includeAcr"].(bool) - require.True(t, ok) - assert.False(t, includeAcr) + assert.False(t, result.RequiresAcr) } func TestSynthesize_RejectsCoreFieldsFromAgentRef(t *testing.T) { @@ -1130,9 +1150,7 @@ func TestSynthesize_ResolvesAgentHostFromRefForAcr(t *testing.T) { }) require.NoError(t, err) - includeAcr, ok := result.Parameters["includeAcr"].(bool) - require.True(t, ok) - assert.True(t, includeAcr) + assert.True(t, result.RequiresAcr) } func TestSynthesize_SkipsRefsOnUnrelatedServices(t *testing.T) { @@ -1156,8 +1174,8 @@ func TestSynthesize_SkipsRefsOnUnrelatedServices(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, false, result.Parameters["includeAcr"]) - assert.Empty(t, result.Parameters["connections"]) + assert.False(t, result.RequiresAcr) + assert.Empty(t, result.Connections) } func TestSynthesize_InputValidation(t *testing.T) { @@ -1523,7 +1541,7 @@ services: require.NotNil(t, res) assert.Equal(t, tt.wantMode, res.NetworkMode) if tt.check != nil { - tt.check(t, res.Parameters) + tt.check(t, res.Parameters()) } }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go index 3cde22f6a66..70bc2db9ae7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go @@ -51,9 +51,8 @@ func ARMTemplate() ([]byte, error) { } // BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// creates/upserts model deployments on an EXISTING Foundry account (referenced, -// not created). Used by the provider when the project sets endpoint: and declares -// deployments: to add to the existing project. +// reconciles synthesized resources on an EXISTING Foundry account (referenced, +// not created). func BrownfieldARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/brownfield.arm.json") } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index b34a97ba7fd..d19f74b64bc 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -29,7 +29,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" @@ -82,22 +81,6 @@ type FoundryProvisioningProvider struct { armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists - // brownfieldEndpoint is the existing project endpoint when the foundry - // service sets endpoint: (bring-your-own). When non-empty the provider skips - // provisioning and connects to that project instead of creating a new one. - brownfieldEndpoint string - - // brownfieldDeployments are the model deployments declared under a brownfield - // (endpoint:) project service. They are created/upserted on the existing - // account at Deploy time; the existing account itself is never re-created. - brownfieldDeployments []synthesis.Deployment - - // brownfieldConnections are the host: azure.ai.connection services declared - // alongside a brownfield (endpoint:) project. They are created/upserted on - // the existing project at Deploy time, mirroring greenfield synthesis, so - // the deploy-time connection service target does not have to create them. - brownfieldConnections []synthesis.Connection - // Lazily constructed on first compile. nil until needed. bicepCliInstance bicepCompiler } @@ -124,8 +107,8 @@ func NewFoundryProvisioningProvider(azdClient *azdext.AzdClient) azdext.Provisio } // Initialize loads azure.yaml, decides between the embedded ARM template -// and the on-disk Bicep path, and resolves required env values. It rejects -// brownfield (endpoint:) and missing services with structured errors. +// and the on-disk Bicep path, and resolves required env values. Brownfield +// services produce a synthesis result for the provider's existing-project path. // // Initialize is cheap by contract: it does no network I/O and builds no // credential. Tenant lookup and credential construction happen lazily in @@ -169,46 +152,23 @@ func (p *FoundryProvisioningProvider) Initialize( return err } - // Detect on-disk Bicep before synthesizing. Stat-only; no compile here. + // User-authored greenfield Bicep owns its own parameter contract, so preserve + // the existing behavior of skipping synthesis for that path. Brownfield still + // synthesizes azure.yaml below because its embedded template is provider-owned. if p.onDiskTemplatePresent() { - log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ - "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) - // endpoint: (brownfield) reuse skips provisioning even on the on-disk - // path; connect to the existing project instead of compiling Bicep. - endpoint, endpointErr := foundryServiceEndpointAtRoot( - rawYAML, - projectPath, - svcName, - ) - if endpointErr != nil { + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectPath) + if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, - fmt.Sprintf( - "resolve existing Foundry project endpoint: %s", - endpointErr, - ), + fmt.Sprintf("resolve existing Foundry project endpoint: %s", err), "fix the project service configuration in azure.yaml", ) } - if endpoint != "" { - if err := warnNetworkIgnoredInBrownfield( - rawYAML, - projectPath, - svcName, - ); err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("resolve Foundry service configuration: %s", err), - "fix the project service configuration in azure.yaml", - ) - } - p.brownfieldEndpoint = endpoint - if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { - return err - } - return p.resolveEnvName(ctx) + if endpoint == "" { + log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ + "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) + return p.resolveEnv(ctx) } - return p.resolveEnv(ctx) } res, err := synthesis.Synthesize(synthesis.Input{ @@ -219,40 +179,6 @@ func (p *FoundryProvisioningProvider) Initialize( ProjectRoot: projectPath, }) switch { - case errors.Is(err, synthesis.ErrEndpointBrownfield): - // endpoint: reuse — connect to the existing project, skip provisioning. - // network: has no effect in brownfield mode; warn if both are present. - if err := warnNetworkIgnoredInBrownfield( - rawYAML, - projectPath, - svcName, - ); err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("resolve Foundry service configuration: %s", err), - "fix the project service configuration in azure.yaml", - ) - } - endpoint, endpointErr := foundryServiceEndpointAtRoot( - rawYAML, - projectPath, - svcName, - ) - if endpointErr != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf( - "resolve existing Foundry project endpoint: %s", - endpointErr, - ), - "fix the project service configuration in azure.yaml", - ) - } - p.brownfieldEndpoint = endpoint - if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { - return err - } - return p.resolveEnvName(ctx) case errors.Is(err, synthesis.ErrServiceNotFound): return exterrors.Dependency( exterrors.CodeProvisioningServiceNotFound, @@ -267,6 +193,14 @@ func (p *FoundryProvisioningProvider) Initialize( ) } p.synthResult = res + if res.Mode == synthesis.ModeBrownfield { + // network: has no effect when the project is referenced by endpoint. + if res.NetworkConfigured { + log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ + "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) + } + return p.resolveEnvName(ctx) + } tmplBytes, err := synthesis.ARMTemplate() if err != nil { @@ -321,51 +255,6 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str return out } -// warnNetworkIgnoredInBrownfield logs a warning when a service declares both -// endpoint: (brownfield) and network:. The account's network posture is fixed -// by whoever created it, so the network: block has no effect. -func warnNetworkIgnoredInBrownfield( - rawYAML []byte, - projectRoot string, - svcName string, -) error { - type svc struct { - Endpoint string `yaml:"endpoint,omitempty"` - Network yaml.Node `yaml:"network,omitempty"` - } - type root struct { - Services map[string]map[string]any `yaml:"services"` - } - var r root - if err := yaml.Unmarshal(rawYAML, &r); err != nil { - return err - } - values := r.Services[svcName] - if values == nil { - return nil - } - if projectRoot != "" { - resolved, err := foundry.ResolveFileRefs(values, projectRoot) - if err != nil { - return err - } - values = resolved - } - data, err := yaml.Marshal(values) - if err != nil { - return err - } - var service svc - if err := yaml.Unmarshal(data, &service); err != nil { - return err - } - if strings.TrimSpace(service.Endpoint) != "" && !service.Network.IsZero() { - log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ - "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) - } - return nil -} - // or infra/main.bicep exists under p.projectPath. Stat-only. func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { infraDir := filepath.Join(p.projectPath, onDiskInfraDir) @@ -373,46 +262,6 @@ func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { fileExistsAt(filepath.Join(infraDir, onDiskBicepFile)) } -func foundryServiceEndpointAtRoot( - rawYAML []byte, - projectRoot string, - svcName string, -) (string, error) { - type svc struct { - Endpoint string `yaml:"endpoint,omitempty"` - } - type root struct { - Services map[string]map[string]any `yaml:"services"` - } - var r root - if err := yaml.Unmarshal(rawYAML, &r); err != nil { - return "", err - } - values := r.Services[svcName] - if values == nil { - return "", nil - } - if projectRoot != "" { - resolved, err := foundry.ResolveFileRefs( - values, - projectRoot, - ) - if err != nil { - return "", err - } - values = resolved - } - data, err := yaml.Marshal(values) - if err != nil { - return "", err - } - var service svc - if err := yaml.Unmarshal(data, &service); err != nil { - return "", err - } - return strings.TrimSpace(service.Endpoint), nil -} - // resolveEnvName resolves just the active azd environment name. The brownfield // (endpoint:) path uses it instead of resolveEnv because connecting to an // existing project needs no subscription, location, or resource group. @@ -704,16 +553,27 @@ func (p *FoundryProvisioningProvider) EnsureEnv(ctx context.Context) error { return nil } +func (p *FoundryProvisioningProvider) isBrownfield() bool { + return p.synthResult != nil && p.synthResult.Mode == synthesis.ModeBrownfield +} + +func (p *FoundryProvisioningProvider) brownfieldDeployments() []synthesis.Deployment { + if !p.isBrownfield() { + return nil + } + return p.synthResult.Deployments +} + // State returns the most recent deployment's outputs as the current state, // or empty state when no deployment exists yet. func (p *FoundryProvisioningProvider) State( ctx context.Context, options *azdext.ProvisioningStateOptions, ) (*azdext.ProvisioningStateResult, error) { - if p.brownfieldEndpoint != "" { + if p.isBrownfield() { return &azdext.ProvisioningStateResult{ State: &azdext.ProvisioningState{ - Outputs: p.withTenantOutput(brownfieldOutputs(p.brownfieldEndpoint)), + Outputs: p.withTenantOutput(brownfieldOutputs(p.synthResult.Endpoint)), Resources: []*azdext.ProvisioningResource{}, }, }, nil @@ -754,7 +614,7 @@ func (p *FoundryProvisioningProvider) Deploy( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDeployResult, error) { - if p.brownfieldEndpoint != "" { + if p.isBrownfield() { return p.deployBrownfield(ctx, progress) } @@ -815,42 +675,6 @@ func (p *FoundryProvisioningProvider) Deploy( }, nil } -// captureBrownfieldDeployments records the model deployments declared on a -// brownfield (endpoint:) project service so Deploy can create them on the -// existing account. No-op (nil) when none are declared. -func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( - ctx context.Context, rawYAML []byte, svcName string, -) error { - deployments, err := synthesis.BrownfieldDeployments( - rawYAML, - svcName, - p.projectPath, - ) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("read deployments for existing Foundry project service %q: %s", svcName, err), - "check the deployments: list under your azure.ai.project service", - ) - } - p.brownfieldDeployments = deployments - - connections, err := synthesis.BrownfieldConnections( - rawYAML, - p.networkEnvMap(ctx), - p.projectPath, - ) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("read connections for existing Foundry project service %q: %s", svcName, err), - "check the host: azure.ai.connection services in azure.yaml", - ) - } - p.brownfieldConnections = connections - return nil -} - // deployBrownfield handles the existing-project (endpoint:) Deploy path. Via a // single resource-group-scoped ARM deployment against the existing (referenced, // never re-created) account it reconciles declared model deployments and, when @@ -862,8 +686,10 @@ func (p *FoundryProvisioningProvider) deployBrownfield( progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDeployResult, error) { createACR := p.brownfieldACRRequested(ctx) + deployments := p.brownfieldDeployments() + connections := p.synthResult.Connections - if len(p.brownfieldDeployments) == 0 && !createACR && len(p.brownfieldConnections) == 0 { + if len(deployments) == 0 && !createACR && len(connections) == 0 { progress("Using existing Foundry project (endpoint set); skipping provisioning") // Best-effort tenant lookup so AZURE_TENANT_ID is still surfaced for the // existing-project path (no resources are provisioned here). Log on @@ -874,12 +700,12 @@ func (p *FoundryProvisioningProvider) deployBrownfield( } return &azdext.ProvisioningDeployResult{ Deployment: &azdext.ProvisioningDeployment{ - Outputs: p.withTenantOutput(brownfieldOutputs(p.brownfieldEndpoint)), + Outputs: p.withTenantOutput(brownfieldOutputs(p.synthResult.Endpoint)), }, }, nil } - progress(brownfieldReconcileMessage(len(p.brownfieldDeployments) > 0, createACR, len(p.brownfieldConnections) > 0)) + progress(brownfieldReconcileMessage(len(deployments) > 0, createACR, len(connections) > 0)) // Locate the existing account (subscription, resource group, account name). // resolveBrownfieldTarget sets p.subID, which the deployments client needs. @@ -929,7 +755,7 @@ func (p *FoundryProvisioningProvider) deployBrownfield( // Merge endpoint/project outputs with any ACR outputs the template emitted, // skipping empty values (includeAcr=false leg) so we don't clobber the env. - outputs := brownfieldOutputs(p.brownfieldEndpoint) + outputs := brownfieldOutputs(p.synthResult.Endpoint) for k, v := range armOutputsToProto(deploymentOutputs(resp.Properties)) { if v != nil && v.Value == "" { continue @@ -968,14 +794,12 @@ func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) func (p *FoundryProvisioningProvider) brownfieldParams( ctx context.Context, account, rg string, createACR bool, ) (map[string]any, error) { - connections, connectionCredentials := synthesis.SplitConnectionCredentials( - p.brownfieldConnections, - ) + deployments := p.brownfieldDeployments() params := map[string]any{ "accountName": map[string]any{"value": account}, - "deployments": map[string]any{"value": p.brownfieldDeployments}, - "connections": map[string]any{"value": connections}, - "connectionCredentials": map[string]any{"value": connectionCredentials}, + "deployments": map[string]any{"value": deployments}, + "connections": map[string]any{"value": p.synthResult.Connections}, + "connectionCredentials": map[string]any{"value": p.synthResult.ConnectionCredentials}, // projectName feeds the unconditional existing `foundryAccountPreview::project` // resource, so it must always be set -- even on the model-deployments-only // reconcile path. Omitting it collapses the resource name to "/" @@ -1004,7 +828,7 @@ func (p *FoundryProvisioningProvider) previewBrownfield( progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningPreviewResult, error) { createACR := p.brownfieldACRRequested(ctx) - if len(p.brownfieldDeployments) == 0 && !createACR && len(p.brownfieldConnections) == 0 { + if len(p.brownfieldDeployments()) == 0 && !createACR && len(p.synthResult.Connections) == 0 { progress("Using existing Foundry project (endpoint set); nothing to provision") return &azdext.ProvisioningPreviewResult{ Preview: &azdext.ProvisioningDeploymentPreview{}, @@ -1083,7 +907,7 @@ func (p *FoundryProvisioningProvider) brownfieldACRRequested(ctx context.Context // resources (the ACR connection and user connections), preferring the value // parsed from the endpoint and falling back to p.foundryName. func (p *FoundryProvisioningProvider) brownfieldProjectName() string { - if name := projectNameFromEndpoint(p.brownfieldEndpoint); name != "" { + if name := projectNameFromEndpoint(p.synthResult.Endpoint); name != "" { return name } return p.foundryName @@ -1325,7 +1149,7 @@ func (p *FoundryProvisioningProvider) Preview( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningPreviewResult, error) { - if p.brownfieldEndpoint != "" { + if p.isBrownfield() { return p.previewBrownfield(ctx, progress) } @@ -1404,7 +1228,7 @@ func (p *FoundryProvisioningProvider) Destroy( options *azdext.ProvisioningDestroyOptions, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDestroyResult, error) { - if p.brownfieldEndpoint != "" { + if p.isBrownfield() { progress("Foundry project is bring-your-own (endpoint set); azd did not " + "create it, so azd down leaves it in place") return &azdext.ProvisioningDestroyResult{}, nil @@ -1734,7 +1558,7 @@ func (p *FoundryProvisioningProvider) Parameters( if p.synthResult != nil { out = append(out, &azdext.ProvisioningParameter{ Name: "includeAcr", - Value: fmt.Sprintf("%v", p.synthResult.Parameters["includeAcr"]), + Value: fmt.Sprintf("%v", p.synthResult.RequiresAcr), }) } return out, nil @@ -1842,7 +1666,7 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { if p.synthResult == nil { return out } - for k, v := range p.synthResult.Parameters { + for k, v := range p.synthResult.Parameters() { out[k] = map[string]any{"value": v} } return out diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go index 877d900557d..2f8c9220546 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go @@ -17,6 +17,27 @@ import ( "google.golang.org/grpc" ) +func brownfieldResult( + endpoint string, + deployments []synthesis.Deployment, + connections []synthesis.Connection, +) *synthesis.Result { + if deployments == nil { + deployments = []synthesis.Deployment{} + } + if connections == nil { + connections = []synthesis.Connection{} + } + connections, credentials := synthesis.SplitConnectionCredentials(connections) + return &synthesis.Result{ + Mode: synthesis.ModeBrownfield, + Endpoint: endpoint, + Deployments: deployments, + Connections: connections, + ConnectionCredentials: credentials, + } +} + // kvEnvServer is an environment service stub that returns per-key values, // used to drive brownfieldACRRequested's env reads. type kvEnvServer struct { @@ -105,8 +126,9 @@ func TestBrownfieldACRName(t *testing.T) { t.Parallel() p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", + envName: "dev", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), } name := p.brownfieldACRName("acct") @@ -123,8 +145,8 @@ func TestBrownfieldACRName(t *testing.T) { // Different env or account changes the name (collision avoidance). other := &FoundryProvisioningProvider{ - envName: "prod", - brownfieldEndpoint: p.brownfieldEndpoint, + envName: "prod", + synthResult: p.synthResult, } assert.NotEqual(t, name, other.brownfieldACRName("acct")) } @@ -134,15 +156,17 @@ func TestBrownfieldProjectName(t *testing.T) { // Prefers the name parsed from the endpoint. p := &FoundryProvisioningProvider{ - foundryName: "fallback", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", + foundryName: "fallback", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), } assert.Equal(t, "my-project", p.brownfieldProjectName()) // Falls back to foundryName when the endpoint has no project segment. p2 := &FoundryProvisioningProvider{ - foundryName: "fallback", - brownfieldEndpoint: "https://acct.services.ai.azure.com/", + foundryName: "fallback", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/", nil, nil), } assert.Equal(t, "fallback", p2.brownfieldProjectName()) } @@ -180,9 +204,9 @@ func TestBrownfieldParams(t *testing.T) { // test for the InvalidTemplate failure where the name collapsed to // "/" because projectName was omitted. p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - brownfieldDeployments: deployments, + envName: "dev", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", deployments, nil), } params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) require.NoError(t, err) @@ -208,9 +232,9 @@ func TestBrownfieldParams(t *testing.T) { Credentials: map[string]any{"key": "secret"}, }} p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - brownfieldConnections: conns, + envName: "dev", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, conns), } params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) require.NoError(t, err) @@ -239,9 +263,10 @@ func TestBrownfieldParams(t *testing.T) { t.Run("with ACR adds registry params", func(t *testing.T) { t.Parallel() p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - azdClient: newKVEnvClient(t, map[string]string{"AZURE_LOCATION": "westus2"}), + envName: "dev", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), + azdClient: newKVEnvClient(t, map[string]string{"AZURE_LOCATION": "westus2"}), } params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) require.NoError(t, err) @@ -257,9 +282,10 @@ func TestBrownfieldParams(t *testing.T) { // AZURE_LOCATION unset and no usable credential => brownfieldLocation // returns ""; the param must be omitted, not set to "". p := &FoundryProvisioningProvider{ - envName: "dev", - brownfieldEndpoint: "https://acct.services.ai.azure.com/api/projects/my-project", - azdClient: newKVEnvClient(t, map[string]string{}), + envName: "dev", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), + azdClient: newKVEnvClient(t, map[string]string{}), } params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 4eb58ccd033..9b3e8c16b32 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -575,7 +575,7 @@ func TestParameters_EmbeddedPath_IncludesSynthResultDerivedValues(t *testing.T) foundryName: "fp", principalID: "pid", synthResult: &synthesis.Result{ - Parameters: map[string]any{"includeAcr": true}, + RequiresAcr: true, }, } got, err := p.Parameters(t.Context()) @@ -620,9 +620,7 @@ func TestArmParameters_NilSafeOnMissingSynthResult(t *testing.T) { func TestArmParameters_UseValueEnvelopeForSecureConnections(t *testing.T) { p := &FoundryProvisioningProvider{ synthResult: &synthesis.Result{ - Parameters: map[string]any{ - "connections": `[{"name":"search-conn"}]`, - }, + Connections: []synthesis.Connection{{Name: "search-conn"}}, }, } @@ -630,7 +628,7 @@ func TestArmParameters_UseValueEnvelopeForSecureConnections(t *testing.T) { assert.Equal( t, - map[string]any{"value": `[{"name":"search-conn"}]`}, + map[string]any{"value": []synthesis.Connection{{Name: "search-conn"}}}, out["connections"], ) } @@ -719,10 +717,7 @@ func TestResolveTemplate_FallsBackToEmbeddedWhenNoOnDisk(t *testing.T) { principalID: "pid", armTemplate: map[string]any{"$schema": "embedded", "contentVersion": "1.0.0.0"}, synthResult: &synthesis.Result{ - Parameters: map[string]any{ - "includeAcr": false, - "deployments": []any{}, - }, + Deployments: []synthesis.Deployment{}, }, } @@ -775,7 +770,7 @@ func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { principalID: "pid", armTemplate: map[string]any{"$schema": "embedded"}, synthResult: &synthesis.Result{ - Parameters: map[string]any{"includeAcr": false}, + RequiresAcr: false, }, onDiskSource: &templateSource{ mode: templateModeBicep, @@ -832,7 +827,7 @@ func TestResolveTemplate_OnDiskFallsBackWhenSourceLoaderReturnsNil(t *testing.T) principalID: "pid", armTemplate: map[string]any{"$schema": "embedded"}, synthResult: &synthesis.Result{ - Parameters: map[string]any{"includeAcr": false}, + RequiresAcr: false, }, } @@ -841,112 +836,6 @@ func TestResolveTemplate_OnDiskFallsBackWhenSourceLoaderReturnsNil(t *testing.T) assert.Equal(t, templateModeEmbedded, got.mode) } -func TestFoundryServiceEndpointAtRoot(t *testing.T) { - t.Parallel() - tests := []struct { - name string - yaml string - svcName string - wantEndpoint string - wantErr bool - }{ - { - name: "greenfield (no endpoint:) -> empty", - yaml: `name: x -services: - foundry: - host: azure.ai.project`, - svcName: "foundry", - wantEndpoint: "", - }, - { - name: "endpoint set -> returned for brownfield reuse", - yaml: `name: x -services: - foundry: - host: azure.ai.project - endpoint: https://example.foundry.example.com`, - svcName: "foundry", - wantEndpoint: "https://example.foundry.example.com", - }, - { - name: "blank endpoint -> empty", - yaml: `name: x -services: - foundry: - host: azure.ai.project - endpoint: " "`, - svcName: "foundry", - wantEndpoint: "", - }, - { - name: "service not in yaml -> empty", - yaml: `name: x -services: - other: - host: containerapp`, - svcName: "foundry", - wantEndpoint: "", - }, - { - name: "malformed yaml returns parse error", - yaml: "not: : valid: yaml", - svcName: "foundry", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - endpoint, err := foundryServiceEndpointAtRoot( - []byte(tt.yaml), - "", - tt.svcName, - ) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.wantEndpoint, endpoint) - }) - } -} - -func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( - t *testing.T, -) { - t.Parallel() - - root := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(root, "project.yaml"), - []byte( - "endpoint: https://acct.services.ai.azure.com/"+ - "api/projects/existing\n", - ), - 0o600, - )) - raw := []byte(`services: - foundry: - host: azure.ai.project - $ref: ./project.yaml -`) - - endpoint, err := foundryServiceEndpointAtRoot( - raw, - root, - "foundry", - ) - - require.NoError(t, err) - assert.Equal( - t, - "https://acct.services.ai.azure.com/api/projects/existing", - endpoint, - ) -} - func TestProjectNameFromEndpoint(t *testing.T) { t.Parallel() assert.Equal(t, "my-project", projectNameFromEndpoint( diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index 5f49d8f0f92..c7acb9a9d01 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -9,6 +9,7 @@ import ( "strings" "azure.ai.projects/internal/azure" + "azure.ai.projects/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -281,11 +282,7 @@ func (c *ResourceGroupLocationCheck) isBrownfieldFoundryProject(ctx context.Cont return false } - endpoint, err := foundryServiceEndpointAtRoot( - rawYAML, - projectPath, - svcName, - ) + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectPath) return err == nil && endpoint != "" } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index 8c4e7d7baea..0648beea834 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -8,8 +8,9 @@ // for the bicep compiler // - a Parameters map of the values the template's params consume // -// Greenfield only: if the service has an endpoint: field, ErrEndpointBrownfield -// is returned so callers can short-circuit the provision path. +// Both greenfield and brownfield services produce a Result. Brownfield services +// reference an existing project through endpoint: while still synthesizing the +// deployments and connections that azd manages on that project. package synthesis import ( @@ -26,16 +27,17 @@ import ( "go.yaml.in/yaml/v3" ) -// Sentinel errors returned by Synthesize. -var ( - // ErrEndpointBrownfield indicates the service points at an existing - // Foundry project via endpoint:. The provider should skip ARM - // provisioning and connect to the endpoint directly. - ErrEndpointBrownfield = errors.New("synthesis: service has endpoint: (brownfield)") +// ErrServiceNotFound indicates the requested service does not exist in +// azure.yaml or its host: value is not in AcceptedHosts. +var ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") - // ErrServiceNotFound indicates the requested service does not exist - // in azure.yaml or its host: value is not in AcceptedHosts. - ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") +// Mode identifies whether synthesis creates a Foundry project or reconciles an +// existing project. +type Mode string + +const ( + ModeGreenfield Mode = "greenfield" + ModeBrownfield Mode = "brownfield" ) // Input is the synthesizer's view of azure.yaml. @@ -72,20 +74,41 @@ type Input struct { ProjectRoot string } -// Result bundles the bicep sources and the parameter values derived -// from the service body. Callers stage Templates on disk, compile -// main.bicep, and pass Parameters when invoking the resulting ARM -// deployment. +// Result contains the typed resources and settings derived from one Foundry +// project service. type Result struct { - // Parameters maps bicep param names to plain Go values. Callers wrap - // these in ARM's {"value": ...} envelope when serializing. - Parameters map[string]any + // Mode selects the greenfield or brownfield provisioning template. + Mode Mode + + // Endpoint is the existing Foundry project endpoint in brownfield mode. + // It is empty in greenfield mode. + Endpoint string + + Deployments []Deployment + Connections []Connection + ConnectionCredentials map[string]map[string]any + RequiresAcr bool + NetworkConfigured bool + networkParameters map[string]any // NetworkMode is "none", "byo", or "managed" — derived from the // network: block (or its absence). Exposed for telemetry. NetworkMode string } +// Parameters returns the Bicep parameter contract derived from the typed +// synthesis result. The external parameter remains includeAcr for compatibility. +func (r *Result) Parameters() map[string]any { + params := map[string]any{ + "deployments": r.Deployments, + "includeAcr": r.RequiresAcr, + "connections": r.Connections, + "connectionCredentials": r.ConnectionCredentials, + } + maps.Copy(params, r.networkParameters) + return params +} + // Deployment mirrors the deploymentType in main.bicep. type Deployment struct { Name string `yaml:"name" json:"name"` @@ -270,18 +293,6 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { - return nil, ErrEndpointBrownfield - } - - includeAcr, err := deriveIncludeAcr( - root.Services, - svc, - in.ProjectRoot, - ) - if err != nil { - return nil, err - } deployments := svc.Deployments if deployments == nil { @@ -298,84 +309,46 @@ func Synthesize(in Input) (*Result, error) { return nil, err } connections, connectionCredentials := SplitConnectionCredentials(connections) + + endpoint := strings.TrimSpace(svc.Endpoint) + if endpoint != "" { + return &Result{ + Mode: ModeBrownfield, + Endpoint: endpoint, + Deployments: deployments, + Connections: connections, + ConnectionCredentials: connectionCredentials, + NetworkConfigured: svc.Network != nil, + }, nil + } + + requiresAcr, err := deriveRequiresAcr(root.Services, svc, in.ProjectRoot) + if err != nil { + return nil, err + } + netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) if err != nil { return nil, err } - if includeAcr && netMode != NetworkModeNone { + if requiresAcr && netMode != NetworkModeNone { return nil, errors.New( "synthesis: private networking does not support an auto-created Azure Container Registry; specify an image instead", ) } - params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - "connections": connections, - "connectionCredentials": connectionCredentials, - } - maps.Copy(params, netParams) - return &Result{ - Parameters: params, - NetworkMode: netMode, + Mode: ModeGreenfield, + Deployments: deployments, + Connections: connections, + ConnectionCredentials: connectionCredentials, + RequiresAcr: requiresAcr, + NetworkConfigured: svc.Network != nil, + networkParameters: netParams, + NetworkMode: netMode, }, nil } -// BrownfieldDeployments returns the model deployments declared on a brownfield -// (endpoint:) Foundry project service. Synthesize short-circuits with -// ErrEndpointBrownfield before reading deployments:, so the provider uses this -// to learn which model deployments to create on the existing account. Returns -// nil (not an error) when the service declares no deployments. -func BrownfieldDeployments( - raw []byte, - serviceName string, - projectRoot string, -) ([]Deployment, error) { - if len(raw) == 0 { - return nil, errors.New("synthesis: raw azure.yaml is empty") - } - if serviceName == "" { - return nil, errors.New("synthesis: serviceName is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - svc, err := loadProjectService(root.Services, serviceName, projectRoot) - if err != nil { - return nil, err - } - - return svc.Deployments, nil -} - -// BrownfieldConnections returns the host: azure.ai.connection services declared -// in azure.yaml, for a brownfield (endpoint:) project. Synthesize short-circuits -// with ErrEndpointBrownfield before collecting connections, so the provider uses -// this to create the same connections on the existing account. ${VAR} is -// resolved from env since brownfield provisions immediately; Foundry ${{...}} -// expressions pass through. Returns an empty slice (not an error) when none -// are declared. -func BrownfieldConnections( - raw []byte, - env map[string]string, - projectRoot string, -) ([]Connection, error) { - if len(raw) == 0 { - return nil, errors.New("synthesis: raw azure.yaml is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - return collectConnections(root.Services, env, true, projectRoot) -} - // ProjectEndpoint returns the endpoint configured on a Foundry project service. // It resolves $ref includes before decoding the service body. func ProjectEndpoint( @@ -542,7 +515,7 @@ func validateAgentRootRefCoreFields( return nil } -// deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is +// deriveRequiresAcr reports whether the project needs an ACR. An ACR is // needed when any agent is a hosted, build-from-source agent: azd builds its // image and pushes it to the registry. Agents live on sibling azure.ai.agent // services in the split shape, or under agents[] in the legacy inline shape; @@ -555,7 +528,7 @@ func validateAgentRootRefCoreFields( // Keying on image/codeConfiguration rather than the optional docker: block is // deliberate: docker: is not in the agent schema and is dropped by omitempty // when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr( +func deriveRequiresAcr( services map[string]yaml.Node, svc projectService, projectRoot string, diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 04f90f496f7..6c796b10a87 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -21,6 +21,8 @@ func TestSynthesize(t *testing.T) { serviceName string wantErr error + wantMode Mode + wantEndpoint string wantDeployLen int wantIncludeAcr bool // wantDeployName0, if non-empty, asserts the name of the first deployment. @@ -411,7 +413,7 @@ services: wantConnectionNames: []string{}, }, { - name: "brownfield: endpoint set => ErrEndpointBrownfield", + name: "brownfield: endpoint returns managed resources", yaml: ` services: my-project: @@ -422,11 +424,13 @@ services: model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} sku: {capacity: 10, name: GlobalStandard} `, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, + serviceName: "my-project", + wantMode: ModeBrownfield, + wantEndpoint: "https://existing.services.ai.azure.com/api/projects/p1", + wantDeployLen: 1, }, { - name: "brownfield: endpoint + network => network ignored, still ErrEndpointBrownfield", + name: "brownfield: endpoint ignores network", yaml: ` services: my-project: @@ -435,8 +439,9 @@ services: network: peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} `, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, + serviceName: "my-project", + wantMode: ModeBrownfield, + wantEndpoint: "https://existing.services.ai.azure.com/api/projects/p1", }, { name: "blank endpoint is treated as greenfield", @@ -487,18 +492,20 @@ services: require.NoError(t, err) require.NotNil(t, res) + if tt.wantMode != "" { + assert.Equal(t, tt.wantMode, res.Mode) + } else { + assert.Equal(t, ModeGreenfield, res.Mode) + } + assert.Equal(t, tt.wantEndpoint, res.Endpoint) - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - assert.Len(t, deployments, tt.wantDeployLen) + assert.Len(t, res.Deployments, tt.wantDeployLen) if tt.wantDeployName0 != "" { - require.NotEmpty(t, deployments) - assert.Equal(t, tt.wantDeployName0, deployments[0].Name) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantDeployName0, res.Deployments[0].Name) } - includeAcr, ok := res.Parameters["includeAcr"].(bool) - require.True(t, ok, "includeAcr param should be bool") - assert.Equal(t, tt.wantIncludeAcr, includeAcr) + assert.Equal(t, tt.wantIncludeAcr, res.RequiresAcr) connections := resultConnections(t, res) if tt.wantConnectionNames != nil { @@ -560,10 +567,8 @@ services: assert.Equal(t, "secret-value", keys["x-api-key"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) - publicConnections := res.Parameters["connections"].([]Connection) - assert.Nil(t, publicConnections[0].Credentials) - secureCredentials := res.Parameters["connectionCredentials"].(map[string]map[string]any) - assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) + assert.Nil(t, res.Connections[0].Credentials) + assert.Equal(t, "secret-value", res.ConnectionCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) }) t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { @@ -658,17 +663,16 @@ services: ` t.Run("collects and resolves connections (sorted)", func(t *testing.T) { - conns, err := BrownfieldConnections( - []byte(yaml), - map[string]string{"SEARCH_API_KEY": "secret"}, - "", - ) + res, err := Synthesize(Input{RawAzureYAML: []byte(yaml), ServiceName: "my-project", Env: map[string]string{ + "SEARCH_API_KEY": "secret", + }}) require.NoError(t, err) - require.Len(t, conns, 2) - assert.Equal(t, "bing-conn", conns[0].Name) - assert.Equal(t, "search-conn", conns[1].Name) - assert.Equal(t, "CognitiveSearch", conns[1].Category) - assert.Equal(t, "secret", conns[1].Credentials["key"]) + assert.Equal(t, ModeBrownfield, res.Mode) + require.Len(t, res.Connections, 2) + assert.Equal(t, "bing-conn", res.Connections[0].Name) + assert.Equal(t, "search-conn", res.Connections[1].Name) + assert.Equal(t, "CognitiveSearch", res.Connections[1].Category) + assert.Equal(t, "secret", res.ConnectionCredentials["search-conn"]["key"]) }) t.Run("no connection services yields empty slice", func(t *testing.T) { @@ -678,17 +682,51 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil, "") + res, err := Synthesize(Input{RawAzureYAML: []byte(noConns), ServiceName: "my-project"}) require.NoError(t, err) - assert.Empty(t, conns) + assert.Empty(t, res.Connections) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil, "") + _, err := Synthesize(Input{ServiceName: "my-project"}) require.Error(t, err) }) } +func TestSynthesize_BrownfieldSkipsAgentReferences(t *testing.T) { + t.Parallel() + raw := []byte(`services: + project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + $ref: ./missing-agent.yaml +`) + + res, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: t.TempDir()}) + + require.NoError(t, err) + assert.Equal(t, ModeBrownfield, res.Mode) + assert.False(t, res.RequiresAcr) +} + +func TestSynthesize_BrownfieldValidatesManagedConnectionReferences(t *testing.T) { + t.Parallel() + raw := []byte(`services: + project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + connection: + host: azure.ai.connection + $ref: ./missing-connection.yaml +`) + + _, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: t.TempDir()}) + + require.ErrorContains(t, err, "missing-connection.yaml") +} + func TestBrownfieldDeployments(t *testing.T) { tests := []struct { name string @@ -768,7 +806,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BrownfieldDeployments([]byte(tt.yaml), tt.serviceName, "") + res, err := Synthesize(Input{RawAzureYAML: []byte(tt.yaml), ServiceName: tt.serviceName}) if tt.serviceName == "" { require.Error(t, err) @@ -781,21 +819,21 @@ services: } require.NoError(t, err) - assert.Len(t, got, tt.wantLen) + assert.Len(t, res.Deployments, tt.wantLen) if tt.wantName0 != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantName0, got[0].Name) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantName0, res.Deployments[0].Name) } if tt.wantVersion != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantVersion, got[0].Model.Version) + require.NotEmpty(t, res.Deployments) + assert.Equal(t, tt.wantVersion, res.Deployments[0].Model.Version) } }) } } func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { - _, err := BrownfieldDeployments(nil, "my-project", "") + _, err := Synthesize(Input{ServiceName: "my-project"}) require.Error(t, err) } @@ -821,9 +859,10 @@ services: }) require.NoError(t, err, "unset ${VAR} must not fail on the eject path") require.NotNil(t, res) - assert.Equal(t, "${AZURE_VNET_ID}", res.Parameters["vnetId"]) - assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", res.Parameters["dnsZonesSubscription"]) - assert.Equal(t, "rg-dns", res.Parameters["dnsZonesResourceGroup"]) + params := res.Parameters() + assert.Equal(t, "${AZURE_VNET_ID}", params["vnetId"]) + assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", params["dnsZonesSubscription"]) + assert.Equal(t, "rg-dns", params["dnsZonesResourceGroup"]) } func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) { @@ -869,12 +908,10 @@ services: ProjectRoot: root, }) require.NoError(t, err) - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) - assert.Equal(t, "gpt-4o", deployments[0].Model.Name) - assert.Equal(t, 10, deployments[0].Sku.Capacity) + require.Len(t, res.Deployments, 1) + assert.Equal(t, "gpt-4o", res.Deployments[0].Name) + assert.Equal(t, "gpt-4o", res.Deployments[0].Model.Name) + assert.Equal(t, 10, res.Deployments[0].Sku.Capacity) } func TestSynthesize_ResolvesSiblingServiceRefs(t *testing.T) { @@ -911,7 +948,7 @@ services: ProjectRoot: root, }) require.NoError(t, err) - assert.Equal(t, false, res.Parameters["includeAcr"]) + assert.False(t, res.RequiresAcr) connections := resultConnections(t, res) require.Len(t, connections, 1) @@ -923,11 +960,7 @@ services: func resultConnections(t *testing.T, result *Result) []Connection { t.Helper() - connections, ok := result.Parameters["connections"].([]Connection) - require.True(t, ok, "connections param should be []Connection") - credentials, ok := result.Parameters["connectionCredentials"].(map[string]map[string]any) - require.True(t, ok, "connectionCredentials param should be a credential map") - return JoinConnectionCredentials(connections, credentials) + return JoinConnectionCredentials(result.Connections, result.ConnectionCredentials) } func TestBrownfieldServiceResolversResolveRefs(t *testing.T) { @@ -967,15 +1000,13 @@ services: endpoint, ) - deployments, err := BrownfieldDeployments([]byte(yaml), "project", root) - require.NoError(t, err) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) - - connections, err := BrownfieldConnections([]byte(yaml), nil, root) + res, err := Synthesize(Input{RawAzureYAML: []byte(yaml), ServiceName: "project", ProjectRoot: root}) require.NoError(t, err) - require.Len(t, connections, 1) - assert.Equal(t, "CognitiveSearch", connections[0].Category) + assert.Equal(t, ModeBrownfield, res.Mode) + require.Len(t, res.Deployments, 1) + assert.Equal(t, "gpt-4o", res.Deployments[0].Name) + require.Len(t, res.Connections, 1) + assert.Equal(t, "CognitiveSearch", res.Connections[0].Category) } func TestSynthesize_InputValidation(t *testing.T) { @@ -1374,7 +1405,7 @@ services: require.NotNil(t, res) assert.Equal(t, tt.wantMode, res.NetworkMode) if tt.check != nil { - tt.check(t, res.Parameters) + tt.check(t, res.Parameters()) } }) } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go index 3cde22f6a66..70bc2db9ae7 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go @@ -51,9 +51,8 @@ func ARMTemplate() ([]byte, error) { } // BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// creates/upserts model deployments on an EXISTING Foundry account (referenced, -// not created). Used by the provider when the project sets endpoint: and declares -// deployments: to add to the existing project. +// reconciles synthesized resources on an EXISTING Foundry account (referenced, +// not created). func BrownfieldARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/brownfield.arm.json") } From 2c02dfab99500f8c36efc852dcccf093a58dd709 Mon Sep 17 00:00:00 2001 From: hund030 Date: Tue, 28 Jul 2026 16:51:54 +0800 Subject: [PATCH 2/3] refactor(foundry): use one provisioning template --- .../internal/synthesis/schema_test.go | 31 - .../internal/synthesis/synthesizer_test.go | 2 +- .../synthesis/templates/brownfield.arm.json | 338 ---------- .../synthesis/templates/brownfield.bicep | 190 ------ .../synthesis/templates/main.arm.json | 493 ++++++++++++--- .../internal/synthesis/templates/main.bicep | 41 +- .../modules/acr-pull-role-assignment.bicep | 12 +- .../synthesis/templates/modules/acr.bicep | 63 +- .../templates/modules/resources.bicep | 166 +++-- .../modules/validate-project-name.bicep | 6 + .../internal/synthesis/templates_embed.go | 10 - .../foundry_provisioning_provider.go | 597 ++++++++++-------- ...ovisioning_provider_brownfield_acr_test.go | 439 +++++++------ ...y_provisioning_provider_resolveenv_test.go | 25 + .../foundry_provisioning_provider_test.go | 56 +- .../internal/provisioning/ondisk_template.go | 15 +- .../provisioning/ondisk_template_test.go | 17 + .../internal/synthesis/schema_test.go | 33 - .../internal/synthesis/synthesizer_test.go | 21 +- .../synthesis/templates/brownfield.arm.json | 338 ---------- .../synthesis/templates/brownfield.bicep | 190 ------ .../synthesis/templates/main.arm.json | 493 ++++++++++++--- .../internal/synthesis/templates/main.bicep | 41 +- .../modules/acr-pull-role-assignment.bicep | 12 +- .../synthesis/templates/modules/acr.bicep | 63 +- .../templates/modules/resources.bicep | 166 +++-- .../modules/validate-project-name.bicep | 6 + .../internal/synthesis/templates_embed.go | 10 - 28 files changed, 1946 insertions(+), 1928 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/validate-project-name.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/validate-project-name.bicep 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 56bc7904bb2..0d05c866dec 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 @@ -120,37 +120,6 @@ func TestARMTemplate_MatchesBicepBuild(t *testing.T) { "--outfile main.arm.json` from the templates directory") } -// TestBrownfieldARMTemplate_MatchesBicepBuild is the brownfield.bicep counterpart -// of TestARMTemplate_MatchesBicepBuild: it catches a forgotten `bicep build` after -// editing the brownfield model-deployment template. Skipped when bicep is absent. -func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := lookupBicep() - if bicep == "" { - t.Skip("bicep CLI not found on PATH; skipping ARM drift check") - } - - templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "brownfield.arm.json")) - require.NoError(t, err) - - out := filepath.Join(t.TempDir(), "brownfield.arm.json") - cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "brownfield.bicep"), "--outfile", out) - var stderr bytes.Buffer - cmd.Stderr = &stderr - require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) - - rebuilt, err := os.ReadFile(out) - require.NoError(t, err) - - committedNormalized := normalizeArmTemplate(t, committed) - rebuiltNormalized := normalizeArmTemplate(t, rebuilt) - - assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/brownfield.arm.json is stale; regenerate with `bicep build "+ - "brownfield.bicep --outfile brownfield.arm.json` from the templates directory") -} - // normalizeArmTemplate returns a stable JSON representation of an ARM template // for drift comparison. Bicep generator metadata includes the local Bicep CLI // version/hash and can differ between developer machines and CI images without 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 22f78a325cf..2b1269bc56d 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 @@ -1344,7 +1344,7 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { // enableNetworkIsolation (not on egress mode), so a network-bound account is // never left public. This is the regression guard for the data-plane fix. text := string(data) - wantDisable := `"disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]"` + wantDisable := `"disablePublicDataPlaneAccess": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]"` wantPublic := `"publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]"` assert.Contains(t, text, wantDisable, "public data-plane access must be disabled for every network-isolated account") diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json deleted file mode 100644 index 77cda60d3cf..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.arm.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "5428399781259274778" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of Foundry project connections." - } - }, - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "target": { - "type": "string" - }, - "authType": { - "type": "string" - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - } - }, - "parameters": { - "accountName": { - "type": "string", - "minLength": 2, - "maxLength": 64, - "metadata": { - "description": "Name of the existing Foundry (AIServices) account." - } - }, - "projectName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to create or update on the existing account." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for the container registry. Defaults to the resource group location." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to created resources." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent." - } - }, - "acrName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true." - } - }, - "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], - "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." - } - }, - "connectionCredentials": { - "type": "secureObject", - "defaultValue": {}, - "metadata": { - "description": "Credentials keyed by Foundry project connection name." - } - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "registry": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - "acrConnection": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}-conn', parameters('accountName'), parameters('projectName'), parameters('acrName'))]", - "properties": { - "category": "ContainerRegistry", - "target": "[reference('registry').loginServer]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "foundryAcrPull", - "registry" - ] - }, - "projectConnections": { - "copy": { - "name": "projectConnections", - "count": "[length(parameters('connections'))]" - }, - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" - }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-acr-pull", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('acrName')]" - }, - "principalId": { - "value": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]" - }, - "roleDefinitionId": { - "value": "[variables('acrPullRoleId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "16037481882754055301" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving AcrPull on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "AcrPull role definition resource ID." - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - } - }, - "outputs": { - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(parameters('includeAcr'), reference('registry').loginServer, '')]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(parameters('includeAcr'), resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[if(parameters('includeAcr'), format('{0}-conn', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_CONNECTION_NAMES": { - "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep deleted file mode 100644 index 16c41c23f4d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/brownfield.bicep +++ /dev/null @@ -1,190 +0,0 @@ -// Resource-group-scoped template for an EXISTING Foundry (AIServices) account. -// The account and project are REFERENCED, never created. It reconciles model -// deployments declared in azure.yaml and, when includeAcr is true, creates a -// container registry wired to the project (AcrPull + ContainerRegistry -// connection) for a hosted container agent. Used by the brownfield path. - -targetScope = 'resourceGroup' - -// User-defined types (match the deploymentType in main.bicep). - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -@description('Shape of a list of Foundry project connections.') -type connectionsType = connectionType[] - -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - name: string - category: string - target: string - authType: string - metadata: object? -} - -// Parameters - -@description('Name of the existing Foundry (AIServices) account.') -@minLength(2) -@maxLength(64) -param accountName string - -@description('Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true.') -param projectName string = '' - -@description('Model deployments to create or update on the existing account.') -param deployments deploymentsType = [] - -@description('Azure region for the container registry. Defaults to the resource group location.') -param location string = resourceGroup().location - -@description('Tags applied to created resources.') -param tags object = {} - -@description('Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent.') -param includeAcr bool = false - -@description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') -param acrName string = '' - -@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') -param connections connectionsType = [] - -@description('Credentials keyed by Foundry project connection name.') -@secure() -param connectionCredentials object = {} - -// Resources - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: accountName -} - -// Sequential creation; ARM throttles concurrent deployments on one account. -// CreateOrUpdate is an idempotent upsert, so re-running reconciles an existing -// deployment rather than duplicating it. -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for d in deployments: { - parent: foundryAccount - name: d.name - properties: { - model: d.model - } - sku: d.sku - } -] - -// Existing project reference (preview API): exposes the project's system-assigned -// managed identity principal id, used as the AcrPull grantee and the connection -// credential identity. Pinned to 2025-04-01-preview to match acr.bicep; the GA -// API fails to resolve the projects/connections ContainerRegistry sub-resource. -resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -// Container registry for the hosted container agent. Premium SKU mirrors the -// greenfield acr.bicep. -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (includeAcr) { - name: acrName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -// Built-in AcrPull role. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// The nested module makes the runtime project principal a deployment -// parameter. The assignment name can then include that principal. -module foundryAcrPull 'modules/acr-pull-role-assignment.bicep' = if (includeAcr) { - name: 'foundry-acr-pull' - params: { - registryName: registry.name - principalId: foundryAccountPreview::project.identity.principalId - roleDefinitionId: acrPullRoleId - } -} - -// Project-scoped ContainerRegistry connection so Foundry can resolve the registry -// by name when running the hosted agent. -resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (includeAcr) { - name: '${accountName}/${projectName}/${acrName}-conn' - properties: { - category: 'ContainerRegistry' - target: registry!.properties.loginServer - authType: 'ManagedIdentity' - credentials: { - clientId: foundryAccountPreview::project.identity.principalId - resourceId: registry!.id - } - isSharedToAll: true - metadata: { - ResourceId: registry!.id - } - } - dependsOn: [ - foundryAcrPull - ] -} - -// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as -// host: azure.ai.connection services, created on the existing project at -// provision time. Optional properties (credentials / metadata) are emitted only -// when supplied so None / identity-token connections don't send empty objects. -resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { - parent: foundryAccountPreview::project - name: c.name - properties: union( - { - category: c.category - target: c.target - authType: c.authType - }, - contains(connectionCredentials, c.name) - ? { credentials: connectionCredentials[c.name] } - : {}, - c.?metadata != null ? { metadata: c.?metadata } : {} - ) - } -] - -// Outputs - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? registry!.properties.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? registry!.id : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? '${acrName}-conn' : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json index 31f7af1ec23..0965d2ff7e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "10316642581792918930" + "templateHash": "7253913134310958286" } }, "definitions": { @@ -120,10 +120,15 @@ }, "foundryProjectName": { "type": "string", - "minLength": 3, - "maxLength": 32, "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + "description": "Foundry project name. New projects require 3-32 characters; existing project names are preserved." + } + }, + "foundryAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Foundry account name to reuse. Empty provisions a new account." } }, "deployments": { @@ -140,6 +145,39 @@ "description": "Include an Azure Container Registry. Set true when any agent uses docker:." } }, + "acrMode": { + "type": "string", + "defaultValue": "[if(parameters('includeAcr'), 'create', 'none')]", + "allowedValues": [ + "none", + "create", + "existing" + ] + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[parameters('resourceGroupName')]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('acrName')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "connections": { "$ref": "#/definitions/connectionsType", "defaultValue": [], @@ -253,8 +291,12 @@ } } }, + "variables": { + "createFoundryResources": "[empty(parameters('foundryAccountName'))]" + }, "resources": { - "resourceGroup": { + "managedResourceGroup": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.Resources/resourceGroups", "apiVersion": "2021-04-01", "name": "[parameters('resourceGroupName')]", @@ -272,6 +314,9 @@ }, "mode": "Incremental", "parameters": { + "foundryAccountName": { + "value": "[parameters('foundryAccountName')]" + }, "location": { "value": "[parameters('location')]" }, @@ -290,6 +335,27 @@ "includeAcr": { "value": "[parameters('includeAcr')]" }, + "acrMode": { + "value": "[parameters('acrMode')]" + }, + "acrName": { + "value": "[parameters('acrName')]" + }, + "existingAcrSubscriptionId": { + "value": "[parameters('existingAcrSubscriptionId')]" + }, + "existingAcrResourceGroup": { + "value": "[parameters('existingAcrResourceGroup')]" + }, + "existingAcrName": { + "value": "[parameters('existingAcrName')]" + }, + "existingAcrEndpoint": { + "value": "[parameters('existingAcrEndpoint')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" + }, "connections": { "value": "[parameters('connections')]" }, @@ -347,7 +413,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "9277725728848811858" + "templateHash": "4484218828981058848" } }, "definitions": { @@ -438,6 +504,13 @@ "description": "Azure region for all resources." } }, + "foundryAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Foundry account name to reuse. Empty provisions a new account." + } + }, "tags": { "type": "object", "defaultValue": {}, @@ -454,10 +527,8 @@ }, "foundryProjectName": { "type": "string", - "minLength": 3, - "maxLength": 32, "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + "description": "Foundry project name. New projects require 3-32 characters; existing project names are preserved." } }, "deployments": { @@ -474,6 +545,39 @@ "description": "Include an Azure Container Registry. Set true when any agent uses docker:." } }, + "acrMode": { + "type": "string", + "defaultValue": "[if(parameters('includeAcr'), 'create', 'none')]", + "allowedValues": [ + "none", + "create", + "existing" + ] + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('acrName')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "connections": { "$ref": "#/definitions/connectionsType", "defaultValue": [], @@ -594,37 +698,23 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", - "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", - "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", - "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", - "disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]", + "createFoundryResources": "[empty(parameters('foundryAccountName'))]", + "resolvedFoundryAccountName": "[if(variables('createFoundryResources'), format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken')), parameters('foundryAccountName'))]", + "projectResourceId": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", + "useByoNetwork": "[and(and(variables('createFoundryResources'), parameters('enableNetworkIsolation')), not(parameters('useManagedEgress')))]", + "useManagedNetwork": "[and(and(variables('createFoundryResources'), parameters('enableNetworkIsolation')), parameters('useManagedEgress'))]", + "disablePublicDataPlaneAccess": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]", "cognitiveServicesUserRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", "agentSubnetArmId": "[format('{0}/subnets/{1}', parameters('vnetId'), parameters('agentSubnetName'))]", - "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]" + "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]", + "resolvedAcrName": "[if(and(equals(parameters('acrMode'), 'create'), empty(parameters('acrName'))), format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken')), if(empty(parameters('existingAcrName')), parameters('acrName'), parameters('existingAcrName')))]" }, "resources": { - "foundryAccount::modelDeployments": { - "copy": { - "name": "foundryAccount::modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]", - "dependsOn": [ - "foundryAccount" - ] - }, "foundryAccount::project": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.CognitiveServices/accounts/projects", "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", "location": "[parameters('location')]", "identity": { "type": "SystemAssigned" @@ -635,13 +725,14 @@ }, "dependsOn": [ "foundryAccount", - "foundryAccount::modelDeployments" + "modelDeployments" ] }, "foundryAccount": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.CognitiveServices/accounts", "apiVersion": "2025-06-01", - "name": "[variables('foundryAccountName')]", + "name": "[variables('resolvedFoundryAccountName')]", "location": "[parameters('location')]", "tags": "[parameters('tags')]", "sku": { @@ -653,7 +744,7 @@ }, "properties": { "allowProjectManagement": true, - "customSubDomainName": "[variables('foundryAccountName')]", + "customSubDomainName": "[variables('resolvedFoundryAccountName')]", "publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]", "disableLocalAuth": true, "networkAcls": { @@ -665,14 +756,48 @@ "networkInjections": "[variables('agentNetworkInjections')]" }, "dependsOn": [ - "network" + "network", + "projectNameValidation" + ] + }, + "existingFoundryAccount": { + "condition": "[not(variables('createFoundryResources'))]", + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[variables('resolvedFoundryAccountName')]" + }, + "existingFoundryProject": { + "condition": "[not(variables('createFoundryResources'))]", + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]" + }, + "modelDeployments": { + "copy": { + "name": "modelDeployments", + "count": "[length(parameters('deployments'))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('deployments')[copyIndex()].name)]", + "properties": { + "model": "[parameters('deployments')[copyIndex()].model]" + }, + "sku": "[parameters('deployments')[copyIndex()].sku]", + "dependsOn": [ + "existingFoundryAccount", + "foundryAccount" ] }, "foundryManagedNetwork": { "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks", "apiVersion": "2025-10-01-preview", - "name": "[format('{0}/{1}', variables('foundryAccountName'), 'default')]", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), 'default')]", "properties": { "managedNetwork": { "isolationMode": "[parameters('managedIsolationMode')]" @@ -683,11 +808,11 @@ ] }, "developerCognitiveServicesUser": { - "condition": "[not(empty(parameters('principalId')))]", + "condition": "[and(variables('createFoundryResources'), not(empty(parameters('principalId'))))]", "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName')), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", + "name": "[guid(variables('projectResourceId'), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", "properties": { "principalId": "[parameters('principalId')]", "principalType": "[parameters('principalType')]", @@ -1011,8 +1136,53 @@ } } }, + "projectNameValidation": { + "condition": "[variables('createFoundryResources')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "validate-foundry-project-name", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[parameters('foundryProjectName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "6141112058032284375" + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 32, + "metadata": { + "description": "Foundry project name. 3-32 characters for newly created projects." + } + } + }, + "resources": [], + "outputs": { + "validatedName": { + "type": "string", + "value": "[parameters('name')]" + } + } + } + } + }, "acr": { - "condition": "[parameters('includeAcr')]", + "condition": "[not(equals(parameters('acrMode'), 'none'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "acr", @@ -1028,20 +1198,36 @@ "tags": { "value": "[parameters('tags')]" }, + "acrMode": "[if(equals(parameters('acrMode'), 'existing'), createObject('value', 'existing'), createObject('value', 'create'))]", "name": { - "value": "[format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken'))]" + "value": "[variables('resolvedAcrName')]" + }, + "existingAcrSubscriptionId": { + "value": "[parameters('existingAcrSubscriptionId')]" + }, + "existingAcrResourceGroup": { + "value": "[parameters('existingAcrResourceGroup')]" + }, + "existingAcrName": { + "value": "[variables('resolvedAcrName')]" + }, + "existingAcrEndpoint": { + "value": "[parameters('existingAcrEndpoint')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" }, "foundryAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "foundryProjectName": { "value": "[parameters('foundryProjectName')]" }, "foundryProjectPrincipalId": { - "value": "[reference('foundryAccount::project', '2025-06-01', 'full').identity.principalId]" + "value": "[reference(variables('projectResourceId'), '2025-04-01-preview', 'full').identity.principalId]" }, "enableNetworkIsolation": { - "value": "[parameters('enableNetworkIsolation')]" + "value": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]" } }, "template": { @@ -1051,7 +1237,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2948233716175969636" + "templateHash": "11720581132744672797" } }, "parameters": { @@ -1076,6 +1262,34 @@ "description": "Registry name. 5-50 alphanumeric chars." } }, + "acrMode": { + "type": "string", + "defaultValue": "create", + "allowedValues": [ + "create", + "existing" + ] + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('name')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "foundryAccountName": { "type": "string", "metadata": { @@ -1103,32 +1317,38 @@ } }, "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + "createAcr": "[equals(parameters('acrMode'), 'create')]", + "configureAcr": "[or(variables('createAcr'), empty(parameters('existingAcrConnectionName')))]", + "resolvedAcrName": "[if(variables('createAcr'), parameters('name'), parameters('existingAcrName'))]", + "resolvedAcrId": "[resourceId(parameters('existingAcrSubscriptionId'), parameters('existingAcrResourceGroup'), 'Microsoft.ContainerRegistry/registries', variables('resolvedAcrName'))]" }, "resources": [ { + "condition": "[variables('configureAcr')]", "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', parameters('name')))]", + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', variables('resolvedAcrName')))]", "properties": { "category": "ContainerRegistry", - "target": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]", + "target": "[if(variables('createAcr'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer, parameters('existingAcrEndpoint'))]", "authType": "ManagedIdentity", "credentials": { "clientId": "[parameters('foundryProjectPrincipalId')]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "resourceId": "[variables('resolvedAcrId')]" }, "isSharedToAll": true, "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "ResourceId": "[variables('resolvedAcrId')]" } }, "dependsOn": [ - "[extensionResourceId(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId')))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('existingAcrSubscriptionId'), parameters('existingAcrResourceGroup')), 'Microsoft.Resources/deployments', 'foundry-acr-pull-existing')]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-acr-pull-new')]", "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" ] }, { + "condition": "[variables('createAcr')]", "type": "Microsoft.ContainerRegistry/registries", "apiVersion": "2023-07-01", "name": "[parameters('name')]", @@ -1147,39 +1367,150 @@ } }, { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId'))]", + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-acr-pull-new", "properties": { - "principalId": "[parameters('foundryProjectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - ] + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "registryName": { + "value": "[parameters('name')]" + }, + "principalId": { + "value": "[parameters('foundryProjectPrincipalId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "752655472682753460" + } + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Name of the Azure Container Registry." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Principal receiving AcrPull on the registry." + } + } + }, + "variables": { + "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), variables('acrPullRoleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + } + } + ] + } + } + }, + { + "condition": "[and(not(variables('createAcr')), empty(parameters('existingAcrConnectionName')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-acr-pull-existing", + "subscriptionId": "[parameters('existingAcrSubscriptionId')]", + "resourceGroup": "[parameters('existingAcrResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "registryName": { + "value": "[parameters('existingAcrName')]" + }, + "principalId": { + "value": "[parameters('foundryProjectPrincipalId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "752655472682753460" + } + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Name of the Azure Container Registry." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Principal receiving AcrPull on the registry." + } + } + }, + "variables": { + "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), variables('acrPullRoleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + } + } + ] + } + } } ], "outputs": { "loginServer": { "type": "string", - "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]" + "value": "[if(variables('createAcr'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer, parameters('existingAcrEndpoint'))]" }, "resourceId": { "type": "string", - "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "value": "[variables('resolvedAcrId')]" }, "connectionName": { "type": "string", - "value": "[format('{0}-conn', parameters('name'))]" + "value": "[if(variables('configureAcr'), format('{0}-conn', variables('resolvedAcrName')), parameters('existingAcrConnectionName'))]" } } } }, "dependsOn": [ + "existingFoundryProject", "foundryAccount", - "foundryAccount::project" + "modelDeployments" ] }, "privateEndpointDns": { @@ -1194,7 +1525,7 @@ "mode": "Incremental", "parameters": { "aiAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "location": { "value": "[reference('network').outputs.vnetLocation.value]" @@ -1413,7 +1744,6 @@ } }, "dependsOn": [ - "foundryAccount", "network" ] }, @@ -1429,7 +1759,7 @@ "mode": "Incremental", "parameters": { "foundryAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "foundryProjectName": { "value": "[parameters('foundryProjectName')]" @@ -1566,19 +1896,20 @@ } }, "dependsOn": [ + "existingFoundryProject", "foundryAccount", - "foundryAccount::project" + "modelDeployments" ] } }, "outputs": { "AZURE_AI_PROJECT_ID": { "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]" + "value": "[variables('projectResourceId')]" }, "AZURE_AI_ACCOUNT_NAME": { "type": "string", - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "AZURE_AI_PROJECT_NAME": { "type": "string", @@ -1586,23 +1917,23 @@ }, "AZURE_OPENAI_ENDPOINT": { "type": "string", - "value": "[format('https://{0}.openai.azure.com/', variables('foundryAccountName'))]" + "value": "[format('https://{0}.openai.azure.com/', variables('resolvedFoundryAccountName'))]" }, "FOUNDRY_PROJECT_ENDPOINT": { "type": "string", - "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]" + "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]" }, "AZURE_CONTAINER_REGISTRY_ENDPOINT": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.loginServer.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.loginServer.value, '')]" }, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.resourceId.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.resourceId.value, '')]" }, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.connectionName.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.connectionName.value, '')]" }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", @@ -1610,7 +1941,7 @@ }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", - "value": "[if(not(parameters('enableNetworkIsolation')), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" + "value": "[if(or(not(variables('createFoundryResources')), not(parameters('enableNetworkIsolation'))), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" }, "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { "type": "string", @@ -1620,7 +1951,7 @@ } }, "dependsOn": [ - "resourceGroup" + "managedResourceGroup" ] } }, 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 1cbd1c389f0..7bd767f881c 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 Foundry project service. // // 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 handled by the provider before synthesis. +// azure.yaml by internal/synthesis. Parameters select whether azd creates the +// Foundry account/project or reconciles resources on an existing project. // // Subscription-scoped so the resource group is part of the deployment. This // keeps `azd provision --preview` side-effect free: the resource group shows @@ -58,17 +58,31 @@ param tags object = {} @description('Optional salt to vary resource names across re-provisions.') param resourceTokenSalt string = '' -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) +@description('Foundry project name. New projects require 3-32 characters; existing project names are preserved.') param foundryProjectName string +@description('Foundry account name to reuse. Empty provisions a new account.') +param foundryAccountName string = '' + @description('Model deployments to provision on the Foundry account.') param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false +@allowed([ + 'none' + 'create' + 'existing' +]) +param acrMode string = includeAcr ? 'create' : 'none' +param acrName string = '' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroupName +param existingAcrName string = acrName +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' + @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] @@ -123,7 +137,9 @@ param dnsZonesSubscription string = '' // Resources -resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { +var createFoundryResources = empty(foundryAccountName) + +resource managedResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createFoundryResources) { name: resourceGroupName location: location tags: tags @@ -131,14 +147,22 @@ resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { module resources 'modules/resources.bicep' = { name: 'foundry-resources' - scope: resourceGroup + scope: resourceGroup(resourceGroupName) params: { + foundryAccountName: foundryAccountName location: location tags: tags resourceTokenSalt: resourceTokenSalt foundryProjectName: foundryProjectName deployments: deployments includeAcr: includeAcr + acrMode: acrMode + acrName: acrName + existingAcrSubscriptionId: existingAcrSubscriptionId + existingAcrResourceGroup: existingAcrResourceGroup + existingAcrName: existingAcrName + existingAcrEndpoint: existingAcrEndpoint + existingAcrConnectionName: existingAcrConnectionName connections: connections connectionCredentials: connectionCredentials principalId: principalId @@ -156,11 +180,12 @@ module resources 'modules/resources.bicep' = { dnsZonesResourceGroup: dnsZonesResourceGroup dnsZonesSubscription: dnsZonesSubscription } + dependsOn: [managedResourceGroup] } // Outputs -output AZURE_RESOURCE_GROUP string = resourceGroup.name +output AZURE_RESOURCE_GROUP string = resourceGroupName output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep index f54a2d5a706..309bb56c668 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep @@ -6,19 +6,21 @@ param registryName string @description('Principal receiving AcrPull on the registry.') param principalId string -@description('AcrPull role definition resource ID.') -param roleDefinitionId string - resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { name: registryName } +var acrPullRoleDefinitionId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, principalId, roleDefinitionId) + name: guid(registry.id, principalId, acrPullRoleDefinitionId) scope: registry properties: { principalId: principalId principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId + roleDefinitionId: acrPullRoleDefinitionId } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep index cf9c12543ea..645c7e01f29 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep @@ -19,6 +19,17 @@ param tags object = {} @maxLength(50) param name string +@allowed([ + 'create' + 'existing' +]) +param acrMode string = 'create' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroup().name +param existingAcrName string = name +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' + @description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') param foundryAccountName string @@ -33,15 +44,19 @@ param enableNetworkIsolation bool = false // Variables -// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' +var createAcr = acrMode == 'create' +var configureAcr = createAcr || empty(existingAcrConnectionName) +var resolvedAcrName = createAcr ? name : existingAcrName +var resolvedAcrId = resourceId( + existingAcrSubscriptionId, + existingAcrResourceGroup, + 'Microsoft.ContainerRegistry/registries', + resolvedAcrName ) // Resources -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (createAcr) { name: name location: location tags: tags @@ -64,13 +79,20 @@ resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { // Grant the Foundry project's managed identity AcrPull on this registry so the // hosted agent can pull images using the project identity. -resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) - scope: registry - properties: { +module foundryAcrPullNew 'acr-pull-role-assignment.bicep' = if (createAcr) { + name: 'foundry-acr-pull-new' + params: { + registryName: name + principalId: foundryProjectPrincipalId + } +} + +module foundryAcrPullExisting 'acr-pull-role-assignment.bicep' = if (!createAcr && empty(existingAcrConnectionName)) { + name: 'foundry-acr-pull-existing' + scope: resourceGroup(existingAcrSubscriptionId, existingAcrResourceGroup) + params: { + registryName: existingAcrName principalId: foundryProjectPrincipalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId } } @@ -84,25 +106,26 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview name: foundryProjectName // Project-scoped connection so Foundry can resolve the registry by name. - resource acrConnection 'connections' = { - name: '${name}-conn' + resource acrConnection 'connections' = if (configureAcr) { + name: '${resolvedAcrName}-conn' properties: { category: 'ContainerRegistry' - target: registry.properties.loginServer + target: createAcr ? registry!.properties.loginServer : existingAcrEndpoint authType: 'ManagedIdentity' // RegistryIdentity auth requires both the identity client id (the // project principal) and the registry resource id. credentials: { clientId: foundryProjectPrincipalId - resourceId: registry.id + resourceId: resolvedAcrId } isSharedToAll: true metadata: { - ResourceId: registry.id + ResourceId: resolvedAcrId } } dependsOn: [ - foundryAcrPull + foundryAcrPullNew + foundryAcrPullExisting ] } } @@ -110,6 +133,6 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // Outputs -output loginServer string = registry.properties.loginServer -output resourceId string = registry.id -output connectionName string = foundryAccount::project::acrConnection.name +output loginServer string = createAcr ? registry!.properties.loginServer : existingAcrEndpoint +output resourceId string = resolvedAcrId +output connectionName string = configureAcr ? foundryAccount::project::acrConnection!.name : existingAcrConnectionName diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep index efc8671b98a..98321b247ae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep @@ -27,6 +27,7 @@ type deploymentType = { } } +// @description('Shape of a list of Foundry project connections.') type connectionsType = connectionType[] @@ -39,34 +40,53 @@ type connectionType = { metadata: object? } +// + // Parameters @description('Azure region for all resources.') param location string = resourceGroup().location +@description('Foundry account name to reuse. Empty provisions a new account.') +param foundryAccountName string = '' + @description('Tags applied to all resources.') param tags object = {} @description('Optional salt to vary resource names across re-provisions.') param resourceTokenSalt string = '' -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) +@description('Foundry project name. New projects require 3-32 characters; existing project names are preserved.') param foundryProjectName string @description('Model deployments to provision on the Foundry account.') param deployments deploymentsType = [] +// @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false +@allowed([ + 'none' + 'create' + 'existing' +]) +param acrMode string = includeAcr ? 'create' : 'none' +param acrName string = '' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroup().name +param existingAcrName string = acrName +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' +// +// @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] @description('Credentials keyed by Foundry project connection name.') @secure() param connectionCredentials object = {} +// @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -74,6 +94,7 @@ param principalId string = '' @description('Principal type used in the developer role assignment.') param principalType string = 'User' +// // Network isolation parameters. All default off so an absent network: block in // azure.yaml yields a public account identical to the pre-network template. @@ -112,6 +133,7 @@ param dnsZonesResourceGroup string = '' @description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') param dnsZonesSubscription string = '' +// // Variables @@ -121,14 +143,18 @@ var resourceToken = empty(resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') -var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' +var createFoundryResources = empty(foundryAccountName) +var resolvedFoundryAccountName = createFoundryResources ? '${abbrs.cognitiveServicesAccounts}${resourceToken}' : foundryAccountName +var projectResourceId = resourceId('Microsoft.CognitiveServices/accounts/projects', resolvedFoundryAccountName, foundryProjectName) // Egress: byo injects the agent into a customer subnet; managed uses the // Microsoft-managed network. Ingress: an account private endpoint is always // provisioned when isolation is on, so the data plane is never left public. -var useByoNetwork = enableNetworkIsolation && !useManagedEgress -var useManagedNetwork = enableNetworkIsolation && useManagedEgress -var disablePublicDataPlaneAccess = enableNetworkIsolation +// +var useByoNetwork = createFoundryResources && enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = createFoundryResources && enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = createFoundryResources && enableNetworkIsolation +// // Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles var cognitiveServicesUserRoleId = subscriptionResourceId( @@ -141,6 +167,7 @@ var cognitiveServicesUserRoleId = subscriptionResourceId( // Customer VNet wiring: reference the VNet and create or reference the agent // (byo egress only) + private-endpoint subnets. Runs whenever isolation is on // because the account private endpoint is always provisioned. +// module network 'network.bicep' = if (enableNetworkIsolation) { name: 'foundry-network' params: { @@ -153,6 +180,7 @@ module network 'network.bicep' = if (enableNetworkIsolation) { createPESubnet: createPESubnet } } +// // networkInjections wires the account into the agent subnet (byo) or the // Microsoft-managed network (managed). Null when isolation is off. @@ -164,6 +192,7 @@ module network 'network.bicep' = if (enableNetworkIsolation) { // networkInjections to its typed contract (ARM what-if does not catch this). // The deterministic id avoids the unresolved reference; an explicit dependsOn // on the network module preserves ordering (the subnet must exist first). +// var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' var agentNetworkInjections = useByoNetwork ? [ @@ -181,9 +210,17 @@ var agentNetworkInjections = useByoNetwork } ] : null) +// -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { - name: foundryAccountName +module projectNameValidation 'validate-project-name.bicep' = if (createFoundryResources) { + name: 'validate-foundry-project-name' + params: { + name: foundryProjectName + } +} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = if (createFoundryResources) { + name: resolvedFoundryAccountName location: location tags: tags sku: { @@ -195,7 +232,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } properties: { allowProjectManagement: true - customSubDomainName: foundryAccountName + customSubDomainName: resolvedFoundryAccountName publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' disableLocalAuth: true networkAcls: { @@ -204,26 +241,17 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { virtualNetworkRules: [] ipRules: [] } + // networkInjections: agentNetworkInjections + // } // The account injects into the agent subnet via a deterministic id (above), // so Bicep cannot infer the dependency on the network module that creates // that subnet. Declare it explicitly so the subnet exists before injection. - dependsOn: useByoNetwork ? [network] : [] - - // Sequential model deployment creation; ARM throttles concurrent - // deployments on the same account. - @batchSize(1) - resource modelDeployments 'deployments' = [ - for d in deployments: { - name: d.name - properties: { - model: d.model - } - sku: d.sku - } - ] + // + dependsOn: [projectNameValidation, network] + // resource project 'projects' = { name: foundryProjectName @@ -244,12 +272,35 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } } +resource existingFoundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (!createFoundryResources) { + name: resolvedFoundryAccountName +} + +resource existingFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (!createFoundryResources) { + parent: existingFoundryAccount + name: foundryProjectName +} + +@batchSize(1) +#disable-next-line use-parent-property // Parent switches between created and existing account resources. +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + name: '${resolvedFoundryAccountName}/${d.name}' + properties: { + model: d.model + } + sku: d.sku + dependsOn: [foundryAccount, existingFoundryAccount] + } +] + // Managed-network isolation (managed egress only). Applies the chosen outbound // isolation mode to the Microsoft-managed VNet that hosts the agent runtime. // Only deployed when an explicit isolationMode is requested; otherwise the // platform default applies. Note: AllowOnlyApprovedOutbound additionally // requires approved outbound rules for the agent to reach dependent resources; // for the platform-managed stores used here those are managed by the platform. +// resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = if (useManagedNetwork && !empty(managedIsolationMode)) { parent: foundryAccount @@ -260,27 +311,44 @@ resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetw } } } +// + +// +var resolvedAcrName = acrMode == 'create' && empty(acrName) + ? '${abbrs.containerRegistryRegistries}${resourceToken}' + : (empty(existingAcrName) ? acrName : existingAcrName) -module acr 'acr.bicep' = if (includeAcr) { +module acr 'acr.bicep' = if (acrMode != 'none') { name: 'acr' params: { location: location tags: tags - name: '${abbrs.containerRegistryRegistries}${resourceToken}' - foundryAccountName: foundryAccount.name - foundryProjectName: foundryAccount::project.name - foundryProjectPrincipalId: foundryAccount::project.identity.principalId - enableNetworkIsolation: enableNetworkIsolation + acrMode: acrMode == 'existing' ? 'existing' : 'create' + name: resolvedAcrName + existingAcrSubscriptionId: existingAcrSubscriptionId + existingAcrResourceGroup: existingAcrResourceGroup + existingAcrName: resolvedAcrName + existingAcrEndpoint: existingAcrEndpoint + existingAcrConnectionName: existingAcrConnectionName + foundryAccountName: resolvedFoundryAccountName + foundryProjectName: foundryProjectName + foundryProjectPrincipalId: reference(projectResourceId, '2025-04-01-preview', 'full').identity.principalId + // + enableNetworkIsolation: createFoundryResources && enableNetworkIsolation + // } + dependsOn: [foundryAccount, existingFoundryProject, modelDeployments] } +// // Account private endpoint + AI private DNS zones. The account is always given a // private endpoint when isolation is on (byo or managed egress); dependent // stores stay platform-managed, so only the account gets an endpoint. +// module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { name: 'foundry-private-endpoint-dns' params: { - aiAccountName: foundryAccount.name + aiAccountName: resolvedFoundryAccountName location: network!.outputs.vnetLocation vnetId: network!.outputs.vnetId peSubnetId: network!.outputs.peSubnetId @@ -289,25 +357,29 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat dnsZonesSubscription: dnsZonesSubscription } } +// // Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. +// module projectConnections 'connections.bicep' = if (!empty(connections)) { name: 'foundry-connections' params: { - foundryAccountName: foundryAccount.name - foundryProjectName: foundryAccount::project.name + foundryAccountName: resolvedFoundryAccountName + foundryProjectName: foundryProjectName connections: connections connectionCredentials: connectionCredentials } + dependsOn: [foundryAccount, existingFoundryProject, modelDeployments] } +// // Grant the developer Cognitive Services User on the project so they can call // the Foundry data-plane (chat/completions, agents API) from their machine. -resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { - name: guid(foundryAccount::project.id, principalId, cognitiveServicesUserRoleId) +resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (createFoundryResources && !empty(principalId)) { + name: guid(projectResourceId, principalId, cognitiveServicesUserRoleId) scope: foundryAccount::project properties: { principalId: principalId @@ -318,14 +390,20 @@ resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments // Outputs -output AZURE_AI_PROJECT_ID string = foundryAccount::project.id -output AZURE_AI_ACCOUNT_NAME string = foundryAccount.name -output AZURE_AI_PROJECT_NAME string = foundryAccount::project.name -output AZURE_OPENAI_ENDPOINT string = 'https://${foundryAccount.name}.openai.azure.com/' -output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.services.ai.azure.com/api/projects/${foundryAccount::project.name}' -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = resolvedFoundryAccountName +output AZURE_AI_PROJECT_NAME string = foundryProjectName +output AZURE_OPENAI_ENDPOINT string = 'https://${resolvedFoundryAccountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = 'https://${resolvedFoundryAccountName}.services.ai.azure.com/api/projects/${foundryProjectName}' +// +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode != 'none' ? acr!.outputs.loginServer : '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode != 'none' ? acr!.outputs.resourceId : '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = acrMode != 'none' ? acr!.outputs.connectionName : '' +// +// output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames -output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +// +// +output AZURE_FOUNDRY_NETWORK_MODE string = !createFoundryResources || !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' +// diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/validate-project-name.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/validate-project-name.bicep new file mode 100644 index 00000000000..d4aebd9bfed --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/validate-project-name.bicep @@ -0,0 +1,6 @@ +@description('Foundry project name. 3-32 characters for newly created projects.') +@minLength(3) +@maxLength(32) +param name string + +output validatedName string = name diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go index 70bc2db9ae7..4faed8f67a1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go @@ -11,12 +11,9 @@ import "embed" // needs a bicep CLI at user runtime. // //go:generate bicep build templates/main.bicep --outfile templates/main.arm.json -//go:generate bicep build templates/brownfield.bicep --outfile templates/brownfield.arm.json //go:embed templates/main.bicep //go:embed templates/main.arm.json -//go:embed templates/brownfield.bicep -//go:embed templates/brownfield.arm.json //go:embed templates/abbreviations.json //go:embed templates/modules/*.bicep var templatesFS embed.FS @@ -49,10 +46,3 @@ func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } func ARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/main.arm.json") } - -// BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// reconciles synthesized resources on an EXISTING Foundry account (referenced, -// not created). -func BrownfieldARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/brownfield.arm.json") -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index d19f74b64bc..55d2e91810a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -67,19 +67,22 @@ type FoundryProvisioningProvider struct { azdClient *azdext.AzdClient // Populated by Initialize. - projectPath string - synthResult *synthesis.Result // nil when onDiskSource != nil - envName string - subID string - location string - rgName string - rgExplicit bool // AZURE_RESOURCE_GROUP came from env, not the rg- default - foundryName string - principalID string - credential azcore.TokenCredential - tenantID string // resolved lazily by ensureCredential; surfaced as AZURE_TENANT_ID - armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set - onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists + projectPath string + synthResult *synthesis.Result // nil when onDiskSource != nil + envName string + subID string + location string + rgName string + rgExplicit bool // AZURE_RESOURCE_GROUP came from env, not the rg- default + foundryName string + principalID string + brownfieldAccount string + brownfieldACR *existingACR + brownfieldCreateACR bool + credential azcore.TokenCredential + tenantID string // resolved lazily by ensureCredential; surfaced as AZURE_TENANT_ID + armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set + onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists // Lazily constructed on first compile. nil until needed. bicepCliInstance bicepCompiler @@ -152,25 +155,6 @@ func (p *FoundryProvisioningProvider) Initialize( return err } - // User-authored greenfield Bicep owns its own parameter contract, so preserve - // the existing behavior of skipping synthesis for that path. Brownfield still - // synthesizes azure.yaml below because its embedded template is provider-owned. - if p.onDiskTemplatePresent() { - endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectPath) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf("resolve existing Foundry project endpoint: %s", err), - "fix the project service configuration in azure.yaml", - ) - } - if endpoint == "" { - log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ - "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) - return p.resolveEnv(ctx) - } - } - res, err := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: rawYAML, ServiceName: svcName, @@ -194,12 +178,13 @@ func (p *FoundryProvisioningProvider) Initialize( } p.synthResult = res if res.Mode == synthesis.ModeBrownfield { - // network: has no effect when the project is referenced by endpoint. if res.NetworkConfigured { - log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ - "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("service %q cannot configure network: while reusing an existing Foundry project", svcName), + "remove network: or remove endpoint: to provision a new network-isolated project", + ) } - return p.resolveEnvName(ctx) } tmplBytes, err := synthesis.ARMTemplate() @@ -218,7 +203,26 @@ func (p *FoundryProvisioningProvider) Initialize( } p.armTemplate = tmpl - return p.resolveEnv(ctx) + if res.Mode == synthesis.ModeBrownfield { + if err := p.resolveEnvName(ctx); err != nil { + return err + } + p.brownfieldACR, err = p.brownfieldExistingACR(ctx) + if err != nil { + return err + } + p.brownfieldCreateACR = p.brownfieldACRRequested(ctx) && p.brownfieldACR == nil + if !p.brownfieldNeedsProvisioning() { + p.foundryName = p.brownfieldProjectName() + return nil + } + return p.resolveBrownfieldDeploymentContext(ctx) + } + + if err := p.resolveEnv(ctx); err != nil { + return err + } + return nil } // networkEnvMap returns a best-effort name -> value map of the azd environment @@ -564,6 +568,14 @@ func (p *FoundryProvisioningProvider) brownfieldDeployments() []synthesis.Deploy return p.synthResult.Deployments } +func (p *FoundryProvisioningProvider) brownfieldNeedsProvisioning() bool { + return p.isBrownfield() && + (len(p.synthResult.Deployments) > 0 || len(p.synthResult.Connections) > 0 || + p.brownfieldCreateACR || + (p.brownfieldACR != nil && p.brownfieldACR.connectionName == "") || + p.onDiskTemplatePresent()) +} + // State returns the most recent deployment's outputs as the current state, // or empty state when no deployment exists yet. func (p *FoundryProvisioningProvider) State( @@ -571,12 +583,21 @@ func (p *FoundryProvisioningProvider) State( options *azdext.ProvisioningStateOptions, ) (*azdext.ProvisioningStateResult, error) { if p.isBrownfield() { - return &azdext.ProvisioningStateResult{ - State: &azdext.ProvisioningState{ - Outputs: p.withTenantOutput(brownfieldOutputs(p.synthResult.Endpoint)), - Resources: []*azdext.ProvisioningResource{}, - }, - }, nil + if !p.brownfieldNeedsProvisioning() { + return p.brownfieldState(nil), nil + } + client, err := p.deploymentsClient(ctx) + if err != nil { + return nil, err + } + resp, err := client.GetAtSubscriptionScope(ctx, p.brownfieldDeploymentName(), nil) + if err != nil { + if isNotFound(err) { + return p.brownfieldState(nil), nil + } + return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentGet) + } + return p.brownfieldState(resp.Properties), nil } client, err := p.deploymentsClient(ctx) @@ -607,6 +628,27 @@ func (p *FoundryProvisioningProvider) State( }, nil } +func (p *FoundryProvisioningProvider) brownfieldState( + properties *armresources.DeploymentPropertiesExtended, +) *azdext.ProvisioningStateResult { + outputs := brownfieldOutputs(p.synthResult.Endpoint) + for name, value := range armOutputsToProto(deploymentOutputs(properties)) { + if value == nil || value.Value == "" { + continue + } + if _, canonical := outputs[name]; canonical { + continue + } + outputs[name] = value + } + return &azdext.ProvisioningStateResult{ + State: &azdext.ProvisioningState{ + Outputs: p.withTenantOutput(outputs), + Resources: armResourcesToProto(deploymentResources(properties)), + }, + } +} + // Deploy runs an ARM deployment of the resolved template (embedded ARM JSON // or the user's on-disk Bicep) with the appropriate parameters, streaming // progress to the caller. @@ -614,8 +656,19 @@ func (p *FoundryProvisioningProvider) Deploy( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDeployResult, error) { - if p.isBrownfield() { - return p.deployBrownfield(ctx, progress) + if p.isBrownfield() && !p.brownfieldNeedsProvisioning() { + progress("Using existing Foundry project (endpoint set); skipping provisioning") + if p.azdClient != nil { + p.resolveBrownfieldSubscription(ctx) + if err := p.ensureCredential(ctx); err != nil { + log.Printf("[debug] best-effort tenant lookup for brownfield deploy: %v", err) + } + } + return &azdext.ProvisioningDeployResult{ + Deployment: &azdext.ProvisioningDeployment{ + Outputs: p.withTenantOutput(brownfieldOutputs(p.synthResult.Endpoint)), + }, + }, nil } progress("Preparing Foundry provisioning template...") @@ -653,6 +706,9 @@ func (p *FoundryProvisioningProvider) Deploy( } name := p.deploymentName() + if p.isBrownfield() { + name = p.brownfieldDeploymentName() + } progress(fmt.Sprintf("Starting ARM deployment %q...", name)) poller, err := client.BeginCreateOrUpdateAtSubscriptionScope(ctx, name, dep, nil) @@ -668,106 +724,36 @@ func (p *FoundryProvisioningProvider) Deploy( progress("Foundry deployment complete") return &azdext.ProvisioningDeployResult{ - Deployment: &azdext.ProvisioningDeployment{ - Parameters: armInputsToProto(src.parameters), - Outputs: p.withTenantOutput(armOutputsToProto(deploymentOutputs(resp.Properties))), - }, + Deployment: p.deploymentResult(src, resp.Properties), }, nil } -// deployBrownfield handles the existing-project (endpoint:) Deploy path. Via a -// single resource-group-scoped ARM deployment against the existing (referenced, -// never re-created) account it reconciles declared model deployments and, when -// init flagged "acr" as pending provision, creates a container registry for the -// hosted agent. With neither needed it skips provisioning and only surfaces the -// endpoint (plus a best-effort tenant). -func (p *FoundryProvisioningProvider) deployBrownfield( - ctx context.Context, - progress grpcbroker.ProgressFunc, -) (*azdext.ProvisioningDeployResult, error) { - createACR := p.brownfieldACRRequested(ctx) - deployments := p.brownfieldDeployments() - connections := p.synthResult.Connections - - if len(deployments) == 0 && !createACR && len(connections) == 0 { - progress("Using existing Foundry project (endpoint set); skipping provisioning") - // Best-effort tenant lookup so AZURE_TENANT_ID is still surfaced for the - // existing-project path (no resources are provisioned here). Log on - // failure so a stale login is visible in the debug trace rather than - // surfacing later as a confusing "AZURE_TENANT_ID is not set" error. - if err := p.ensureCredential(ctx); err != nil { - log.Printf("[debug] best-effort tenant lookup for brownfield deploy: %v", err) - } - return &azdext.ProvisioningDeployResult{ - Deployment: &azdext.ProvisioningDeployment{ - Outputs: p.withTenantOutput(brownfieldOutputs(p.synthResult.Endpoint)), - }, - }, nil - } - - progress(brownfieldReconcileMessage(len(deployments) > 0, createACR, len(connections) > 0)) - - // Locate the existing account (subscription, resource group, account name). - // resolveBrownfieldTarget sets p.subID, which the deployments client needs. - rg, account, err := p.resolveBrownfieldTarget(ctx) - if err != nil { - return nil, err - } - - tmpl, err := brownfieldARMTemplate() - if err != nil { - return nil, err - } - params, err := p.brownfieldParams(ctx, account, rg, createACR) - if err != nil { - return nil, err - } - - dep := armresources.Deployment{ - Properties: &armresources.DeploymentProperties{ - Template: tmpl, - Parameters: params, - Mode: new(armresources.DeploymentModeIncremental), - }, - Tags: map[string]*string{ - "azd-env-name": new(p.envName), - }, - } - - client, err := p.deploymentsClient(ctx) - if err != nil { - return nil, err - } - - name := p.brownfieldDeploymentName() - progress(fmt.Sprintf("Starting deployment %q on %s...", name, account)) - - poller, err := client.BeginCreateOrUpdate(ctx, rg, name, dep, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) +func (p *FoundryProvisioningProvider) deploymentResult( + src *templateSource, + properties *armresources.DeploymentPropertiesExtended, +) *azdext.ProvisioningDeployment { + parameters := armInputsToProto(src.parameters) + for name := range secureParameterNames(src.armTemplate) { + delete(parameters, name) } - resp, err := pollWithProgress(ctx, poller, progress, "Brownfield deployment in progress") - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) + outputs := p.withTenantOutput(armOutputsToProto(deploymentOutputs(properties))) + if p.isBrownfield() { + outputs = p.brownfieldState(properties).State.Outputs } + return &azdext.ProvisioningDeployment{Parameters: parameters, Outputs: outputs} +} - progress("Existing Foundry project reconciled") - - // Merge endpoint/project outputs with any ACR outputs the template emitted, - // skipping empty values (includeAcr=false leg) so we don't clobber the env. - outputs := brownfieldOutputs(p.synthResult.Endpoint) - for k, v := range armOutputsToProto(deploymentOutputs(resp.Properties)) { - if v != nil && v.Value == "" { - continue +func secureParameterNames(tmpl map[string]any) map[string]struct{} { + secure := map[string]struct{}{} + parameters, _ := tmpl["parameters"].(map[string]any) + for name, raw := range parameters { + definition, _ := raw.(map[string]any) + paramType, _ := definition["type"].(string) + if strings.EqualFold(paramType, "secureString") || strings.EqualFold(paramType, "secureObject") { + secure[name] = struct{}{} } - outputs[k] = v } - - return &azdext.ProvisioningDeployResult{ - Deployment: &azdext.ProvisioningDeployment{ - Outputs: p.withTenantOutput(outputs), - }, - }, nil + return secure } // brownfieldReconcileMessage builds the progress line for what deployBrownfield @@ -775,112 +761,69 @@ func (p *FoundryProvisioningProvider) deployBrownfield( // true (the caller's guard skips provisioning otherwise), so the message never // claims work that isn't actually happening -- e.g. a brownfield project with // only a pending connection no longer says "reconciling model deployments". -func brownfieldReconcileMessage(hasDeployments, createACR, hasConnections bool) string { - var parts []string - if hasDeployments { - parts = append(parts, "model deployments") - } - if createACR { - parts = append(parts, "container registry") - } - if hasConnections { - parts = append(parts, "connections") - } - return fmt.Sprintf("Using existing Foundry project; reconciling %s...", strings.Join(parts, ", ")) -} - -// brownfieldParams builds the ARM parameter set for brownfield.arm.json, shared -// by the Deploy and Preview paths. ACR params are added only when createACR. -func (p *FoundryProvisioningProvider) brownfieldParams( - ctx context.Context, account, rg string, createACR bool, -) (map[string]any, error) { - deployments := p.brownfieldDeployments() - params := map[string]any{ - "accountName": map[string]any{"value": account}, - "deployments": map[string]any{"value": deployments}, - "connections": map[string]any{"value": p.synthResult.Connections}, - "connectionCredentials": map[string]any{"value": p.synthResult.ConnectionCredentials}, - // projectName feeds the unconditional existing `foundryAccountPreview::project` - // resource, so it must always be set -- even on the model-deployments-only - // reconcile path. Omitting it collapses the resource name to "/" - // and fails ARM template validation with InvalidTemplate. - "projectName": map[string]any{"value": p.brownfieldProjectName()}, - } - if createACR { - params["includeAcr"] = map[string]any{"value": true} - params["acrName"] = map[string]any{"value": p.brownfieldACRName(account)} - params["tags"] = map[string]any{"value": map[string]string{"azd-env-name": p.envName}} - // Only set location when resolved; an empty value would override the - // template default (resourceGroup().location) and fail the deployment. - if loc := p.brownfieldLocation(ctx, rg); loc != "" { - params["location"] = map[string]any{"value": loc} - } - } - return params, nil +type existingACR struct { + subscriptionID string + resourceGroup string + name string + endpoint string + connectionName string } -// previewBrownfield runs a resource-group-scoped what-if on brownfield.arm.json -// so `azd provision --preview` shows the container registry and/or model -// deployments that Deploy would create on the existing account. With nothing to -// provision it reports an empty preview. -func (p *FoundryProvisioningProvider) previewBrownfield( - ctx context.Context, - progress grpcbroker.ProgressFunc, -) (*azdext.ProvisioningPreviewResult, error) { - createACR := p.brownfieldACRRequested(ctx) - if len(p.brownfieldDeployments()) == 0 && !createACR && len(p.synthResult.Connections) == 0 { - progress("Using existing Foundry project (endpoint set); nothing to provision") - return &azdext.ProvisioningPreviewResult{ - Preview: &azdext.ProvisioningDeploymentPreview{}, - }, nil +func (p *FoundryProvisioningProvider) brownfieldExistingACR(ctx context.Context) (*existingACR, error) { + if p.azdClient == nil { + return nil, nil } - - progress("Computing deployment plan...") - - rg, account, err := p.resolveBrownfieldTarget(ctx) - if err != nil { - return nil, err + resourceID, _ := p.envValue(ctx, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID") + endpoint, _ := p.envValue(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT") + if resourceID == "" && endpoint == "" { + return nil, nil } - tmpl, err := brownfieldARMTemplate() - if err != nil { - return nil, err + if resourceID == "" { + return nil, exterrors.Dependency( + exterrors.CodeInvalidServiceConfig, + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID is required when reusing an existing container registry", + "re-run `azd ai agent init` to select the registry, or set its full ARM resource ID", + ) } - params, err := p.brownfieldParams(ctx, account, rg, createACR) - if err != nil { - return nil, err + resID, err := arm.ParseResourceID(resourceID) + if err != nil || resID.SubscriptionID == "" || resID.ResourceGroupName == "" || resID.Name == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("parse AZURE_CONTAINER_REGISTRY_RESOURCE_ID %q as an ACR resource ID", resourceID), + "verify it is a full Microsoft.ContainerRegistry/registries ARM resource ID", + ) } - - client, err := p.deploymentsClient(ctx) - if err != nil { - return nil, err + if !strings.EqualFold(resID.ResourceType.Namespace, "Microsoft.ContainerRegistry") || + !strings.EqualFold(resID.ResourceType.Type, "registries") { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("AZURE_CONTAINER_REGISTRY_RESOURCE_ID %q is not a container registry resource ID", resourceID), + "set it to a Microsoft.ContainerRegistry/registries resource ID", + ) } - - whatIf := armresources.DeploymentWhatIf{ - Properties: &armresources.DeploymentWhatIfProperties{ - Template: tmpl, - Parameters: params, - Mode: new(armresources.DeploymentModeIncremental), - }, + if endpoint == "" { + return nil, exterrors.Dependency( + exterrors.CodeInvalidServiceConfig, + "AZURE_CONTAINER_REGISTRY_ENDPOINT is required when reusing an existing container registry", + "re-run `azd ai agent init` to select the registry and populate its login server", + ) } + connectionName, _ := p.envValue(ctx, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME") + return &existingACR{ + subscriptionID: resID.SubscriptionID, + resourceGroup: resID.ResourceGroupName, + name: resID.Name, + endpoint: endpoint, + connectionName: connectionName, + }, nil +} - poller, err := client.BeginWhatIf(ctx, rg, p.brownfieldDeploymentName(), whatIf, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentWhatIf) - } - resp, err := pollWithProgress(ctx, poller, progress, "What-if analysis in progress") +func (p *FoundryProvisioningProvider) brownfieldExistingACRNeedsConfiguration(ctx context.Context) (bool, error) { + existing, err := p.brownfieldExistingACR(ctx) if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentWhatIf) - } - if err := whatIfFailure(resp.WhatIfOperationResult); err != nil { - return nil, err + return false, err } - - return &azdext.ProvisioningPreviewResult{ - Preview: &azdext.ProvisioningDeploymentPreview{ - Summary: summarizeWhatIf(resp.WhatIfOperationResult), - Changes: whatIfChanges(resp.WhatIfOperationResult), - }, - }, nil + return existing != nil && existing.connectionName == "", nil } // brownfieldACRRequested reports whether the brownfield Deploy should create a @@ -978,9 +921,54 @@ func (p *FoundryProvisioningProvider) resolveBrownfieldTarget(ctx context.Contex } p.subID = resID.SubscriptionID + endpointURL, _ := url.Parse(p.synthResult.Endpoint) + endpointAccount := strings.Split(endpointURL.Hostname(), ".")[0] + endpointProject := projectNameFromEndpoint(p.synthResult.Endpoint) + if !strings.EqualFold(endpointAccount, resID.Parent.Name) || + (endpointProject != "" && !strings.EqualFold(endpointProject, resID.Name)) { + return "", "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_AI_PROJECT_ID does not match the Foundry project endpoint configured in azure.yaml", + "re-run `azd ai agent init` against the intended existing project", + ) + } return resID.ResourceGroupName, resID.Parent.Name, nil } +func (p *FoundryProvisioningProvider) resolveBrownfieldDeploymentContext(ctx context.Context) error { + rg, account, err := p.resolveBrownfieldTarget(ctx) + if err != nil { + return err + } + p.rgName = rg + p.brownfieldAccount = account + p.foundryName = p.brownfieldProjectName() + if p.location, _ = p.envValue(ctx, envKeyLocation); p.location == "" { + p.location = p.resourceGroupLocation(ctx, rg) + } + if p.location == "" { + return exterrors.Dependency( + exterrors.CodeMissingAzureLocation, + fmt.Sprintf("could not determine the location of existing resource group %q", rg), + fmt.Sprintf("set it with `azd env set %s `", envKeyLocation), + ) + } + return nil +} + +func (p *FoundryProvisioningProvider) resolveBrownfieldSubscription(ctx context.Context) { + if p.subID != "" || p.azdClient == nil { + return + } + projectID, err := p.envValue(ctx, "AZURE_AI_PROJECT_ID") + if err != nil { + return + } + if resID, err := arm.ParseResourceID(projectID); err == nil { + p.subID = resID.SubscriptionID + } +} + // envValue reads a single value from the active azd environment, trimmed. func (p *FoundryProvisioningProvider) envValue(ctx context.Context, key string) (string, error) { resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ @@ -993,26 +981,6 @@ func (p *FoundryProvisioningProvider) envValue(ctx context.Context, key string) return strings.TrimSpace(resp.Value), nil } -// brownfieldARMTemplate loads and parses the embedded resource-group-scoped ARM -// template that creates model deployments on an existing Foundry account. -func brownfieldARMTemplate() (map[string]any, error) { - tmplBytes, err := synthesis.BrownfieldARMTemplate() - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("load embedded brownfield ARM template: %s", err), - ) - } - var tmpl map[string]any - if err := json.Unmarshal(tmplBytes, &tmpl); err != nil { - return nil, exterrors.Internal( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("parse embedded brownfield ARM template: %s", err), - ) - } - return tmpl, nil -} - // resolveTemplate returns the on-disk Bicep source if present, else the // embedded ARM JSON. Lazy: compiles on-disk Bicep on first call and caches // the result on the provider so re-runs skip the bicep CLI. @@ -1042,7 +1010,11 @@ func (p *FoundryProvisioningProvider) resolveTemplate( if p.onDiskSource != nil { log.Printf("[debug] foundry provider: using on-disk template at %s", p.onDiskSource.sourcePath) - merged := mergeParameters(p.onDiskSource.parameters, p.armParameters()) + if err := validateUnifiedTemplateContract(p.onDiskSource.armTemplate, p.isBrownfield()); err != nil { + return nil, err + } + merged := mergeUnifiedParameters(p.onDiskSource.parameters, p.armParameters()) + merged = filterDeclaredParameters(merged, p.onDiskSource.armTemplate) return &templateSource{ mode: p.onDiskSource.mode, armTemplate: p.onDiskSource.armTemplate, @@ -1069,6 +1041,68 @@ func (p *FoundryProvisioningProvider) resolveTemplate( }, nil } +func filterDeclaredParameters(parameters map[string]any, template map[string]any) map[string]any { + declared, _ := template["parameters"].(map[string]any) + out := make(map[string]any, len(parameters)) + for name, value := range parameters { + if _, ok := declared[name]; ok { + out[name] = value + } + } + return out +} + +func validateUnifiedTemplateContract(tmpl map[string]any, brownfield bool) error { + schema, _ := tmpl["$schema"].(string) + if !strings.Contains(schema, "subscriptionDeploymentTemplate.json#") { + return exterrors.Validation( + exterrors.CodeOnDiskParametersInvalid, + "Foundry infrastructure Bicep must target subscription scope", + "restore `targetScope = 'subscription'` in infra/main.bicep", + ) + } + parameters, _ := tmpl["parameters"].(map[string]any) + required := []string{"resourceGroupName", "foundryProjectName", "deployments"} + if brownfield { + required = append(required, "foundryAccountName") + } + for _, name := range required { + if _, ok := parameters[name]; !ok { + return exterrors.Validation( + exterrors.CodeOnDiskParametersInvalid, + fmt.Sprintf("Foundry infrastructure Bicep is missing required parameter %q", name), + "restore the parameter from the generated infra/main.bicep template", + ) + } + } + return nil +} + +var unifiedProtectedParameters = map[string]struct{}{ + "resourceGroupName": {}, + "foundryAccountName": {}, + "foundryProjectName": {}, + "acrMode": {}, + "acrName": {}, + "existingAcrSubscriptionId": {}, + "existingAcrResourceGroup": {}, + "existingAcrName": {}, + "existingAcrEndpoint": {}, + "existingAcrConnectionName": {}, +} + +func mergeUnifiedParameters(userParams, hostParams map[string]any) map[string]any { + out := mergeParameters(userParams, hostParams) + for name := range unifiedProtectedParameters { + if value, ok := hostParams[name]; ok { + out[name] = value + } else { + delete(out, name) + } + } + return out +} + // bicepCli lazily constructs a *bicep.Cli using azd-core's download-on-demand // wrapper. The first call on a machine without bicep triggers a download under // a spinner; subsequent calls reuse the cached binary. @@ -1098,13 +1132,19 @@ func (p *FoundryProvisioningProvider) bicepCli() bicepCompiler { // canonical names so a user's ${AZURE_LOCATION} reference works even before // their azd env file persists them. func (p *FoundryProvisioningProvider) envValues(ctx context.Context) map[string]string { - out := map[string]string{ + out := map[string]string{} + canonical := map[string]string{ envKeySubscriptionID: p.subID, envKeyLocation: p.location, envKeyResourceGroup: p.rgName, envKeyProjectName: p.foundryName, envKeyPrincipalID: p.principalID, } + for key, value := range canonical { + if value != "" { + out[key] = value + } + } // Also surface the broader azd env. Best-effort: fall back to the // canonical values above if the env service is unavailable. if p.azdClient == nil { @@ -1149,8 +1189,11 @@ func (p *FoundryProvisioningProvider) Preview( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningPreviewResult, error) { - if p.isBrownfield() { - return p.previewBrownfield(ctx, progress) + if p.isBrownfield() && !p.brownfieldNeedsProvisioning() { + progress("Using existing Foundry project (endpoint set); nothing to provision") + return &azdext.ProvisioningPreviewResult{ + Preview: &azdext.ProvisioningDeploymentPreview{}, + }, nil } progress("Computing deployment plan...") @@ -1174,7 +1217,11 @@ func (p *FoundryProvisioningProvider) Preview( }, } - poller, err := client.BeginWhatIfAtSubscriptionScope(ctx, p.deploymentName(), whatIf, nil) + name := p.deploymentName() + if p.isBrownfield() { + name = p.brownfieldDeploymentName() + } + poller, err := client.BeginWhatIfAtSubscriptionScope(ctx, name, whatIf, nil) if err != nil { return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentWhatIf) } @@ -1189,12 +1236,27 @@ func (p *FoundryProvisioningProvider) Preview( return nil, err } + changes := whatIfChanges(resp.WhatIfOperationResult) + if p.isBrownfield() { + for _, synthetic := range []*azdext.ProvisioningDeploymentPreviewChange{ + {ChangeType: "Ignore", ResourceType: "Microsoft.CognitiveServices/accounts", Name: p.brownfieldAccount}, + {ChangeType: "Ignore", ResourceType: "Microsoft.CognitiveServices/accounts/projects", + Name: p.brownfieldAccount + "/" + p.foundryName}, + } { + if !slices.ContainsFunc(changes, func(change *azdext.ProvisioningDeploymentPreviewChange) bool { + return strings.EqualFold(change.ResourceType, synthetic.ResourceType) && + strings.EqualFold(change.Name, synthetic.Name) + }) { + changes = append([]*azdext.ProvisioningDeploymentPreviewChange{synthetic}, changes...) + } + } + } // Summary is kept for diagnostics/telemetry; the core preview UX renders // the structured Changes (colored per change type). return &azdext.ProvisioningPreviewResult{ Preview: &azdext.ProvisioningDeploymentPreview{ Summary: summarizeWhatIf(resp.WhatIfOperationResult), - Changes: whatIfChanges(resp.WhatIfOperationResult), + Changes: changes, }, }, nil } @@ -1666,9 +1728,28 @@ func (p *FoundryProvisioningProvider) armParameters() map[string]any { if p.synthResult == nil { return out } + if p.isBrownfield() { + out["foundryAccountName"] = map[string]any{"value": p.brownfieldAccount} + out["principalId"] = map[string]any{"value": ""} + } for k, v := range p.synthResult.Parameters() { out[k] = map[string]any{"value": v} } + if p.isBrownfield() { + if p.brownfieldCreateACR { + out["acrMode"] = map[string]any{"value": "create"} + out["acrName"] = map[string]any{"value": p.brownfieldACRName(p.brownfieldAccount)} + } else if existing := p.brownfieldACR; existing != nil { + out["acrMode"] = map[string]any{"value": "existing"} + out["existingAcrSubscriptionId"] = map[string]any{"value": existing.subscriptionID} + out["existingAcrResourceGroup"] = map[string]any{"value": existing.resourceGroup} + out["existingAcrName"] = map[string]any{"value": existing.name} + out["existingAcrEndpoint"] = map[string]any{"value": existing.endpoint} + out["existingAcrConnectionName"] = map[string]any{"value": existing.connectionName} + } else { + out["acrMode"] = map[string]any{"value": "none"} + } + } return out } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go index 2f8c9220546..8bb158e2a5d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_brownfield_acr_test.go @@ -6,11 +6,13 @@ package provisioning import ( "context" "net" - "strings" + "os" + "path/filepath" "testing" "azure.ai.projects/internal/synthesis" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -45,16 +47,37 @@ type kvEnvServer struct { values map[string]string } +type kvAccountServer struct { + azdext.UnimplementedAccountServiceServer +} + +func (s *kvAccountServer) LookupTenant( + context.Context, *azdext.LookupTenantRequest, +) (*azdext.LookupTenantResponse, error) { + return &azdext.LookupTenantResponse{TenantId: "tenant-id"}, nil +} + func (s *kvEnvServer) GetValue( _ context.Context, req *azdext.GetEnvRequest, ) (*azdext.KeyValueResponse, error) { return &azdext.KeyValueResponse{Value: s.values[req.Key]}, nil } +func (s *kvEnvServer) GetValues( + _ context.Context, _ *azdext.GetEnvironmentRequest, +) (*azdext.KeyValueListResponse, error) { + values := make([]*azdext.KeyValue, 0, len(s.values)) + for key, value := range s.values { + values = append(values, &azdext.KeyValue{Key: key, Value: value}) + } + return &azdext.KeyValueListResponse{KeyValues: values}, nil +} + func newKVEnvClient(t *testing.T, values map[string]string) *azdext.AzdClient { t.Helper() srv := grpc.NewServer() azdext.RegisterEnvironmentServiceServer(srv, &kvEnvServer{values: values}) + azdext.RegisterAccountServiceServer(srv, &kvAccountServer{}) lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -122,6 +145,164 @@ func TestBrownfieldACRRequested(t *testing.T) { } } +func TestBrownfieldNeedsProvisioning(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *synthesis.Result + createACR bool + existingACR *existingACR + onDisk bool + want bool + }{ + {name: "endpoint only", result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil)}, + { + name: "model deployment", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", + []synthesis.Deployment{{Name: "model"}}, nil), + want: true, + }, + { + name: "connection", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", + nil, []synthesis.Connection{{Name: "connection"}}), + want: true, + }, + { + name: "pending ACR", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + createACR: true, + want: true, + }, + { + name: "existing ACR needs connection", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + existingACR: &existingACR{name: "acr"}, + want: true, + }, + { + name: "existing ACR already configured", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + existingACR: &existingACR{name: "acr", connectionName: "acr-conn"}, + }, + { + name: "on-disk Bicep", + result: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + onDisk: true, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + projectPath := t.TempDir() + if tt.onDisk { + infraDir := filepath.Join(projectPath, onDiskInfraDir) + require.NoError(t, os.MkdirAll(infraDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(infraDir, onDiskBicepFile), nil, 0o600)) + } + p := &FoundryProvisioningProvider{ + projectPath: projectPath, + synthResult: tt.result, + brownfieldCreateACR: tt.createACR, + brownfieldACR: tt.existingACR, + } + assert.Equal(t, tt.want, p.brownfieldNeedsProvisioning()) + }) + } +} + +func TestBrownfieldNoWorkSkipsARM(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + synthResult: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + } + + state, err := p.State(t.Context(), nil) + require.NoError(t, err) + assert.Equal(t, "p", state.State.Outputs["AZURE_AI_PROJECT_NAME"].Value) + + deployed, err := p.Deploy(t.Context(), func(string) {}) + require.NoError(t, err) + assert.Equal(t, "p", deployed.Deployment.Outputs["AZURE_AI_PROJECT_NAME"].Value) + + preview, err := p.Preview(t.Context(), func(string) {}) + require.NoError(t, err) + assert.Empty(t, preview.Preview.Changes) +} + +func TestBrownfieldNoWorkIncludesTenantOutput(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + envName: "dev", + azdClient: newKVEnvClient(t, map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p", + }), + synthResult: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + } + + deployed, err := p.Deploy(t.Context(), func(string) {}) + require.NoError(t, err) + assert.Equal(t, "tenant-id", deployed.Deployment.Outputs[envKeyTenantID].Value) +} + +func TestBrownfieldPendingACRUsesCreateMode(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + envName: "dev", + brownfieldAccount: "account", + brownfieldCreateACR: true, + synthResult: brownfieldResult("https://account.services.ai.azure.com/api/projects/p", nil, nil), + } + + params := p.armParameters() + assert.Equal(t, "create", params["acrMode"].(map[string]any)["value"]) + assert.NotEmpty(t, params["acrName"].(map[string]any)["value"]) +} + +func TestBrownfieldExistingACRNeedsConfiguration(t *testing.T) { + t.Parallel() + resourceID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/acr" + + needs := &FoundryProvisioningProvider{envName: "dev", azdClient: newKVEnvClient(t, map[string]string{ + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceID, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "acr.azurecr.io", + })} + got, err := needs.brownfieldExistingACRNeedsConfiguration(t.Context()) + require.NoError(t, err) + assert.True(t, got) + + configured := &FoundryProvisioningProvider{envName: "dev", azdClient: newKVEnvClient(t, map[string]string{ + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": resourceID, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "acr.azurecr.io", + "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "acr-conn", + })} + got, err = configured.brownfieldExistingACRNeedsConfiguration(t.Context()) + require.NoError(t, err) + assert.False(t, got) +} + +func TestBrownfieldExistingACRValidation(t *testing.T) { + t.Parallel() + validID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/acr" + + missingEndpoint := &FoundryProvisioningProvider{envName: "dev", azdClient: newKVEnvClient(t, map[string]string{ + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": validID, + })} + _, err := missingEndpoint.brownfieldExistingACR(t.Context()) + require.ErrorContains(t, err, "AZURE_CONTAINER_REGISTRY_ENDPOINT") + + wrongType := &FoundryProvisioningProvider{envName: "dev", azdClient: newKVEnvClient(t, map[string]string{ + "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/notacr", + "AZURE_CONTAINER_REGISTRY_ENDPOINT": "notacr.azurecr.io", + })} + _, err = wrongType.brownfieldExistingACR(t.Context()) + require.ErrorContains(t, err, "not a container registry") +} + func TestBrownfieldACRName(t *testing.T) { t.Parallel() @@ -171,203 +352,85 @@ func TestBrownfieldProjectName(t *testing.T) { assert.Equal(t, "fallback", p2.brownfieldProjectName()) } -func TestBrownfieldDeploymentName(t *testing.T) { +func TestBrownfieldStateMergesPersistedDeploymentOutputs(t *testing.T) { t.Parallel() - - // Short env name: full "-brownfield" fits under 64 chars. - short := &FoundryProvisioningProvider{envName: "dev", projectPath: "/p"} - name := short.brownfieldDeploymentName() - assert.LessOrEqual(t, len(name), 64) - assert.True(t, strings.HasSuffix(name, "-brownfield"), "got %q", name) - assert.Equal(t, short.deploymentName()+"-brownfield", name) - - // Long env name: must be capped at 64 while keeping the suffix. - long := &FoundryProvisioningProvider{ - envName: "agent-framework-agent-basic-invocations-dev", - projectPath: "/some/long/project/path", + p := &FoundryProvisioningProvider{ + tenantID: "tenant", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), + } + properties := &armresources.DeploymentPropertiesExtended{ + Outputs: map[string]any{ + "AZURE_CONTAINER_REGISTRY_ENDPOINT": map[string]any{"type": "String", "value": "acr.azurecr.io"}, + "EMPTY": map[string]any{"type": "String", "value": ""}, + }, + OutputResources: []*armresources.ResourceReference{{ID: new("/subscriptions/sub/resourceGroups/rg/providers/Test/type/name")}}, } - lname := long.brownfieldDeploymentName() - assert.LessOrEqual(t, len(lname), 64, "got %q (len %d)", lname, len(lname)) - assert.True(t, strings.HasSuffix(lname, "-brownfield"), "got %q", lname) -} - -func TestBrownfieldParams(t *testing.T) { - t.Parallel() - - deployments := []synthesis.Deployment{{Name: "gpt-4o-mini"}} - - t.Run("without ACR still carries projectName for the existing project resource", func(t *testing.T) { - t.Parallel() - // The brownfield template declares `foundryAccountPreview::project` as an - // unconditional existing resource, so projectName must be supplied even - // when no ACR is created (model-deployments-only reconcile). Regression - // test for the InvalidTemplate failure where the name collapsed to - // "/" because projectName was omitted. - p := &FoundryProvisioningProvider{ - envName: "dev", - synthResult: brownfieldResult( - "https://acct.services.ai.azure.com/api/projects/my-project", deployments, nil), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) - require.NoError(t, err) - - assert.Equal(t, map[string]any{"value": "acct"}, params["accountName"]) - assert.Equal(t, map[string]any{"value": deployments}, params["deployments"]) - assert.Equal(t, map[string]any{"value": []synthesis.Connection{}}, params["connections"]) - assert.Equal( - t, - map[string]any{"value": map[string]map[string]any{}}, - params["connectionCredentials"], - ) - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.NotContains(t, params, "includeAcr") - assert.NotContains(t, params, "acrName") - }) - t.Run("connections without ACR carry connections and set projectName", func(t *testing.T) { - t.Parallel() - conns := []synthesis.Connection{{ - Name: "search-conn", - Category: "CognitiveSearch", - Credentials: map[string]any{"key": "secret"}, - }} - p := &FoundryProvisioningProvider{ - envName: "dev", - synthResult: brownfieldResult( - "https://acct.services.ai.azure.com/api/projects/my-project", nil, conns), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", false) - require.NoError(t, err) - - assert.Equal( - t, - map[string]any{"value": []synthesis.Connection{{ - Name: "search-conn", - Category: "CognitiveSearch", - }}}, - params["connections"], - ) - assert.Equal( - t, - map[string]any{"value": map[string]map[string]any{ - "search-conn": {"key": "secret"}, - }}, - params["connectionCredentials"], - ) - // Connections are project-scoped, so projectName must be supplied even - // without ACR. - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.NotContains(t, params, "includeAcr") - }) + state := p.brownfieldState(properties).State - t.Run("with ACR adds registry params", func(t *testing.T) { - t.Parallel() - p := &FoundryProvisioningProvider{ - envName: "dev", - synthResult: brownfieldResult( - "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), - azdClient: newKVEnvClient(t, map[string]string{"AZURE_LOCATION": "westus2"}), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) - require.NoError(t, err) - - assert.Equal(t, map[string]any{"value": true}, params["includeAcr"]) - assert.Equal(t, map[string]any{"value": "my-project"}, params["projectName"]) - assert.Equal(t, map[string]any{"value": "westus2"}, params["location"]) - assert.Equal(t, map[string]any{"value": p.brownfieldACRName("acct")}, params["acrName"]) - }) + assert.Equal(t, "my-project", state.Outputs["AZURE_AI_PROJECT_NAME"].Value) + assert.Equal(t, "acr.azurecr.io", state.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"].Value) + assert.NotContains(t, state.Outputs, "EMPTY") + require.Len(t, state.Resources, 1) - t.Run("omits location when unresolved so template default applies", func(t *testing.T) { - t.Parallel() - // AZURE_LOCATION unset and no usable credential => brownfieldLocation - // returns ""; the param must be omitted, not set to "". - p := &FoundryProvisioningProvider{ - envName: "dev", - synthResult: brownfieldResult( - "https://acct.services.ai.azure.com/api/projects/my-project", nil, nil), - azdClient: newKVEnvClient(t, map[string]string{}), - } - params, err := p.brownfieldParams(t.Context(), "acct", "rg", true) - require.NoError(t, err) - - assert.Contains(t, params, "includeAcr") - assert.NotContains(t, params, "location") - }) + empty := p.brownfieldState(nil).State + assert.Contains(t, empty.Outputs, "FOUNDRY_PROJECT_ENDPOINT") + assert.Empty(t, empty.Resources) } -// TestBrownfieldReconcileMessage covers every combination the caller can -// reach (deployBrownfield's own guard skips provisioning entirely when all -// three are false, so at least one is always true here). Regression guard -// for a live-tested bug: a brownfield project declaring only a connection -// (no deployments, no ACR) previously printed "reconciling declared model -// deployments..." even though zero deployments existed. -func TestBrownfieldReconcileMessage(t *testing.T) { +func TestBrownfieldDeploymentResultRedactsSecretsAndPreservesCanonicalOutputs(t *testing.T) { t.Parallel() - - tests := []struct { - name string - hasDeployments bool - createACR bool - hasConnections bool - want string - }{ - { - name: "connections only (the live-tested regression case)", - hasConnections: true, - want: "Using existing Foundry project; reconciling connections...", - }, - { - name: "deployments only", - hasDeployments: true, - want: "Using existing Foundry project; reconciling model deployments...", - }, - { - name: "ACR only", - createACR: true, - want: "Using existing Foundry project; reconciling container registry...", - }, - { - name: "deployments and ACR", - hasDeployments: true, - createACR: true, - want: "Using existing Foundry project; reconciling model deployments, container registry...", - }, - { - name: "deployments and connections", - hasDeployments: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling model deployments, connections...", - }, - { - name: "ACR and connections", - createACR: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling container registry, connections...", - }, - { - name: "all three", - hasDeployments: true, - createACR: true, - hasConnections: true, - want: "Using existing Foundry project; reconciling model deployments, container registry, connections...", + p := &FoundryProvisioningProvider{ + tenantID: "tenant", + synthResult: brownfieldResult( + "https://acct.services.ai.azure.com/api/projects/current-project", nil, nil), + } + src := &templateSource{ + armTemplate: map[string]any{"parameters": map[string]any{ + "connectionCredentials": map[string]any{"type": "secureObject"}, + "customSecret": map[string]any{"type": "secureString"}, + "location": map[string]any{"type": "string"}, + }}, + parameters: map[string]any{ + "connectionCredentials": map[string]any{"value": map[string]any{"key": "secret"}}, + "customSecret": map[string]any{"value": "secret"}, + "location": map[string]any{"value": "eastus"}, }, } + properties := &armresources.DeploymentPropertiesExtended{Outputs: map[string]any{ + "FOUNDRY_PROJECT_ENDPOINT": map[string]any{"type": "String", "value": ""}, + "AZURE_AI_PROJECT_NAME": map[string]any{"type": "String", "value": "stale-project"}, + }} + + result := p.deploymentResult(src, properties) + assert.NotContains(t, result.Parameters, "connectionCredentials") + assert.NotContains(t, result.Parameters, "customSecret") + assert.Equal(t, "eastus", result.Parameters["location"].Value) + assert.Equal(t, "https://acct.services.ai.azure.com/api/projects/current-project", + result.Outputs["FOUNDRY_PROJECT_ENDPOINT"].Value) + assert.Equal(t, "current-project", result.Outputs["AZURE_AI_PROJECT_NAME"].Value) + assert.Equal(t, "tenant", result.Outputs[envKeyTenantID].Value) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := brownfieldReconcileMessage(tt.hasDeployments, tt.createACR, tt.hasConnections) - assert.Equal(t, tt.want, got) - // Never claim to reconcile something that isn't actually pending. - if !tt.hasDeployments { - assert.NotContains(t, got, "model deployments") - } - if !tt.createACR { - assert.NotContains(t, got, "container registry") - } - if !tt.hasConnections { - assert.NotContains(t, got, "connections") - } - }) +func TestMergeUnifiedParametersProtectsTargeting(t *testing.T) { + t.Parallel() + host := map[string]any{ + "foundryAccountName": map[string]any{"value": "host-account"}, + "foundryProjectName": map[string]any{"value": "host-project"}, + "deployments": map[string]any{"value": []any{"host"}}, } + user := map[string]any{ + "foundryAccountName": map[string]any{"value": "user-account"}, + "foundryProjectName": map[string]any{"value": "user-project"}, + "deployments": map[string]any{"value": []any{"user"}}, + "acrMode": map[string]any{"value": "existing"}, + } + + got := mergeUnifiedParameters(user, host) + + assert.Equal(t, host["foundryAccountName"], got["foundryAccountName"]) + assert.Equal(t, host["foundryProjectName"], got["foundryProjectName"]) + assert.Equal(t, user["deployments"], got["deployments"]) + assert.NotContains(t, got, "acrMode") } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go index de9c5c55dcb..1553b0f704d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go @@ -141,6 +141,31 @@ func TestResolveEnv_PromptsAndPersistsSubscriptionAndLocation(t *testing.T) { "location should be persisted to the azd environment") } +func TestResolveBrownfieldDeploymentContextDoesNotPromptForSubscriptionOrLocation(t *testing.T) { + t.Parallel() + env := &resolveEnvStubEnvServer{ + envName: "dev", + get: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/project-sub/resourceGroups/project-rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p", + envKeyLocation: "eastus", + }, + } + prompt := &resolveEnvStubPromptServer{} + p := &FoundryProvisioningProvider{ + envName: "dev", + azdClient: newResolveEnvTestClient(t, env, prompt), + synthResult: brownfieldResult("https://acct.services.ai.azure.com/api/projects/p", nil, nil), + } + + require.NoError(t, p.resolveBrownfieldDeploymentContext(t.Context())) + assert.Equal(t, "project-sub", p.subID) + assert.Equal(t, "project-rg", p.rgName) + assert.Equal(t, "eastus", p.location) + assert.Zero(t, prompt.subscriptionN) + assert.Zero(t, prompt.locationN) +} + func TestResolveEnv_NoPromptSubscriptionReturnsActionableError(t *testing.T) { // Under `--no-prompt` the azd host returns a "prompt required" error. The // provider must surface an actionable suggestion naming the env var so CI diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 9b3e8c16b32..d521d498133 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -441,6 +441,15 @@ func TestDeploymentName_LongEnvironmentName(t *testing.T) { assert.NotEqual(t, name, other.deploymentName()) } +func TestBrownfieldDeploymentNameIsIsolated(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{envName: "dev", projectPath: "/proj/a"} + + assert.NotEqual(t, p.deploymentName(), p.brownfieldDeploymentName()) + assert.Contains(t, p.brownfieldDeploymentName(), "brownfield") + assert.LessOrEqual(t, len(p.brownfieldDeploymentName()), maxDeploymentNameLength) +} + func TestDeploymentOutputsResources_NilSafe(t *testing.T) { assert.Nil(t, deploymentOutputs(nil)) assert.Nil(t, deploymentResources(nil)) @@ -761,7 +770,18 @@ func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { // (resolveTemplate skips the loadOnDiskTemplate call when // onDiskSource is already set; this lets the test exercise the // merge logic in isolation.) - armFromDisk := map[string]any{"$schema": "ondisk", "contentVersion": "1.0.0.0"} + armFromDisk := map[string]any{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": map[string]any{ + "location": map[string]any{}, + "userOnly": map[string]any{}, + "resourceGroupName": map[string]any{}, + "foundryAccountName": map[string]any{}, "foundryProjectName": map[string]any{}, + "deployments": map[string]any{}, "connections": map[string]any{}, + "connectionCredentials": map[string]any{}, + }, + } p := &FoundryProvisioningProvider{ projectPath: dir, envName: "dev", @@ -788,7 +808,7 @@ func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { require.NotNil(t, got) assert.Equal(t, templateModeBicep, got.mode, "on-disk Bicep mode wins") - assert.Equal(t, "ondisk", got.armTemplate["$schema"], + assert.Contains(t, got.armTemplate["$schema"], "subscriptionDeploymentTemplate", "on-disk template is returned, not the embedded one") assert.Equal(t, filepath.Join(infraDir, onDiskBicepFile), got.sourcePath) @@ -836,6 +856,22 @@ func TestResolveTemplate_OnDiskFallsBackWhenSourceLoaderReturnsNil(t *testing.T) assert.Equal(t, templateModeEmbedded, got.mode) } +func TestValidateUnifiedTemplateContractAllowsLegacyGreenfieldTemplate(t *testing.T) { + t.Parallel() + tmpl := map[string]any{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "parameters": map[string]any{ + "resourceGroupName": map[string]any{}, + "foundryProjectName": map[string]any{}, + "deployments": map[string]any{}, + }, + } + + require.NoError(t, validateUnifiedTemplateContract(tmpl, false)) + err := validateUnifiedTemplateContract(tmpl, true) + require.ErrorContains(t, err, "foundryAccountName") +} + func TestProjectNameFromEndpoint(t *testing.T) { t.Parallel() assert.Equal(t, "my-project", projectNameFromEndpoint( @@ -919,6 +955,22 @@ func TestEnvValues_IncludesCanonicalKeysEvenWithoutAzdClient(t *testing.T) { assert.Equal(t, "pid", got[envKeyPrincipalID]) } +func TestEnvValues_DoesNotMaskAzdValuesWithEmptyCanonicalValues(t *testing.T) { + t.Parallel() + p := &FoundryProvisioningProvider{ + envName: "dev", + azdClient: newKVEnvClient(t, map[string]string{ + envKeySubscriptionID: "sub-from-env", + envKeyLocation: "westus2", + }), + } + + got := p.envValues(t.Context()) + + assert.Equal(t, "sub-from-env", got[envKeySubscriptionID]) + assert.Equal(t, "westus2", got[envKeyLocation]) +} + func TestCollectPurgeableAccounts(t *testing.T) { // Pure helper -- maps the SDK's pointer-heavy Account model down to the // {name, location} pairs the purge step needs, skipping anything with a diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go index d291aec028d..b52a63a1e34 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "slices" "azure.ai.projects/internal/exterrors" @@ -248,11 +249,11 @@ func substituteParamValue( ) } - hasUnsetEnvVar := false + unsetEnvVars := map[string]struct{}{} substituted, err := envsubst.Eval(string(enc), func(varName string) string { v, ok := envValues[varName] if !ok { - hasUnsetEnvVar = true + unsetEnvVars[varName] = struct{}{} return "" } // The value is being injected into a JSON-encoded entry, so JSON-escape @@ -272,6 +273,14 @@ func substituteParamValue( "check for malformed ${VAR} references in the parameters file", ) } + if len(unsetEnvVars) > 0 && (name == "connections" || name == "connectionCredentials") { + return nil, exterrors.Validation( + exterrors.CodeOnDiskParametersInvalid, + fmt.Sprintf("parameter %q in %s references unset environment variables %v", + name, sourcePath, slices.Sorted(maps.Keys(unsetEnvVars))), + "set the missing azd environment values before provisioning", + ) + } var resolved any if err := json.Unmarshal([]byte(substituted), &resolved); err != nil { @@ -286,7 +295,7 @@ func substituteParamValue( // because of an unresolved ${VAR}. Non-string values are always kept. if entry, ok := resolved.(map[string]any); ok { if val, ok := entry["value"]; ok { - if str, ok := val.(string); ok && str == "" && hasUnsetEnvVar { + if str, ok := val.(string); ok && str == "" && len(unsetEnvVars) > 0 { return nil, nil } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go index 5f566b06531..ccfdced6a4c 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go @@ -416,6 +416,23 @@ func TestLoadParametersFile_NestedUnresolvedIsKept(t *testing.T) { require.Contains(t, got, "ok") } +func TestLoadParametersFile_BrownfieldConnectionVariablesMustResolve(t *testing.T) { + t.Parallel() + body := minimalARMParametersFile(t, map[string]any{ + "connections": []any{map[string]any{"name": "search", "target": "${SEARCH_ENDPOINT}"}}, + }) + path := filepath.Join(t.TempDir(), "main.parameters.json") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + + _, err := loadParametersFile(path, map[string]string{}) + + require.Error(t, err) + var local *azdext.LocalError + require.True(t, errors.As(err, &local)) + assert.Equal(t, exterrors.CodeOnDiskParametersInvalid, local.Code) + assert.Contains(t, local.Message, "SEARCH_ENDPOINT") +} + // Sanity test that the helper handles the path the production code // actually exercises: writing the file via writeParametersFile in // init_infra.go produces output that loadParametersFile can read back. diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go index cf52f4536a9..071827cb2fe 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go @@ -122,39 +122,6 @@ func TestARMTemplate_MatchesBicepBuild(t *testing.T) { "--outfile main.arm.json` from the templates directory") } -// TestBrownfieldARMTemplate_MatchesBicepBuild is the brownfield.bicep counterpart -// of TestARMTemplate_MatchesBicepBuild: it catches a forgotten `bicep build` after -// editing the brownfield model-deployment template. Skipped when bicep is absent. -func TestBrownfieldARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := lookupBicep() - if bicep == "" { - t.Skip("bicep CLI not found on PATH; skipping ARM drift check") - } - - templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "brownfield.arm.json")) - require.NoError(t, err) - - out := filepath.Join(t.TempDir(), "brownfield.arm.json") - //nolint:gosec // bicep comes from PATH or the Azure CLI - cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "brownfield.bicep"), "--outfile", out) - var stderr bytes.Buffer - cmd.Stderr = &stderr - require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) - - //nolint:gosec // test-owned temporary output path - rebuilt, err := os.ReadFile(out) - require.NoError(t, err) - - committedNormalized := normalizeArmTemplate(t, committed) - rebuiltNormalized := normalizeArmTemplate(t, rebuilt) - - assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/brownfield.arm.json is stale; regenerate with `bicep build "+ - "brownfield.bicep --outfile brownfield.arm.json` from the templates directory") -} - // normalizeArmTemplate returns a stable JSON representation of an ARM template // for drift comparison. Bicep generator metadata includes the local Bicep CLI // version/hash and can differ between developer machines and CI images without diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 6c796b10a87..50180302649 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -1186,7 +1186,7 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { // enableNetworkIsolation (not on egress mode), so a network-bound account is // never left public. This is the regression guard for the data-plane fix. text := string(data) - wantDisable := `"disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]"` + wantDisable := `"disablePublicDataPlaneAccess": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]"` wantPublic := `"publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]"` assert.Contains(t, text, wantDisable, "public data-plane access must be disabled for every network-isolated account") @@ -1215,25 +1215,6 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "private endpoint location must come from the customer VNet") } -func TestBrownfieldARMTemplate_SecuresConnectionCredentials(t *testing.T) { - data, err := BrownfieldARMTemplate() - require.NoError(t, err) - - var arm map[string]any - require.NoError(t, json.Unmarshal(data, &arm)) - params, ok := arm["parameters"].(map[string]any) - require.True(t, ok, "parameters must be an object") - connections, ok := params["connections"].(map[string]any) - require.True(t, ok, "connections param must be an object") - assert.Equal(t, "#/definitions/connectionsType", connections["$ref"]) - credentials, ok := params["connectionCredentials"].(map[string]any) - require.True(t, ok, "connectionCredentials param must be an object") - assert.Equal(t, "secureObject", credentials["type"]) - assert.Contains(t, string(data), - "parameters('principalId'), parameters('roleDefinitionId')", - "ACR role assignment name must include the assigned principal") -} - func TestSynthesize_Network(t *testing.T) { t.Setenv("AZURE_VNET_ID", "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json deleted file mode 100644 index 77cda60d3cf..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.arm.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "5428399781259274778" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "connectionsType": { - "type": "array", - "items": { - "$ref": "#/definitions/connectionType" - }, - "metadata": { - "description": "Shape of a list of Foundry project connections." - } - }, - "connectionType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "target": { - "type": "string" - }, - "authType": { - "type": "string" - }, - "metadata": { - "type": "object", - "nullable": true - } - }, - "metadata": { - "description": "Shape of one Foundry project connection (a host: azure.ai.connection service)." - } - } - }, - "parameters": { - "accountName": { - "type": "string", - "minLength": 2, - "maxLength": 64, - "metadata": { - "description": "Name of the existing Foundry (AIServices) account." - } - }, - "projectName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to create or update on the existing account." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for the container registry. Defaults to the resource group location." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to created resources." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent." - } - }, - "acrName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true." - } - }, - "connections": { - "$ref": "#/definitions/connectionsType", - "defaultValue": [], - "metadata": { - "description": "Foundry project connections to create on the existing project (host: azure.ai.connection services)." - } - }, - "connectionCredentials": { - "type": "secureObject", - "defaultValue": {}, - "metadata": { - "description": "Credentials keyed by Foundry project connection name." - } - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "registry": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - "acrConnection": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}-conn', parameters('accountName'), parameters('projectName'), parameters('acrName'))]", - "properties": { - "category": "ContainerRegistry", - "target": "[reference('registry').loginServer]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "foundryAcrPull", - "registry" - ] - }, - "projectConnections": { - "copy": { - "name": "projectConnections", - "count": "[length(parameters('connections'))]" - }, - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connections')[copyIndex()].name)]", - "properties": "[union(createObject('category', parameters('connections')[copyIndex()].category, 'target', parameters('connections')[copyIndex()].target, 'authType', parameters('connections')[copyIndex()].authType), if(contains(parameters('connectionCredentials'), parameters('connections')[copyIndex()].name), createObject('credentials', parameters('connectionCredentials')[parameters('connections')[copyIndex()].name]), createObject()), if(not(equals(tryGet(parameters('connections')[copyIndex()], 'metadata'), null())), createObject('metadata', tryGet(parameters('connections')[copyIndex()], 'metadata')), createObject()))]" - }, - "foundryAcrPull": { - "condition": "[parameters('includeAcr')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-acr-pull", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('acrName')]" - }, - "principalId": { - "value": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]" - }, - "roleDefinitionId": { - "value": "[variables('acrPullRoleId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "16037481882754055301" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving AcrPull on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "AcrPull role definition resource ID." - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "foundryAccountPreview::project", - "registry" - ] - } - }, - "outputs": { - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(parameters('includeAcr'), reference('registry').loginServer, '')]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(parameters('includeAcr'), resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[if(parameters('includeAcr'), format('{0}-conn', parameters('acrName')), '')]" - }, - "AZURE_AI_PROJECT_CONNECTION_NAMES": { - "type": "string", - "value": "[join(map(parameters('connections'), lambda('c', lambdaVariables('c').name)), ',')]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep deleted file mode 100644 index 16c41c23f4d..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/brownfield.bicep +++ /dev/null @@ -1,190 +0,0 @@ -// Resource-group-scoped template for an EXISTING Foundry (AIServices) account. -// The account and project are REFERENCED, never created. It reconciles model -// deployments declared in azure.yaml and, when includeAcr is true, creates a -// container registry wired to the project (AcrPull + ContainerRegistry -// connection) for a hosted container agent. Used by the brownfield path. - -targetScope = 'resourceGroup' - -// User-defined types (match the deploymentType in main.bicep). - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -@description('Shape of a list of Foundry project connections.') -type connectionsType = connectionType[] - -@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') -type connectionType = { - name: string - category: string - target: string - authType: string - metadata: object? -} - -// Parameters - -@description('Name of the existing Foundry (AIServices) account.') -@minLength(2) -@maxLength(64) -param accountName string - -@description('Name of the existing Foundry project that receives the ACR connection. Required when includeAcr is true.') -param projectName string = '' - -@description('Model deployments to create or update on the existing account.') -param deployments deploymentsType = [] - -@description('Azure region for the container registry. Defaults to the resource group location.') -param location string = resourceGroup().location - -@description('Tags applied to created resources.') -param tags object = {} - -@description('Create an Azure Container Registry and wire it to the existing project. Set true for a hosted container agent.') -param includeAcr bool = false - -@description('Container registry name. 5-50 alphanumeric chars. Required when includeAcr is true.') -param acrName string = '' - -@description('Foundry project connections to create on the existing project (host: azure.ai.connection services).') -param connections connectionsType = [] - -@description('Credentials keyed by Foundry project connection name.') -@secure() -param connectionCredentials object = {} - -// Resources - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: accountName -} - -// Sequential creation; ARM throttles concurrent deployments on one account. -// CreateOrUpdate is an idempotent upsert, so re-running reconciles an existing -// deployment rather than duplicating it. -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for d in deployments: { - parent: foundryAccount - name: d.name - properties: { - model: d.model - } - sku: d.sku - } -] - -// Existing project reference (preview API): exposes the project's system-assigned -// managed identity principal id, used as the AcrPull grantee and the connection -// credential identity. Pinned to 2025-04-01-preview to match acr.bicep; the GA -// API fails to resolve the projects/connections ContainerRegistry sub-resource. -resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -// Container registry for the hosted container agent. Premium SKU mirrors the -// greenfield acr.bicep. -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (includeAcr) { - name: acrName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -// Built-in AcrPull role. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// The nested module makes the runtime project principal a deployment -// parameter. The assignment name can then include that principal. -module foundryAcrPull 'modules/acr-pull-role-assignment.bicep' = if (includeAcr) { - name: 'foundry-acr-pull' - params: { - registryName: registry.name - principalId: foundryAccountPreview::project.identity.principalId - roleDefinitionId: acrPullRoleId - } -} - -// Project-scoped ContainerRegistry connection so Foundry can resolve the registry -// by name when running the hosted agent. -resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (includeAcr) { - name: '${accountName}/${projectName}/${acrName}-conn' - properties: { - category: 'ContainerRegistry' - target: registry!.properties.loginServer - authType: 'ManagedIdentity' - credentials: { - clientId: foundryAccountPreview::project.identity.principalId - resourceId: registry!.id - } - isSharedToAll: true - metadata: { - ResourceId: registry!.id - } - } - dependsOn: [ - foundryAcrPull - ] -} - -// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as -// host: azure.ai.connection services, created on the existing project at -// provision time. Optional properties (credentials / metadata) are emitted only -// when supplied so None / identity-token connections don't send empty objects. -resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ - for c in connections: { - parent: foundryAccountPreview::project - name: c.name - properties: union( - { - category: c.category - target: c.target - authType: c.authType - }, - contains(connectionCredentials, c.name) - ? { credentials: connectionCredentials[c.name] } - : {}, - c.?metadata != null ? { metadata: c.?metadata } : {} - ) - } -] - -// Outputs - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? registry!.properties.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? registry!.id : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? '${acrName}-conn' : '' -output AZURE_AI_PROJECT_CONNECTION_NAMES string = join(map(connections, c => c.name), ',') diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json index 31f7af1ec23..0965d2ff7e5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "10316642581792918930" + "templateHash": "7253913134310958286" } }, "definitions": { @@ -120,10 +120,15 @@ }, "foundryProjectName": { "type": "string", - "minLength": 3, - "maxLength": 32, "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + "description": "Foundry project name. New projects require 3-32 characters; existing project names are preserved." + } + }, + "foundryAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Foundry account name to reuse. Empty provisions a new account." } }, "deployments": { @@ -140,6 +145,39 @@ "description": "Include an Azure Container Registry. Set true when any agent uses docker:." } }, + "acrMode": { + "type": "string", + "defaultValue": "[if(parameters('includeAcr'), 'create', 'none')]", + "allowedValues": [ + "none", + "create", + "existing" + ] + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[parameters('resourceGroupName')]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('acrName')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "connections": { "$ref": "#/definitions/connectionsType", "defaultValue": [], @@ -253,8 +291,12 @@ } } }, + "variables": { + "createFoundryResources": "[empty(parameters('foundryAccountName'))]" + }, "resources": { - "resourceGroup": { + "managedResourceGroup": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.Resources/resourceGroups", "apiVersion": "2021-04-01", "name": "[parameters('resourceGroupName')]", @@ -272,6 +314,9 @@ }, "mode": "Incremental", "parameters": { + "foundryAccountName": { + "value": "[parameters('foundryAccountName')]" + }, "location": { "value": "[parameters('location')]" }, @@ -290,6 +335,27 @@ "includeAcr": { "value": "[parameters('includeAcr')]" }, + "acrMode": { + "value": "[parameters('acrMode')]" + }, + "acrName": { + "value": "[parameters('acrName')]" + }, + "existingAcrSubscriptionId": { + "value": "[parameters('existingAcrSubscriptionId')]" + }, + "existingAcrResourceGroup": { + "value": "[parameters('existingAcrResourceGroup')]" + }, + "existingAcrName": { + "value": "[parameters('existingAcrName')]" + }, + "existingAcrEndpoint": { + "value": "[parameters('existingAcrEndpoint')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" + }, "connections": { "value": "[parameters('connections')]" }, @@ -347,7 +413,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "9277725728848811858" + "templateHash": "4484218828981058848" } }, "definitions": { @@ -438,6 +504,13 @@ "description": "Azure region for all resources." } }, + "foundryAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Foundry account name to reuse. Empty provisions a new account." + } + }, "tags": { "type": "object", "defaultValue": {}, @@ -454,10 +527,8 @@ }, "foundryProjectName": { "type": "string", - "minLength": 3, - "maxLength": 32, "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." + "description": "Foundry project name. New projects require 3-32 characters; existing project names are preserved." } }, "deployments": { @@ -474,6 +545,39 @@ "description": "Include an Azure Container Registry. Set true when any agent uses docker:." } }, + "acrMode": { + "type": "string", + "defaultValue": "[if(parameters('includeAcr'), 'create', 'none')]", + "allowedValues": [ + "none", + "create", + "existing" + ] + }, + "acrName": { + "type": "string", + "defaultValue": "" + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('acrName')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "connections": { "$ref": "#/definitions/connectionsType", "defaultValue": [], @@ -594,37 +698,23 @@ }, "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", "abbrs": "[variables('$fxv#0')]", - "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", - "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", - "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", - "disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]", + "createFoundryResources": "[empty(parameters('foundryAccountName'))]", + "resolvedFoundryAccountName": "[if(variables('createFoundryResources'), format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken')), parameters('foundryAccountName'))]", + "projectResourceId": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", + "useByoNetwork": "[and(and(variables('createFoundryResources'), parameters('enableNetworkIsolation')), not(parameters('useManagedEgress')))]", + "useManagedNetwork": "[and(and(variables('createFoundryResources'), parameters('enableNetworkIsolation')), parameters('useManagedEgress'))]", + "disablePublicDataPlaneAccess": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]", "cognitiveServicesUserRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", "agentSubnetArmId": "[format('{0}/subnets/{1}', parameters('vnetId'), parameters('agentSubnetName'))]", - "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]" + "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]", + "resolvedAcrName": "[if(and(equals(parameters('acrMode'), 'create'), empty(parameters('acrName'))), format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken')), if(empty(parameters('existingAcrName')), parameters('acrName'), parameters('existingAcrName')))]" }, "resources": { - "foundryAccount::modelDeployments": { - "copy": { - "name": "foundryAccount::modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]", - "dependsOn": [ - "foundryAccount" - ] - }, "foundryAccount::project": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.CognitiveServices/accounts/projects", "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", "location": "[parameters('location')]", "identity": { "type": "SystemAssigned" @@ -635,13 +725,14 @@ }, "dependsOn": [ "foundryAccount", - "foundryAccount::modelDeployments" + "modelDeployments" ] }, "foundryAccount": { + "condition": "[variables('createFoundryResources')]", "type": "Microsoft.CognitiveServices/accounts", "apiVersion": "2025-06-01", - "name": "[variables('foundryAccountName')]", + "name": "[variables('resolvedFoundryAccountName')]", "location": "[parameters('location')]", "tags": "[parameters('tags')]", "sku": { @@ -653,7 +744,7 @@ }, "properties": { "allowProjectManagement": true, - "customSubDomainName": "[variables('foundryAccountName')]", + "customSubDomainName": "[variables('resolvedFoundryAccountName')]", "publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]", "disableLocalAuth": true, "networkAcls": { @@ -665,14 +756,48 @@ "networkInjections": "[variables('agentNetworkInjections')]" }, "dependsOn": [ - "network" + "network", + "projectNameValidation" + ] + }, + "existingFoundryAccount": { + "condition": "[not(variables('createFoundryResources'))]", + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[variables('resolvedFoundryAccountName')]" + }, + "existingFoundryProject": { + "condition": "[not(variables('createFoundryResources'))]", + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]" + }, + "modelDeployments": { + "copy": { + "name": "modelDeployments", + "count": "[length(parameters('deployments'))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), parameters('deployments')[copyIndex()].name)]", + "properties": { + "model": "[parameters('deployments')[copyIndex()].model]" + }, + "sku": "[parameters('deployments')[copyIndex()].sku]", + "dependsOn": [ + "existingFoundryAccount", + "foundryAccount" ] }, "foundryManagedNetwork": { "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks", "apiVersion": "2025-10-01-preview", - "name": "[format('{0}/{1}', variables('foundryAccountName'), 'default')]", + "name": "[format('{0}/{1}', variables('resolvedFoundryAccountName'), 'default')]", "properties": { "managedNetwork": { "isolationMode": "[parameters('managedIsolationMode')]" @@ -683,11 +808,11 @@ ] }, "developerCognitiveServicesUser": { - "condition": "[not(empty(parameters('principalId')))]", + "condition": "[and(variables('createFoundryResources'), not(empty(parameters('principalId'))))]", "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName')), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]", + "name": "[guid(variables('projectResourceId'), parameters('principalId'), variables('cognitiveServicesUserRoleId'))]", "properties": { "principalId": "[parameters('principalId')]", "principalType": "[parameters('principalType')]", @@ -1011,8 +1136,53 @@ } } }, + "projectNameValidation": { + "condition": "[variables('createFoundryResources')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "validate-foundry-project-name", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[parameters('foundryProjectName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "6141112058032284375" + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 32, + "metadata": { + "description": "Foundry project name. 3-32 characters for newly created projects." + } + } + }, + "resources": [], + "outputs": { + "validatedName": { + "type": "string", + "value": "[parameters('name')]" + } + } + } + } + }, "acr": { - "condition": "[parameters('includeAcr')]", + "condition": "[not(equals(parameters('acrMode'), 'none'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "acr", @@ -1028,20 +1198,36 @@ "tags": { "value": "[parameters('tags')]" }, + "acrMode": "[if(equals(parameters('acrMode'), 'existing'), createObject('value', 'existing'), createObject('value', 'create'))]", "name": { - "value": "[format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken'))]" + "value": "[variables('resolvedAcrName')]" + }, + "existingAcrSubscriptionId": { + "value": "[parameters('existingAcrSubscriptionId')]" + }, + "existingAcrResourceGroup": { + "value": "[parameters('existingAcrResourceGroup')]" + }, + "existingAcrName": { + "value": "[variables('resolvedAcrName')]" + }, + "existingAcrEndpoint": { + "value": "[parameters('existingAcrEndpoint')]" + }, + "existingAcrConnectionName": { + "value": "[parameters('existingAcrConnectionName')]" }, "foundryAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "foundryProjectName": { "value": "[parameters('foundryProjectName')]" }, "foundryProjectPrincipalId": { - "value": "[reference('foundryAccount::project', '2025-06-01', 'full').identity.principalId]" + "value": "[reference(variables('projectResourceId'), '2025-04-01-preview', 'full').identity.principalId]" }, "enableNetworkIsolation": { - "value": "[parameters('enableNetworkIsolation')]" + "value": "[and(variables('createFoundryResources'), parameters('enableNetworkIsolation'))]" } }, "template": { @@ -1051,7 +1237,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2948233716175969636" + "templateHash": "11720581132744672797" } }, "parameters": { @@ -1076,6 +1262,34 @@ "description": "Registry name. 5-50 alphanumeric chars." } }, + "acrMode": { + "type": "string", + "defaultValue": "create", + "allowedValues": [ + "create", + "existing" + ] + }, + "existingAcrSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "existingAcrResourceGroup": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "existingAcrName": { + "type": "string", + "defaultValue": "[parameters('name')]" + }, + "existingAcrEndpoint": { + "type": "string", + "defaultValue": "" + }, + "existingAcrConnectionName": { + "type": "string", + "defaultValue": "" + }, "foundryAccountName": { "type": "string", "metadata": { @@ -1103,32 +1317,38 @@ } }, "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + "createAcr": "[equals(parameters('acrMode'), 'create')]", + "configureAcr": "[or(variables('createAcr'), empty(parameters('existingAcrConnectionName')))]", + "resolvedAcrName": "[if(variables('createAcr'), parameters('name'), parameters('existingAcrName'))]", + "resolvedAcrId": "[resourceId(parameters('existingAcrSubscriptionId'), parameters('existingAcrResourceGroup'), 'Microsoft.ContainerRegistry/registries', variables('resolvedAcrName'))]" }, "resources": [ { + "condition": "[variables('configureAcr')]", "type": "Microsoft.CognitiveServices/accounts/projects/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', parameters('name')))]", + "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', variables('resolvedAcrName')))]", "properties": { "category": "ContainerRegistry", - "target": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]", + "target": "[if(variables('createAcr'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer, parameters('existingAcrEndpoint'))]", "authType": "ManagedIdentity", "credentials": { "clientId": "[parameters('foundryProjectPrincipalId')]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "resourceId": "[variables('resolvedAcrId')]" }, "isSharedToAll": true, "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "ResourceId": "[variables('resolvedAcrId')]" } }, "dependsOn": [ - "[extensionResourceId(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId')))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('existingAcrSubscriptionId'), parameters('existingAcrResourceGroup')), 'Microsoft.Resources/deployments', 'foundry-acr-pull-existing')]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-acr-pull-new')]", "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" ] }, { + "condition": "[variables('createAcr')]", "type": "Microsoft.ContainerRegistry/registries", "apiVersion": "2023-07-01", "name": "[parameters('name')]", @@ -1147,39 +1367,150 @@ } }, { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId'))]", + "condition": "[variables('createAcr')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-acr-pull-new", "properties": { - "principalId": "[parameters('foundryProjectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - ] + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "registryName": { + "value": "[parameters('name')]" + }, + "principalId": { + "value": "[parameters('foundryProjectPrincipalId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "752655472682753460" + } + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Name of the Azure Container Registry." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Principal receiving AcrPull on the registry." + } + } + }, + "variables": { + "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), variables('acrPullRoleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + } + } + ] + } + } + }, + { + "condition": "[and(not(variables('createAcr')), empty(parameters('existingAcrConnectionName')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-acr-pull-existing", + "subscriptionId": "[parameters('existingAcrSubscriptionId')]", + "resourceGroup": "[parameters('existingAcrResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "registryName": { + "value": "[parameters('existingAcrName')]" + }, + "principalId": { + "value": "[parameters('foundryProjectPrincipalId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "752655472682753460" + } + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Name of the Azure Container Registry." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Principal receiving AcrPull on the registry." + } + } + }, + "variables": { + "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), variables('acrPullRoleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + } + } + ] + } + } } ], "outputs": { "loginServer": { "type": "string", - "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]" + "value": "[if(variables('createAcr'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer, parameters('existingAcrEndpoint'))]" }, "resourceId": { "type": "string", - "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + "value": "[variables('resolvedAcrId')]" }, "connectionName": { "type": "string", - "value": "[format('{0}-conn', parameters('name'))]" + "value": "[if(variables('configureAcr'), format('{0}-conn', variables('resolvedAcrName')), parameters('existingAcrConnectionName'))]" } } } }, "dependsOn": [ + "existingFoundryProject", "foundryAccount", - "foundryAccount::project" + "modelDeployments" ] }, "privateEndpointDns": { @@ -1194,7 +1525,7 @@ "mode": "Incremental", "parameters": { "aiAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "location": { "value": "[reference('network').outputs.vnetLocation.value]" @@ -1413,7 +1744,6 @@ } }, "dependsOn": [ - "foundryAccount", "network" ] }, @@ -1429,7 +1759,7 @@ "mode": "Incremental", "parameters": { "foundryAccountName": { - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "foundryProjectName": { "value": "[parameters('foundryProjectName')]" @@ -1566,19 +1896,20 @@ } }, "dependsOn": [ + "existingFoundryProject", "foundryAccount", - "foundryAccount::project" + "modelDeployments" ] } }, "outputs": { "AZURE_AI_PROJECT_ID": { "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('foundryAccountName'), parameters('foundryProjectName'))]" + "value": "[variables('projectResourceId')]" }, "AZURE_AI_ACCOUNT_NAME": { "type": "string", - "value": "[variables('foundryAccountName')]" + "value": "[variables('resolvedFoundryAccountName')]" }, "AZURE_AI_PROJECT_NAME": { "type": "string", @@ -1586,23 +1917,23 @@ }, "AZURE_OPENAI_ENDPOINT": { "type": "string", - "value": "[format('https://{0}.openai.azure.com/', variables('foundryAccountName'))]" + "value": "[format('https://{0}.openai.azure.com/', variables('resolvedFoundryAccountName'))]" }, "FOUNDRY_PROJECT_ENDPOINT": { "type": "string", - "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]" + "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('resolvedFoundryAccountName'), parameters('foundryProjectName'))]" }, "AZURE_CONTAINER_REGISTRY_ENDPOINT": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.loginServer.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.loginServer.value, '')]" }, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.resourceId.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.resourceId.value, '')]" }, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { "type": "string", - "value": "[if(parameters('includeAcr'), reference('acr').outputs.connectionName.value, '')]" + "value": "[if(not(equals(parameters('acrMode'), 'none')), reference('acr').outputs.connectionName.value, '')]" }, "AZURE_AI_PROJECT_CONNECTION_NAMES": { "type": "string", @@ -1610,7 +1941,7 @@ }, "AZURE_FOUNDRY_NETWORK_MODE": { "type": "string", - "value": "[if(not(parameters('enableNetworkIsolation')), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" + "value": "[if(or(not(variables('createFoundryResources')), not(parameters('enableNetworkIsolation'))), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" }, "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { "type": "string", @@ -1620,7 +1951,7 @@ } }, "dependsOn": [ - "resourceGroup" + "managedResourceGroup" ] } }, diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep index 1cbd1c389f0..7bd767f881c 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep @@ -1,8 +1,8 @@ // Provisioning template for a Foundry project service. // // 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 handled by the provider before synthesis. +// azure.yaml by internal/synthesis. Parameters select whether azd creates the +// Foundry account/project or reconciles resources on an existing project. // // Subscription-scoped so the resource group is part of the deployment. This // keeps `azd provision --preview` side-effect free: the resource group shows @@ -58,17 +58,31 @@ param tags object = {} @description('Optional salt to vary resource names across re-provisions.') param resourceTokenSalt string = '' -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) +@description('Foundry project name. New projects require 3-32 characters; existing project names are preserved.') param foundryProjectName string +@description('Foundry account name to reuse. Empty provisions a new account.') +param foundryAccountName string = '' + @description('Model deployments to provision on the Foundry account.') param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false +@allowed([ + 'none' + 'create' + 'existing' +]) +param acrMode string = includeAcr ? 'create' : 'none' +param acrName string = '' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroupName +param existingAcrName string = acrName +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' + @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] @@ -123,7 +137,9 @@ param dnsZonesSubscription string = '' // Resources -resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { +var createFoundryResources = empty(foundryAccountName) + +resource managedResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createFoundryResources) { name: resourceGroupName location: location tags: tags @@ -131,14 +147,22 @@ resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { module resources 'modules/resources.bicep' = { name: 'foundry-resources' - scope: resourceGroup + scope: resourceGroup(resourceGroupName) params: { + foundryAccountName: foundryAccountName location: location tags: tags resourceTokenSalt: resourceTokenSalt foundryProjectName: foundryProjectName deployments: deployments includeAcr: includeAcr + acrMode: acrMode + acrName: acrName + existingAcrSubscriptionId: existingAcrSubscriptionId + existingAcrResourceGroup: existingAcrResourceGroup + existingAcrName: existingAcrName + existingAcrEndpoint: existingAcrEndpoint + existingAcrConnectionName: existingAcrConnectionName connections: connections connectionCredentials: connectionCredentials principalId: principalId @@ -156,11 +180,12 @@ module resources 'modules/resources.bicep' = { dnsZonesResourceGroup: dnsZonesResourceGroup dnsZonesSubscription: dnsZonesSubscription } + dependsOn: [managedResourceGroup] } // Outputs -output AZURE_RESOURCE_GROUP string = resourceGroup.name +output AZURE_RESOURCE_GROUP string = resourceGroupName output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep index f54a2d5a706..309bb56c668 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep @@ -6,19 +6,21 @@ param registryName string @description('Principal receiving AcrPull on the registry.') param principalId string -@description('AcrPull role definition resource ID.') -param roleDefinitionId string - resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { name: registryName } +var acrPullRoleDefinitionId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, principalId, roleDefinitionId) + name: guid(registry.id, principalId, acrPullRoleDefinitionId) scope: registry properties: { principalId: principalId principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId + roleDefinitionId: acrPullRoleDefinitionId } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep index cf9c12543ea..645c7e01f29 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep @@ -19,6 +19,17 @@ param tags object = {} @maxLength(50) param name string +@allowed([ + 'create' + 'existing' +]) +param acrMode string = 'create' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroup().name +param existingAcrName string = name +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' + @description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') param foundryAccountName string @@ -33,15 +44,19 @@ param enableNetworkIsolation bool = false // Variables -// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' +var createAcr = acrMode == 'create' +var configureAcr = createAcr || empty(existingAcrConnectionName) +var resolvedAcrName = createAcr ? name : existingAcrName +var resolvedAcrId = resourceId( + existingAcrSubscriptionId, + existingAcrResourceGroup, + 'Microsoft.ContainerRegistry/registries', + resolvedAcrName ) // Resources -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (createAcr) { name: name location: location tags: tags @@ -64,13 +79,20 @@ resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { // Grant the Foundry project's managed identity AcrPull on this registry so the // hosted agent can pull images using the project identity. -resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) - scope: registry - properties: { +module foundryAcrPullNew 'acr-pull-role-assignment.bicep' = if (createAcr) { + name: 'foundry-acr-pull-new' + params: { + registryName: name + principalId: foundryProjectPrincipalId + } +} + +module foundryAcrPullExisting 'acr-pull-role-assignment.bicep' = if (!createAcr && empty(existingAcrConnectionName)) { + name: 'foundry-acr-pull-existing' + scope: resourceGroup(existingAcrSubscriptionId, existingAcrResourceGroup) + params: { + registryName: existingAcrName principalId: foundryProjectPrincipalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId } } @@ -84,25 +106,26 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview name: foundryProjectName // Project-scoped connection so Foundry can resolve the registry by name. - resource acrConnection 'connections' = { - name: '${name}-conn' + resource acrConnection 'connections' = if (configureAcr) { + name: '${resolvedAcrName}-conn' properties: { category: 'ContainerRegistry' - target: registry.properties.loginServer + target: createAcr ? registry!.properties.loginServer : existingAcrEndpoint authType: 'ManagedIdentity' // RegistryIdentity auth requires both the identity client id (the // project principal) and the registry resource id. credentials: { clientId: foundryProjectPrincipalId - resourceId: registry.id + resourceId: resolvedAcrId } isSharedToAll: true metadata: { - ResourceId: registry.id + ResourceId: resolvedAcrId } } dependsOn: [ - foundryAcrPull + foundryAcrPullNew + foundryAcrPullExisting ] } } @@ -110,6 +133,6 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview // Outputs -output loginServer string = registry.properties.loginServer -output resourceId string = registry.id -output connectionName string = foundryAccount::project::acrConnection.name +output loginServer string = createAcr ? registry!.properties.loginServer : existingAcrEndpoint +output resourceId string = resolvedAcrId +output connectionName string = configureAcr ? foundryAccount::project::acrConnection!.name : existingAcrConnectionName diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep index efc8671b98a..98321b247ae 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep @@ -27,6 +27,7 @@ type deploymentType = { } } +// @description('Shape of a list of Foundry project connections.') type connectionsType = connectionType[] @@ -39,34 +40,53 @@ type connectionType = { metadata: object? } +// + // Parameters @description('Azure region for all resources.') param location string = resourceGroup().location +@description('Foundry account name to reuse. Empty provisions a new account.') +param foundryAccountName string = '' + @description('Tags applied to all resources.') param tags object = {} @description('Optional salt to vary resource names across re-provisions.') param resourceTokenSalt string = '' -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) +@description('Foundry project name. New projects require 3-32 characters; existing project names are preserved.') param foundryProjectName string @description('Model deployments to provision on the Foundry account.') param deployments deploymentsType = [] +// @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false +@allowed([ + 'none' + 'create' + 'existing' +]) +param acrMode string = includeAcr ? 'create' : 'none' +param acrName string = '' +param existingAcrSubscriptionId string = subscription().subscriptionId +param existingAcrResourceGroup string = resourceGroup().name +param existingAcrName string = acrName +param existingAcrEndpoint string = '' +param existingAcrConnectionName string = '' +// +// @description('Foundry project connections to create (host: azure.ai.connection services).') param connections connectionsType = [] @description('Credentials keyed by Foundry project connection name.') @secure() param connectionCredentials object = {} +// @description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') param principalId string = '' @@ -74,6 +94,7 @@ param principalId string = '' @description('Principal type used in the developer role assignment.') param principalType string = 'User' +// // Network isolation parameters. All default off so an absent network: block in // azure.yaml yields a public account identical to the pre-network template. @@ -112,6 +133,7 @@ param dnsZonesResourceGroup string = '' @description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') param dnsZonesSubscription string = '' +// // Variables @@ -121,14 +143,18 @@ var resourceToken = empty(resourceTokenSalt) var abbrs = loadJsonContent('../abbreviations.json') -var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' +var createFoundryResources = empty(foundryAccountName) +var resolvedFoundryAccountName = createFoundryResources ? '${abbrs.cognitiveServicesAccounts}${resourceToken}' : foundryAccountName +var projectResourceId = resourceId('Microsoft.CognitiveServices/accounts/projects', resolvedFoundryAccountName, foundryProjectName) // Egress: byo injects the agent into a customer subnet; managed uses the // Microsoft-managed network. Ingress: an account private endpoint is always // provisioned when isolation is on, so the data plane is never left public. -var useByoNetwork = enableNetworkIsolation && !useManagedEgress -var useManagedNetwork = enableNetworkIsolation && useManagedEgress -var disablePublicDataPlaneAccess = enableNetworkIsolation +// +var useByoNetwork = createFoundryResources && enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = createFoundryResources && enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = createFoundryResources && enableNetworkIsolation +// // Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles var cognitiveServicesUserRoleId = subscriptionResourceId( @@ -141,6 +167,7 @@ var cognitiveServicesUserRoleId = subscriptionResourceId( // Customer VNet wiring: reference the VNet and create or reference the agent // (byo egress only) + private-endpoint subnets. Runs whenever isolation is on // because the account private endpoint is always provisioned. +// module network 'network.bicep' = if (enableNetworkIsolation) { name: 'foundry-network' params: { @@ -153,6 +180,7 @@ module network 'network.bicep' = if (enableNetworkIsolation) { createPESubnet: createPESubnet } } +// // networkInjections wires the account into the agent subnet (byo) or the // Microsoft-managed network (managed). Null when isolation is off. @@ -164,6 +192,7 @@ module network 'network.bicep' = if (enableNetworkIsolation) { // networkInjections to its typed contract (ARM what-if does not catch this). // The deterministic id avoids the unresolved reference; an explicit dependsOn // on the network module preserves ordering (the subnet must exist first). +// var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' var agentNetworkInjections = useByoNetwork ? [ @@ -181,9 +210,17 @@ var agentNetworkInjections = useByoNetwork } ] : null) +// -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { - name: foundryAccountName +module projectNameValidation 'validate-project-name.bicep' = if (createFoundryResources) { + name: 'validate-foundry-project-name' + params: { + name: foundryProjectName + } +} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = if (createFoundryResources) { + name: resolvedFoundryAccountName location: location tags: tags sku: { @@ -195,7 +232,7 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } properties: { allowProjectManagement: true - customSubDomainName: foundryAccountName + customSubDomainName: resolvedFoundryAccountName publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' disableLocalAuth: true networkAcls: { @@ -204,26 +241,17 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { virtualNetworkRules: [] ipRules: [] } + // networkInjections: agentNetworkInjections + // } // The account injects into the agent subnet via a deterministic id (above), // so Bicep cannot infer the dependency on the network module that creates // that subnet. Declare it explicitly so the subnet exists before injection. - dependsOn: useByoNetwork ? [network] : [] - - // Sequential model deployment creation; ARM throttles concurrent - // deployments on the same account. - @batchSize(1) - resource modelDeployments 'deployments' = [ - for d in deployments: { - name: d.name - properties: { - model: d.model - } - sku: d.sku - } - ] + // + dependsOn: [projectNameValidation, network] + // resource project 'projects' = { name: foundryProjectName @@ -244,12 +272,35 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } } +resource existingFoundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (!createFoundryResources) { + name: resolvedFoundryAccountName +} + +resource existingFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (!createFoundryResources) { + parent: existingFoundryAccount + name: foundryProjectName +} + +@batchSize(1) +#disable-next-line use-parent-property // Parent switches between created and existing account resources. +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + name: '${resolvedFoundryAccountName}/${d.name}' + properties: { + model: d.model + } + sku: d.sku + dependsOn: [foundryAccount, existingFoundryAccount] + } +] + // Managed-network isolation (managed egress only). Applies the chosen outbound // isolation mode to the Microsoft-managed VNet that hosts the agent runtime. // Only deployed when an explicit isolationMode is requested; otherwise the // platform default applies. Note: AllowOnlyApprovedOutbound additionally // requires approved outbound rules for the agent to reach dependent resources; // for the platform-managed stores used here those are managed by the platform. +// resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = if (useManagedNetwork && !empty(managedIsolationMode)) { parent: foundryAccount @@ -260,27 +311,44 @@ resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetw } } } +// + +// +var resolvedAcrName = acrMode == 'create' && empty(acrName) + ? '${abbrs.containerRegistryRegistries}${resourceToken}' + : (empty(existingAcrName) ? acrName : existingAcrName) -module acr 'acr.bicep' = if (includeAcr) { +module acr 'acr.bicep' = if (acrMode != 'none') { name: 'acr' params: { location: location tags: tags - name: '${abbrs.containerRegistryRegistries}${resourceToken}' - foundryAccountName: foundryAccount.name - foundryProjectName: foundryAccount::project.name - foundryProjectPrincipalId: foundryAccount::project.identity.principalId - enableNetworkIsolation: enableNetworkIsolation + acrMode: acrMode == 'existing' ? 'existing' : 'create' + name: resolvedAcrName + existingAcrSubscriptionId: existingAcrSubscriptionId + existingAcrResourceGroup: existingAcrResourceGroup + existingAcrName: resolvedAcrName + existingAcrEndpoint: existingAcrEndpoint + existingAcrConnectionName: existingAcrConnectionName + foundryAccountName: resolvedFoundryAccountName + foundryProjectName: foundryProjectName + foundryProjectPrincipalId: reference(projectResourceId, '2025-04-01-preview', 'full').identity.principalId + // + enableNetworkIsolation: createFoundryResources && enableNetworkIsolation + // } + dependsOn: [foundryAccount, existingFoundryProject, modelDeployments] } +// // Account private endpoint + AI private DNS zones. The account is always given a // private endpoint when isolation is on (byo or managed egress); dependent // stores stay platform-managed, so only the account gets an endpoint. +// module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { name: 'foundry-private-endpoint-dns' params: { - aiAccountName: foundryAccount.name + aiAccountName: resolvedFoundryAccountName location: network!.outputs.vnetLocation vnetId: network!.outputs.vnetId peSubnetId: network!.outputs.peSubnetId @@ -289,25 +357,29 @@ module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolat dnsZonesSubscription: dnsZonesSubscription } } +// // Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as // host: azure.ai.connection services. Created at provision time so a toolbox // that references a connection by name resolves it at deploy. Depends on the // project via foundryAccount.name / project.name so ordering is correct. +// module projectConnections 'connections.bicep' = if (!empty(connections)) { name: 'foundry-connections' params: { - foundryAccountName: foundryAccount.name - foundryProjectName: foundryAccount::project.name + foundryAccountName: resolvedFoundryAccountName + foundryProjectName: foundryProjectName connections: connections connectionCredentials: connectionCredentials } + dependsOn: [foundryAccount, existingFoundryProject, modelDeployments] } +// // Grant the developer Cognitive Services User on the project so they can call // the Foundry data-plane (chat/completions, agents API) from their machine. -resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { - name: guid(foundryAccount::project.id, principalId, cognitiveServicesUserRoleId) +resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (createFoundryResources && !empty(principalId)) { + name: guid(projectResourceId, principalId, cognitiveServicesUserRoleId) scope: foundryAccount::project properties: { principalId: principalId @@ -318,14 +390,20 @@ resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments // Outputs -output AZURE_AI_PROJECT_ID string = foundryAccount::project.id -output AZURE_AI_ACCOUNT_NAME string = foundryAccount.name -output AZURE_AI_PROJECT_NAME string = foundryAccount::project.name -output AZURE_OPENAI_ENDPOINT string = 'https://${foundryAccount.name}.openai.azure.com/' -output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.services.ai.azure.com/api/projects/${foundryAccount::project.name}' -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = resolvedFoundryAccountName +output AZURE_AI_PROJECT_NAME string = foundryProjectName +output AZURE_OPENAI_ENDPOINT string = 'https://${resolvedFoundryAccountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = 'https://${resolvedFoundryAccountName}.services.ai.azure.com/api/projects/${foundryProjectName}' +// +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode != 'none' ? acr!.outputs.loginServer : '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode != 'none' ? acr!.outputs.resourceId : '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = acrMode != 'none' ? acr!.outputs.connectionName : '' +// +// output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames -output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +// +// +output AZURE_FOUNDRY_NETWORK_MODE string = !createFoundryResources || !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' +// diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/validate-project-name.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/validate-project-name.bicep new file mode 100644 index 00000000000..d4aebd9bfed --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/validate-project-name.bicep @@ -0,0 +1,6 @@ +@description('Foundry project name. 3-32 characters for newly created projects.') +@minLength(3) +@maxLength(32) +param name string + +output validatedName string = name diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go index 70bc2db9ae7..4faed8f67a1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go @@ -11,12 +11,9 @@ import "embed" // needs a bicep CLI at user runtime. // //go:generate bicep build templates/main.bicep --outfile templates/main.arm.json -//go:generate bicep build templates/brownfield.bicep --outfile templates/brownfield.arm.json //go:embed templates/main.bicep //go:embed templates/main.arm.json -//go:embed templates/brownfield.bicep -//go:embed templates/brownfield.arm.json //go:embed templates/abbreviations.json //go:embed templates/modules/*.bicep var templatesFS embed.FS @@ -49,10 +46,3 @@ func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } func ARMTemplate() ([]byte, error) { return templatesFS.ReadFile("templates/main.arm.json") } - -// BrownfieldARMTemplate returns the compiled ARM JSON for brownfield.bicep, which -// reconciles synthesized resources on an EXISTING Foundry account (referenced, -// not created). -func BrownfieldARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/brownfield.arm.json") -} From 7f3ff14d3ab76dd2a781ecfdf852bddf6b2a3018 Mon Sep 17 00:00:00 2001 From: hund030 Date: Tue, 28 Jul 2026 16:52:02 +0800 Subject: [PATCH 3/3] feat(agents): eject Bicep for existing Foundry projects --- .../internal/cmd/init_infra.go | 144 +++++++------ .../internal/cmd/init_infra_test.go | 203 +++++++++++++++--- 2 files changed, 256 insertions(+), 91 deletions(-) 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 ae8b68d81c4..8be7aba011c 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 @@ -9,8 +9,10 @@ import ( "errors" "fmt" "io/fs" + "maps" "os" "path/filepath" + "regexp" "slices" "strings" "text/template" @@ -147,16 +149,6 @@ func ejectInfra(projectRoot, provider string) error { "fix the project service configuration in azure.yaml", ) } - if endpoint != "" { - return exterrors.Validation( - exterrors.CodeInfraEjectBrownfieldUnsupported, - "`azd ai agent init --infra` is not supported for a project that reuses an existing "+ - "Foundry resource (the azure.ai.project service sets endpoint:)", - "remove --infra: the extension provisions the existing project (and any required "+ - "container registry) directly with `azd provision`", - ) - } - infraDir := filepath.Join(projectRoot, "infra") if _, err := os.Stat(infraDir); err == nil { return exterrors.Validation( @@ -167,6 +159,14 @@ func ejectInfra(projectRoot, provider string) error { } else if !os.IsNotExist(err) { return fmt.Errorf("stat infra directory: %w", err) } + if endpoint != "" && provider == project.TerraformProviderName { + return exterrors.Validation( + exterrors.CodeInfraEjectBrownfieldUnsupported, + "Terraform infrastructure ejection is not supported for a project that reuses an existing "+ + "Foundry resource (the azure.ai.project service sets endpoint:)", + "eject Bicep instead with `azd ai agent init --infra` or `--infra=bicep`", + ) + } res, err := synthesis.Synthesize(synthesis.Input{ RawAzureYAML: rawYAML, @@ -211,7 +211,28 @@ func ejectInfra(projectRoot, provider string) error { if provider == project.TerraformProviderName { ejectErr = ejectTerraform(projectRoot, infraDir, res.Parameters()) } else { - ejectErr = ejectBicep(infraDir, res.Parameters()) + params := res.Parameters() + if res.Mode == synthesis.ModeBrownfield { + if res.NetworkConfigured { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("service %q cannot configure network: while reusing an existing Foundry project", svcName), + "remove network: or remove endpoint: to provision a new network-isolated project", + ) + } + for connectionName, credentials := range res.ConnectionCredentials { + if path, _, ok := firstLiteralCredential(credentials, ""); ok { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("connection %q contains a literal credential at credentials%s", connectionName, path), + "move the credential into an azd environment variable and reference it as ${VAR}", + ) + } + } + params["foundryAccountName"] = "${AZURE_AI_ACCOUNT_NAME}" + params["foundryProjectName"] = "${AZURE_AI_PROJECT_NAME}" + } + ejectErr = ejectBicep(infraDir, params) } if ejectErr != nil { _ = os.RemoveAll(infraDir) @@ -219,6 +240,33 @@ func ejectInfra(projectRoot, provider string) error { return ejectErr } +var credentialVarRefPattern = regexp.MustCompile(`^\$\{[A-Za-z_][A-Za-z0-9_]*\}$`) + +func firstLiteralCredential(value any, path string) (string, string, bool) { + switch value := value.(type) { + case string: + if credentialVarRefPattern.MatchString(value) { + return "", "", false + } + return path, value, true + case map[string]any: + for _, key := range slices.Sorted(maps.Keys(value)) { + if foundPath, foundValue, ok := firstLiteralCredential(value[key], path+"."+key); ok { + return foundPath, foundValue, true + } + } + case []any: + for i, item := range value { + if foundPath, foundValue, ok := firstLiteralCredential(item, fmt.Sprintf("%s[%d]", path, i)); ok { + return foundPath, foundValue, true + } + } + default: + return path, fmt.Sprintf("%v", value), true + } + return "", "", false +} + // ejectBicep writes the embedded Bicep tree plus the synthesized // main.parameters.json into infraDir and prints the summary. It does not // modify azure.yaml; the declared infra.provider is left unchanged. @@ -376,14 +424,8 @@ func findFoundryServiceForEject(raw []byte) (string, error) { // templates/ root into infraDir, preserving the relative tree, and returns the // files written (with sizes). On any error it removes the partial infraDir. // -// Three files are skipped: -// - main.arm.json (the pre-compiled ARM JSON): would be stale once the user -// edits main.bicep. -// - brownfield.bicep and brownfield.arm.json: unreachable in a greenfield -// eject. ejectInfra already refuses to eject a brownfield (endpoint:) -// project, main.bicep never references brownfield.bicep, and the -// provider's brownfield path always loads the embedded -// synthesis.BrownfieldARMTemplate() instead of anything under infra/. +// The pre-compiled main.arm.json is skipped because it would be stale once the +// user edits main.bicep. func writeEmbeddedTemplates(infraDir string) (_ []ejectArtifact, retErr error) { //nolint:gosec // G301: ejected infra/ directory must be readable/traversable by IDEs, Git, and CI if err := os.MkdirAll(infraDir, 0o755); err != nil { @@ -399,68 +441,36 @@ func writeEmbeddedTemplates(infraDir string) (_ []ejectArtifact, retErr error) { } }() - const templatesRoot = "templates" tfs := synthesis.TemplatesFS() - + files := []string{ + "abbreviations.json", "main.bicep", "modules/acr-pull-role-assignment.bicep", "modules/acr.bicep", + "modules/connections.bicep", "modules/network.bicep", "modules/private-endpoint-dns.bicep", + "modules/resources.bicep", "modules/subnet.bicep", "modules/validate-project-name.bicep", + } var artifacts []ejectArtifact - err := fs.WalkDir(tfs, templatesRoot, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if p == templatesRoot { - return nil - } - rel, err := filepath.Rel(templatesRoot, p) + for _, rel := range files { + data, err := fs.ReadFile(tfs, "templates/"+rel) if err != nil { - return err + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, + fmt.Sprintf("read infra template %s: %s", rel, err)) } - // embed.FS always returns forward slashes; normalize for the OS. dst := filepath.Join(infraDir, filepath.FromSlash(rel)) - - if d.IsDir() { - //nolint:gosec // G301: ejected infra/ subdirectories must remain readable/traversable - if err := os.MkdirAll(dst, 0o755); err != nil { - return err - } - return nil - } - - switch filepath.Base(p) { - case "main.arm.json", "brownfield.bicep", "brownfield.arm.json": - return nil - } - - data, err := fs.ReadFile(tfs, p) - if err != nil { - return err + //nolint:gosec // G301: ejected infra/ subdirectories must remain readable/traversable + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, fmt.Sprintf("create template directory: %s", err)) } //nolint:gosec // G306: ejected Bicep sources are intended to be human-readable if err := os.WriteFile(dst, data, 0o644); err != nil { - return err + return nil, exterrors.Internal(exterrors.CodeInfraEjectWriteFailed, fmt.Sprintf("write %s: %s", rel, err)) } - artifacts = append(artifacts, ejectArtifact{ - relPath: filepath.ToSlash(filepath.Join("infra", rel)), - bytes: len(data), - }) - return nil - }) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeInfraEjectWriteFailed, - fmt.Sprintf("write infra templates: %s", err), - ) + artifacts = append(artifacts, ejectArtifact{relPath: filepath.ToSlash(filepath.Join("infra", rel)), bytes: len(data)}) } - return artifacts, nil } // writeParametersFile emits infra/main.parameters.json in the standard ARM -// parameter file shape. Only synthesizer-known values (`deployments`, -// `includeAcr`) are written; deploy-time parameters (foundryProjectName, -// location, resourceGroupName, principalId, resourceTokenSalt, tags) are -// supplied by the provider at `azd provision`. The result is a partial -// parameters file -- enough for `bicep build` to validate, not for a -// standalone `az deployment sub create`. +// parameter file shape. Callers pass only synthesizer-owned values; targeting, +// location, identity, and tags remain provider-owned at provision time. func writeParametersFile(infraDir string, params map[string]any) (ejectArtifact, error) { type paramValue struct { Value any `json:"value"` 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 d2dd70820eb..e2d156d8bf7 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 @@ -7,8 +7,10 @@ import ( "encoding/json" "errors" "io" + "maps" "os" "path/filepath" + "slices" "testing" "azureaiagent/internal/exterrors" @@ -19,6 +21,24 @@ import ( "go.yaml.in/yaml/v3" ) +type parametersDocument struct { + Schema string `json:"$schema"` + ContentVersion string `json:"contentVersion"` + Parameters map[string]struct { + Value any `json:"value"` + } `json:"parameters"` +} + +func readParametersDocument(t *testing.T, path string) parametersDocument { + t.Helper() + //nolint:gosec // test path is rooted under t.TempDir + raw, err := os.ReadFile(path) + require.NoError(t, err) + var doc parametersDocument + require.NoError(t, json.Unmarshal(raw, &doc)) + return doc +} + // validFoundryAzureYAML returns an azure.yaml payload exercising the // synthesizer's two derived parameters: deployments and includeAcr. // Container deployment via the `docker:` block forces includeAcr=true. @@ -150,7 +170,84 @@ services: assert.Contains(t, localErr.Message, "[agent-a agent-b]") } -func TestEjectInfra_RefusesWhenBrownfieldEndpoint(t *testing.T) { +func TestEjectInfra_BrownfieldWritesUnifiedBicepTree(t *testing.T) { + dir := t.TempDir() + const azureYAML = `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + deployments: + - name: gpt-4o-mini-e2e + model: {name: gpt-4o-mini, format: OpenAI, version: "2024-07-18"} + sku: {name: GlobalStandard, capacity: 1} + test-connection: + host: azure.ai.connection + category: RemoteTool + target: ${MCP_ENDPOINT} + authType: CustomKeys + credentials: + key: ${MCP_KEY} +` + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), azureYAML) + + withCapturedStdout(t, func() { + require.NoError(t, ejectInfra(dir, "bicep")) + }) + + expected := []string{ + filepath.Join("infra", "main.bicep"), + filepath.Join("infra", "main.parameters.json"), + filepath.Join("infra", "abbreviations.json"), + filepath.Join("infra", "modules", "resources.bicep"), + filepath.Join("infra", "modules", "acr.bicep"), + filepath.Join("infra", "modules", "acr-pull-role-assignment.bicep"), + filepath.Join("infra", "modules", "connections.bicep"), + filepath.Join("infra", "modules", "network.bicep"), + filepath.Join("infra", "modules", "subnet.bicep"), + filepath.Join("infra", "modules", "private-endpoint-dns.bicep"), + filepath.Join("infra", "modules", "validate-project-name.bicep"), + } + for _, rel := range expected { + _, err := os.Stat(filepath.Join(dir, rel)) + require.NoError(t, err, "expected %s", rel) + } + + mainBicep, err := os.ReadFile(filepath.Join(dir, "infra", "main.bicep")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(mainBicep), "module resources 'modules/resources.bicep'") + + params := readParametersDocument(t, filepath.Join(dir, "infra", "main.parameters.json")) + assert.NotContains(t, slices.Collect(maps.Keys(params.Parameters)), "provisioningMode") + assert.Equal(t, "${AZURE_AI_ACCOUNT_NAME}", params.Parameters["foundryAccountName"].Value) + assert.Equal(t, "${AZURE_AI_PROJECT_NAME}", params.Parameters["foundryProjectName"].Value) + assert.Equal(t, "${MCP_ENDPOINT}", params.Parameters["connections"].Value.([]any)[0].(map[string]any)["target"]) + credentials := params.Parameters["connectionCredentials"].Value.(map[string]any) + assert.Equal(t, "${MCP_KEY}", credentials["test-connection"].(map[string]any)["key"]) + + gotYAML, err := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec + require.NoError(t, err) + assert.Equal(t, azureYAML, string(gotYAML)) +} + +func TestEjectInfra_BrownfieldSkipsAgentRefValidation(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + agent: + host: azure.ai.agent + $ref: ./missing-agent.yaml +`) + + withCapturedStdout(t, func() { + require.NoError(t, ejectInfra(dir, "bicep")) + }) +} + +func TestEjectInfra_BrownfieldTerraformUnsupported(t *testing.T) { t.Parallel() dir := t.TempDir() mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project @@ -160,16 +257,15 @@ services: endpoint: https://acct.services.ai.azure.com/api/projects/p1 `) - err := ejectInfra(dir, "bicep") + err := ejectInfra(dir, "terraform") require.Error(t, err) - localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok) assert.Equal(t, exterrors.CodeInfraEjectBrownfieldUnsupported, localErr.Code) - assert.Contains(t, localErr.Message, "endpoint:") + assert.Contains(t, localErr.Message, "Terraform") } -func TestEjectInfra_BrownfieldRefusalPrecedesSiblingRefValidation(t *testing.T) { +func TestEjectInfra_BrownfieldRejectsLiteralCredentials(t *testing.T) { t.Parallel() dir := t.TempDir() mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project @@ -177,19 +273,86 @@ services: ai-project: host: azure.ai.project endpoint: https://acct.services.ai.azure.com/api/projects/p1 - agent: - host: azure.ai.agent - $ref: ./missing-agent.yaml connection: host: azure.ai.connection - $ref: ./missing-connection.yaml + category: CognitiveSearch + target: https://search.example + authType: ApiKey + credentials: + key: literal-secret-value `) err := ejectInfra(dir, "bicep") require.Error(t, err) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok) - assert.Equal(t, exterrors.CodeInfraEjectBrownfieldUnsupported, localErr.Code) + assert.Equal(t, exterrors.CodeInvalidAzureYaml, localErr.Code) + assert.Contains(t, localErr.Message, "credentials.key") + assert.NotContains(t, localErr.Message, "literal-secret-value") + _, statErr := os.Stat(filepath.Join(dir, "infra")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestEjectInfra_BrownfieldRejectsNetwork(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p1 + network: + managed: {} +`) + + err := ejectInfra(dir, "bicep") + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, exterrors.CodeInvalidAzureYaml, localErr.Code) + assert.Contains(t, localErr.Message, "cannot configure network") + _, statErr := os.Stat(filepath.Join(dir, "infra")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestFirstLiteralCredentialRequiresExactVariableReference(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + literal bool + }{ + {value: "${KEY}"}, + {value: "${_KEY_1}"}, + {value: "${KEY}literal}", literal: true}, + {value: "prefix${KEY}", literal: true}, + {value: "${KEY:-default}", literal: true}, + {value: "${1KEY}", literal: true}, + {value: "literal", literal: true}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + _, _, got := firstLiteralCredential(tt.value, ".key") + assert.Equal(t, tt.literal, got) + }) + } +} + +func TestFirstLiteralCredentialRejectsNonStringLeaves(t *testing.T) { + t.Parallel() + + for name, value := range map[string]any{ + "integer": 42, + "boolean": true, + "null": nil, + } { + t.Run(name, func(t *testing.T) { + path, _, literal := firstLiteralCredential(value, ".key") + assert.True(t, literal) + assert.Equal(t, ".key", path) + }) + } } func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { @@ -204,17 +367,13 @@ func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { require.NoError(t, err) }) - // Every embedded template under templates/ (except main.arm.json and the - // dead-in-a-greenfield-eject brownfield.bicep/brownfield.arm.json) should - // be on disk under ./infra/, plus the synthesized main.parameters.json. + // This fixture needs ACR but has no connections or network configuration. expected := []string{ filepath.Join("infra", "main.bicep"), filepath.Join("infra", "abbreviations.json"), filepath.Join("infra", "modules", "acr.bicep"), - filepath.Join("infra", "modules", "connections.bicep"), - filepath.Join("infra", "modules", "network.bicep"), - filepath.Join("infra", "modules", "subnet.bicep"), - filepath.Join("infra", "modules", "private-endpoint-dns.bicep"), + filepath.Join("infra", "modules", "acr-pull-role-assignment.bicep"), + filepath.Join("infra", "modules", "resources.bicep"), filepath.Join("infra", "main.parameters.json"), } for _, rel := range expected { @@ -230,8 +389,7 @@ func TestEjectInfra_HappyPath_WritesExpectedFiles(t *testing.T) { "main.arm.json should be excluded from the ejected tree (it would be stale "+ "the moment the user edits main.bicep)") - // brownfield.bicep/brownfield.arm.json are excluded too: unreachable in a - // greenfield eject (see TestEjectInfra_RefusesWhenBrownfieldEndpoint). + // Brownfield templates are excluded from a greenfield eject. for _, rel := range []string{ filepath.Join("infra", "brownfield.bicep"), filepath.Join("infra", "brownfield.arm.json"), @@ -308,9 +466,7 @@ func TestEjectInfra_HappyPath_ParametersFileShape(t *testing.T) { func TestEjectInfra_HappyPath_NoDockerOmitsAcrParam(t *testing.T) { // See TestEjectInfra_HappyPath_WritesExpectedFiles for why this is not parallel. dir := t.TempDir() - // No docker: block -> includeAcr should be false in the params file - // but the acr.bicep module is still written (the template files are a - // static set; whether ACR is provisioned is a parameter decision). + // No docker: block -> includeAcr is false; the static ACR module remains available for user edits. mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project services: my-foundry: @@ -325,9 +481,8 @@ services: require.NoError(t, ejectInfra(dir, "bicep")) }) - // acr.bicep is still in the ejected tree -- the template is static. _, err := os.Stat(filepath.Join(dir, "infra", "modules", "acr.bicep")) - assert.NoError(t, err, "acr.bicep module is part of the static template set") + assert.NoError(t, err, "acr.bicep is part of the static ejected tree") raw, err := os.ReadFile(filepath.Join(dir, "infra", "main.parameters.json")) //nolint:gosec // G304: test file path from t.TempDir() require.NoError(t, err)