diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index a5d3569ccf9..7eb9ff2d34b 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.12 (Unreleased) + +### Bugs Fixed + +- Diagnose enabled standalone `azure.ai.connection` services in Doctor and next-step, and suggest `azd provision` after init writes a connection service. + ## 1.0.0-beta.11 (2026-08-20) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go index c8fc40ef91e..2e289f13cc3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections.go @@ -35,13 +35,13 @@ type foundryConnectionsProbeFn func( accountName, projectName string, ) ([]string, error) -// newCheckConnections produces Check `remote.connections` (P5.1 -// C15). For each `ConnectionResource` declared in any service's -// `agent.manifest.yaml` (collected by the C2 manifest walker), the -// check queries the Foundry project's connection list and verifies a -// connection with the matching name exists. The check Passes when -// every manifest-declared connection has a corresponding entry; -// Fails when one or more are missing. +// newCheckConnections produces Check `remote.connections`. For each +// enabled connection collected from unified azure.yaml services or +// compatible sources, the check queries the Foundry project's +// connection list and verifies a connection with the matching name +// exists. The check Passes when every configured connection has a +// corresponding entry; Fails when one or more are missing or when +// connection configuration cannot be loaded. // // # Skip cascade // @@ -55,26 +55,27 @@ type foundryConnectionsProbeFn func( // let the auth check own the diagnosis. // - `remote.foundry-endpoint` failed → same root cause, same // remediation. -// - state.HasConnections == false → no manifest connection -// declarations; the check has nothing to verify. Surface as -// Skip with a short explanation rather than a vacuous Pass. +// - state.ConnectionLoadErrors set → configuration could not be +// read. Fail without probing so a bad $ref is not Skip. +// - state.HasConnections == false → no enabled connection +// services or legacy resources; Skip rather than a vacuous Pass. // - `AZURE_AI_PROJECT_ID` not set / cannot be parsed → can not // derive the account + project to probe. Skip cleanly; the // rbac check already emits the canonical `azd env set` fix. // // # Classification // -// - Every manifest connection matches a Foundry connection name → -// Pass with the matched count. +// - Every configured connection matches a Foundry connection +// name → Pass with the matched count. // - One or more missing → Fail with the missing names listed in // the Message and structured under `Details["missingConnections"]` -// (each entry carries Name, ServiceName, Detail — the manifest's -// " | " identifier surfaced by the C2 walker). +// (each entry carries Name, ServiceName, Detail — the +// " | " identifier from collected state). // - Probe error → Skip with the underlying error verbatim. func newCheckConnections(deps Dependencies) Check { return Check{ ID: "remote.connections", - Name: "Manifest connections exist on Foundry project", + Name: "Configured connections exist on Foundry project", Remote: true, Fn: func(ctx context.Context, _ Options, prior []Result) Result { if deps.AzdClient == nil { @@ -125,10 +126,26 @@ func newCheckConnections(deps Dependencies) Check { Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.", } } + if len(state.ConnectionLoadErrors) > 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "failed to load configured connections: %s", + strings.Join(state.ConnectionLoadErrors, "; "), + ), + Suggestion: "Fix azure.yaml, its $ref files, or the " + + "legacy agent.manifest.yaml, then retry " + + "`azd ai agent doctor`.", + Details: map[string]any{ + "loadErrors": state.ConnectionLoadErrors, + }, + } + } if !state.HasConnections { return Result{ - Status: StatusSkip, - Message: "skipped: no connection resources declared in any service's agent.manifest.yaml.", + Status: StatusSkip, + Message: "skipped: no enabled connection services " + + "or legacy connection resources found.", } } @@ -281,11 +298,12 @@ func classifyConnections( return Result{ Status: StatusFail, Message: fmt.Sprintf( - "%d connection(s) referenced by agent.manifest.yaml are missing on project %s: %s", + "%d configured connection(s) are missing on project %s: %s", len(missing), project, sb.String()), - Suggestion: "Run `azd provision` to create the missing connection(s), " + - "or update the agent.manifest.yaml `resources[].name` entries to " + - "match connections that already exist on the Foundry project.", + Suggestion: "Run `azd provision` to create or reconcile the " + + "missing connection(s), or update the configured connection " + + "services or legacy manifest resources to match connections " + + "that already exist on the Foundry project.", Details: map[string]any{ "missingConnections": missing, "matchedCount": matched, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go index 834ade78b00..6c1eea6aa58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_connections_test.go @@ -91,13 +91,51 @@ func TestCheckConnections_SkipsCascadeFromUpstream(t *testing.T) { func TestCheckConnections_SkipsWhenNoManifestConnections(t *testing.T) { t.Parallel() + var probeCalls int deps := Dependencies{ - assembleState: fixedAssembler(&nextstep.State{HasConnections: false}), - probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil), + assembleState: fixedAssembler(&nextstep.State{HasConnections: false}), + probeFoundryConnections: func( + _ context.Context, _, _ string, + ) ([]string, error) { + probeCalls++ + return nil, nil + }, } res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) require.Equal(t, StatusSkip, res.Status) - require.Contains(t, res.Message, "no connection resources declared") + require.Contains(t, res.Message, "no enabled connection services") + require.Contains(t, res.Message, "legacy connection resources found") + require.Equal(t, 0, probeCalls) +} + +func TestCheckConnections_FailsOnLoadErrorsBeforeSkip(t *testing.T) { + t.Parallel() + var probeCalls int + deps := Dependencies{ + assembleState: fixedAssembler(&nextstep.State{ + HasConnections: false, + ConnectionLoadErrors: []string{ + `connection service "bad-conn" has an invalid deployment condition: malformed condition template`, + }, + }), + probeFoundryConnections: func( + _ context.Context, _, _ string, + ) ([]string, error) { + probeCalls++ + return nil, nil + }, + } + res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) + require.Equal(t, StatusFail, res.Status) + require.Equal(t, 0, probeCalls) + require.Contains(t, res.Message, "failed to load configured connections") + require.Contains(t, res.Message, `connection service "bad-conn"`) + require.Contains(t, res.Suggestion, "Fix azure.yaml") + require.Contains(t, res.Suggestion, "azd ai agent doctor") + require.NotContains(t, res.Suggestion, "azd deploy") + require.Equal(t, []string{ + `connection service "bad-conn" has an invalid deployment condition: malformed condition template`, + }, res.Details["loadErrors"]) } func TestCheckConnections_FailsWhenAssemblerReturnsNilState(t *testing.T) { @@ -220,11 +258,13 @@ func TestCheckConnections_FailsWithMissing(t *testing.T) { } res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) require.Equal(t, StatusFail, res.Status) - require.Contains(t, res.Message, "2 connection(s)") + require.Contains(t, res.Message, "2 configured connection(s)") require.Contains(t, res.Message, "openai-default [AzureOpenAI | https://openai.test] (service chat)") require.Contains(t, res.Message, "search-conn [CognitiveSearch | search.test] (service search)") require.NotContains(t, res.Message, "blob-storage") require.Contains(t, res.Suggestion, "azd provision") + require.NotContains(t, res.Suggestion, "azd deploy") + require.Contains(t, res.Suggestion, "configured connection services") require.EqualValues(t, 1, res.Details["matchedCount"]) } @@ -243,7 +283,7 @@ func TestCheckConnections_FailsWhenAllMissing(t *testing.T) { } res := runConnectionsCheck(t, deps, healthyConnectionsPrior()) require.Equal(t, StatusFail, res.Status) - require.Contains(t, res.Message, "1 connection(s)") + require.Contains(t, res.Message, "1 configured connection(s)") require.Contains(t, res.Message, "blob-storage (service chat)") } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go index 2f877a81cd4..53c1c19e349 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_remote_test.go @@ -58,7 +58,7 @@ func TestNewRemoteChecks_HasAuthFoundryEndpointRBACAgentStatusConnections(t *tes require.True(t, got[3].Remote, "remote.agent-status must declare Remote=true") require.NotNil(t, got[3].Fn, "remote.agent-status must have a non-nil Fn") require.Equal(t, "remote.connections", got[4].ID) - require.Equal(t, "Manifest connections exist on Foundry project", got[4].Name) + require.Equal(t, "Configured connections exist on Foundry project", got[4].Name) require.True(t, got[4].Remote, "remote.connections must declare Remote=true") require.NotNil(t, got[4].Fn, "remote.connections must have a non-nil Fn") } 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 af9bce3a2b0..e62e5c77c49 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3412,14 +3412,21 @@ 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. - if err := emitResourceServices( + emittedConnections, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), resourceDeployments, resourceConnections, resourceToolboxes, - ); err != nil { + ) + if err != nil { return err } + recordPendingConnectionProvision( + ctx, + a.azdClient, + a.environment.Name, + emittedConnections, + ) printAgentAddedMessage(agentDef.Name) @@ -3491,7 +3498,7 @@ func (a *InitAction) addVoiceAgentToProject( // project. Voice init emits no deployment/connection/toolbox siblings; managed // models are service-hosted, and BYOM model deployments are referenced from // azure.yaml and must already exist. - if err := emitResourceServices( + if _, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index a37a34a1beb..e2ed85ac68f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -886,7 +886,7 @@ func (a *InitFromCodeAction) addToProject( // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. - if err := emitResourceServices( + if _, err := emitResourceServices( ctx, a.azdClient, agentServiceName, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), a.selectedFoundryProject.Endpoint(), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/condition.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/condition.go index dac09cf52c9..616e33ef5e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/condition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/condition.go @@ -7,10 +7,7 @@ import ( "context" "fmt" "os" - "strconv" - "strings" - "github.com/azure/azure-dev/cli/azd/pkg/foundry" "google.golang.org/protobuf/types/known/structpb" ) @@ -32,66 +29,50 @@ func isServiceEnabled( return true, nil } - condition, err := conditionValueString(value) - if err != nil { - return false, err - } - if strings.TrimSpace(condition) == "" { - return true, nil - } - - expanded, err := expandServiceCondition( - ctx, - src, - envName, - condition, + var lookupErr error + enabled, err := evaluateCondition( + conditionRawValue(value), + serviceConditionLookup(ctx, src, envName, &lookupErr), ) if err != nil { return false, err } - return isTruthyCondition(expanded), nil + if lookupErr != nil { + return false, lookupErr + } + return enabled, nil } -func conditionValueString(value *structpb.Value) (string, error) { +func conditionRawValue(value *structpb.Value) any { if value == nil { - return "", nil + return nil } - - switch kind := value.Kind.(type) { - case *structpb.Value_StringValue: - return kind.StringValue, nil - case *structpb.Value_BoolValue: - return strconv.FormatBool(kind.BoolValue), nil - case *structpb.Value_NumberValue: - return strconv.FormatFloat(kind.NumberValue, 'g', -1, 64), nil + switch value.Kind.(type) { case *structpb.Value_NullValue: - return "", nil + return nil default: - return "", fmt.Errorf( - "condition must be a string, boolean, or number", - ) + return value.AsInterface() } } -func expandServiceCondition( +func serviceConditionLookup( ctx context.Context, src Source, envName string, - condition string, -) (string, error) { + lookupErr *error, +) func(string) string { if envName == "" { - return foundry.ExpandEnv(condition, os.Getenv) + return os.Getenv } values := map[string]string{} - var lookupErr error - expanded, err := foundry.ExpandEnv(condition, func(name string) string { + return func(name string) string { if value, ok := values[name]; ok { return value } value, err := src.EnvValue(ctx, envName, name) if err != nil { - lookupErr = fmt.Errorf( + *lookupErr = fmt.Errorf( "read condition environment variable %q: %w", name, err, @@ -100,21 +81,5 @@ func expandServiceCondition( } values[name] = value return value - }) - if err != nil { - return "", err - } - if lookupErr != nil { - return "", lookupErr - } - return expanded, nil -} - -func isTruthyCondition(value string) bool { - switch value { - case "1", "true", "TRUE", "True", "yes", "YES", "Yes": - return true - default: - return false } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections.go new file mode 100644 index 00000000000..c3706e5fe2e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections.go @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "google.golang.org/protobuf/types/known/structpb" +) + +type bundledConnection struct { + Name string `json:"name"` + Category string `json:"category"` + Target string `json:"target"` +} + +type bundledConnectionConfig struct { + Connections []bundledConnection `json:"connections"` +} + +// populateConnections merges enabled unified connection services, +// bundled agent config, and legacy manifest resources. Same Foundry +// names keep split > bundled > manifest precedence. +func populateConnections( + ctx context.Context, + src Source, + envName string, + projectCfg *azdext.ProjectConfig, + state *State, + errs *[]error, +) { + if projectCfg == nil || state == nil { + return + } + + collected := map[string]ResourceRef{} + collectSplitConnections( + ctx, + src, + envName, + projectCfg, + state, + errs, + collected, + ) + collectBundledConnections( + ctx, + src, + envName, + projectCfg, + state, + errs, + collected, + ) + collectManifestConnections( + ctx, + src, + envName, + projectCfg, + state, + errs, + collected, + ) + + refs := make([]ResourceRef, 0, len(collected)) + for _, ref := range collected { + refs = append(refs, ref) + } + slices.SortFunc(refs, func(a, b ResourceRef) int { + if c := strings.Compare(a.Name, b.Name); c != 0 { + return c + } + return strings.Compare(a.ServiceName, b.ServiceName) + }) + if len(refs) == 0 { + state.Connections = nil + } else { + state.Connections = refs + } + state.HasConnections = len(refs) > 0 + slices.Sort(state.ConnectionLoadErrors) +} + +func collectSplitConnections( + ctx context.Context, + src Source, + envName string, + projectCfg *azdext.ProjectConfig, + state *State, + errs *[]error, + collected map[string]ResourceRef, +) { + for _, serviceName := range sortedServiceKeys(projectCfg) { + svc := projectCfg.Services[serviceName] + if svc == nil || svc.GetHost() != connectionHost { + continue + } + + enabled, err := isServiceEnabled(ctx, src, envName, serviceName) + if err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "connection service %q has an invalid deployment condition: %v", + serviceName, + err, + ), + ) + continue + } + if !enabled { + continue + } + + resolved, err := resolveServiceProperties(svc, projectCfg.Path) + if err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "connection service %q: %v", + serviceName, + err, + ), + ) + continue + } + + var decoded bundledConnection + if err := decodeJSONMap(resolved, &decoded); err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "connection service %q: decode connection: %v", + serviceName, + err, + ), + ) + continue + } + if _, exists := collected[serviceName]; exists { + continue + } + collected[serviceName] = ResourceRef{ + Name: serviceName, + ServiceName: serviceName, + Detail: formatConnectionDetail( + decoded.Category, + decoded.Target, + ), + } + } +} + +func collectBundledConnections( + ctx context.Context, + src Source, + envName string, + projectCfg *azdext.ProjectConfig, + state *State, + errs *[]error, + collected map[string]ResourceRef, +) { + for _, serviceName := range sortedServiceKeys(projectCfg) { + svc := projectCfg.Services[serviceName] + if svc == nil || svc.GetHost() != agentHost { + continue + } + enabled, err := isServiceEnabled(ctx, src, envName, serviceName) + if err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "agent service %q deployment condition: %v", + serviceName, + err, + ), + ) + continue + } + if !enabled { + continue + } + + resolved, err := resolveAgentDefinition(svc, projectCfg.Path) + if err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "agent service %q: %v", + serviceName, + err, + ), + ) + continue + } + if resolved == nil { + continue + } + + var decoded bundledConnectionConfig + if err := decodeJSONMap(resolved, &decoded); err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "agent service %q: decode connections: %v", + serviceName, + err, + ), + ) + continue + } + for _, conn := range decoded.Connections { + if conn.Name == "" { + continue + } + if _, exists := collected[conn.Name]; exists { + continue + } + collected[conn.Name] = ResourceRef{ + Name: conn.Name, + ServiceName: serviceName, + Detail: formatConnectionDetail( + conn.Category, + conn.Target, + ), + } + } + } +} + +func collectManifestConnections( + ctx context.Context, + src Source, + envName string, + projectCfg *azdext.ProjectConfig, + state *State, + errs *[]error, + collected map[string]ResourceRef, +) { + for _, serviceName := range sortedServiceKeys(projectCfg) { + svc := projectCfg.Services[serviceName] + if svc == nil || svc.GetHost() != agentHost { + continue + } + enabled, err := isServiceEnabled(ctx, src, envName, serviceName) + if err != nil { + recordConnectionLoadError( + state, + errs, + fmt.Sprintf( + "agent service %q deployment condition: %v", + serviceName, + err, + ), + ) + continue + } + if !enabled { + continue + } + + data := readManifestBytes(projectCfg.Path, svc.GetRelativePath()) + if data == nil { + continue + } + resources, err := agent_yaml.ExtractResourceDefinitions(data) + if err != nil { + continue + } + for _, resource := range resources { + conn, ok := resource.(agent_yaml.ConnectionResource) + if !ok || conn.Name == "" { + continue + } + if _, exists := collected[conn.Name]; exists { + continue + } + collected[conn.Name] = ResourceRef{ + Name: conn.Name, + ServiceName: serviceName, + Detail: connectionDetail(conn), + } + } + } +} + +func resolveServiceProperties( + svc *azdext.ServiceConfig, + projectRoot string, +) (map[string]any, error) { + raw := map[string]any{} + if props := svc.GetAdditionalProperties(); props != nil { + raw = props.AsMap() + } + if projectRoot == "" { + return raw, nil + } + resolved, err := foundry.ResolveFileRefs(raw, projectRoot) + if err != nil { + return nil, fmt.Errorf("resolve $ref includes: %w", err) + } + return resolved, nil +} + +func resolveAgentDefinition( + svc *azdext.ServiceConfig, + projectRoot string, +) (map[string]any, error) { + for _, candidate := range []struct { + name string + props *structpb.Struct + }{ + { + name: "service-level properties", + props: svc.GetAdditionalProperties(), + }, + { + name: "deprecated config", + props: svc.GetConfig(), + }, + } { + if candidate.props == nil || + len(candidate.props.GetFields()) == 0 { + continue + } + raw := candidate.props.AsMap() + if projectRoot == "" { + return raw, nil + } + resolved, err := foundry.ResolveFileRefs(raw, projectRoot) + if err != nil { + return nil, fmt.Errorf( + "resolve %s: %w", + candidate.name, + err, + ) + } + return resolved, nil + } + return nil, nil +} + +func decodeJSONMap(values map[string]any, out any) error { + data, err := json.Marshal(values) + if err != nil { + return err + } + return json.Unmarshal(data, out) +} + +func sortedServiceKeys(projectCfg *azdext.ProjectConfig) []string { + keys := make([]string, 0, len(projectCfg.Services)) + for name := range projectCfg.Services { + keys = append(keys, name) + } + slices.Sort(keys) + return keys +} + +func recordConnectionLoadError( + state *State, + errs *[]error, + issue string, +) { + if slices.Contains(state.ConnectionLoadErrors, issue) { + return + } + state.ConnectionLoadErrors = append( + state.ConnectionLoadErrors, + issue, + ) + *errs = append(*errs, errors.New(issue)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections_test.go new file mode 100644 index 00000000000..37a14e49773 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/connections_test.go @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestAssembleState_SplitConnectionsOnly(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "search-conn": { + Name: "search-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "CognitiveSearch", + "target": "https://search.example", + }), + }, + "bing-conn": { + Name: "bing-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "ApiKey", + "target": "https://api.bing.example", + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.True(t, state.HasConnections) + require.Empty(t, state.ConnectionLoadErrors) + require.Len(t, state.Connections, 2) + assert.Equal(t, "bing-conn", state.Connections[0].Name) + assert.Equal(t, "bing-conn", state.Connections[0].ServiceName) + assert.Equal(t, "ApiKey | https://api.bing.example", state.Connections[0].Detail) + assert.Equal(t, "search-conn", state.Connections[1].Name) + assert.Equal(t, "CognitiveSearch | https://search.example", state.Connections[1].Detail) +} + +func TestAssembleState_ConnectionUsesServiceKeyAsName(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "azure-search": { + Name: "azure-search", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "name": "ignored-body-name", + "category": "CognitiveSearch", + "target": "https://search.example", + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Len(t, state.Connections, 1) + assert.Equal(t, "azure-search", state.Connections[0].Name) +} + +func TestAssembleState_DisabledConnectionIsSkipped(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + configValues: map[string]*structpb.Value{ + "off-conn/condition": structpb.NewBoolValue(false), + }, + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "live-conn": { + Name: "live-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "ApiKey", + "target": "https://live.example", + }), + }, + "off-conn": { + Name: "off-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "ApiKey", + "target": "https://off.example", + "credentials": map[string]any{"key": "super-secret"}, + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Empty(t, state.ConnectionLoadErrors) + require.Len(t, state.Connections, 1) + assert.Equal(t, "live-conn", state.Connections[0].Name) + assert.NotContains(t, state.Connections[0].Detail, "super-secret") +} + +func TestAssembleState_InvalidConnectionConditionIsLoadError(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + configValues: map[string]*structpb.Value{ + "bad-conn/condition": structpb.NewStringValue("${"), + }, + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "bad-conn": { + Name: "bad-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "ApiKey", + "target": "https://bad.example", + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.NotEmpty(t, errs) + require.False(t, state.HasConnections) + require.Len(t, state.ConnectionLoadErrors, 1) + assert.Contains(t, state.ConnectionLoadErrors[0], `connection service "bad-conn"`) + assert.Contains(t, state.ConnectionLoadErrors[0], "invalid deployment condition") +} + +func TestAssembleState_InvalidBundledAgentConditionIsLoadError(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + configValues: map[string]*structpb.Value{ + "agent/condition": structpb.NewStringValue("${"), + }, + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "agent": { + Name: "agent", + Host: agentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "hostedAgent", + "connections": []any{ + map[string]any{ + "name": "search", + "category": "ApiKey", + "target": "https://search.example", + }, + }, + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.NotEmpty(t, errs) + require.False(t, state.HasConnections) + require.Len(t, state.ConnectionLoadErrors, 1) + assert.Contains(t, state.ConnectionLoadErrors[0], `agent service "agent"`) + assert.Contains(t, state.ConnectionLoadErrors[0], "deployment condition") +} + +func TestAssembleState_InvalidManifestAgentConditionIsLoadError(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeManifest(t, root, "src/echo", ` +template: + kind: containerAgent + name: echo +resources: + - name: search + kind: connection + category: ApiKey + target: https://search.example +`) + src := &fakeSource{ + envName: "dev", + configValues: map[string]*structpb.Value{ + "echo/condition": structpb.NewStringValue("${"), + }, + project: &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "echo": { + Name: "echo", + Host: agentHost, + RelativePath: "src/echo", + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.NotEmpty(t, errs) + require.False(t, state.HasConnections) + require.Len(t, state.ConnectionLoadErrors, 1) + assert.Contains(t, state.ConnectionLoadErrors[0], `agent service "echo"`) + assert.Contains(t, state.ConnectionLoadErrors[0], "deployment condition") +} + +func TestAssembleState_ActiveConnectionRefErrorIsLoadError(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "broken-conn": { + Name: "broken-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "$ref": "./missing-connection.yaml", + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.NotEmpty(t, errs) + require.False(t, state.HasConnections) + require.Len(t, state.ConnectionLoadErrors, 1) + assert.Contains(t, state.ConnectionLoadErrors[0], `connection service "broken-conn"`) + assert.Contains(t, state.ConnectionLoadErrors[0], "resolve $ref") +} + +func TestAssembleState_ConnectionTargetKeepsVarRef(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "search-conn": { + Name: "search-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "CognitiveSearch", + "target": "${SEARCH_URL}", + "credentials": map[string]any{"key": "super-secret"}, + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Len(t, state.Connections, 1) + assert.Equal(t, "CognitiveSearch | ${SEARCH_URL}", state.Connections[0].Detail) + assert.NotContains(t, state.Connections[0].Detail, "super-secret") +} + +func TestAssembleState_ConnectionSourcePrecedence(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeManifest(t, root, "src/echo", ` +template: + kind: containerAgent + name: echo +resources: + - name: shared-conn + kind: connection + category: BingLLMSearch + target: https://manifest.example + - name: manifest-only + kind: connection + category: ApiKey + target: https://manifest-only.example +`) + + agent := newAgentService(t, map[string]any{ + "kind": "hostedAgent", + "connections": []any{ + map[string]any{ + "name": "shared-conn", + "category": "ApiKey", + "target": "https://bundled.example", + }, + map[string]any{ + "name": "bundled-only", + "category": "RemoteTool", + "target": "https://bundled-only.example", + }, + }, + }) + agent.RelativePath = "src/echo" + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "echo": agent, + "shared-conn": { + Name: "shared-conn", + Host: connectionHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "category": "CognitiveSearch", + "target": "https://split.example", + }), + }, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.True(t, state.HasConnections) + require.Len(t, state.Connections, 3) + + byName := map[string]ResourceRef{} + for _, ref := range state.Connections { + byName[ref.Name] = ref + } + assert.Equal(t, "CognitiveSearch | https://split.example", byName["shared-conn"].Detail) + assert.Equal(t, "shared-conn", byName["shared-conn"].ServiceName) + assert.Equal(t, "RemoteTool | https://bundled-only.example", byName["bundled-only"].Detail) + assert.Equal(t, "echo", byName["bundled-only"].ServiceName) + assert.Equal(t, "ApiKey | https://manifest-only.example", byName["manifest-only"].Detail) + assert.Equal(t, "echo", byName["manifest-only"].ServiceName) +} + +func TestAssembleState_BundledWinsOverManifest(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeManifest(t, root, "src/echo", ` +template: + kind: containerAgent + name: echo +resources: + - name: shared-conn + kind: connection + category: BingLLMSearch + target: https://manifest.example +`) + + agent := newAgentService(t, map[string]any{ + "kind": "hostedAgent", + "connections": []any{ + map[string]any{ + "name": "shared-conn", + "category": "ApiKey", + "target": "https://bundled.example", + }, + }, + }) + agent.RelativePath = "src/echo" + + src := &fakeSource{ + envName: "dev", + project: &azdext.ProjectConfig{ + Path: root, + Services: map[string]*azdext.ServiceConfig{ + "echo": agent, + }, + }, + } + + state, errs := assembleState(t.Context(), src) + require.Empty(t, errs) + require.Len(t, state.Connections, 1) + assert.Equal(t, "ApiKey | https://bundled.example", state.Connections[0].Detail) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/evaluate.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/evaluate.go new file mode 100644 index 00000000000..eb9f6e0fdbe --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/evaluate.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package nextstep + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// evaluateCondition matches foundry.EvaluateCondition. Extensions +// pin a published azd module, so they cannot import new core +// helpers until that module is bumped. +func evaluateCondition( + value any, + getenv func(string) string, +) (bool, error) { + if value == nil { + return true, nil + } + + switch v := value.(type) { + case bool: + return v, nil + case string: + return evaluateConditionString(v, getenv) + case json.Number: + return evaluateConditionString(string(v), getenv) + case int: + return isTruthyCondition(strconv.Itoa(v)), nil + case int8: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int16: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int32: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int64: + return isTruthyCondition(strconv.FormatInt(v, 10)), nil + case uint: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint8: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint16: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint32: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint64: + return isTruthyCondition(strconv.FormatUint(v, 10)), nil + case float32: + return isTruthyCondition( + strconv.FormatFloat(float64(v), 'g', -1, 32), + ), nil + case float64: + return isTruthyCondition( + strconv.FormatFloat(v, 'g', -1, 64), + ), nil + default: + return false, fmt.Errorf( + "condition must be a string, boolean, or number", + ) + } +} + +func evaluateConditionString( + value string, + getenv func(string) string, +) (bool, error) { + if strings.TrimSpace(value) == "" { + return true, nil + } + if getenv == nil { + getenv = func(string) string { return "" } + } + expanded, err := foundry.ExpandEnv(value, getenv) + if err != nil { + return false, fmt.Errorf( + "malformed condition template: %w", + err, + ) + } + return isTruthyCondition(expanded), nil +} + +func isTruthyCondition(value string) bool { + switch value { + case "1", "true", "TRUE", "True", "yes", "YES", "Yes": + return true + default: + return false + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go index 4f4baa93af0..a12dfcd9d93 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/manifest.go @@ -68,7 +68,6 @@ func populateManifestResources(projectPath string, state *State) { models := map[resourceKey]ResourceRef{} toolboxes := map[resourceKey]ResourceRef{} - connections := map[resourceKey]ResourceRef{} for _, svc := range state.Services { data := readManifestBytes(projectPath, svc.RelativePath) @@ -107,29 +106,14 @@ func populateManifestResources(projectPath string, state *State) { ServiceName: svc.Name, ToolboxSource: ToolboxSourceLegacyManifest, } - case agent_yaml.ConnectionResource: - if r.Name == "" { - continue - } - k := resourceKey{service: svc.Name, name: r.Name} - if _, dup := connections[k]; dup { - continue - } - connections[k] = ResourceRef{ - Name: r.Name, - ServiceName: svc.Name, - Detail: connectionDetail(r), - } } } } state.ModelRefs = sortedResourceRefs(models) state.Toolboxes = sortedResourceRefs(toolboxes) - state.Connections = sortedResourceRefs(connections) state.HasModels = len(state.ModelRefs) > 0 state.HasToolboxes = len(state.Toolboxes) > 0 - state.HasConnections = len(state.Connections) > 0 } // populateSplitToolboxes adds active toolbox dependencies to state. @@ -424,8 +408,10 @@ func readManifestBytes(projectPath, relativePath string) []byte { // to whichever side is populated so we never emit a useless // " | " separator with both halves blank. func connectionDetail(r agent_yaml.ConnectionResource) string { - category := string(r.Category) - target := r.Target + return formatConnectionDetail(string(r.Category), r.Target) +} + +func formatConnectionDetail(category, target string) string { switch { case category != "" && target != "": return category + " | " + target 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 6032781f22a..464624643f0 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 @@ -104,6 +104,15 @@ 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", + }, { // Multiple pending reasons collected during init — // e.g. user left ACR blank and configured a new model. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 5ffe129eeb1..8973527c4e8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -31,6 +31,10 @@ const ( // wire cmd → nextstep, so the reverse import would close a cycle. agentHost = "azure.ai.agent" + // connectionHost matches azure.yaml for an azure.ai.connection + // service. Duplicated here so nextstep stays free of cmd imports. + connectionHost = "azure.ai.connection" + // agentVersionVarFormat is the env-var name that signals a deployed // agent service. Filled with the upper-cased service key. agentVersionVarFormat = "AGENT_%s_VERSION" @@ -339,6 +343,14 @@ func assembleState(ctx context.Context, src Source, opts ...Option) (*State, []e state, &errs, ) + populateConnections( + ctx, + src, + envName, + project, + state, + &errs, + ) } if project != nil && envName != "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go index e70cc6637fc..6cb2b0d2500 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go @@ -159,13 +159,15 @@ type State struct { CreatedFolderDisplay string // HasModels, HasToolboxes, and HasConnections are aggregate flags. - // They describe resources. Models and connections come from - // agent manifests. Toolboxes include split services and manifest - // resources. Doctor checks skip when no matching resource exists, - // while resolvers can tailor remediation. + // They describe resources. Models still come from agent manifests. + // Toolboxes include split services and manifest resources. + // Connections include enabled azure.ai.connection services, + // bundled agent config, and legacy manifest resources. Doctor + // checks skip when no matching resource exists, while resolvers + // can tailor remediation. // - // All three flags are false when the manifest file is missing, - // malformed, or declares no resources — the walker is deliberately + // Model and toolbox flags stay false when the manifest file is + // missing, malformed, or declares no resources — the walker is // silent on those failure modes so a missing/in-flight manifest // never blocks the rest of state assembly. HasModels bool @@ -173,12 +175,18 @@ type State struct { HasConnections bool // ModelRefs, Toolboxes, and Connections list collected resources. - // ModelRefs and Connections still come from manifests. Entries are - // sorted by Name, then ServiceName, so callers can render them - // deterministically. + // ModelRefs still come from manifests. Connections come from + // enabled unified connection services or compatible sources. + // Entries are sorted by Name, then ServiceName, so callers can + // render them deterministically. ModelRefs []ResourceRef Toolboxes []ResourceRef Connections []ResourceRef + + // ConnectionLoadErrors lists failures while reading enabled + // connection configuration. Doctor fails on these instead of + // treating the project as having no connections. + ConnectionLoadErrors []string } // ResourceRef is a slim summary of a manifest resource that the 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 948e2b5fa49..b131a7d68a9 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,6 +6,7 @@ package cmd import ( "context" "fmt" + "log" "slices" "strings" @@ -45,6 +46,7 @@ const ( pendingReasonModelDeployment = "model_deployment" pendingReasonACR = "acr" pendingReasonAppInsights = "app_insights" + pendingReasonConnection = "connection" ) // parsePendingProvisionReasons splits the comma-separated env-var @@ -107,6 +109,31 @@ func addPendingProvisionReason( }) } +// recordPendingConnectionProvision stores a connection pending-provision +// reason after init actually writes at least one connection service. +// Write failures are warnings so a successful init is not rolled back. +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 569ae089302..1aa1ba224d8 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,6 +204,53 @@ 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("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 83b5c8ae13b..39709ba51ca 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 @@ -59,8 +59,9 @@ func emitResourceServices( deployments []project.Deployment, connections []project.Connection, toolboxes []project.Toolbox, -) error { +) (int, error) { var agentUses []string + emittedConnections := 0 // Track every azure.yaml service key we emit so two resource names that // sanitize to the same key (e.g. "my conn" and "myconn") fail fast instead @@ -93,14 +94,14 @@ func emitResourceServices( Deployments: deployments, }) if err != nil { - return fmt.Errorf("marshaling project service config: %w", err) + return 0, fmt.Errorf("marshaling project service config: %w", err) } projectServiceName := resolveProjectServiceKey(ctx, azdClient, projectName, agentServiceName) if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { - return err + return 0, err } if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { - return err + return 0, err } agentUses = append(agentUses, projectServiceName) @@ -119,16 +120,17 @@ func emitResourceServices( continue } if err := reserveServiceName(usedNames, connName, fmt.Sprintf("connection %q", conn.Name)); err != nil { - return err + return 0, err } connCfg, err := project.MarshalStruct(&conn) if err != nil { - return fmt.Errorf("marshaling connection service %q config: %w", connName, err) + return 0, fmt.Errorf("marshaling connection service %q config: %w", connName, err) } if err := addResourceService(ctx, azdClient, connName, AiConnectionHost, connCfg, siblingUses); err != nil { - return err + return 0, err } agentUses = append(agentUses, connName) + emittedConnections++ } for i := range toolboxes { @@ -142,14 +144,14 @@ func emitResourceServices( continue } if err := reserveServiceName(usedNames, toolboxName, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { - return err + return 0, err } toolboxCfg, err := project.MarshalStruct(&toolbox) if err != nil { - return fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) + return 0, fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) } if err := addResourceService(ctx, azdClient, toolboxName, AiToolboxHost, toolboxCfg, siblingUses); err != nil { - return err + return 0, err } agentUses = append(agentUses, toolboxName) } @@ -157,11 +159,11 @@ func emitResourceServices( // Wire the agent service to its resource siblings so azd walks them first. if len(agentUses) > 0 && agentServiceName != "" { if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { - return err + return 0, err } } - return nil + return emittedConnections, nil } // resolveProjectServiceKey picks the azure.yaml service key for the single 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 e01c46c4801..80f7d62461c 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 @@ -588,7 +588,7 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) require.NoError(t, err) server.mu.Lock() @@ -610,7 +610,7 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) require.NoError(t, err) server.mu.Lock() @@ -626,6 +626,38 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) } +func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { + t.Parallel() + + t.Run("valid connection returns 1", func(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} + + got, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, conns, nil) + require.NoError(t, err) + assert.Equal(t, 1, got) + }) + + t.Run("blank name returns 0", func(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + conns := []project.Connection{{Name: " ", Category: "ApiKey"}} + + got, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, conns, nil) + require.NoError(t, err) + assert.Equal(t, 0, got) + + server.mu.Lock() + defer server.mu.Unlock() + for _, svc := range server.added { + assert.NotEqual(t, AiConnectionHost, svc.Host) + } + }) +} + // TestEmitResourceServices_WritesServiceLevelProps verifies resource services are // written with their keys composed at the service level (inline via // AdditionalProperties, matching the agent service shape and the config:false @@ -643,7 +675,9 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", deployments, conns, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", deployments, conns, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -658,7 +692,7 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { // Init must write a project shape the owning extension can parse. var projectCfg project.ServiceTargetAgentConfig - err := project.UnmarshalStruct( + err = project.UnmarshalStruct( project.ServiceConfigProps(services["ai-project"]), &projectCfg, ) @@ -686,7 +720,9 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", endpoint, nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", endpoint, nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -702,7 +738,9 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -726,8 +764,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -746,8 +785,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { // A different project name is supplied, but the existing key wins so a // repeated init does not create a second project service. - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -759,8 +799,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my agent", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "my agent", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() @@ -773,8 +814,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil)) + _, err := emitResourceServices( + t.Context(), client, "myagent", "", "", nil, nil, nil) + require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go new file mode 100644 index 00000000000..7ff64592c3a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// evaluateCondition matches foundry.EvaluateCondition. Extensions +// pin a published azd module, so they cannot import new core +// helpers until that module is bumped. +func evaluateCondition( + value any, + getenv func(string) string, +) (bool, error) { + if value == nil { + return true, nil + } + + switch v := value.(type) { + case bool: + return v, nil + case string: + return evaluateConditionString(v, getenv) + case json.Number: + return evaluateConditionString(string(v), getenv) + case int: + return isTruthyCondition(strconv.Itoa(v)), nil + case int8: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int16: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int32: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int64: + return isTruthyCondition(strconv.FormatInt(v, 10)), nil + case uint: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint8: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint16: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint32: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint64: + return isTruthyCondition(strconv.FormatUint(v, 10)), nil + case float32: + return isTruthyCondition( + strconv.FormatFloat(float64(v), 'g', -1, 32), + ), nil + case float64: + return isTruthyCondition( + strconv.FormatFloat(v, 'g', -1, 64), + ), nil + default: + return false, fmt.Errorf( + "condition must be a string, boolean, or number", + ) + } +} + +func evaluateConditionString( + value string, + getenv func(string) string, +) (bool, error) { + if strings.TrimSpace(value) == "" { + return true, nil + } + if getenv == nil { + getenv = func(string) string { return "" } + } + expanded, err := foundry.ExpandEnv(value, getenv) + if err != nil { + return false, fmt.Errorf( + "malformed condition template: %w", + err, + ) + } + return isTruthyCondition(expanded), nil +} + +func isTruthyCondition(value string) bool { + switch value { + case "1", "true", "TRUE", "True", "yes", "YES", "Yes": + return true + default: + return false + } +} 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 67c4d536464..3d4518b3f96 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -327,11 +327,14 @@ func Synthesize(in Input) (*Result, error) { }, nil } -// ConnectionEnvironmentScopes returns services that declare env. -// An empty env block still establishes an isolated service scope. +// ConnectionEnvironmentScopes returns enabled connection services +// that declare env. An empty env block still establishes an +// isolated service scope. Disabled connections are omitted so +// on-disk Bicep does not treat them as managed inputs. func ConnectionEnvironmentScopes( raw []byte, projectRoot string, + env map[string]string, ) (map[string]bool, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") @@ -343,19 +346,19 @@ func ConnectionEnvironmentScopes( } scopes := map[string]bool{} - for name, node := range root.Services { - node, matches, err := serviceForHost( - node, - projectRoot, - name, - aiConnectionHost, - ) - if err != nil { - return nil, err - } - if matches && connectionEnvDeclared(node) { - scopes[name] = true - } + err := visitEnabledConnectionServices( + root.Services, + projectRoot, + env, + func(name string, node yaml.Node) error { + if connectionEnvDeclared(node) { + scopes[name] = true + } + return nil + }, + ) + if err != nil { + return nil, err } return scopes, nil } @@ -671,9 +674,11 @@ func agentNeedsAcr(a agentBlock) bool { return kind == "" || strings.EqualFold(kind, "hosted") } -// collectConnections scans all services for host: azure.ai.connection entries -// (the service key is the connection name) and returns them sorted by name so -// the synthesized parameter is deterministic regardless of YAML map order. +// collectConnections scans enabled host: azure.ai.connection services +// (the service key is the connection name) and returns them sorted by +// name so the synthesized parameter is deterministic regardless of +// YAML map order. Disabled services are omitted before payload +// expansion so their ${VAR} values cannot fail provision. // // Provisioning resolves ${VAR} from service env when present. // Legacy services use project and process values. @@ -686,66 +691,192 @@ func collectConnections( projectRoot string, ) ([]Connection, error) { connections := []Connection{} + err := visitEnabledConnectionServices( + services, + projectRoot, + env, + func(name string, node yaml.Node) error { + var svc connectionService + if err := node.Decode(&svc); err != nil { + return fmt.Errorf( + "services.%s: decode connection: %w", + name, + err, + ) + } + + declared := len(serviceEnvironments[name]) > 0 || + connectionEnvDeclared(node) + mapping := connectionEnvironmentMapping( + env, + serviceEnvironments[name], + declared, + ) + target, err := maybeExpand(svc.Target, mapping, resolve) + if err != nil { + return fmt.Errorf("services.%s.target: %w", name, err) + } + + credentials, err := expandCredentials( + svc.Credentials, + mapping, + resolve, + ) + if err != nil { + return fmt.Errorf( + "services.%s.credentials: %w", + name, + err, + ) + } + + metadata, err := expandMetadata( + svc.Metadata, + mapping, + resolve, + ) + if err != nil { + return fmt.Errorf( + "services.%s.metadata: %w", + name, + err, + ) + } + + connections = append(connections, Connection{ + Name: name, + Category: svc.Category, + Target: target, + AuthType: svc.AuthType, + Credentials: credentials, + Metadata: metadata, + }) + return nil + }, + ) + if err != nil { + return nil, err + } + slices.SortFunc(connections, func(a, b Connection) int { + return strings.Compare(a.Name, b.Name) + }) + return connections, 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 +// explicit false condition skips payload $ref resolution. +func visitEnabledConnectionServices( + services map[string]yaml.Node, + projectRoot string, + env map[string]string, + visit func(name string, node yaml.Node) error, +) error { + lookup := projectConditionLookup(env) for name, node := range services { - var matches bool - var err error - node, matches, err = serviceForHost( + skip, err := skipDisabledConnectionWithoutRef(node, lookup) + if err != nil { + return fmt.Errorf("services.%s.condition: %w", name, err) + } + if skip { + continue + } + + resolved, matches, err := serviceForHost( node, projectRoot, name, aiConnectionHost, ) if err != nil { - return nil, err + return err } if !matches { continue } - var svc connectionService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("services.%s: decode connection: %w", name, err) - } - declared := len(serviceEnvironments[name]) > 0 || connectionEnvDeclared(node) - mapping := connectionEnvironmentMapping( - env, - serviceEnvironments[name], - declared, - ) - target, err := maybeExpand(svc.Target, mapping, resolve) + enabled, err := serviceNodeEnabled(resolved, lookup) if err != nil { - return nil, fmt.Errorf("services.%s.target: %w", name, err) + return fmt.Errorf("services.%s.condition: %w", name, err) } - - credentials, err := expandCredentials( - svc.Credentials, - mapping, - resolve, - ) - if err != nil { - return nil, fmt.Errorf("services.%s.credentials: %w", name, err) + if !enabled { + continue } - - metadata, err := expandMetadata(svc.Metadata, mapping, resolve) - if err != nil { - return nil, fmt.Errorf("services.%s.metadata: %w", name, err) + if err := visit(name, resolved); err != nil { + return err } + } + return nil +} + +func skipDisabledConnectionWithoutRef( + node yaml.Node, + lookup func(string) string, +) (bool, error) { + var selector struct { + Host string `yaml:"host"` + Ref string `yaml:"$ref"` + } + if err := node.Decode(&selector); err != nil { + return false, nil + } + if selector.Host != aiConnectionHost { + return false, nil + } + value, present, err := serviceConditionValue(node) + if err != nil { + return false, err + } + if !present { + return false, nil + } + enabled, err := evaluateCondition(value, lookup) + if err != nil { + return false, err + } + return !enabled, nil +} - connections = append(connections, Connection{ - Name: name, - Category: svc.Category, - Target: target, - AuthType: svc.AuthType, - Credentials: credentials, - Metadata: metadata, - }) +func serviceNodeEnabled( + node yaml.Node, + lookup func(string) string, +) (bool, error) { + value, present, err := serviceConditionValue(node) + if err != nil { + return false, err + } + if !present { + return true, nil } + return evaluateCondition(value, lookup) +} - slices.SortFunc(connections, func(a, b Connection) int { - return strings.Compare(a.Name, b.Name) - }) - return connections, nil +func serviceConditionValue(node yaml.Node) (any, bool, error) { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return nil, false, nil + } + cond, ok := fields["condition"] + if !ok { + return nil, false, nil + } + var value any + if err := cond.Decode(&value); err != nil { + return nil, true, fmt.Errorf("decode condition: %w", err) + } + return value, true, nil +} + +func projectConditionLookup(env map[string]string) func(string) string { + return func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } } // connectionEnvDeclared reports whether the service node diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index d7802cefdf3..f2d07262c12 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.7 (Unreleased) + +### Bugs Fixed + +- Honor `condition` on `azure.ai.connection` services during Foundry project provisioning so disabled connections are not created or updated. + ## 1.0.0-beta.6 (2026-08-13) ### Features Added 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 d507dae7942..665ee9b9798 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 @@ -302,10 +302,13 @@ func (p *FoundryProvisioningProvider) Initialize( onDisk := p.onDiskTemplatePresent() if !onDisk { // Validate embedded config before any interactive prompts. + // 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, }) @@ -315,19 +318,6 @@ func (p *FoundryProvisioningProvider) Initialize( } } - p.connectionEnvironmentScopes, err = - synthesis.ConnectionEnvironmentScopes(rawYAML, projectRoot) - 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", - ) - } - // Resolve the environment before reading service values. azd core // expands ${VAR} in service env against the environment, so // reading them first would capture empty strings for values the @@ -342,6 +332,23 @@ func (p *FoundryProvisioningProvider) Initialize( return err } + 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", + ) + } + p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go new file mode 100644 index 00000000000..7ff64592c3a --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// evaluateCondition matches foundry.EvaluateCondition. Extensions +// pin a published azd module, so they cannot import new core +// helpers until that module is bumped. +func evaluateCondition( + value any, + getenv func(string) string, +) (bool, error) { + if value == nil { + return true, nil + } + + switch v := value.(type) { + case bool: + return v, nil + case string: + return evaluateConditionString(v, getenv) + case json.Number: + return evaluateConditionString(string(v), getenv) + case int: + return isTruthyCondition(strconv.Itoa(v)), nil + case int8: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int16: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int32: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int64: + return isTruthyCondition(strconv.FormatInt(v, 10)), nil + case uint: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint8: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint16: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint32: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint64: + return isTruthyCondition(strconv.FormatUint(v, 10)), nil + case float32: + return isTruthyCondition( + strconv.FormatFloat(float64(v), 'g', -1, 32), + ), nil + case float64: + return isTruthyCondition( + strconv.FormatFloat(v, 'g', -1, 64), + ), nil + default: + return false, fmt.Errorf( + "condition must be a string, boolean, or number", + ) + } +} + +func evaluateConditionString( + value string, + getenv func(string) string, +) (bool, error) { + if strings.TrimSpace(value) == "" { + return true, nil + } + if getenv == nil { + getenv = func(string) string { return "" } + } + expanded, err := foundry.ExpandEnv(value, getenv) + if err != nil { + return false, fmt.Errorf( + "malformed condition template: %w", + err, + ) + } + return isTruthyCondition(expanded), nil +} + +func isTruthyCondition(value string) bool { + switch value { + case "1", "true", "TRUE", "True", "yes", "YES", "Yes": + return true + default: + return false + } +} 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 67c4d536464..3d4518b3f96 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -327,11 +327,14 @@ func Synthesize(in Input) (*Result, error) { }, nil } -// ConnectionEnvironmentScopes returns services that declare env. -// An empty env block still establishes an isolated service scope. +// ConnectionEnvironmentScopes returns enabled connection services +// that declare env. An empty env block still establishes an +// isolated service scope. Disabled connections are omitted so +// on-disk Bicep does not treat them as managed inputs. func ConnectionEnvironmentScopes( raw []byte, projectRoot string, + env map[string]string, ) (map[string]bool, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") @@ -343,19 +346,19 @@ func ConnectionEnvironmentScopes( } scopes := map[string]bool{} - for name, node := range root.Services { - node, matches, err := serviceForHost( - node, - projectRoot, - name, - aiConnectionHost, - ) - if err != nil { - return nil, err - } - if matches && connectionEnvDeclared(node) { - scopes[name] = true - } + err := visitEnabledConnectionServices( + root.Services, + projectRoot, + env, + func(name string, node yaml.Node) error { + if connectionEnvDeclared(node) { + scopes[name] = true + } + return nil + }, + ) + if err != nil { + return nil, err } return scopes, nil } @@ -671,9 +674,11 @@ func agentNeedsAcr(a agentBlock) bool { return kind == "" || strings.EqualFold(kind, "hosted") } -// collectConnections scans all services for host: azure.ai.connection entries -// (the service key is the connection name) and returns them sorted by name so -// the synthesized parameter is deterministic regardless of YAML map order. +// collectConnections scans enabled host: azure.ai.connection services +// (the service key is the connection name) and returns them sorted by +// name so the synthesized parameter is deterministic regardless of +// YAML map order. Disabled services are omitted before payload +// expansion so their ${VAR} values cannot fail provision. // // Provisioning resolves ${VAR} from service env when present. // Legacy services use project and process values. @@ -686,66 +691,192 @@ func collectConnections( projectRoot string, ) ([]Connection, error) { connections := []Connection{} + err := visitEnabledConnectionServices( + services, + projectRoot, + env, + func(name string, node yaml.Node) error { + var svc connectionService + if err := node.Decode(&svc); err != nil { + return fmt.Errorf( + "services.%s: decode connection: %w", + name, + err, + ) + } + + declared := len(serviceEnvironments[name]) > 0 || + connectionEnvDeclared(node) + mapping := connectionEnvironmentMapping( + env, + serviceEnvironments[name], + declared, + ) + target, err := maybeExpand(svc.Target, mapping, resolve) + if err != nil { + return fmt.Errorf("services.%s.target: %w", name, err) + } + + credentials, err := expandCredentials( + svc.Credentials, + mapping, + resolve, + ) + if err != nil { + return fmt.Errorf( + "services.%s.credentials: %w", + name, + err, + ) + } + + metadata, err := expandMetadata( + svc.Metadata, + mapping, + resolve, + ) + if err != nil { + return fmt.Errorf( + "services.%s.metadata: %w", + name, + err, + ) + } + + connections = append(connections, Connection{ + Name: name, + Category: svc.Category, + Target: target, + AuthType: svc.AuthType, + Credentials: credentials, + Metadata: metadata, + }) + return nil + }, + ) + if err != nil { + return nil, err + } + slices.SortFunc(connections, func(a, b Connection) int { + return strings.Compare(a.Name, b.Name) + }) + return connections, 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 +// explicit false condition skips payload $ref resolution. +func visitEnabledConnectionServices( + services map[string]yaml.Node, + projectRoot string, + env map[string]string, + visit func(name string, node yaml.Node) error, +) error { + lookup := projectConditionLookup(env) for name, node := range services { - var matches bool - var err error - node, matches, err = serviceForHost( + skip, err := skipDisabledConnectionWithoutRef(node, lookup) + if err != nil { + return fmt.Errorf("services.%s.condition: %w", name, err) + } + if skip { + continue + } + + resolved, matches, err := serviceForHost( node, projectRoot, name, aiConnectionHost, ) if err != nil { - return nil, err + return err } if !matches { continue } - var svc connectionService - if err := node.Decode(&svc); err != nil { - return nil, fmt.Errorf("services.%s: decode connection: %w", name, err) - } - declared := len(serviceEnvironments[name]) > 0 || connectionEnvDeclared(node) - mapping := connectionEnvironmentMapping( - env, - serviceEnvironments[name], - declared, - ) - target, err := maybeExpand(svc.Target, mapping, resolve) + enabled, err := serviceNodeEnabled(resolved, lookup) if err != nil { - return nil, fmt.Errorf("services.%s.target: %w", name, err) + return fmt.Errorf("services.%s.condition: %w", name, err) } - - credentials, err := expandCredentials( - svc.Credentials, - mapping, - resolve, - ) - if err != nil { - return nil, fmt.Errorf("services.%s.credentials: %w", name, err) + if !enabled { + continue } - - metadata, err := expandMetadata(svc.Metadata, mapping, resolve) - if err != nil { - return nil, fmt.Errorf("services.%s.metadata: %w", name, err) + if err := visit(name, resolved); err != nil { + return err } + } + return nil +} + +func skipDisabledConnectionWithoutRef( + node yaml.Node, + lookup func(string) string, +) (bool, error) { + var selector struct { + Host string `yaml:"host"` + Ref string `yaml:"$ref"` + } + if err := node.Decode(&selector); err != nil { + return false, nil + } + if selector.Host != aiConnectionHost { + return false, nil + } + value, present, err := serviceConditionValue(node) + if err != nil { + return false, err + } + if !present { + return false, nil + } + enabled, err := evaluateCondition(value, lookup) + if err != nil { + return false, err + } + return !enabled, nil +} - connections = append(connections, Connection{ - Name: name, - Category: svc.Category, - Target: target, - AuthType: svc.AuthType, - Credentials: credentials, - Metadata: metadata, - }) +func serviceNodeEnabled( + node yaml.Node, + lookup func(string) string, +) (bool, error) { + value, present, err := serviceConditionValue(node) + if err != nil { + return false, err + } + if !present { + return true, nil } + return evaluateCondition(value, lookup) +} - slices.SortFunc(connections, func(a, b Connection) int { - return strings.Compare(a.Name, b.Name) - }) - return connections, nil +func serviceConditionValue(node yaml.Node) (any, bool, error) { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return nil, false, nil + } + cond, ok := fields["condition"] + if !ok { + return nil, false, nil + } + var value any + if err := cond.Decode(&value); err != nil { + return nil, true, fmt.Errorf("decode condition: %w", err) + } + return value, true, nil +} + +func projectConditionLookup(env map[string]string) func(string) string { + return func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } } // connectionEnvDeclared reports whether the service node 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 46d62e83583..82b4f1b2fb2 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 @@ -683,6 +683,7 @@ services: scopes, err := ConnectionEnvironmentScopes( []byte(scopesYAML), "", + nil, ) require.NoError(t, err) assert.Equal(t, map[string]bool{ @@ -754,6 +755,118 @@ services: keys := getKeys(t, c) assert.Equal(t, "", keys["x-api-key"]) }) + + t.Run("disabled condition is omitted from ARM params", func(t *testing.T) { + const conditionedYAML = ` +services: + my-project: + host: azure.ai.project + enabled-conn: + host: azure.ai.connection + category: ApiKey + target: https://enabled.example + disabled-conn: + host: azure.ai.connection + condition: false + category: ApiKey + target: ${MISSING_TARGET} + env: + KEY: ${MISSING_KEY} + env-gated: + host: azure.ai.connection + condition: ${ENABLE_CONNECTION} + category: RemoteTool + target: https://gated.example +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(conditionedYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{ + "ENABLE_CONNECTION": "false", + }, + }) + require.NoError(t, err) + names := resultConnectionNames(t, res) + assert.Equal(t, []string{"enabled-conn"}, names) + + scopes, err := ConnectionEnvironmentScopes( + []byte(conditionedYAML), + "", + map[string]string{"ENABLE_CONNECTION": "false"}, + ) + require.NoError(t, err) + assert.Empty(t, scopes) + }) + + t.Run("eject still evaluates condition", func(t *testing.T) { + const ejectYAML = ` +services: + my-project: + host: azure.ai.project + live-conn: + host: azure.ai.connection + condition: true + category: ApiKey + target: ${LIVE_URL} + skipped-conn: + host: azure.ai.connection + condition: false + category: ApiKey + target: ${SKIP_URL} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(ejectYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + PreserveVarRefs: true, + }) + require.NoError(t, err) + assert.Equal(t, []string{"live-conn"}, resultConnectionNames(t, res)) + c := getConn(t, res) + assert.Equal(t, "${LIVE_URL}", c.Target) + }) + + t.Run("disabled connection skips missing payload $ref", func(t *testing.T) { + const skippedRefYAML = ` +services: + my-project: + host: azure.ai.project + skipped-conn: + host: azure.ai.connection + condition: false + $ref: ./missing-connection.yaml +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(skippedRefYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: t.TempDir(), + }) + require.NoError(t, err) + assert.Empty(t, resultConnectionNames(t, res)) + }) + + t.Run("invalid condition fails synthesis", func(t *testing.T) { + const invalidYAML = ` +services: + my-project: + host: azure.ai.project + bad-conn: + host: azure.ai.connection + condition: + nested: true + category: ApiKey + target: https://example +` + _, err := Synthesize(Input{ + RawAzureYAML: []byte(invalidYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "condition") + }) } // TestBrownfieldConnections verifies connection services are collected for a @@ -831,6 +944,33 @@ services: _, err := BrownfieldConnections(nil, nil, nil, "") require.Error(t, err) }) + + t.Run("omits disabled connections", func(t *testing.T) { + const conditioned = ` +services: + my-project: + host: azure.ai.project + endpoint: https://existing.services.ai.azure.com/api/projects/p1 + live-conn: + host: azure.ai.connection + category: ApiKey + target: https://live.example + skipped-conn: + host: azure.ai.connection + condition: false + category: ApiKey + target: https://skipped.example +` + conns, err := BrownfieldConnections( + []byte(conditioned), + nil, + nil, + "", + ) + require.NoError(t, err) + require.Len(t, conns, 1) + assert.Equal(t, "live-conn", conns[0].Name) + }) } func TestBrownfieldDeployments(t *testing.T) { @@ -1074,6 +1214,16 @@ func resultConnections(t *testing.T, result *Result) []Connection { return JoinConnectionCredentials(connections, credentials) } +func resultConnectionNames(t *testing.T, result *Result) []string { + t.Helper() + connections := resultConnections(t, result) + names := make([]string, len(connections)) + for i, connection := range connections { + names[i] = connection.Name + } + return names +} + func TestBrownfieldServiceResolversResolveRefs(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile( diff --git a/cli/azd/pkg/foundry/condition.go b/cli/azd/pkg/foundry/condition.go new file mode 100644 index 00000000000..50b810bd61b --- /dev/null +++ b/cli/azd/pkg/foundry/condition.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package foundry + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// EvaluateCondition reports whether a service condition enables the +// service. A missing, null, or empty condition is enabled. String +// values expand with ExpandEnv so ${VAR} matches other Foundry +// fields. True values match +// pkg/project.ServiceConfig.IsEnabled: +// 1, true, TRUE, True, yes, YES, Yes. +func EvaluateCondition( + value any, + getenv func(string) string, +) (bool, error) { + if value == nil { + return true, nil + } + + switch v := value.(type) { + case bool: + return v, nil + case string: + return evaluateConditionString(v, getenv) + case json.Number: + return evaluateConditionString(string(v), getenv) + case int: + return isTruthyCondition(strconv.Itoa(v)), nil + case int8: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int16: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int32: + return isTruthyCondition(strconv.Itoa(int(v))), nil + case int64: + return isTruthyCondition(strconv.FormatInt(v, 10)), nil + case uint: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint8: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint16: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint32: + return isTruthyCondition(strconv.FormatUint(uint64(v), 10)), nil + case uint64: + return isTruthyCondition(strconv.FormatUint(v, 10)), nil + case float32: + return isTruthyCondition( + strconv.FormatFloat(float64(v), 'g', -1, 32), + ), nil + case float64: + return isTruthyCondition( + strconv.FormatFloat(v, 'g', -1, 64), + ), nil + default: + return false, fmt.Errorf( + "condition must be a string, boolean, or number", + ) + } +} + +func evaluateConditionString( + value string, + getenv func(string) string, +) (bool, error) { + if strings.TrimSpace(value) == "" { + return true, nil + } + if getenv == nil { + getenv = func(string) string { return "" } + } + expanded, err := ExpandEnv(value, getenv) + if err != nil { + return false, fmt.Errorf( + "malformed condition template: %w", + err, + ) + } + return isTruthyCondition(expanded), nil +} + +func isTruthyCondition(value string) bool { + switch value { + case "1", "true", "TRUE", "True", "yes", "YES", "Yes": + return true + default: + return false + } +} diff --git a/cli/azd/pkg/foundry/condition_test.go b/cli/azd/pkg/foundry/condition_test.go new file mode 100644 index 00000000000..4e252f54979 --- /dev/null +++ b/cli/azd/pkg/foundry/condition_test.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package foundry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluateCondition(t *testing.T) { + env := map[string]string{ + "ENABLE_CONNECTION": "true", + "DISABLE_CONNECTION": "false", + "ENABLE_YES": "YES", + } + lookup := func(name string) string { return env[name] } + + tests := []struct { + name string + value any + getenv func(string) string + want bool + wantErr string + }{ + {name: "nil is enabled", value: nil, want: true}, + {name: "empty string is enabled", value: "", want: true}, + {name: "whitespace is enabled", value: " ", want: true}, + {name: "bool true", value: true, want: true}, + {name: "bool false", value: false, want: false}, + {name: "literal 1", value: "1", want: true}, + {name: "literal true", value: "true", want: true}, + {name: "literal TRUE", value: "TRUE", want: true}, + {name: "literal True", value: "True", want: true}, + {name: "literal yes", value: "yes", want: true}, + {name: "literal YES", value: "YES", want: true}, + {name: "literal Yes", value: "Yes", want: true}, + {name: "literal false", value: "false", want: false}, + {name: "literal 0", value: "0", want: false}, + {name: "int 1", value: 1, want: true}, + {name: "int 0", value: 0, want: false}, + {name: "float 1", value: 1.0, want: true}, + { + name: "expanded true", + value: "${ENABLE_CONNECTION}", + getenv: lookup, + want: true, + }, + { + name: "expanded false", + value: "${DISABLE_CONNECTION}", + getenv: lookup, + want: false, + }, + { + name: "expanded YES", + value: "${ENABLE_YES}", + getenv: lookup, + want: true, + }, + { + name: "missing var is false", + value: "${MISSING}", + getenv: lookup, + want: false, + }, + { + name: "map is invalid", + value: map[string]any{"x": true}, + wantErr: "condition must be a string, boolean, or number", + }, + { + name: "list is invalid", + value: []any{"true"}, + wantErr: "condition must be a string, boolean, or number", + }, + { + name: "malformed template", + value: "${", + getenv: lookup, + wantErr: "malformed condition template", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EvaluateCondition(tt.value, tt.getenv) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}