diff --git a/cli/azd/cmd/util_test.go b/cli/azd/cmd/util_test.go index a0598dc6fc9..acbe13f309f 100644 --- a/cli/azd/cmd/util_test.go +++ b/cli/azd/cmd/util_test.go @@ -315,6 +315,10 @@ func (m *mockProjectManager) Initialize(ctx context.Context, projectConfig *proj return m.Called(ctx, projectConfig).Error(0) } +func (m *mockProjectManager) InitializeServices(ctx context.Context, services []*project.ServiceConfig) error { + return m.Called(ctx, services).Error(0) +} + func (m *mockProjectManager) InitializeFrameworks( ctx context.Context, projectConfig *project.ProjectConfig, ) ([]*project.ServiceConfig, []project.ServiceFrameworkInitFailure, error) { 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..c0ce1febc21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// evaluateCondition matches project.ServiceConfig.IsEnabled. +// Extensions pin a published azd module, so they cannot import +// newer core helpers until that module is bumped. +func evaluateCondition( + value string, + getenv func(string) string, +) (bool, error) { + if 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..9d7bbd9bb5f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -282,6 +282,7 @@ func Synthesize(in Input) (*Result, error) { root.Services, svc, in.ProjectRoot, + in.Env, ) if err != nil { return nil, err @@ -327,11 +328,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 +347,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 } @@ -604,14 +608,28 @@ func deriveIncludeAcr( services map[string]yaml.Node, svc projectService, projectRoot string, + env map[string]string, ) (bool, error) { if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { return true, nil } + lookup := projectConditionLookup(env) for serviceName, node := range services { + // A ref-only connection has no host to filter yet. + skip, err := skipDisabledConnectionWithoutRef(node, lookup) + if err != nil { + return false, fmt.Errorf( + "services.%s.condition: %w", + serviceName, + err, + ) + } + if skip { + continue + } + var matches bool - var err error node, matches, err = serviceForHost( node, projectRoot, @@ -671,9 +689,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 +706,227 @@ 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) + if err := validateConnectionCondition(node, resolved, name); err != nil { + return err + } + 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) + } + if !enabled { + continue + } + if err := visit(name, resolved); err != nil { + return err } + } + return nil +} - credentials, err := expandCredentials( - svc.Credentials, - mapping, - resolve, +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 != "" && selector.Host != aiConnectionHost { + return false, nil + } + if selector.Host == "" && selector.Ref == "" { + 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 +} + +func validateConnectionCondition( + rootNode yaml.Node, + resolvedNode yaml.Node, + serviceName string, +) error { + _, rootPresent, err := serviceConditionValue(rootNode) + if err != nil { + return err + } + if rootPresent { + return nil + } + + _, resolvedPresent, err := serviceConditionValue(resolvedNode) + if err != nil { + return err + } + if resolvedPresent { + return fmt.Errorf( + "services.%s: put condition beside host in azure.yaml; "+ + "referenced payloads must not define condition", + serviceName, ) - if err != nil { - return nil, fmt.Errorf("services.%s.credentials: %w", name, err) - } + } + return nil +} - metadata, err := expandMetadata(svc.Metadata, mapping, resolve) - if err != nil { - return nil, fmt.Errorf("services.%s.metadata: %w", name, err) - } +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) +} - connections = append(connections, Connection{ - Name: name, - Category: svc.Category, - Target: target, - AuthType: svc.AuthType, - Credentials: credentials, - Metadata: metadata, - }) +func serviceConditionValue(node yaml.Node) (string, bool, error) { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return "", false, nil + } + cond, ok := fields["condition"] + if !ok { + return "", false, nil + } + if cond.Kind != yaml.ScalarNode { + return "", true, fmt.Errorf("condition must be a scalar") + } + if cond.Tag == "!!null" { + return "", true, nil } + return cond.Value, true, nil +} - slices.SortFunc(connections, func(a, b Connection) int { - return strings.Compare(a.Name, b.Name) - }) - return connections, 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.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 5a725848dde..33d6b19da3e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -6,6 +6,7 @@ package synthesis import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "testing" @@ -664,6 +665,150 @@ services: }) } +func TestSynthesize_ConnectionConditions(t *testing.T) { + t.Run("whitespace condition disables connection", func(t *testing.T) { + const yaml = ` +services: + my-project: + host: azure.ai.project + whitespace-conn: + host: azure.ai.connection + condition: " " + target: ${MISSING_TARGET} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{}, + }) + require.NoError(t, err) + assert.Empty(t, resultConnections(t, res)) + }) + + t.Run("numeric condition preserves YAML text", func(t *testing.T) { + const yamlTemplate = ` +services: + my-project: + host: azure.ai.project + numeric-conn: + host: azure.ai.connection + condition: %s + target: https://example +` + for _, condition := range []string{"1.0", "01", "0x1"} { + t.Run(condition, func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: fmt.Appendf(nil, yamlTemplate, condition), + ServiceName: "my-project", + AcceptedHosts: []string{ + "azure.ai.project", + }, + }) + require.NoError(t, err) + assert.Empty(t, resultConnections(t, res)) + }) + } + }) + + t.Run("root false skips missing payload ref", func(t *testing.T) { + const yaml = ` +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(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: t.TempDir(), + }) + require.NoError(t, err) + assert.Empty(t, resultConnections(t, res)) + }) + + t.Run("ref-only false skips missing payload ref", func(t *testing.T) { + const yaml = ` +services: + my-project: + host: azure.ai.project + skipped-conn: + condition: false + $ref: ./missing-connection.yaml +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yaml), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: t.TempDir(), + }) + require.NoError(t, err) + assert.Empty(t, resultConnections(t, res)) + }) + + t.Run("root condition wins over payload condition", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`host: azure.ai.connection +condition: false +category: ApiKey +target: https://example +`), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + root-conditioned: + host: azure.ai.connection + condition: true + $ref: ./connection.yaml +`) + + res, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + require.NoError(t, err) + assert.Len(t, resultConnections(t, res), 1) + }) + + t.Run("ref-only condition returns configuration error", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`host: azure.ai.connection +condition: false +category: ApiKey +target: https://example +`), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + ref-only: + $ref: ./connection.yaml +`) + + _, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "put condition beside host in azure.yaml") + }) +} + func TestSynthesizeConnectionsAtRootResolvesFileRef(t *testing.T) { t.Parallel() 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..c0ce1febc21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// evaluateCondition matches project.ServiceConfig.IsEnabled. +// Extensions pin a published azd module, so they cannot import +// newer core helpers until that module is bumped. +func evaluateCondition( + value string, + getenv func(string) string, +) (bool, error) { + if 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..9d7bbd9bb5f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -282,6 +282,7 @@ func Synthesize(in Input) (*Result, error) { root.Services, svc, in.ProjectRoot, + in.Env, ) if err != nil { return nil, err @@ -327,11 +328,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 +347,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 } @@ -604,14 +608,28 @@ func deriveIncludeAcr( services map[string]yaml.Node, svc projectService, projectRoot string, + env map[string]string, ) (bool, error) { if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { return true, nil } + lookup := projectConditionLookup(env) for serviceName, node := range services { + // A ref-only connection has no host to filter yet. + skip, err := skipDisabledConnectionWithoutRef(node, lookup) + if err != nil { + return false, fmt.Errorf( + "services.%s.condition: %w", + serviceName, + err, + ) + } + if skip { + continue + } + var matches bool - var err error node, matches, err = serviceForHost( node, projectRoot, @@ -671,9 +689,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 +706,227 @@ 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) + if err := validateConnectionCondition(node, resolved, name); err != nil { + return err + } + 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) + } + if !enabled { + continue + } + if err := visit(name, resolved); err != nil { + return err } + } + return nil +} - credentials, err := expandCredentials( - svc.Credentials, - mapping, - resolve, +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 != "" && selector.Host != aiConnectionHost { + return false, nil + } + if selector.Host == "" && selector.Ref == "" { + 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 +} + +func validateConnectionCondition( + rootNode yaml.Node, + resolvedNode yaml.Node, + serviceName string, +) error { + _, rootPresent, err := serviceConditionValue(rootNode) + if err != nil { + return err + } + if rootPresent { + return nil + } + + _, resolvedPresent, err := serviceConditionValue(resolvedNode) + if err != nil { + return err + } + if resolvedPresent { + return fmt.Errorf( + "services.%s: put condition beside host in azure.yaml; "+ + "referenced payloads must not define condition", + serviceName, ) - if err != nil { - return nil, fmt.Errorf("services.%s.credentials: %w", name, err) - } + } + return nil +} - metadata, err := expandMetadata(svc.Metadata, mapping, resolve) - if err != nil { - return nil, fmt.Errorf("services.%s.metadata: %w", name, err) - } +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) +} - connections = append(connections, Connection{ - Name: name, - Category: svc.Category, - Target: target, - AuthType: svc.AuthType, - Credentials: credentials, - Metadata: metadata, - }) +func serviceConditionValue(node yaml.Node) (string, bool, error) { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return "", false, nil + } + cond, ok := fields["condition"] + if !ok { + return "", false, nil + } + if cond.Kind != yaml.ScalarNode { + return "", true, fmt.Errorf("condition must be a scalar") + } + if cond.Tag == "!!null" { + return "", true, nil } + return cond.Value, true, nil +} - slices.SortFunc(connections, func(a, b Connection) int { - return strings.Compare(a.Name, b.Name) - }) - return connections, 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..5178344f65b 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,240 @@ 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("ref-only disabled connection skips missing payload $ref", func(t *testing.T) { + const skippedRefYAML = ` +services: + my-project: + host: azure.ai.project + skipped-conn: + 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("whitespace condition disables connection", func(t *testing.T) { + const whitespaceYAML = ` +services: + my-project: + host: azure.ai.project + whitespace-conn: + host: azure.ai.connection + condition: " " + target: ${MISSING_TARGET} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(whitespaceYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{}, + }) + require.NoError(t, err) + assert.Empty(t, resultConnectionNames(t, res)) + }) + + t.Run("numeric condition preserves YAML text", func(t *testing.T) { + const yamlTemplate = ` +services: + my-project: + host: azure.ai.project + numeric-conn: + host: azure.ai.connection + condition: %s + target: https://example +` + for _, condition := range []string{"1.0", "01", "0x1"} { + t.Run(condition, func(t *testing.T) { + res, err := Synthesize(Input{ + RawAzureYAML: fmt.Appendf(nil, yamlTemplate, condition), + ServiceName: "my-project", + AcceptedHosts: []string{ + "azure.ai.project", + }, + }) + require.NoError(t, err) + assert.Empty(t, resultConnectionNames(t, res)) + }) + } + }) + + t.Run("root condition wins over payload condition", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`host: azure.ai.connection +condition: false +category: ApiKey +target: https://example +`), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + root-conditioned: + host: azure.ai.connection + condition: true + $ref: ./connection.yaml +`) + + res, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + require.NoError(t, err) + assert.Equal(t, []string{"root-conditioned"}, resultConnectionNames(t, res)) + }) + + t.Run("ref-only condition returns configuration error", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`host: azure.ai.connection +condition: false +category: ApiKey +target: https://example +`), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + ref-only: + $ref: ./connection.yaml +`) + + _, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "put condition beside host in azure.yaml") + }) + + 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 +1066,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 +1336,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/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index 83272f1b309..dd2f287f499 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -249,6 +249,11 @@ func (m *mockDeployProjectManager) Initialize(ctx context.Context, projectConfig return args.Error(0) } +func (m *mockDeployProjectManager) InitializeServices(ctx context.Context, services []*project.ServiceConfig) error { + args := m.Called(services) + return args.Error(0) +} + func (m *mockDeployProjectManager) InitializeFrameworks( ctx context.Context, projectConfig *project.ProjectConfig, ) ([]*project.ServiceConfig, []project.ServiceFrameworkInitFailure, error) { diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index 8421790455a..abe906197ba 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -230,14 +230,6 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error startTime := time.Now() - if err := p.projectManager.Initialize(ctx, p.projectConfig); err != nil { - return nil, err - } - - if err := p.projectManager.EnsureAllTools(ctx, p.projectConfig, nil); err != nil { - return nil, err - } - // Apply --subscription and --location flags to the environment before provisioning envChanged := false if p.flags.subscription != "" { @@ -270,6 +262,19 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error } } + services, err := p.importManager.ServiceStableFiltered(ctx, p.projectConfig, "", p.env.Getenv) + if err != nil { + return nil, err + } + + if err := p.projectManager.InitializeServices(ctx, services); err != nil { + return nil, err + } + + if err := p.projectManager.EnsureAllTools(ctx, p.projectConfig, selectedServiceFilter(services)); err != nil { + return nil, err + } + infra, err := p.importManager.ProjectInfrastructure(ctx, p.projectConfig) if err != nil { return nil, err @@ -311,6 +316,18 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error return p.provisionLayersGraph(ctx, layers, startTime, previewMode) } +func selectedServiceFilter(services []*project.ServiceConfig) project.ServiceFilterPredicate { + serviceNames := make(map[string]struct{}, len(services)) + for _, service := range services { + serviceNames[service.Name] = struct{}{} + } + + return func(service *project.ServiceConfig) bool { + _, ok := serviceNames[service.Name] + return ok + } +} + // deployResultToUx creates the ux element to display from a provision preview func deployResultToUx(previewResult *provisioning.DeployPreviewResult) ux.UxItem { var operations []*ux.Resource diff --git a/cli/azd/internal/cmd/provision_test.go b/cli/azd/internal/cmd/provision_test.go index 6e2b1c9d695..590406521f6 100644 --- a/cli/azd/internal/cmd/provision_test.go +++ b/cli/azd/internal/cmd/provision_test.go @@ -18,6 +18,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -29,12 +30,20 @@ import ( // mockProjectManager implements project.ProjectManager for testing. type mockProjectManager struct { mock.Mock + + initializedServices []*project.ServiceConfig + ensureAllToolsFilter project.ServiceFilterPredicate } func (m *mockProjectManager) Initialize(ctx context.Context, projectConfig *project.ProjectConfig) error { return m.Called(ctx, projectConfig).Error(0) } +func (m *mockProjectManager) InitializeServices(ctx context.Context, services []*project.ServiceConfig) error { + m.initializedServices = services + return m.Called(ctx, services).Error(0) +} + func (m *mockProjectManager) InitializeFrameworks( ctx context.Context, projectConfig *project.ProjectConfig, ) ([]*project.ServiceConfig, []project.ServiceFrameworkInitFailure, error) { @@ -45,8 +54,9 @@ func (m *mockProjectManager) InitializeFrameworks( } func (m *mockProjectManager) EnsureAllTools( - ctx context.Context, projectConfig *project.ProjectConfig, _ project.ServiceFilterPredicate, + ctx context.Context, projectConfig *project.ProjectConfig, filter project.ServiceFilterPredicate, ) error { + m.ensureAllToolsFilter = filter return m.Called(ctx, projectConfig).Error(0) } @@ -155,7 +165,7 @@ func TestProvisionAction_ProvisionValidationCanceled(t *testing.T) { ) pm := &mockProjectManager{} - pm.On("Initialize", mock.Anything, mock.Anything).Return(nil) + pm.On("InitializeServices", mock.Anything, mock.Anything).Return(nil) pm.On("EnsureAllTools", mock.Anything, mock.Anything).Return(nil) projectConfig := &project.ProjectConfig{ @@ -167,6 +177,18 @@ func TestProvisionAction_ProvisionValidationCanceled(t *testing.T) { Module: "main", }, } + connection := &project.ServiceConfig{ + Name: "connection", + Host: project.ServiceTargetKind("azure.ai.connection"), + Project: projectConfig, + Condition: osutil.NewExpandableString("false"), + AdditionalProperties: map[string]any{ + "$ref": "missing-connection.yaml", + }, + } + projectConfig.Services = map[string]*project.ServiceConfig{ + connection.Name: connection, + } projectConfig.EventDispatcher = ext.NewEventDispatcher[project.ProjectLifecycleEventArgs]( project.ProjectEvents..., ) @@ -197,4 +219,7 @@ func TestProvisionAction_ProvisionValidationCanceled(t *testing.T) { // Verify project manager was called (action didn't exit prematurely) pm.AssertExpectations(t) + require.Empty(t, pm.initializedServices) + require.NotNil(t, pm.ensureAllToolsFilter) + require.False(t, pm.ensureAllToolsFilter(connection)) } diff --git a/cli/azd/internal/cmd/up_graph.go b/cli/azd/internal/cmd/up_graph.go index 0fba333c340..7698909c80d 100644 --- a/cli/azd/internal/cmd/up_graph.go +++ b/cli/azd/internal/cmd/up_graph.go @@ -808,12 +808,12 @@ func (u *UpGraphAction) initializeServices(ctx context.Context) ([]*project.Serv return nil, fmt.Errorf("enumerating services: %w", err) } - if err := u.projectManager.Initialize(ctx, u.projectConfig); err != nil { + if err := u.projectManager.InitializeServices(ctx, stableServices); err != nil { return nil, fmt.Errorf("initializing project: %w", err) } if err := u.projectManager.EnsureServiceTargetTools( - ctx, u.projectConfig, func(_ *project.ServiceConfig) bool { return true }, + ctx, u.projectConfig, selectedServiceFilter(stableServices), ); err != nil { return nil, fmt.Errorf("ensuring service tools: %w", err) } diff --git a/cli/azd/pkg/project/project_manager.go b/cli/azd/pkg/project/project_manager.go index 8cf845543b5..eee2a147be3 100644 --- a/cli/azd/pkg/project/project_manager.go +++ b/cli/azd/pkg/project/project_manager.go @@ -47,6 +47,9 @@ type ProjectManager interface { // handlers to participate in the lifecycle of an azd project Initialize(ctx context.Context, projectConfig *ProjectConfig) error + // InitializeServices initializes the supplied services. + InitializeServices(ctx context.Context, services []*ServiceConfig) error + // InitializeFrameworks initializes only the framework service for each service in the project, // best-effort: unlike Initialize, it never resolves service targets, and a per-service failure // is skipped rather than fatal. It exists for read-only flows such as `env refresh`, which @@ -118,14 +121,18 @@ func (pm *projectManager) Initialize(ctx context.Context, projectConfig *Project return err } - serviceTargets := make([]string, 0, len(servicesStable)) - for _, svc := range servicesStable { + return pm.InitializeServices(ctx, servicesStable) +} + +func (pm *projectManager) InitializeServices(ctx context.Context, services []*ServiceConfig) error { + serviceTargets := make([]string, 0, len(services)) + for _, svc := range services { serviceTargets = append(serviceTargets, string(svc.Host)) } tracing.SetUsageAttributes(fields.ProjectServiceTargetsKey.StringSlice(serviceTargets)) - for _, svc := range servicesStable { + for _, svc := range services { if err := pm.serviceManager.Initialize(ctx, svc); err != nil { return fmt.Errorf("initializing service '%s', %w", svc.Name, err) } diff --git a/cli/azd/pkg/project/project_manager_test.go b/cli/azd/pkg/project/project_manager_test.go index 33fd6bba68d..7e23fe3e835 100644 --- a/cli/azd/pkg/project/project_manager_test.go +++ b/cli/azd/pkg/project/project_manager_test.go @@ -17,6 +17,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -140,6 +141,7 @@ type fakeServiceManager struct { initErr error initFrameworkErr error initFrameworkErrForService map[string]error + initializedServices []string } func (f *fakeServiceManager) GetRequiredTools( @@ -149,6 +151,7 @@ func (f *fakeServiceManager) GetRequiredTools( } func (f *fakeServiceManager) Initialize(ctx context.Context, sc *ServiceConfig) error { + f.initializedServices = append(f.initializedServices, sc.Name) return f.initErr } @@ -304,6 +307,56 @@ func Test_projectManager_Initialize(t *testing.T) { }) } +func Test_projectManager_InitializeServices_RespectsRootConditions(t *testing.T) { + tests := []struct { + name string + condition string + wantInit bool + }{ + {name: "RootFalse", condition: "false"}, + {name: "Whitespace", condition: " "}, + {name: "RootAbsent", wantInit: true}, + {name: "RootTrue", condition: "true", wantInit: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + projectConfig := &ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*ServiceConfig{}, + } + serviceConfig := &ServiceConfig{ + Name: "connection", + Host: ServiceTargetKind("azure.ai.connection"), + Project: projectConfig, + Condition: osutil.NewExpandableString(tt.condition), + AdditionalProperties: map[string]any{ + "$ref": "missing-connection.yaml", + }, + } + projectConfig.Services[serviceConfig.Name] = serviceConfig + + serviceManager := &fakeServiceManager{frameworkSvc: &noOpProject{}} + projectManager := &projectManager{ + importManager: NewImportManager(nil), + serviceManager: serviceManager, + } + + services, err := projectManager.importManager.ServiceStableFiltered( + t.Context(), projectConfig, "", func(string) string { return "" }, + ) + require.NoError(t, err) + require.NoError(t, projectManager.InitializeServices(t.Context(), services)) + + if tt.wantInit { + assert.Equal(t, []string{"connection"}, serviceManager.initializedServices) + } else { + assert.Empty(t, serviceManager.initializedServices) + } + }) + } +} + func Test_projectManager_InitializeFrameworks(t *testing.T) { newProject := func(dir string) *ProjectConfig { prj := &ProjectConfig{Path: dir, Services: map[string]*ServiceConfig{}}