From d0c9293b4484becd72744d5b7418015bca3f1d2f Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 13:18:00 +0800 Subject: [PATCH 1/9] fix: align connection condition semantics --- .../internal/synthesis/condition.go | 94 ++++++ .../internal/synthesis/synthesizer.go | 278 ++++++++++++++---- .../internal/synthesis/synthesizer_test.go | 100 +++++++ .../extensions/azure.ai.projects/CHANGELOG.md | 6 + .../foundry_provisioning_provider.go | 33 ++- .../internal/synthesis/condition.go | 94 ++++++ .../internal/synthesis/synthesizer.go | 278 ++++++++++++++---- .../internal/synthesis/synthesizer_test.go | 228 ++++++++++++++ cli/azd/pkg/foundry/condition.go | 95 ++++++ cli/azd/pkg/foundry/condition_test.go | 99 +++++++ 10 files changed, 1178 insertions(+), 127 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go create mode 100644 cli/azd/pkg/foundry/condition.go create mode 100644 cli/azd/pkg/foundry/condition_test.go 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..c5b8a61f371 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "encoding/json" + "fmt" + "strconv" + + "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 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..04e319707e2 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,225 @@ 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) (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 +} - 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..bbf1f326063 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 @@ -664,6 +664,106 @@ 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("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("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/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..c5b8a61f371 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "encoding/json" + "fmt" + "strconv" + + "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 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..04e319707e2 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,225 @@ 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) (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 +} - 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..ebd9c8f73b9 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,196 @@ 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("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("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 +1022,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 +1292,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..0f8348746e2 --- /dev/null +++ b/cli/azd/pkg/foundry/condition.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package foundry + +import ( + "encoding/json" + "fmt" + "strconv" +) + +// EvaluateCondition reports whether a service condition enables the +// service. A missing, null, or exactly 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 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..8028b1cbd9b --- /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 disabled", value: " ", want: false}, + {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) + }) + } +} From a00c1c77281d2db02c2d8487767ebf772b35544f Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 15:04:03 +0800 Subject: [PATCH 2/9] fix: skip disabled connection refs during ACR derivation --- .../internal/synthesis/synthesizer.go | 17 ++++++++++++++++- .../internal/synthesis/synthesizer_test.go | 19 +++++++++++++++++++ .../internal/synthesis/synthesizer.go | 17 ++++++++++++++++- .../internal/synthesis/synthesizer_test.go | 19 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) 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 04e319707e2..7aaa1e1f41d 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 @@ -607,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, 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 bbf1f326063..6edbf0107b6 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 @@ -705,6 +705,25 @@ services: 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( 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 04e319707e2..7aaa1e1f41d 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 @@ -607,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, 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 ebd9c8f73b9..64983a4f7b0 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 @@ -847,6 +847,25 @@ services: 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: From 5731db3d86940a0f423654037e291edb05284840 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 16:58:37 +0800 Subject: [PATCH 3/9] fix: cover all condition value forms --- cli/azd/pkg/foundry/condition_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cli/azd/pkg/foundry/condition_test.go b/cli/azd/pkg/foundry/condition_test.go index 8028b1cbd9b..bbacfb073a9 100644 --- a/cli/azd/pkg/foundry/condition_test.go +++ b/cli/azd/pkg/foundry/condition_test.go @@ -4,6 +4,7 @@ package foundry import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -41,6 +42,17 @@ func TestEvaluateCondition(t *testing.T) { {name: "literal 0", value: "0", want: false}, {name: "int 1", value: 1, want: true}, {name: "int 0", value: 0, want: false}, + {name: "json number 1", value: json.Number("1"), want: true}, + {name: "int8 1", value: int8(1), want: true}, + {name: "int16 1", value: int16(1), want: true}, + {name: "int32 1", value: int32(1), want: true}, + {name: "int64 1", value: int64(1), want: true}, + {name: "uint 1", value: uint(1), want: true}, + {name: "uint8 1", value: uint8(1), want: true}, + {name: "uint16 1", value: uint16(1), want: true}, + {name: "uint32 1", value: uint32(1), want: true}, + {name: "uint64 1", value: uint64(1), want: true}, + {name: "float32 1", value: float32(1), want: true}, {name: "float 1", value: 1.0, want: true}, { name: "expanded true", @@ -66,6 +78,11 @@ func TestEvaluateCondition(t *testing.T) { getenv: lookup, want: false, }, + { + name: "nil environment lookup", + value: "${MISSING}", + want: false, + }, { name: "map is invalid", value: map[string]any{"x": true}, From fce83f87c19f3fd2c394247a37f1241e9af9a630 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 17:16:44 +0800 Subject: [PATCH 4/9] fix: keep condition support in extensions --- cli/azd/pkg/foundry/condition.go | 95 --------------------- cli/azd/pkg/foundry/condition_test.go | 116 -------------------------- 2 files changed, 211 deletions(-) delete mode 100644 cli/azd/pkg/foundry/condition.go delete mode 100644 cli/azd/pkg/foundry/condition_test.go diff --git a/cli/azd/pkg/foundry/condition.go b/cli/azd/pkg/foundry/condition.go deleted file mode 100644 index 0f8348746e2..00000000000 --- a/cli/azd/pkg/foundry/condition.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package foundry - -import ( - "encoding/json" - "fmt" - "strconv" -) - -// EvaluateCondition reports whether a service condition enables the -// service. A missing, null, or exactly 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 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 deleted file mode 100644 index bbacfb073a9..00000000000 --- a/cli/azd/pkg/foundry/condition_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package foundry - -import ( - "encoding/json" - "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 disabled", value: " ", want: false}, - {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: "json number 1", value: json.Number("1"), want: true}, - {name: "int8 1", value: int8(1), want: true}, - {name: "int16 1", value: int16(1), want: true}, - {name: "int32 1", value: int32(1), want: true}, - {name: "int64 1", value: int64(1), want: true}, - {name: "uint 1", value: uint(1), want: true}, - {name: "uint8 1", value: uint8(1), want: true}, - {name: "uint16 1", value: uint16(1), want: true}, - {name: "uint32 1", value: uint32(1), want: true}, - {name: "uint64 1", value: uint64(1), want: true}, - {name: "float32 1", value: float32(1), want: true}, - {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: "nil environment lookup", - value: "${MISSING}", - 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) - }) - } -} From c62814234ecbcfd7c6a81b77f58669868ccc754a Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 20:28:25 +0800 Subject: [PATCH 5/9] fix: remove extension changelog entry --- cli/azd/extensions/azure.ai.projects/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index f2d07262c12..d7802cefdf3 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,11 +1,5 @@ # 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 From d5970abcd464f0c442015bd739f78f112be7647a Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 21:17:55 +0800 Subject: [PATCH 6/9] fix: clarify local condition compatibility --- .../azure.ai.agents/internal/synthesis/condition.go | 6 +++--- .../azure.ai.projects/internal/synthesis/condition.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go index c5b8a61f371..d65ac79388e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go @@ -11,9 +11,9 @@ import ( "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. +// 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 any, getenv func(string) string, diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go index c5b8a61f371..d65ac79388e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go @@ -11,9 +11,9 @@ import ( "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. +// 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 any, getenv func(string) string, From 0c4db8168888076da412b7f92cc92ba7f2a188d7 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 22:49:47 +0800 Subject: [PATCH 7/9] fix: skip disabled services before provisioning --- cli/azd/cmd/util_test.go | 4 ++ cli/azd/internal/cmd/deploy_test.go | 5 ++ cli/azd/internal/cmd/provision.go | 33 +++++++++---- cli/azd/internal/cmd/provision_test.go | 29 ++++++++++- cli/azd/internal/cmd/up_graph.go | 4 +- cli/azd/pkg/project/project_manager.go | 13 +++-- cli/azd/pkg/project/project_manager_test.go | 53 +++++++++++++++++++++ 7 files changed, 126 insertions(+), 15 deletions(-) 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/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{}} From 32cb21d3d7557cae2645b0b22050d62305bcc2dd Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 23:15:59 +0800 Subject: [PATCH 8/9] fix: preserve condition scalar text --- .../internal/synthesis/condition.go | 52 ------------------- .../internal/synthesis/synthesizer.go | 16 +++--- .../internal/synthesis/synthesizer_test.go | 26 ++++++++++ .../internal/synthesis/condition.go | 52 ------------------- .../internal/synthesis/synthesizer.go | 16 +++--- .../internal/synthesis/synthesizer_test.go | 25 +++++++++ 6 files changed, 69 insertions(+), 118 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go index d65ac79388e..c0ce1febc21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go @@ -4,9 +4,7 @@ package synthesis import ( - "encoding/json" "fmt" - "strconv" "github.com/azure/azure-dev/cli/azd/pkg/foundry" ) @@ -15,56 +13,6 @@ import ( // Extensions pin a published azd module, so they cannot import // newer 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) { 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 7aaa1e1f41d..9d7bbd9bb5f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -901,20 +901,22 @@ func serviceNodeEnabled( return evaluateCondition(value, lookup) } -func serviceConditionValue(node yaml.Node) (any, bool, error) { +func serviceConditionValue(node yaml.Node) (string, bool, error) { var fields map[string]yaml.Node if err := node.Decode(&fields); err != nil { - return nil, false, nil + return "", false, nil } cond, ok := fields["condition"] if !ok { - return nil, false, nil + return "", false, nil } - var value any - if err := cond.Decode(&value); err != nil { - return nil, true, fmt.Errorf("decode condition: %w", err) + if cond.Kind != yaml.ScalarNode { + return "", true, fmt.Errorf("condition must be a scalar") } - return value, true, nil + if cond.Tag == "!!null" { + return "", true, nil + } + return cond.Value, true, nil } func projectConditionLookup(env map[string]string) func(string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 6edbf0107b6..62827e62c50 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" @@ -685,6 +686,31 @@ services: 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: []byte(fmt.Sprintf(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: diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go index d65ac79388e..c0ce1febc21 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go @@ -4,9 +4,7 @@ package synthesis import ( - "encoding/json" "fmt" - "strconv" "github.com/azure/azure-dev/cli/azd/pkg/foundry" ) @@ -15,56 +13,6 @@ import ( // Extensions pin a published azd module, so they cannot import // newer 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) { 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 7aaa1e1f41d..9d7bbd9bb5f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -901,20 +901,22 @@ func serviceNodeEnabled( return evaluateCondition(value, lookup) } -func serviceConditionValue(node yaml.Node) (any, bool, error) { +func serviceConditionValue(node yaml.Node) (string, bool, error) { var fields map[string]yaml.Node if err := node.Decode(&fields); err != nil { - return nil, false, nil + return "", false, nil } cond, ok := fields["condition"] if !ok { - return nil, false, nil + return "", false, nil } - var value any - if err := cond.Decode(&value); err != nil { - return nil, true, fmt.Errorf("decode condition: %w", err) + if cond.Kind != yaml.ScalarNode { + return "", true, fmt.Errorf("condition must be a scalar") } - return value, true, nil + if cond.Tag == "!!null" { + return "", true, nil + } + return cond.Value, true, nil } func projectConditionLookup(env map[string]string) func(string) string { diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 64983a4f7b0..40007ff03aa 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 @@ -886,6 +886,31 @@ services: 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: []byte(fmt.Sprintf(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( From c58231e8a55299a9c3264afb60ca6be255d6a9f7 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 21 Aug 2026 23:26:33 +0800 Subject: [PATCH 9/9] fix: modernize condition regression tests --- .../azure.ai.agents/internal/synthesis/synthesizer_test.go | 2 +- .../azure.ai.projects/internal/synthesis/synthesizer_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 62827e62c50..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 @@ -699,7 +699,7 @@ services: for _, condition := range []string{"1.0", "01", "0x1"} { t.Run(condition, func(t *testing.T) { res, err := Synthesize(Input{ - RawAzureYAML: []byte(fmt.Sprintf(yamlTemplate, condition)), + RawAzureYAML: fmt.Appendf(nil, yamlTemplate, condition), ServiceName: "my-project", AcceptedHosts: []string{ "azure.ai.project", 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 40007ff03aa..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 @@ -899,7 +899,7 @@ services: for _, condition := range []string{"1.0", "01", "0x1"} { t.Run(condition, func(t *testing.T) { res, err := Synthesize(Input{ - RawAzureYAML: []byte(fmt.Sprintf(yamlTemplate, condition)), + RawAzureYAML: fmt.Appendf(nil, yamlTemplate, condition), ServiceName: "my-project", AcceptedHosts: []string{ "azure.ai.project",