diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index d26f52ef2b4..dab3f626fb2 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -409,11 +409,10 @@ connection must exist on the selected project before `azd deploy` runs. ### Declarative sibling connection -To let `azd provision` create the connection, declare an -`azure.ai.connection` sibling. For this declarative path, both the agent's -`registryConnectionId` and `uses` identify the sibling's azure.yaml service key, -which is also the Foundry connection name provisioned by the current Projects -extension: +To let azd manage the connection, declare an `azure.ai.connection` sibling. For +this declarative path, both the agent's `registryConnectionId` and `uses` +identify the sibling's azure.yaml service key, which is also the Foundry +connection name reconciled by the Connections extension: ```yaml services: @@ -453,10 +452,11 @@ services: version: 1.0.0 ``` -Set the referenced credential environment values, run `azd provision`, and then -run `azd deploy`. Omitting the sibling from `uses`, disabling it with a deployment -condition, or omitting image passthrough causes validation to fail before agent -deployment. +Set the referenced credential environment values and run `azd up`, or run +`azd provision` followed by `azd deploy`. Provision creates the Project; +deploy reconciles the Connection before the dependent Agent. Omitting the +sibling from `uses`, disabling it with a deployment condition, or omitting image +passthrough causes validation to fail before Agent deployment. ## Private networking for `host: azure.ai.project` diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index ec2495ad922..383d14b2619 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3537,7 +3537,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected // existing project contributes its endpoint so provision reuses it. - emittedConnections, err := emitResourceServices( + _, err = emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), @@ -3546,13 +3546,6 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if err != nil { return err } - recordPendingConnectionProvision( - ctx, - a.azdClient, - a.environment.Name, - emittedConnections, - ) - printAgentAddedMessage(agentDef.Name) // Replace the legacy hardcoded `azd up` / `azd deploy` hint with the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index f864a5d2b41..d07d6c40395 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -70,7 +70,7 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args ); err != nil { return err } - connections, err := collectConnections( + connections, err := collectLegacyConnections( args.Project.Services, args.Project.Path, ) @@ -241,7 +241,7 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az ); err != nil { return err } - connections, err := collectConnections( + connections, err := collectLegacyConnections( args.Project.Services, args.Project.Path, ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 18f4f2e43cf..9dcd4ea7830 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -202,7 +202,7 @@ func ResolveAfterInit(state *State, readmeExists func(relativePath string) bool) } out = append(out, Suggestion{ Command: "azd provision", - Description: "set up your Foundry project, models, and connections", + Description: "set up your Foundry project and models", Priority: priority, }) priority++ @@ -246,17 +246,10 @@ func ResolveAfterInit(state *State, readmeExists func(relativePath string) bool) ) // Toolbox sub-branch: configured toolboxes declare one or more // whose azd-injected TOOLBOX__MCP_ENDPOINT variable is - // not yet present in the azd environment. The variable is - // written by `azd provision` (listen.go::registerToolboxEnvVars) - // after the azure.ai.toolbox service target publishes the toolbox version, - // so the canonical fix is provision — NOT `azd env set`, which - // the generic manual-vars sub-branch below would otherwise - // suggest. We also surface `azd ai agent doctor` as a follow-up - // so the user can check whether the toolbox already exists in - // their Foundry project. The actual live existence check - // belongs in doctor's local.toolboxes (one HTTP GET per - // toolbox); ResolveAfterInit is offline by contract and must - // not initiate Foundry API calls. + // not yet present. Split Toolboxes publish it from their deploy + // target. Bundled and legacy Toolboxes retain their existing + // migration/provision guidance. ResolveAfterInit remains offline + // and does not probe Foundry. if hasToolboxEndpoints && hasBundledToolboxEndpoints { priority = appendBundledToolboxGuidance( &out, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 9c6b65c31d7..0d4c6ff0bc7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -117,15 +117,6 @@ func TestResolveAfterInit(t *testing.T) { wantPrimaryHas: "azd provision", wantTrailing: "azd deploy", }, - { - name: "new connection in existing project → provision", - state: &State{ - HasProjectEndpoint: true, - PendingProvisionReasons: []string{"connection"}, - }, - wantPrimaryHas: "azd provision", - wantTrailing: "azd deploy", - }, { name: "provision needed with missing Azure context → env set before provision", state: &State{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go index 5bb2c84d30c..948e2b5fa49 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision.go @@ -6,7 +6,6 @@ package cmd import ( "context" "fmt" - "log" "slices" "strings" @@ -46,7 +45,6 @@ const ( pendingReasonModelDeployment = "model_deployment" pendingReasonACR = "acr" pendingReasonAppInsights = "app_insights" - pendingReasonConnection = "connection" ) // parsePendingProvisionReasons splits the comma-separated env-var @@ -109,31 +107,6 @@ func addPendingProvisionReason( }) } -// recordPendingConnectionProvision marks connections that need -// provision after init writes a connection service. -// A signal write failure only produces a warning. -func recordPendingConnectionProvision( - ctx context.Context, - azdClient *azdext.AzdClient, - envName string, - emitted int, -) { - if emitted <= 0 { - return - } - if _, err := addPendingProvisionReason( - ctx, - azdClient, - envName, - pendingReasonConnection, - ); err != nil { - log.Printf( - "warning: could not record pending connection provision: %v", - err, - ) - } -} - // removePendingProvisionReason drops a reason tag from the // AI_AGENT_PENDING_PROVISION env var. Idempotent: removing a tag // that was not present is a no-op (no write performed). Used when diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go index 9994b193be7..569ae089302 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/pending_provision_test.go @@ -204,89 +204,6 @@ func TestRemovePendingProvisionReason(t *testing.T) { }) } -func TestRecordPendingConnectionProvision(t *testing.T) { - t.Parallel() - - t.Run("emitted writes connection reason", func(t *testing.T) { - t.Parallel() - - envServer := &testEnvironmentServiceServer{ - environments: map[string]*azdext.Environment{ - "test-env": {Name: "test-env"}, - }, - } - azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) - - recordPendingConnectionProvision( - context.Background(), azdClient, "test-env", 1) - require.Equal( - t, - pendingReasonConnection, - envServer.values["test-env"][pendingProvisionEnvVar], - ) - }) - - t.Run("zero emitted is no-op", func(t *testing.T) { - t.Parallel() - - envServer := &testEnvironmentServiceServer{ - environments: map[string]*azdext.Environment{ - "test-env": {Name: "test-env"}, - }, - } - azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) - - recordPendingConnectionProvision( - context.Background(), azdClient, "test-env", 0) - _, hit := envServer.values["test-env"][pendingProvisionEnvVar] - require.False(t, hit) - }) - - t.Run("zero emitted preserves existing reason", func(t *testing.T) { - t.Parallel() - - envServer := &testEnvironmentServiceServer{ - environments: map[string]*azdext.Environment{ - "test-env": {Name: "test-env"}, - }, - values: map[string]map[string]string{ - "test-env": {pendingProvisionEnvVar: "connection"}, - }, - } - azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) - - recordPendingConnectionProvision( - context.Background(), azdClient, "test-env", 0) - require.Equal( - t, - "connection", - envServer.values["test-env"][pendingProvisionEnvVar], - ) - }) - - t.Run("sorts with existing reasons", func(t *testing.T) { - t.Parallel() - - envServer := &testEnvironmentServiceServer{ - environments: map[string]*azdext.Environment{ - "test-env": {Name: "test-env"}, - }, - values: map[string]map[string]string{ - "test-env": {pendingProvisionEnvVar: "project"}, - }, - } - azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}) - - recordPendingConnectionProvision( - context.Background(), azdClient, "test-env", 2) - require.Equal( - t, - "connection,project", - envServer.values["test-env"][pendingProvisionEnvVar], - ) - }) -} - func TestClearPendingProvisionReasons(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 39e7eabfe22..58d9ac91591 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -530,43 +530,13 @@ func collectLegacyProjectDeployments( return out, nil } -// collectConnections gathers the connections declared across all -// azure.ai.connection services. Falls back to the connections bundled on the -// agent service when no connection service carries any, so a pre-split -// azure.yaml still provisions without re-running init. -func collectConnections( +// collectLegacyConnections gathers connections bundled on pre-split Agent +// services. Split host: azure.ai.connection services are reconciled by the +// Connections extension and are intentionally not parsed here. +func collectLegacyConnections( services map[string]*azdext.ServiceConfig, projectRoot string, ) ([]project.Connection, error) { - var out []project.Connection - for _, svc := range sortedServices(services) { - if svc.Host != AiConnectionHost { - continue - } - props, err := resolvedResourceServiceProps( - svc, - projectRoot, - ) - if err != nil { - return nil, err - } - if props == nil { - continue - } - var conn *project.Connection - if err := project.UnmarshalStruct(props, &conn); err != nil { - return nil, fmt.Errorf("parsing connection service %q config: %w", svc.Name, err) - } - if conn != nil { - if conn.Name == "" { - conn.Name = svc.Name - } - out = append(out, *conn) - } - } - if len(out) > 0 { - return out, nil - } legacy, err := collectLegacyAgentConfigs( services, projectRoot, @@ -574,62 +544,13 @@ func collectConnections( if err != nil { return nil, err } + var out []project.Connection for _, cfg := range legacy { out = append(out, cfg.Connections...) } return out, nil } -// collectToolboxes gathers the toolboxes declared across all azure.ai.toolbox -// services. Falls back to the toolboxes bundled on the agent service when no -// toolbox service carries any, so a pre-split azure.yaml still provisions -// without re-running init. -func collectToolboxes( - services map[string]*azdext.ServiceConfig, - projectRoot string, -) ([]project.Toolbox, error) { - var out []project.Toolbox - for _, svc := range sortedServices(services) { - if svc.Host != AiToolboxHost { - continue - } - props, err := resolvedResourceServiceProps( - svc, - projectRoot, - ) - if err != nil { - return nil, err - } - if props == nil { - continue - } - var toolbox *project.Toolbox - if err := project.UnmarshalStruct(props, &toolbox); err != nil { - return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.Name, err) - } - if toolbox != nil { - if toolbox.Name == "" { - toolbox.Name = svc.Name - } - out = append(out, *toolbox) - } - } - if len(out) > 0 { - return out, nil - } - legacy, err := collectLegacyAgentConfigs( - services, - projectRoot, - ) - if err != nil { - return nil, err - } - for _, cfg := range legacy { - out = append(out, cfg.Toolboxes...) - } - return out, nil -} - // collectAgentToolConnections gathers the tool connections declared on agent // services. Tool connections stay on the agent service (they are agent tool // configuration), so toolbox enrichment still needs them alongside the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index d31994fe61c..d7b3342c058 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -45,14 +45,6 @@ func connectionService(t *testing.T, name string, conn project.Connection) *azde return svc } -func toolboxService(t *testing.T, name string, toolbox project.Toolbox) *azdext.ServiceConfig { - t.Helper() - svc := mustMarshalConfig(t, &toolbox) - svc.Name = name - svc.Host = AiToolboxHost - return svc -} - func agentService(t *testing.T, name string, toolConnections ...project.ToolConnection) *azdext.ServiceConfig { t.Helper() svc := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{ToolConnections: toolConnections}) @@ -212,9 +204,7 @@ func TestCollectLegacyProjectDeploymentsIgnoresSplitProject( assert.Empty(t, deployments) } -// TestCollectConnections verifies connections are sourced from -// azure.ai.connection services in deterministic (sorted) order. -func TestCollectConnections(t *testing.T) { +func TestCollectLegacyConnectionsIgnoresSplitServices(t *testing.T) { t.Parallel() services := map[string]*azdext.ServiceConfig{ @@ -224,15 +214,12 @@ func TestCollectConnections(t *testing.T) { "agent": agentService(t, "agent"), } - connections, err := collectConnections(services, "") + connections, err := collectLegacyConnections(services, "") require.NoError(t, err) - require.Len(t, connections, 2) - // Sorted by service key (alpha before zeta) for stable env-var output. - assert.Equal(t, "alpha", connections[0].Name) - assert.Equal(t, "zeta", connections[1].Name) + require.Empty(t, connections) } -func TestCollectConnections_UsesAgentConfigPrecedence(t *testing.T) { +func TestCollectLegacyConnectionsUsesAgentConfigPrecedence(t *testing.T) { t.Parallel() inline, err := structpb.NewStruct(map[string]any{ @@ -266,13 +253,13 @@ func TestCollectConnections_UsesAgentConfigPrecedence(t *testing.T) { }, } - connections, err := collectConnections(services, "") + connections, err := collectLegacyConnections(services, "") require.NoError(t, err) require.Len(t, connections, 1) assert.Equal(t, "legacy-connection", connections[0].Name) } -func TestCollectConnections_UsesResolvedInlineConfig(t *testing.T) { +func TestCollectLegacyConnectionsUsesResolvedInlineConfig(t *testing.T) { t.Parallel() root := t.TempDir() @@ -312,29 +299,12 @@ func TestCollectConnections_UsesResolvedInlineConfig(t *testing.T) { }, } - connections, err := collectConnections(services, root) + connections, err := collectLegacyConnections(services, root) require.NoError(t, err) require.Len(t, connections, 1) assert.Equal(t, "inline-connection", connections[0].Name) } -// TestCollectToolboxes verifies toolboxes are sourced from azure.ai.toolbox -// services only. -func TestCollectToolboxes(t *testing.T) { - t.Parallel() - - services := map[string]*azdext.ServiceConfig{ - "tb": toolboxService(t, "tb", project.Toolbox{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}), - "agent": agentService(t, "agent"), - } - - toolboxes, err := collectToolboxes(services, "") - require.NoError(t, err) - require.Len(t, toolboxes, 1) - assert.Equal(t, "tb", toolboxes[0].Name) - require.Len(t, toolboxes[0].Tools, 1) -} - func TestCollectResourceServices_ResolvesFileRefs(t *testing.T) { t.Parallel() @@ -391,11 +361,9 @@ func TestCollectResourceServices_ResolvesFileRefs(t *testing.T) { require.NoError(t, err) assert.Empty(t, deployments) - connections, err := collectConnections(services, root) + connections, err := collectLegacyConnections(services, root) require.NoError(t, err) - require.Len(t, connections, 1) - assert.Equal(t, "search", connections[0].Name) - assert.Equal(t, "ApiKey", connections[0].Category) + require.Empty(t, connections) } // TestCollectAgentToolConnections verifies tool connections stay on the agent @@ -429,26 +397,21 @@ func TestCollectHelpers_EmptyAndNilConfigs(t *testing.T) { require.NoError(t, err) assert.Empty(t, deployments) - connections, err := collectConnections(services, "") + connections, err := collectLegacyConnections(services, "") require.NoError(t, err) assert.Empty(t, connections) - toolboxes, err := collectToolboxes(services, "") - require.NoError(t, err) - assert.Empty(t, toolboxes) } -// TestCollect_FallbackToBundledAgentConfig verifies that a pre-split azure.yaml -// -- deployments, connections, and toolboxes bundled on the agent service with -// no sibling azure.ai. services -- still yields those resources, so -// existing projects provision without re-running init. +// TestCollect_FallbackToBundledAgentConfig verifies that pre-split deployments +// and connections bundled on the agent service remain available to the legacy +// provisioning compatibility path. func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { t.Parallel() bundled := &project.ServiceTargetAgentConfig{ Deployments: []project.Deployment{{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}}}, Connections: []project.Connection{{Name: "conn", Category: "ApiKey"}}, - Toolboxes: []project.Toolbox{{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}}, } svc := mustMarshalConfig(t, bundled) svc.Name = "my-agent" @@ -460,15 +423,11 @@ func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { require.Len(t, deployments, 1) assert.Equal(t, "gpt-4o", deployments[0].Name) - connections, err := collectConnections(services, "") + connections, err := collectLegacyConnections(services, "") require.NoError(t, err) require.Len(t, connections, 1) assert.Equal(t, "conn", connections[0].Name) - toolboxes, err := collectToolboxes(services, "") - require.NoError(t, err) - require.Len(t, toolboxes, 1) - assert.Equal(t, "tb", toolboxes[0].Name) } func TestCollectLegacyProjectDeploymentsSplitDisablesFallback( @@ -871,11 +830,14 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { require.Len(t, projectCfg.Deployments, 1) assert.Equal(t, "gpt-4.1-mini", projectCfg.Deployments[0].Name) - gotConns, err := collectConnections(services, "") + var connectionCfg project.Connection + err = project.UnmarshalStruct( + project.ServiceConfigProps(services["myconn"]), + &connectionCfg, + ) require.NoError(t, err) - require.Len(t, gotConns, 1) - assert.Equal(t, "myconn", gotConns[0].Name) - assert.Equal(t, "ApiKey", gotConns[0].Category) + assert.Equal(t, "myconn", connectionCfg.Name) + assert.Equal(t, "ApiKey", connectionCfg.Category) } // TestEmitResourceServices_WritesEndpointForExistingProject verifies that a diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 66753ec9c9b..2ed9060d5e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -77,7 +77,7 @@ func validateRegistryConnectionDependency( exterrors.CodeFoundryDependencyNotReady, fmt.Sprintf("registry connection service %s is not declared in %s uses", strconv.Quote(connectionRef), strconv.Quote(agent.GetName())), - fmt.Sprintf("add %s to the %s service uses list, run 'azd provision', then retry the agent deployment", + fmt.Sprintf("add %s to the %s service uses list, run 'azd deploy', then retry the agent deployment", strconv.Quote(connectionRef), strconv.Quote(agent.GetName())), ) } @@ -133,13 +133,12 @@ func validateFoundryDependencies( detail := validateFoundryDependency(dependency, env) if detail != "" { failures = append(failures, foundryDependencyFailure{ - name: dependencyName, - host: host, - detail: detail, - requiresProvision: host == foundryProjectHost || host == legacyFoundryHost || - host == foundryConnectionHost, - requiresDeploy: host == foundryToolboxHost || host == foundryAgentHost || - host == foundrySkillHost, + name: dependencyName, + host: host, + detail: detail, + requiresProvision: host == foundryProjectHost || host == legacyFoundryHost, + requiresDeploy: host == foundryConnectionHost || host == foundryToolboxHost || + host == foundryAgentHost || host == foundrySkillHost, }) } } @@ -349,21 +348,10 @@ func validateFoundryProjectDependency(_ *azdext.ServiceConfig, env map[string]st return "" } -func validateFoundryConnectionDependency(service *azdext.ServiceConfig, env map[string]string) string { - connectionProject := strings.TrimSpace(env[envkey.ConnectionProjectEndpoint]) - if connectionProject != "" && !sameProjectEndpoint(connectionProject, env["FOUNDRY_PROJECT_ENDPOINT"]) { - return fmt.Sprintf("%s does not match FOUNDRY_PROJECT_ENDPOINT", envkey.ConnectionProjectEndpoint) - } - found := false - for name := range strings.SplitSeq(env["AZURE_AI_PROJECT_CONNECTION_NAMES"], ",") { - if strings.TrimSpace(name) == service.GetName() { - found = true - break - } - } - if !found { - return "connection is not listed in AZURE_AI_PROJECT_CONNECTION_NAMES" - } +func validateFoundryConnectionDependency(_ *azdext.ServiceConfig, _ map[string]string) string { + // The uses graph runs the Connection service target before its dependent + // Agent. A failed or disabled Connection prevents the Agent deploy step, so + // no environment readiness marker is required here. return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go index 7076390afda..e7f4b6a09bd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go @@ -219,22 +219,6 @@ func TestValidateFoundryDependencies(t *testing.T) { "AZURE_AI_PROJECT_CONNECTION_NAMES": "connection", }, }, - { - name: "connection readiness from another project fails", - uses: []string{"connection"}, - services: map[string]*azdext.ServiceConfig{ - "connection": {Name: "connection", Host: foundryConnectionHost}, - }, - env: map[string]string{ - "FOUNDRY_PROJECT_ENDPOINT": "https://example.test/projects/current", - envkey.ConnectionProjectEndpoint: "https://example.test/projects/old", - "AZURE_AI_PROJECT_CONNECTION_NAMES": "connection", - }, - wantErr: true, - wantDetail: []string{ - "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT does not match FOUNDRY_PROJECT_ENDPOINT", - }, - }, { name: "skill marker from another project fails", uses: []string{"summarize"}, 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 7a93cc93e73..a4abd20a047 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -69,6 +69,12 @@ type Input struct { // provision path), ${VAR} is resolved here and a missing variable fails. PreserveVarRefs bool + // ExcludeConnectionServices leaves host: azure.ai.connection services out + // of provider-managed infrastructure. The Connections extension reconciles + // those services after provision and during deploy. Ejected, user-owned + // infrastructure leaves this false so its declared inputs remain intact. + ExcludeConnectionServices bool + // ProjectRoot is the directory holding azure.yaml. When set, $ref file // includes in the service entry (and its deployment items) are resolved // against it before synthesis, so refs become the actual content rather @@ -233,6 +239,7 @@ type projectService struct { Host string `yaml:"host"` Endpoint string `yaml:"endpoint,omitempty"` Deployments []Deployment `yaml:"deployments,omitempty"` + Connections []Connection `yaml:"connections,omitempty"` Agents []agentBlock `yaml:"agents,omitempty"` Network *networkBlock `yaml:"network,omitempty"` } @@ -318,15 +325,23 @@ func Synthesize(in Input) (*Result, error) { deployments = []Deployment{} } - connections, err := collectConnections( - root.Services, - in.Env, - in.ServiceEnvironments, - !in.PreserveVarRefs, - in.ProjectRoot, - ) - if err != nil { - return nil, err + connections := []Connection{} + if in.ExcludeConnectionServices && svc.Host != "azure.ai.project" { + connections, err = expandLegacyConnections(svc.Connections, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + } else if !in.ExcludeConnectionServices { + connections, err = collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } } connections, connectionCredentials := SplitConnectionCredentials(connections) netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) @@ -387,15 +402,23 @@ func SynthesizeExistingProject(in Input) (*Result, error) { if err != nil { return nil, err } - connections, err := collectConnections( - root.Services, - in.Env, - in.ServiceEnvironments, - !in.PreserveVarRefs, - in.ProjectRoot, - ) - if err != nil { - return nil, err + connections := []Connection{} + if in.ExcludeConnectionServices && svc.Host != "azure.ai.project" { + connections, err = expandLegacyConnections(svc.Connections, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + } else if !in.ExcludeConnectionServices { + connections, err = collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } } connections, connectionCredentials := SplitConnectionCredentials(connections) deployments := svc.Deployments @@ -891,6 +914,46 @@ func collectConnections( return connections, nil } +func expandLegacyConnections( + connections []Connection, + environment map[string]string, + resolve bool, +) ([]Connection, error) { + mapping := connectionEnvironmentMapping(environment, nil, false) + result := make([]Connection, len(connections)) + for i, connection := range connections { + result[i] = connection + var err error + for field, target := range map[string]*string{ + "target": &result[i].Target, + "audience": &result[i].Audience, + "authorizationUrl": &result[i].AuthorizationURL, + "tokenUrl": &result[i].TokenURL, + "refreshUrl": &result[i].RefreshURL, + "connectorName": &result[i].ConnectorName, + } { + *target, err = maybeExpand(*target, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q %s: %w", connection.Name, field, err) + } + } + result[i].Scopes, err = expandStrings(connection.Scopes, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q scopes: %w", connection.Name, err) + } + result[i].Credentials, err = expandCredentials(connection.Credentials, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q credentials: %w", connection.Name, err) + } + result[i].Metadata, err = expandMetadata(connection.Metadata, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q metadata: %w", connection.Name, err) + } + result[i].AuthType = normalizeConnectionAuthType(connection.AuthType) + } + return result, nil +} + // visitEnabledConnectionServices walks azure.ai.connection services // whose condition is enabled. Condition uses the project environment, // not the connection service env: block. A root host plus an diff --git a/cli/azd/extensions/azure.ai.connections/README.md b/cli/azd/extensions/azure.ai.connections/README.md index 4e9b850ad9f..884be644983 100644 --- a/cli/azd/extensions/azure.ai.connections/README.md +++ b/cli/azd/extensions/azure.ai.connections/README.md @@ -1,3 +1,45 @@ # Foundry Connections Manage Microsoft Foundry Connections from your terminal. (Preview) + +## `azure.yaml` ownership + +This extension owns the runtime lifecycle for `host: azure.ai.connection` +services. During the staged ownership migration, `azd ai agent init` may still +author the service block, but the Connections extension reads and reconciles it. + +```yaml +services: + search: + host: azure.ai.connection + uses: [ai-project] + category: CognitiveSearch + target: ${SEARCH_ENDPOINT} + authType: ApiKey + credentials: + key: ${SEARCH_KEY} + env: + SEARCH_ENDPOINT: ${SEARCH_ENDPOINT} + SEARCH_KEY: ${SEARCH_KEY} +``` + +`azd deploy search` creates or replaces this Connection. `azd up` provisions +the referenced Project first, then runs the same Connection deploy target before +dependents such as Toolboxes and Agents. A standalone `azd provision` creates +the Project infrastructure but does not reconcile split Connection services. + +The target resolves `${VAR}` references from the service-level `env` block. If +the service omits `env`, it falls back to the environment selected for the +current invocation, including an explicit `-e` / `--environment`. Project +endpoint, project ID, subscription, and tenant lookup use that same environment. +An explicit empty `env: {}` creates an isolated scope. + +Declarative services preserve every authentication type accepted by the schema, +including `AAD`, `PAT`, `ServicePrincipal`, `UsernamePassword`, `AccessKey`, +`AccountKey`, and `SAS`. The deploy target sends the complete credentials object +through the generic ARM request path instead of narrowing it to command-specific +credential flags. + +Pre-split projects that bundle Connections on an Agent or legacy +`microsoft.foundry` service continue through the legacy provisioning adapter. +User-ejected Bicep or Terraform also retains its existing Connection inputs. diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection.go index d194befedc9..e1930d91410 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection.go @@ -387,7 +387,6 @@ func (a *ConnectionCreateAction) Run(ctx context.Context) error { if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpCreateConnection) } - return emitConnectionCreateResult(a.flags.name, connCtx.project, a.flags.output) } @@ -590,8 +589,8 @@ func (a *ConnectionUpdateAction) Run(ctx context.Context) error { } rawProps.Metadata = parseKVMap(metaPairs) rawProps.Credentials = buildOAuth2Credentials(normalizedAuth, - rawProps.Credentials.clientIDOrEmpty(), - rawProps.Credentials.clientSecretOrEmpty(), + credentialString(rawProps.Credentials, "clientId"), + credentialString(rawProps.Credentials, "clientSecret"), ) err = rawCreateConnection(ctx, connCtx, a.flags.name, *rawProps) default: @@ -1003,6 +1002,12 @@ func normalizeAuthType(armAuthType string) string { // Used for auth types that lack ARM SDK structs and require raw REST. func normalizeAuthTypeToARM(cliAuthType string) string { switch cliAuthType { + case "api-key": + return "ApiKey" + case "custom-keys": + return "CustomKeys" + case "none": + return "None" case "oauth2": return "OAuth2" case "user-entra-token": diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context.go index 5c62c77280a..32861a9e901 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context.go @@ -16,6 +16,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc" ) // dataClient is a type alias for the data-plane client (used in endpoint.go). @@ -42,7 +43,18 @@ func resolveConnectionContext( ctx context.Context, flagEndpoint string, ) (*connectionContext, error) { - resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{FlagValue: flagEndpoint}) + return resolveConnectionContextForEnvironment(ctx, flagEndpoint, "") +} + +func resolveConnectionContextForEnvironment( + ctx context.Context, + flagEndpoint string, + environmentName string, +) (*connectionContext, error) { + resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{ + FlagValue: flagEndpoint, + EnvironmentName: environmentName, + }) if err != nil { return nil, err } @@ -57,7 +69,7 @@ func resolveConnectionContext( // the subscription's user-access tenant (for credential scoping) and the // Foundry project's ARM resource ID (for ARM context on connection-less // projects). Every field is best-effort and may be empty. - envCtx := resolveEnvContext(ctx) + envCtx := resolveEnvContext(ctx, environmentName) // Scope the credential to the subscription's user-access tenant so tokens are // issued for the tenant that owns the Foundry resource. Multi-tenant / guest @@ -149,7 +161,7 @@ type envContext struct { // subscription, which is the tenant that owns the Foundry resource - so // multi-tenant / guest users get a token for that tenant instead of their home // tenant. -func resolveEnvContext(ctx context.Context) envContext { +func resolveEnvContext(ctx context.Context, environmentName string) envContext { var out envContext azdClient, err := azdext.NewAzdClient() @@ -159,22 +171,61 @@ func resolveEnvContext(ctx context.Context) envContext { } defer azdClient.Close() - envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil || envResp.GetEnvironment() == nil { - log.Printf("connections: no active azd environment: %v", err) - return out + return resolveEnvContextWithClients( + ctx, + environmentName, + azdClient.Environment(), + azdClient.Account(), + ) +} + +type environmentContextReader interface { + GetCurrent( + ctx context.Context, + request *azdext.EmptyRequest, + options ...grpc.CallOption, + ) (*azdext.EnvironmentResponse, error) + GetValue( + ctx context.Context, + request *azdext.GetEnvRequest, + options ...grpc.CallOption, + ) (*azdext.KeyValueResponse, error) +} + +type tenantLookup interface { + LookupTenant( + ctx context.Context, + request *azdext.LookupTenantRequest, + options ...grpc.CallOption, + ) (*azdext.LookupTenantResponse, error) +} + +func resolveEnvContextWithClients( + ctx context.Context, + environmentName string, + environmentClient environmentContextReader, + accountClient tenantLookup, +) envContext { + var out envContext + envName := environmentName + if envName == "" { + envResp, err := environmentClient.GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || envResp.GetEnvironment() == nil { + log.Printf("connections: no active azd environment: %v", err) + return out + } + envName = envResp.GetEnvironment().GetName() } - envName := envResp.GetEnvironment().GetName() - out.projectID = envValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ID") + out.projectID = envValue(ctx, environmentClient, envName, "AZURE_AI_PROJECT_ID") - subID := envValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID") + subID := envValue(ctx, environmentClient, envName, "AZURE_SUBSCRIPTION_ID") if subID == "" { log.Printf("connections: AZURE_SUBSCRIPTION_ID unavailable; using default tenant") return out } - tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + tenantResp, err := accountClient.LookupTenant(ctx, &azdext.LookupTenantRequest{ SubscriptionId: subID, }) if err != nil { @@ -188,8 +239,8 @@ func resolveEnvContext(ctx context.Context) envContext { // envValue reads a single value from the named azd environment, returning "" // when the key is unset or the read fails. -func envValue(ctx context.Context, azdClient *azdext.AzdClient, envName, key string) string { - resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ +func envValue(ctx context.Context, environmentClient environmentContextReader, envName, key string) string { + resp, err := environmentClient.GetValue(ctx, &azdext.GetEnvRequest{ EnvName: envName, Key: key, }) diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context_test.go index 5c6e04a4fb2..a2b60769b43 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_context_test.go @@ -4,9 +4,14 @@ package cmd import ( + "context" + "errors" "testing" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" ) // TestNewCredential covers both credential shapes: the default (home) tenant @@ -34,3 +39,66 @@ func TestNewCredential(t *testing.T) { }) } } + +func TestResolveEnvContextUsesSelectedEnvironment(t *testing.T) { + t.Parallel() + + environments := &recordingEnvironmentContextReader{ + values: map[string]map[string]string{ + "default": { + "AZURE_AI_PROJECT_ID": "default-project", + "AZURE_SUBSCRIPTION_ID": "default-subscription", + }, + "staging": { + "AZURE_AI_PROJECT_ID": "staging-project", + "AZURE_SUBSCRIPTION_ID": "staging-subscription", + }, + }, + } + accounts := &recordingTenantLookup{tenantID: "staging-tenant"} + + resolved := resolveEnvContextWithClients(t.Context(), "staging", environments, accounts) + assert.Equal(t, "staging-project", resolved.projectID) + assert.Equal(t, "staging-tenant", resolved.tenantID) + assert.Equal(t, 0, environments.currentCalls) + assert.Equal(t, []string{"staging", "staging"}, environments.valueEnvironments) + assert.Equal(t, "staging-subscription", accounts.subscriptionID) +} + +type recordingEnvironmentContextReader struct { + values map[string]map[string]string + currentCalls int + valueEnvironments []string +} + +func (r *recordingEnvironmentContextReader) GetCurrent( + context.Context, + *azdext.EmptyRequest, + ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + r.currentCalls++ + return nil, errors.New("GetCurrent must not be called for a selected environment") +} + +func (r *recordingEnvironmentContextReader) GetValue( + _ context.Context, + request *azdext.GetEnvRequest, + _ ...grpc.CallOption, +) (*azdext.KeyValueResponse, error) { + r.valueEnvironments = append(r.valueEnvironments, request.GetEnvName()) + return &azdext.KeyValueResponse{Value: r.values[request.GetEnvName()][request.GetKey()]}, nil +} + +type recordingTenantLookup struct { + tenantID string + subscriptionID string +} + +func (r *recordingTenantLookup) LookupTenant( + _ context.Context, + request *azdext.LookupTenantRequest, + _ ...grpc.CallOption, +) (*azdext.LookupTenantResponse, error) { + r.subscriptionID = request.GetSubscriptionId() + return &azdext.LookupTenantResponse{TenantId: r.tenantID}, nil +} diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_test.go index 90c0a9a1886..e74b4992b0c 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/connection_test.go @@ -345,8 +345,8 @@ func TestRawConnectionBody_OAuth2_FullFields(t *testing.T) { RefreshURL: "https://github.com/login/oauth/access_token", Scopes: []string{"read:user", "user:email"}, Credentials: &rawCredentials{ - ClientID: "test-cid", - ClientSecret: "test-csec", + "clientId": "test-cid", + "clientSecret": "test-csec", }, } body := rawConnectionBody{Properties: props} @@ -590,8 +590,8 @@ func TestBuildOAuth2Credentials(t *testing.T) { return } require.NotNil(t, got) - require.Equal(t, tt.wantID, got.ClientID) - require.Equal(t, tt.wantSecret, got.ClientSecret) + require.Equal(t, tt.wantID, credentialString(got, "clientId")) + require.Equal(t, tt.wantSecret, credentialString(got, "clientSecret")) }) } } diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/raw_connection.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/raw_connection.go index 277213d78bd..9ef220d679f 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/raw_connection.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/raw_connection.go @@ -34,25 +34,7 @@ type rawConnectionProperties struct { Credentials *rawCredentials `json:"credentials,omitempty"` } -// rawCredentials represents OAuth2 credentials in the raw REST body. -type rawCredentials struct { - ClientID string `json:"clientId,omitempty"` - ClientSecret string `json:"clientSecret,omitempty"` -} - -func (c *rawCredentials) clientIDOrEmpty() string { - if c == nil { - return "" - } - return c.ClientID -} - -func (c *rawCredentials) clientSecretOrEmpty() string { - if c == nil { - return "" - } - return c.ClientSecret -} +type rawCredentials map[string]any // buildOAuth2Credentials returns the credentials object to embed in a connection // PUT body, based on the user-supplied auth type and (optional) BYO OAuth2 client @@ -71,10 +53,22 @@ func buildOAuth2Credentials(authType, clientID, clientSecret string) *rawCredent if authType != "oauth2" && clientID == "" && clientSecret == "" { return nil } - return &rawCredentials{ - ClientID: clientID, - ClientSecret: clientSecret, + credentials := rawCredentials{} + if clientID != "" { + credentials["clientId"] = clientID + } + if clientSecret != "" { + credentials["clientSecret"] = clientSecret + } + return &credentials +} + +func credentialString(credentials *rawCredentials, key string) string { + if credentials == nil { + return "" } + value, _ := (*credentials)[key].(string) + return value } type rawConnectionBody struct { diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go index 160286c5dd7..3b1beb5ac35 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go @@ -26,7 +26,9 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) - rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + rootCmd.AddCommand(azdext.NewListenCommand(func(host *azdext.ExtensionHost) { + configureExtensionHostForEnvironment(host, extCtx.Environment) + })) // Register -p / --project-endpoint as a persistent flag inherited by // connection CRUD subcommands (list, show, create, update, delete). @@ -48,8 +50,12 @@ func NewRootCommand() *cobra.Command { // azure.ai.connection service target so `azd up`/`azd deploy` upsert connections // declared as services in azure.yaml. func configureExtensionHost(host *azdext.ExtensionHost) { + configureExtensionHostForEnvironment(host, "") +} + +func configureExtensionHostForEnvironment(host *azdext.ExtensionHost, environmentName string) { azdClient := host.Client() host.WithServiceTarget(aiConnectionHost, func() azdext.ServiceTargetProvider { - return newConnectionServiceTarget(azdClient) + return newConnectionServiceTarget(azdClient, environmentName) }) } diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go index 3f7b4b59e14..9042fdf4e8e 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go @@ -5,9 +5,16 @@ package cmd import ( "context" + "encoding/json" "fmt" + "maps" + + "azure.ai.connections/internal/definition" + "azure.ai.connections/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "google.golang.org/grpc" ) // aiConnectionHost is the azure.yaml service host kind owned by this extension. @@ -17,22 +24,48 @@ const aiConnectionHost = "azure.ai.connection" var _ azdext.ServiceTargetProvider = (*connectionServiceTarget)(nil) -// connectionServiceTarget owns the azure.ai.connection host so azd can walk a -// connection entry in the deploy graph. All lifecycle methods are no-ops; see -// Deploy for why. +// connectionServiceTarget owns the azure.ai.connection host so azd can walk and +// reconcile a connection entry in the deploy graph. Package and Publish are +// no-ops; Deploy performs the ARM upsert. type connectionServiceTarget struct { azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig + projectClient serviceConfigReader + envClient serviceEnvironmentReader + environment string + upsert func(context.Context, string, string, rawConnectionProperties) error } // newConnectionServiceTarget creates the azure.ai.connection service-target provider. -func newConnectionServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &connectionServiceTarget{azdClient: azdClient} +func newConnectionServiceTarget( + azdClient *azdext.AzdClient, + environmentName string, +) azdext.ServiceTargetProvider { + target := &connectionServiceTarget{ + azdClient: azdClient, + projectClient: azdClient.Project(), + envClient: azdClient.Environment(), + environment: environmentName, + } + target.upsert = func( + ctx context.Context, + environmentName string, + name string, + properties rawConnectionProperties, + ) error { + connectionContext, err := resolveConnectionContextForEnvironment(ctx, "", environmentName) + if err != nil { + return err + } + if err := rawCreateConnection(ctx, connectionContext, name, properties); err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateConnection) + } + return nil + } + return target } -// Initialize stores the service configuration; no other setup is required. -func (p *connectionServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *connectionServiceTarget) Initialize(context.Context, *azdext.ServiceConfig) error { return nil } @@ -84,16 +117,9 @@ func (p *connectionServiceTarget) Publish( return &azdext.ServicePublishResult{}, nil } -// Deploy is a no-op. Connections declared as host: azure.ai.connection -// services are created at provision time by the microsoft.foundry provider -// (for both greenfield and brownfield projects), so creating them again here -// would be a redundant ARM write. This mirrors azure.ai.project's Deploy, -// which is a no-op for the same reason. -// -// The target still exists so azd can order a connection's deploy step via -// `uses:` (toolboxes/agents that depend on it). Removing a connection from -// azure.yaml stops azd managing it but does not delete it (use -// `azd ai connection delete`). +// Deploy creates or replaces the connection declared by this service. The +// service key is the connection name; the owning extension reads and +// reconciles every other field in the service block. func (p *connectionServiceTarget) Deploy( ctx context.Context, serviceConfig *azdext.ServiceConfig, @@ -101,9 +127,238 @@ func (p *connectionServiceTarget) Deploy( targetResource *azdext.TargetResource, progress azdext.ProgressReporter, ) (*azdext.ServiceDeployResult, error) { + environment, err := p.environmentValues(ctx, serviceConfig) + if err != nil { + return nil, err + } + properties, err := connectionServiceProperties(serviceConfig, environment) + if err != nil { + return nil, err + } if progress != nil { - progress(fmt.Sprintf( - "Connection %q is provisioned by infrastructure; nothing to deploy", serviceConfig.GetName())) + progress(fmt.Sprintf("Upserting connection %q", serviceConfig.GetName())) + } + if err := p.upsert(ctx, p.environment, serviceConfig.GetName(), properties); err != nil { + return nil, err } return &azdext.ServiceDeployResult{}, nil } + +func connectionServiceProperties( + serviceConfig *azdext.ServiceConfig, + environment map[string]string, +) (rawConnectionProperties, error) { + input, err := parseConnectionServiceConfig(serviceConfig) + if err != nil { + return rawConnectionProperties{}, err + } + expand := func(field, value string) (string, error) { + expanded, err := foundry.ExpandEnv(value, func(name string) string { return environment[name] }) + if err != nil { + return "", fmt.Errorf("resolving connection %q %s: %w", serviceConfig.GetName(), field, err) + } + return expanded, nil + } + target, err := expand("target", input.Target) + if err != nil { + return rawConnectionProperties{}, err + } + audience, err := expand("audience", input.Audience) + if err != nil { + return rawConnectionProperties{}, err + } + authorizationURL, err := expand("authorizationUrl", input.AuthorizationURL) + if err != nil { + return rawConnectionProperties{}, err + } + tokenURL, err := expand("tokenUrl", input.TokenURL) + if err != nil { + return rawConnectionProperties{}, err + } + refreshURL, err := expand("refreshUrl", input.RefreshURL) + if err != nil { + return rawConnectionProperties{}, err + } + connectorName, err := expand("connectorName", input.ConnectorName) + if err != nil { + return rawConnectionProperties{}, err + } + properties := rawConnectionProperties{ + AuthType: normalizeAuthTypeToARM(normalizeAuthType(input.AuthType)), + Category: normalizeKind(input.Category), + Target: target, + Metadata: map[string]string{}, + Audience: audience, + AuthorizationURL: authorizationURL, + TokenURL: tokenURL, + RefreshURL: refreshURL, + ConnectorName: connectorName, + } + for index, scope := range input.Scopes { + expanded, err := expand(fmt.Sprintf("scopes[%d]", index), scope) + if err != nil { + return rawConnectionProperties{}, err + } + properties.Scopes = append(properties.Scopes, expanded) + } + if properties.AuthType == "" { + properties.AuthType = "None" + } + + for key, value := range input.Metadata { + expanded, err := expand("metadata."+key, value) + if err != nil { + return rawConnectionProperties{}, err + } + properties.Metadata[key] = expanded + } + if len(properties.Metadata) == 0 { + properties.Metadata = nil + } + credentials, err := expandConnectionCredentials(input.Credentials, environment) + if err != nil { + return rawConnectionProperties{}, fmt.Errorf("resolving connection %q credentials: %w", serviceConfig.GetName(), err) + } + if credentials != nil { + raw := rawCredentials(credentials) + properties.Credentials = &raw + } else if properties.AuthType == "OAuth2" { + raw := rawCredentials{} + properties.Credentials = &raw + } + return properties, nil +} + +func expandConnectionCredentials(value map[string]any, environment map[string]string) (map[string]any, error) { + if value == nil { + return nil, nil + } + expanded, err := expandConnectionCredentialValue(value, environment) + if err != nil { + return nil, err + } + credentials, ok := expanded.(map[string]any) + if !ok { + return nil, fmt.Errorf("credentials must be an object") + } + return credentials, nil +} + +func expandConnectionCredentialValue(value any, environment map[string]string) (any, error) { + switch typed := value.(type) { + case string: + return foundry.ExpandEnv(typed, func(name string) string { return environment[name] }) + case map[string]any: + result := make(map[string]any, len(typed)) + for key, item := range typed { + expanded, err := expandConnectionCredentialValue(item, environment) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + result[key] = expanded + } + return result, nil + case []any: + result := make([]any, len(typed)) + for index, item := range typed { + expanded, err := expandConnectionCredentialValue(item, environment) + if err != nil { + return nil, fmt.Errorf("[%d]: %w", index, err) + } + result[index] = expanded + } + return result, nil + default: + return value, nil + } +} + +type serviceConfigReader interface { + GetServiceConfigValue( + ctx context.Context, + request *azdext.GetServiceConfigValueRequest, + options ...grpc.CallOption, + ) (*azdext.GetServiceConfigValueResponse, error) +} + +type serviceEnvironmentReader interface { + GetCurrent( + ctx context.Context, + request *azdext.EmptyRequest, + options ...grpc.CallOption, + ) (*azdext.EnvironmentResponse, error) + GetValues( + ctx context.Context, + request *azdext.GetEnvironmentRequest, + options ...grpc.CallOption, + ) (*azdext.KeyValueListResponse, error) +} + +func (p *connectionServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if len(serviceConfig.GetEnvironment()) > 0 { + return maps.Clone(serviceConfig.GetEnvironment()), nil + } + declared, err := serviceEnvironmentDeclared(ctx, p.projectClient, serviceConfig.GetName()) + if err != nil { + return nil, err + } + if declared { + return map[string]string{}, nil + } + environmentName := p.environment + if environmentName == "" { + current, err := p.envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("resolving current azd environment: %w", err) + } + environmentName = current.GetEnvironment().GetName() + } + response, err := p.envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: environmentName, + }) + if err != nil { + return nil, fmt.Errorf("loading azd environment values: %w", err) + } + values := make(map[string]string, len(response.GetKeyValues())) + for _, value := range response.GetKeyValues() { + values[value.GetKey()] = value.GetValue() + } + return values, nil +} + +func serviceEnvironmentDeclared( + ctx context.Context, + client serviceConfigReader, + serviceName string, +) (bool, error) { + response, err := client.GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "env", + }) + if err != nil { + return false, fmt.Errorf("reading env for connection service %q: %w", serviceName, err) + } + return response.GetFound(), nil +} + +func parseConnectionServiceConfig(serviceConfig *azdext.ServiceConfig) (*definition.Definition, error) { + props := serviceConfig.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + props = serviceConfig.GetConfig() + } + input := &definition.Definition{} + if props == nil { + return input, nil + } + data, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, fmt.Errorf("encoding connection service %q config: %w", serviceConfig.GetName(), err) + } + if err := json.Unmarshal(data, input); err != nil { + return nil, fmt.Errorf("parsing connection service %q config: %w", serviceConfig.GetName(), err) + } + return input, nil +} diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go index f644710799e..cd25932f619 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go @@ -4,34 +4,168 @@ package cmd import ( + "context" + "errors" "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/structpb" ) -// TestDeploy_IsNoOp verifies the connection service target does not create the -// connection at deploy time. Connections declared as host: azure.ai.connection -// services are provisioned by the microsoft.foundry provider (synthesis) at -// provision time, so Deploy must return an empty result without any ARM call. -func TestDeploy_IsNoOp(t *testing.T) { +func TestDeployUpsertsConnectionFromServiceConfig(t *testing.T) { t.Parallel() - target := &connectionServiceTarget{} - svc := &azdext.ServiceConfig{Name: "search-conn", Host: aiConnectionHost} + props, err := structpb.NewStruct(map[string]any{ + "category": "RemoteTool", + "target": "https://example.test/mcp", + "authType": "CustomKeys", + "credentials": map[string]any{ + "keys": map[string]any{ + "x-api-key": "${CONNECTION_KEY}", + }, + }, + "metadata": map[string]any{ + "region": "test", + }, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "search-conn", + Host: aiConnectionHost, + AdditionalProperties: props, + Environment: map[string]string{"CONNECTION_KEY": "secret"}, + } + var captured rawConnectionProperties + var capturedName string + var capturedEnvironment string + target := &connectionServiceTarget{upsert: func( + _ context.Context, + environmentName string, + name string, + properties rawConnectionProperties, + ) error { + capturedEnvironment = environmentName + capturedName = name + captured = properties + return nil + }} + target.environment = "staging" var progressMsgs []string progress := func(msg string) { progressMsgs = append(progressMsgs, msg) } - // A nil azdClient would panic if Deploy tried to reach the environment or - // ARM; the no-op must not touch either. res, err := target.Deploy(t.Context(), svc, nil, nil, progress) require.NoError(t, err) require.NotNil(t, res) + assert.Equal(t, "search-conn", capturedName) + assert.Equal(t, "staging", capturedEnvironment) + assert.Equal(t, "RemoteTool", captured.Category) + assert.Equal(t, "https://example.test/mcp", captured.Target) + assert.Equal(t, "CustomKeys", captured.AuthType) + require.NotNil(t, captured.Credentials) + assert.Equal(t, rawCredentials{"keys": map[string]any{"x-api-key": "secret"}}, *captured.Credentials) + assert.Equal(t, map[string]string{"region": "test"}, captured.Metadata) require.Len(t, progressMsgs, 1) - assert.Contains(t, progressMsgs[0], "search-conn") - assert.Contains(t, progressMsgs[0], "provisioned by infrastructure") + assert.Equal(t, "Upserting connection \"search-conn\"", progressMsgs[0]) +} + +func TestEnvironmentValuesUsesSelectedEnvironment(t *testing.T) { + t.Parallel() + + environments := &recordingServiceEnvironmentReader{ + values: map[string]map[string]string{ + "default": {"VALUE": "wrong"}, + "staging": {"VALUE": "right"}, + }, + } + target := &connectionServiceTarget{ + environment: "staging", + envClient: environments, + projectClient: missingServiceEnvReader{}, + } + + values, err := target.environmentValues(t.Context(), &azdext.ServiceConfig{Name: "connection"}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"VALUE": "right"}, values) + assert.Equal(t, 0, environments.currentCalls) + assert.Equal(t, []string{"staging"}, environments.valuesRequests) +} + +func TestConnectionServicePropertiesPreservesGenericAuthTypes(t *testing.T) { + t.Parallel() + + authTypes := []string{ + "AAD", "PAT", "ServicePrincipal", "UsernamePassword", + "AccessKey", "AccountKey", "SAS", + } + for _, authType := range authTypes { + t.Run(authType, func(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "category": "AzureOpenAI", + "target": "https://example.test", + "authType": authType, + "credentials": map[string]any{ + "username": "${USERNAME}", + "password": "${PASSWORD}", + "options": []any{"preserved", true, float64(3)}, + }, + }) + require.NoError(t, err) + + got, err := connectionServiceProperties(&azdext.ServiceConfig{ + Name: "generic", + AdditionalProperties: props, + }, map[string]string{"USERNAME": "user", "PASSWORD": "secret"}) + require.NoError(t, err) + assert.Equal(t, authType, got.AuthType) + require.NotNil(t, got.Credentials) + assert.Equal(t, rawCredentials{ + "username": "user", + "password": "secret", + "options": []any{"preserved", true, float64(3)}, + }, *got.Credentials) + }) + } +} + +func TestConnectionServicePropertiesPreservesEmptyOAuth2Credentials(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "category": "RemoteTool", + "target": "https://example.test", + "authType": "OAuth2", + "connectorName": "managed-connector", + }) + require.NoError(t, err) + + got, err := connectionServiceProperties(&azdext.ServiceConfig{ + Name: "oauth", + AdditionalProperties: props, + }, nil) + require.NoError(t, err) + require.NotNil(t, got.Credentials) + assert.Empty(t, *got.Credentials) +} + +func TestParseConnectionServiceConfigFallsBackToLegacyConfig(t *testing.T) { + t.Parallel() + + config, err := structpb.NewStruct(map[string]any{ + "category": "AzureOpenAI", + "target": "https://example.test", + }) + require.NoError(t, err) + + input, err := parseConnectionServiceConfig(&azdext.ServiceConfig{Name: "legacy", Config: config}) + require.NoError(t, err) + assert.Equal(t, "AzureOpenAI", input.Category) + assert.Equal(t, "https://example.test", input.Target) } // TestPackagePublish_AreNoOps verifies the remaining lifecycle methods a @@ -54,3 +188,41 @@ func TestPackagePublish_AreNoOps(t *testing.T) { require.NoError(t, err) assert.Nil(t, endpoints) } + +type recordingServiceEnvironmentReader struct { + values map[string]map[string]string + currentCalls int + valuesRequests []string +} + +func (r *recordingServiceEnvironmentReader) GetCurrent( + context.Context, + *azdext.EmptyRequest, + ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + r.currentCalls++ + return nil, errors.New("GetCurrent must not be called for a selected environment") +} + +func (r *recordingServiceEnvironmentReader) GetValues( + _ context.Context, + request *azdext.GetEnvironmentRequest, + _ ...grpc.CallOption, +) (*azdext.KeyValueListResponse, error) { + r.valuesRequests = append(r.valuesRequests, request.GetName()) + response := &azdext.KeyValueListResponse{} + for key, value := range r.values[request.GetName()] { + response.KeyValues = append(response.KeyValues, &azdext.KeyValue{Key: key, Value: value}) + } + return response, nil +} + +type missingServiceEnvReader struct{} + +func (missingServiceEnvReader) GetServiceConfigValue( + context.Context, + *azdext.GetServiceConfigValueRequest, + ...grpc.CallOption, +) (*azdext.GetServiceConfigValueResponse, error) { + return &azdext.GetServiceConfigValueResponse{}, nil +} diff --git a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver.go b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver.go index dc420f0ec1a..0df007b366d 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver.go +++ b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver.go @@ -24,7 +24,7 @@ var ReadAzdHostedSourcesFunc = readAzdHostedSources // `azd ai agent init` / `azd add` persist). Errors talking to the daemon are // returned only for non-Unavailable cases on the config read — Unavailable is // treated as "no daemon" and the caller falls through to subsequent levels. -func readAzdHostedSources(ctx context.Context) (AzdHostedSources, error) { +func readAzdHostedSources(ctx context.Context, environmentName string) (AzdHostedSources, error) { var out AzdHostedSources azdClient, err := azdext.NewAzdClient() @@ -34,17 +34,23 @@ func readAzdHostedSources(ctx context.Context) (AzdHostedSources, error) { } defer azdClient.Close() - if envResp, err := azdClient.Environment().GetCurrent( - ctx, &azdext.EmptyRequest{}, - ); err == nil { + envName := environmentName + if envName == "" { + if envResp, err := azdClient.Environment().GetCurrent( + ctx, &azdext.EmptyRequest{}, + ); err == nil { + envName = envResp.GetEnvironment().GetName() + } + } + if envName != "" { for _, key := range []string{foundryEnvKey, azureAiEnvKey} { envVal, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: envResp.Environment.Name, + EnvName: envName, Key: key, }) if valErr == nil && envVal.Value != "" { out.EnvValue = envVal.Value - out.EnvName = envResp.Environment.Name + out.EnvName = envName break } } @@ -101,7 +107,7 @@ func Resolve(ctx context.Context, opts ResolveOpts) (*Resolved, error) { } // Levels 2 + 3: azd-hosted sources (active env, then global config). - sources, err := ReadAzdHostedSourcesFunc(ctx) + sources, err := ReadAzdHostedSourcesFunc(ctx, opts.EnvironmentName) if err != nil { return nil, err } diff --git a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver_test.go b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver_test.go index ac09ad159f8..c80645ad9eb 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/resolver_test.go @@ -6,6 +6,7 @@ package projectctx import ( "context" "errors" + "net" "testing" "azure.ai.connections/internal/exterrors" @@ -13,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" ) // withHostedSources installs a stub for ReadAzdHostedSourcesFunc for the @@ -21,12 +23,91 @@ import ( func withHostedSources(t *testing.T, sources AzdHostedSources, err error) { t.Helper() orig := ReadAzdHostedSourcesFunc - ReadAzdHostedSourcesFunc = func(context.Context) (AzdHostedSources, error) { + ReadAzdHostedSourcesFunc = func(context.Context, string) (AzdHostedSources, error) { return sources, err } t.Cleanup(func() { ReadAzdHostedSourcesFunc = orig }) } +func TestResolveUsesSelectedEnvironment(t *testing.T) { + var received string + original := ReadAzdHostedSourcesFunc + ReadAzdHostedSourcesFunc = func(_ context.Context, environmentName string) (AzdHostedSources, error) { + received = environmentName + return AzdHostedSources{ + EnvName: environmentName, + EnvValue: "https://staging.services.ai.azure.com/api/projects/project", + }, nil + } + t.Cleanup(func() { ReadAzdHostedSourcesFunc = original }) + + resolved, err := Resolve(t.Context(), ResolveOpts{EnvironmentName: "staging"}) + require.NoError(t, err) + assert.Equal(t, "staging", received) + assert.Equal(t, "staging", resolved.AzdEnvName) + assert.Equal(t, "https://staging.services.ai.azure.com/api/projects/project", resolved.Endpoint) +} + +func TestReadAzdHostedSourcesUsesSelectedEnvironment(t *testing.T) { + environment := &selectedEnvironmentServer{} + server := grpc.NewServer() + azdext.RegisterEnvironmentServiceServer(server, environment) + azdext.RegisterUserConfigServiceServer(server, emptyUserConfigServer{}) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + t.Setenv("AZD_SERVER", listener.Addr().String()) + + sources, err := readAzdHostedSources(t.Context(), "staging") + require.NoError(t, err) + assert.Equal(t, "staging", sources.EnvName) + assert.Equal(t, "https://staging.services.ai.azure.com/api/projects/project", sources.EnvValue) + assert.Equal(t, 0, environment.currentCalls) + assert.Equal(t, []string{"staging"}, environment.requestedEnvironments) +} + +type selectedEnvironmentServer struct { + azdext.UnimplementedEnvironmentServiceServer + currentCalls int + requestedEnvironments []string +} + +func (s *selectedEnvironmentServer) GetCurrent( + context.Context, + *azdext.EmptyRequest, +) (*azdext.EnvironmentResponse, error) { + s.currentCalls++ + return nil, errors.New("GetCurrent must not be called for a selected environment") +} + +func (s *selectedEnvironmentServer) GetValue( + _ context.Context, + request *azdext.GetEnvRequest, +) (*azdext.KeyValueResponse, error) { + s.requestedEnvironments = append(s.requestedEnvironments, request.GetEnvName()) + if request.GetKey() == foundryEnvKey && request.GetEnvName() == "staging" { + return &azdext.KeyValueResponse{ + Value: "https://staging.services.ai.azure.com/api/projects/project", + }, nil + } + return &azdext.KeyValueResponse{}, nil +} + +type emptyUserConfigServer struct { + azdext.UnimplementedUserConfigServiceServer +} + +func (emptyUserConfigServer) Get( + context.Context, + *azdext.GetUserConfigRequest, +) (*azdext.GetUserConfigResponse, error) { + return &azdext.GetUserConfigResponse{}, nil +} + // isolateFromAzdDaemon installs an empty hosted-sources stub and clears // AZD_SERVER so any code path that bypasses the seam cannot reach a real // daemon. After calling this, the resolver only sees the flag and the diff --git a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/types.go b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/types.go index b0c58568ae1..523a0e511bf 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/types.go +++ b/cli/azd/extensions/azure.ai.connections/internal/foundry/projectctx/types.go @@ -48,6 +48,9 @@ type ResolveOpts struct { // FlagValue is the value of the --project-endpoint flag (level 1). // Empty means the flag was not provided. FlagValue string + // EnvironmentName selects the azd environment for endpoint lookup. Empty + // preserves standalone command behavior by using the persisted current env. + EnvironmentName string } // Resolved holds the result of Resolve. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 34b51402003..4d5434129b5 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -27,7 +27,7 @@ services: When `endpoint` is omitted, `azd provision` creates a Foundry account and project. When it is set, provisioning reuses that project and reconciles the declarations that can be applied to an existing account. -To reconcile deployments, connections, or a pending container registry on an existing project, set the project's full ARM resource ID in the active azd environment: +To reconcile model deployments or a pending container registry on an existing project, set the project's full ARM resource ID in the active azd environment: ```sh azd env set AZURE_AI_PROJECT_ID "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" @@ -35,6 +35,10 @@ azd env set AZURE_AI_PROJECT_ID "/subscriptions//resourceGroups `azd ai agent init` sets this value when initialized against an existing project. An endpoint-only service with no resources to reconcile does not require it. +Split `host: azure.ai.connection` services are reconciled during deploy by the +Connections extension, not by this provisioning provider. Pre-split bundled +Connections and user-ejected infrastructure retain their compatibility paths. + When provisioning reports insufficient Cognitive Services quota, check usage for the target region with `az cognitiveservices usage list --location ` or request a quota increase in the Azure portal. If an existing Foundry project should be reused instead, configure its endpoint and set `AZURE_AI_PROJECT_ID` to the 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 99a55496a41..669fcd1bbc1 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 @@ -301,12 +301,13 @@ func (p *FoundryProvisioningProvider) Initialize( // Pass the current env so connection conditions evaluate // even when payload ${VAR} refs are preserved. _, validationErr := synthesis.Synthesize(synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - PreserveVarRefs: true, - ProjectRoot: projectRoot, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: p.networkEnvMap(ctx), + PreserveVarRefs: true, + ExcludeConnectionServices: true, + ProjectRoot: projectRoot, }) if validationErr != nil && !errors.Is(validationErr, synthesis.ErrEndpointBrownfield) { @@ -314,30 +315,32 @@ func (p *FoundryProvisioningProvider) Initialize( } } - p.connectionEnvironmentScopes, err = - synthesis.ConnectionEnvironmentScopes( + if onDisk { + p.connectionEnvironmentScopes, err = synthesis.ConnectionEnvironmentScopes( rawYAML, projectRoot, p.networkEnvMap(ctx), ) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAzureYaml, - fmt.Sprintf( - "read Foundry connection service configuration: %s", - err, - ), - "fix the connection service configuration in azure.yaml", - ) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "read Foundry connection service configuration: %s", + err, + ), + "fix the connection service configuration in azure.yaml", + ) + } } if endpoint != "" && !onDisk { connectionOnlyResult, synthErr := synthesis.SynthesizeExistingProject(synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - PreserveVarRefs: true, - ProjectRoot: projectRoot, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: p.networkEnvMap(ctx), + PreserveVarRefs: true, + ExcludeConnectionServices: true, + ProjectRoot: projectRoot, }) if synthErr != nil { return foundrySynthesisError(svcName, synthErr) @@ -365,18 +368,21 @@ func (p *FoundryProvisioningProvider) Initialize( } } - p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) - if err != nil { - return err + if onDisk { + p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) + if err != nil { + return err + } } input := synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - ServiceEnvironments: p.serviceEnvironments, - ProjectRoot: projectRoot, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: p.networkEnvMap(ctx), + ServiceEnvironments: p.serviceEnvironments, + ExcludeConnectionServices: !onDisk, + ProjectRoot: projectRoot, } var res *synthesis.Result if endpoint != "" { 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 53b926f1910..8f631dc4bb7 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 @@ -11,7 +11,6 @@ import ( "testing" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" @@ -386,117 +385,6 @@ func TestResolveEnv_EmptyLocationResponseReturnsError(t *testing.T) { assert.Empty(t, env.set, "an empty location name must not be persisted") } -// promptOrderStubProjectServer models azd core expanding ${VAR} in a -// service env at call time: the connection endpoint reads as empty -// until the prompted location has been persisted to the azd -// environment. -type promptOrderStubProjectServer struct { - azdext.UnimplementedProjectServiceServer - projectPath string - env *resolveEnvStubEnvServer -} - -func (s *promptOrderStubProjectServer) Get( - context.Context, *azdext.EmptyRequest, -) (*azdext.GetProjectResponse, error) { - endpoint := "" - if location := s.env.set[envKeyLocation]; location != "" { - endpoint = "https://search." + location + ".example" - } - return &azdext.GetProjectResponse{Project: &azdext.ProjectConfig{ - Path: s.projectPath, - Services: map[string]*azdext.ServiceConfig{ - "connection": { - Environment: map[string]string{"ENDPOINT": endpoint}, - }, - }, - }}, nil -} - -// newPromptOrderTestClient serves the project, environment and prompt -// stubs needed to exercise Initialize end to end. -func newPromptOrderTestClient( - t *testing.T, - projSrv azdext.ProjectServiceServer, - envSrv azdext.EnvironmentServiceServer, - promptSrv azdext.PromptServiceServer, -) *azdext.AzdClient { - t.Helper() - - srv := grpc.NewServer() - azdext.RegisterProjectServiceServer(srv, projSrv) - azdext.RegisterEnvironmentServiceServer(srv, envSrv) - azdext.RegisterPromptServiceServer(srv, promptSrv) - - lis, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - go func() { _ = srv.Serve(lis) }() - t.Cleanup(func() { - srv.Stop() - _ = lis.Close() - }) - - client, err := azdext.NewAzdClient(azdext.WithAddress(lis.Addr().String())) - require.NoError(t, err) - t.Cleanup(func() { client.Close() }) - - return client -} - -func TestInitializeResolvesEnvBeforeReadingServiceEnvironments(t *testing.T) { - // Greenfield: neither AZURE_SUBSCRIPTION_ID nor AZURE_LOCATION is - // set, so Initialize must prompt first. Reading service - // environments before the prompt would synthesize the connection - // with an empty target. - projectPath := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(projectPath, "azure.yaml"), - []byte(` -services: - project: - host: azure.ai.project - connection: - host: azure.ai.connection - uses: [project] - env: - ENDPOINT: ${SEARCH_ENDPOINT} - category: CognitiveSearch - target: ${ENDPOINT} - authType: None -`), - 0o600, - )) - - env := &resolveEnvStubEnvServer{envName: "test", get: map[string]string{}} - prompt := &resolveEnvStubPromptServer{ - subscriptionID: "00000000-0000-0000-0000-000000000001", - location: "westus2", - } - client := newPromptOrderTestClient( - t, - &promptOrderStubProjectServer{projectPath: projectPath, env: env}, - env, - prompt, - ) - provider := &FoundryProvisioningProvider{azdClient: client} - - err := provider.Initialize( - t.Context(), - projectPath, - &azdext.ProvisioningOptions{Provider: FoundryProviderName}, - ) - require.NoError(t, err) - assert.Equal(t, 1, prompt.subscriptionN) - assert.Equal(t, 1, prompt.locationN) - - require.NotNil(t, provider.synthResult) - connections, ok := provider.synthResult.Parameters["connections"].([]synthesis.Connection) - require.True(t, ok) - require.Len(t, connections, 1) - assert.Equal(t, "https://search.westus2.example", connections[0].Target) -} - func TestInitializeValidatesConfigBeforePrompting(t *testing.T) { tests := []struct { name string 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 746398f207a..dd9f3f41da3 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 @@ -221,7 +221,7 @@ func TestProjectServiceEnvironments(t *testing.T) { ) } -func TestInitializeUsesConnectionServiceEnvironment(t *testing.T) { +func TestInitializeExcludesConnectionsFromProviderManagedInfrastructure(t *testing.T) { t.Parallel() projectPath := t.TempDir() @@ -277,8 +277,7 @@ services: require.NotNil(t, provider.synthResult) connections, ok := provider.synthResult.Parameters["connections"].([]synthesis.Connection) require.True(t, ok) - require.Len(t, connections, 1) - require.Equal(t, "https://service.example", connections[0].Target) + require.Empty(t, connections) } func TestResolveTemplateUsesOnDiskConnectionServiceEnvironment( 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 7a93cc93e73..a4abd20a047 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -69,6 +69,12 @@ type Input struct { // provision path), ${VAR} is resolved here and a missing variable fails. PreserveVarRefs bool + // ExcludeConnectionServices leaves host: azure.ai.connection services out + // of provider-managed infrastructure. The Connections extension reconciles + // those services after provision and during deploy. Ejected, user-owned + // infrastructure leaves this false so its declared inputs remain intact. + ExcludeConnectionServices bool + // ProjectRoot is the directory holding azure.yaml. When set, $ref file // includes in the service entry (and its deployment items) are resolved // against it before synthesis, so refs become the actual content rather @@ -233,6 +239,7 @@ type projectService struct { Host string `yaml:"host"` Endpoint string `yaml:"endpoint,omitempty"` Deployments []Deployment `yaml:"deployments,omitempty"` + Connections []Connection `yaml:"connections,omitempty"` Agents []agentBlock `yaml:"agents,omitempty"` Network *networkBlock `yaml:"network,omitempty"` } @@ -318,15 +325,23 @@ func Synthesize(in Input) (*Result, error) { deployments = []Deployment{} } - connections, err := collectConnections( - root.Services, - in.Env, - in.ServiceEnvironments, - !in.PreserveVarRefs, - in.ProjectRoot, - ) - if err != nil { - return nil, err + connections := []Connection{} + if in.ExcludeConnectionServices && svc.Host != "azure.ai.project" { + connections, err = expandLegacyConnections(svc.Connections, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + } else if !in.ExcludeConnectionServices { + connections, err = collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } } connections, connectionCredentials := SplitConnectionCredentials(connections) netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) @@ -387,15 +402,23 @@ func SynthesizeExistingProject(in Input) (*Result, error) { if err != nil { return nil, err } - connections, err := collectConnections( - root.Services, - in.Env, - in.ServiceEnvironments, - !in.PreserveVarRefs, - in.ProjectRoot, - ) - if err != nil { - return nil, err + connections := []Connection{} + if in.ExcludeConnectionServices && svc.Host != "azure.ai.project" { + connections, err = expandLegacyConnections(svc.Connections, in.Env, !in.PreserveVarRefs) + if err != nil { + return nil, err + } + } else if !in.ExcludeConnectionServices { + connections, err = collectConnections( + root.Services, + in.Env, + in.ServiceEnvironments, + !in.PreserveVarRefs, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } } connections, connectionCredentials := SplitConnectionCredentials(connections) deployments := svc.Deployments @@ -891,6 +914,46 @@ func collectConnections( return connections, nil } +func expandLegacyConnections( + connections []Connection, + environment map[string]string, + resolve bool, +) ([]Connection, error) { + mapping := connectionEnvironmentMapping(environment, nil, false) + result := make([]Connection, len(connections)) + for i, connection := range connections { + result[i] = connection + var err error + for field, target := range map[string]*string{ + "target": &result[i].Target, + "audience": &result[i].Audience, + "authorizationUrl": &result[i].AuthorizationURL, + "tokenUrl": &result[i].TokenURL, + "refreshUrl": &result[i].RefreshURL, + "connectorName": &result[i].ConnectorName, + } { + *target, err = maybeExpand(*target, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q %s: %w", connection.Name, field, err) + } + } + result[i].Scopes, err = expandStrings(connection.Scopes, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q scopes: %w", connection.Name, err) + } + result[i].Credentials, err = expandCredentials(connection.Credentials, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q credentials: %w", connection.Name, err) + } + result[i].Metadata, err = expandMetadata(connection.Metadata, mapping, resolve) + if err != nil { + return nil, fmt.Errorf("legacy connection %q metadata: %w", connection.Name, err) + } + result[i].AuthType = normalizeConnectionAuthType(connection.AuthType) + } + return result, nil +} + // visitEnabledConnectionServices walks azure.ai.connection services // whose condition is enabled. Condition uses the project environment, // not the connection service env: block. A root host plus an 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 23a2f42fd62..8507c294169 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 @@ -1002,6 +1002,61 @@ services: }) } +func TestSynthesizeExcludesConnectionServicesForOwningExtension(t *testing.T) { + t.Parallel() + + result, err := Synthesize(Input{ + RawAzureYAML: []byte(` +services: + project: + host: azure.ai.project + search: + host: azure.ai.connection + category: CognitiveSearch + target: https://search.example + authType: None +`), + ServiceName: "project", + ExcludeConnectionServices: true, + }) + require.NoError(t, err) + connections, ok := result.Parameters["connections"].([]Connection) + require.True(t, ok) + require.Empty(t, connections) +} + +func TestSynthesizePreservesPreSplitBundledConnections(t *testing.T) { + t.Parallel() + + result, err := Synthesize(Input{ + RawAzureYAML: []byte(` +services: + agent: + host: azure.ai.agent + connections: + - name: search + category: CognitiveSearch + target: ${SEARCH_ENDPOINT} + authType: ApiKey + credentials: + key: ${SEARCH_KEY} +`), + ServiceName: "agent", + AcceptedHosts: []string{"azure.ai.agent"}, + Env: map[string]string{"SEARCH_ENDPOINT": "https://search.example", "SEARCH_KEY": "secret"}, + ExcludeConnectionServices: true, + }) + require.NoError(t, err) + connections, ok := result.Parameters["connections"].([]Connection) + require.True(t, ok) + require.Len(t, connections, 1) + assert.Equal(t, "search", connections[0].Name) + assert.Equal(t, "https://search.example", connections[0].Target) + credentials, ok := result.Parameters["connectionCredentials"].(map[string]map[string]any) + require.True(t, ok) + assert.Equal(t, "secret", credentials["search"]["key"]) +} + func TestSynthesize_ConnectionExtendedFields(t *testing.T) { const inputYAML = ` services: diff --git a/cli/azd/extensions/azure.ai.toolboxes/README.md b/cli/azd/extensions/azure.ai.toolboxes/README.md index dd3acc39ab0..51f2ba7c1f6 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/README.md +++ b/cli/azd/extensions/azure.ai.toolboxes/README.md @@ -2,6 +2,21 @@ Manage Microsoft Foundry Toolboxes from your terminal. (Preview) +## `azure.yaml` ownership + +This extension owns the runtime lifecycle for `host: azure.ai.toolbox` +services. During the staged ownership migration, `azd ai agent init` may still +author the service block, but the Toolboxes extension reads it, resolves its +Connection and Skill references, creates the Toolbox version during deploy, and +publishes the resulting MCP endpoint. + +`azd deploy ` reconciles one Toolbox. In `azd up`, `uses` +orders the Project and Connection services before the Toolbox, and the Toolbox +before an Agent that consumes it. The Agents extension does not parse split +Toolbox service blocks at provision or deploy time. Its standalone legacy +`azd ai agent deploy` path delegates a sibling `toolbox.yaml` to +`azd ai toolbox deploy`. + ## Reuse an existing toolbox in `azure.yaml` A `host: azure.ai.toolbox` service normally creates a new toolbox version from