From a4eb5769abb2e25148e3bbf25bf842369dea4f26 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 10 Jul 2026 18:35:00 +0800 Subject: [PATCH 01/12] fix: use core env in Foundry service targets --- .../extensions/azure.ai.agents/extension.yaml | 2 +- cli/azd/extensions/azure.ai.agents/go.mod | 7 +- cli/azd/extensions/azure.ai.agents/go.sum | 2 + .../azure.ai.agents/internal/cmd/init.go | 9 ++ .../internal/cmd/init_from_code.go | 13 +- .../azure.ai.agents/internal/cmd/init_test.go | 10 ++ .../internal/cmd/resource_services.go | 95 +++++++++++++ .../internal/cmd/resource_services_test.go | 63 +++++++++ .../internal/project/agent_definition.go | 133 +++++++++++------- .../internal/project/agent_definition_test.go | 50 ++++++- .../internal/project/config.go | 1 - .../internal/project/config_test.go | 5 - .../schemas/azure.ai.agent.json | 7 - .../schemas/examples/complex.azure.yaml | 6 +- .../schemas/examples/simple.azure.yaml | 2 + .../azure.ai.connections/extension.yaml | 2 +- .../extensions/azure.ai.connections/go.mod | 9 +- .../extensions/azure.ai.connections/go.sum | 2 + .../internal/cmd/service_target.go | 50 ++++--- .../internal/cmd/service_target_test.go | 24 +++- .../schemas/azure.ai.connection.json | 4 +- .../azure.ai.routines/extension.yaml | 2 +- cli/azd/extensions/azure.ai.routines/go.mod | 7 +- cli/azd/extensions/azure.ai.routines/go.sum | 2 + .../internal/cmd/service_target.go | 54 ++++--- .../internal/cmd/service_target_test.go | 22 +++ .../schemas/azure.ai.routine.json | 2 +- .../azure.ai.toolboxes/extension.yaml | 2 +- cli/azd/extensions/azure.ai.toolboxes/go.mod | 9 +- cli/azd/extensions/azure.ai.toolboxes/go.sum | 2 + .../internal/cmd/service_target.go | 65 ++++++--- .../internal/cmd/service_target_test.go | 16 ++- .../schemas/azure.ai.toolbox.json | 2 +- .../microsoft.foundry/extension.yaml | 2 +- 34 files changed, 505 insertions(+), 178 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 5d7f1976c29..36c9664c952 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -6,7 +6,7 @@ description: Ship agents with Microsoft Foundry from your terminal. (Beta) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. version: 1.0.0-beta.5 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.inspector version: "~1.0.0-beta.1" diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 7723add84c6..a1fc7a392f5 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -2,11 +2,6 @@ module azureaiagent go 1.26.4 -// TEMPORARY: local validation against the in-tree azd core (proto change for -// provisioning preview changes). Remove before merging — the core change must -// land first, then bump the azd dependency. -replace github.com/azure/azure-dev/cli/azd => ../../ - require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 @@ -17,7 +12,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 - github.com/azure/azure-dev/cli/azd v1.24.3 + github.com/azure/azure-dev/cli/azd v1.27.1 github.com/braydonk/yaml v0.9.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index cbe8b2fbaaa..b8bb9d809cb 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -61,6 +61,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.27.1 h1:0SrTTBDSG9huHfgt6m89EminAErKtpmOi9gQ+b/T5aw= +github.com/azure/azure-dev/cli/azd v1.27.1/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 158474be4ee..51def1c2cee 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2857,6 +2857,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if err != nil { return err } + agentEnvironment := project.AgentEnvironment(containerDef) serviceConfig := &azdext.ServiceConfig{ Name: a.serviceNameOverride, @@ -2890,6 +2891,14 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { return fmt.Errorf("adding agent service to project: %w", err) } + if err := setServiceEnvironment( + ctx, + a.azdClient, + a.serviceNameOverride, + agentEnvironment, + ); err != nil { + return err + } // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 7eb3264bd54..8d6089c8798 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -832,6 +832,7 @@ func (a *InitFromCodeAction) addToProject( if err != nil { return err } + agentEnvironment := project.AgentEnvironment(*definition) language := "python" if !isCodeDeploy { @@ -841,8 +842,9 @@ func (a *InitFromCodeAction) addToProject( language = "csharp" } + agentServiceName := strings.ReplaceAll(agentName, " ", "") serviceConfig := &azdext.ServiceConfig{ - Name: strings.ReplaceAll(agentName, " ", ""), + Name: agentServiceName, RelativePath: targetDir, Host: AiAgentHost, Language: language, @@ -869,11 +871,18 @@ func (a *InitFromCodeAction) addToProject( if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { return fmt.Errorf("adding agent service to project: %w", err) } + if err := setServiceEnvironment( + ctx, + a.azdClient, + agentServiceName, + agentEnvironment, + ); err != nil { + return err + } // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. - agentServiceName := strings.ReplaceAll(agentName, " ", "") if err := emitResourceServices( ctx, a.azdClient, agentServiceName, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 2d865a57ee1..920b1532f64 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -364,6 +364,9 @@ func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { Protocols: []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + {Name: "LOG_LEVEL", Value: "info"}, + }, }, } @@ -387,9 +390,16 @@ func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { require.Equal(t, "docker", agentService.GetLanguage()) require.NotNil(t, agentService.GetDocker()) require.NotNil(t, agentService.GetAdditionalProperties()) + require.Empty(t, agentService.GetEnvironment()) + require.Equal(t, map[string]any{ + "LOG_LEVEL": "info", + }, server.env["my-agent"]) _, hasInlineImage := agentService.GetAdditionalProperties().GetFields()["image"] require.False(t, hasInlineImage, "pre-built image must ride on the top-level service image field") + _, hasInlineEnvironment := agentService.GetAdditionalProperties(). + GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) } func TestValidateInitAgentName(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index bce529919dd..34b220eb497 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "regexp" "slices" "strings" @@ -35,6 +36,10 @@ const ( aiProjectServiceName = "ai-project" ) +var envReferencePattern = regexp.MustCompile( + `\$\{([A-Za-z_][A-Za-z0-9_]*)\}`, +) + // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one // azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and @@ -275,6 +280,7 @@ func addResourceService( cfg *structpb.Struct, uses []string, ) error { + environment := serviceEnvironmentTemplates(cfg) svc := &azdext.ServiceConfig{ Name: name, Host: host, @@ -285,6 +291,15 @@ func addResourceService( return fmt.Errorf("adding %s service %q: %w", host, name, err) } + if err := setServiceEnvironment( + ctx, + azdClient, + name, + environment, + ); err != nil { + return err + } + if len(uses) > 0 { if err := setServiceUses(ctx, azdClient, name, uses); err != nil { return err @@ -294,6 +309,86 @@ func addResourceService( return nil } +func serviceEnvironmentTemplates(cfg *structpb.Struct) map[string]string { + if cfg == nil { + return nil + } + + environment := map[string]string{} + collectEnvironmentTemplates(cfg.AsMap(), environment) + if len(environment) == 0 { + return nil + } + return environment +} + +func collectEnvironmentTemplates(value any, environment map[string]string) { + switch typed := value.(type) { + case string: + for _, match := range envReferencePattern.FindAllStringSubmatchIndex( + typed, + -1, + ) { + if match[0] > 0 && typed[match[0]-1] == '$' { + continue + } + name := typed[match[2]:match[3]] + environment[name] = typed[match[0]:match[1]] + } + case map[string]any: + for _, nested := range typed { + collectEnvironmentTemplates(nested, environment) + } + case []any: + for _, nested := range typed { + collectEnvironmentTemplates(nested, environment) + } + } +} + +func setServiceEnvironment( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + environment map[string]string, +) error { + if len(environment) == 0 { + return nil + } + + sectionValues := make(map[string]any, len(environment)) + for key, value := range environment { + sectionValues[key] = value + } + section, err := structpb.NewStruct(sectionValues) + if err != nil { + return fmt.Errorf( + "encoding env for service %q: %w", + serviceName, + err, + ) + } + + // ServiceConfig.Environment only carries expanded values. + // The config RPC preserves raw ${VAR} templates. + _, err = azdClient.Project().SetServiceConfigSection( + ctx, + &azdext.SetServiceConfigSectionRequest{ + ServiceName: serviceName, + Path: "env", + Section: section, + }, + ) + if err != nil { + return fmt.Errorf( + "setting env for service %q: %w", + serviceName, + err, + ) + } + return nil +} + // setServiceUses sets the uses: list on an existing service. uses is a real // core ServiceConfig field, so it is written via SetServiceConfigValue (a raw // map path) rather than AddService's inlined config map, which cannot carry it. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index 0cfd1caacfc..a0dcacc1213 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -88,6 +88,53 @@ func TestReserveServiceName(t *testing.T) { assert.Contains(t, err.Error(), "agent service") } +func TestServiceEnvironmentTemplates(t *testing.T) { + t.Parallel() + + cfg, err := project.MarshalStruct(&project.Connection{ + Credentials: map[string]any{ + "key": "${SEARCH_KEY}", + }, + Metadata: map[string]string{ + "server": "${SERVER_NAME}", + "token": "${{connections.search.credentials.key}}", + "literal": "$${LITERAL}", + }, + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "SEARCH_KEY": "${SEARCH_KEY}", + "SERVER_NAME": "${SERVER_NAME}", + }, serviceEnvironmentTemplates(cfg)) +} + +func TestAddResourceServiceWritesEnvironment(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + cfg, err := project.MarshalStruct(&project.Connection{ + Credentials: map[string]any{"key": "${SEARCH_KEY}"}, + }) + require.NoError(t, err) + + require.NoError(t, addResourceService( + t.Context(), + client, + "search", + AiConnectionHost, + cfg, + nil, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.added, 1) + assert.Empty(t, server.added[0].GetEnvironment()) + assert.Equal(t, map[string]any{ + "SEARCH_KEY": "${SEARCH_KEY}", + }, server.env["search"]) +} + // TestCollectProjectDeployments verifies deployments are sourced only from // azure.ai.project services and ignore sibling hosts. func TestCollectProjectDeployments(t *testing.T) { @@ -251,6 +298,7 @@ type recordingProjectServer struct { mu sync.Mutex added []*azdext.ServiceConfig uses map[string][]string + env map[string]map[string]any // configValues records non-"uses" SetServiceConfigValue calls keyed by path. configValues map[string]configValueRecord // existing is returned by Get to simulate services already present in the @@ -315,6 +363,21 @@ func (s *recordingProjectServer) SetServiceConfigValue( return &azdext.EmptyResponse{}, nil } +func (s *recordingProjectServer) SetServiceConfigSection( + _ context.Context, + req *azdext.SetServiceConfigSectionRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.env == nil { + s.env = map[string]map[string]any{} + } + if req.Path == "env" && req.Section != nil { + s.env[req.ServiceName] = req.Section.AsMap() + } + return &azdext.EmptyResponse{}, nil +} + // newProjectRecorderClient spins up an in-process gRPC server backed by the // supplied project server stub and returns a client wired to its address. func newProjectRecorderClient(t *testing.T, server azdext.ProjectServiceServer) *azdext.AzdClient { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 0d97d24c693..74ffe9324eb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -8,6 +8,7 @@ import ( "log" "maps" "os" + "slices" "sync" "azureaiagent/internal/exterrors" @@ -83,11 +84,12 @@ func WarnLegacyAgentShape(source AgentDefinitionSource) { type AgentDefinitionInline struct { agent_yaml.AgentDefinition `json:",inline"` Protocols []agent_yaml.ProtocolVersionRecord `json:"protocols,omitempty"` - EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` - AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` - AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` - CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` - Policies []agent_yaml.Policy `json:"policies,omitempty"` + // EnvironmentVariables reads the deprecated inline shape. + EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` + AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` + AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` + CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` + Policies []agent_yaml.Policy `json:"policies,omitempty"` } // agentDefinitionToInline splits a ContainerAgent into the inline definition, @@ -96,13 +98,12 @@ type AgentDefinitionInline struct { // returned separately so the caller can place them on their respective homes. func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInline, *ContainerSettings, string) { inline := AgentDefinitionInline{ - AgentDefinition: ca.AgentDefinition, - Protocols: ca.Protocols, - EnvironmentVariables: ca.EnvironmentVariables, - AgentEndpoint: ca.AgentEndpoint, - AgentCard: ca.AgentCard, - CodeConfiguration: ca.CodeConfiguration, - Policies: ca.Policies, + AgentDefinition: ca.AgentDefinition, + Protocols: ca.Protocols, + AgentEndpoint: ca.AgentEndpoint, + AgentCard: ca.AgentCard, + CodeConfiguration: ca.CodeConfiguration, + Policies: ca.Policies, } var container *ContainerSettings @@ -118,12 +119,21 @@ func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInlin // toContainerAgent rebuilds the agent_yaml.ContainerAgent from the inline // definition, the CPU/memory carried in the `container` config, and the image // carried on the core service field. -func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings, image string) agent_yaml.ContainerAgent { +func (d AgentDefinitionInline) toContainerAgent( + container *ContainerSettings, + image string, + environment map[string]string, +) agent_yaml.ContainerAgent { + environmentVariables := d.EnvironmentVariables + if len(environment) > 0 { + environmentVariables = environmentVariablesFromMap(environment) + } + ca := agent_yaml.ContainerAgent{ AgentDefinition: d.AgentDefinition, Image: image, Protocols: d.Protocols, - EnvironmentVariables: d.EnvironmentVariables, + EnvironmentVariables: environmentVariables, AgentEndpoint: d.AgentEndpoint, AgentCard: d.AgentCard, CodeConfiguration: d.CodeConfiguration, @@ -140,6 +150,40 @@ func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings, im return ca } +// AgentEnvironment converts an agent environment list to a map. +func AgentEnvironment(ca agent_yaml.ContainerAgent) map[string]string { + if ca.EnvironmentVariables == nil || len(*ca.EnvironmentVariables) == 0 { + return nil + } + + environment := make(map[string]string, len(*ca.EnvironmentVariables)) + for _, variable := range *ca.EnvironmentVariables { + environment[variable.Name] = variable.Value + } + return environment +} + +func environmentVariablesFromMap( + environment map[string]string, +) *[]agent_yaml.EnvironmentVariable { + if len(environment) == 0 { + return nil + } + + variables := make( + []agent_yaml.EnvironmentVariable, + 0, + len(environment), + ) + for _, name := range slices.Sorted(maps.Keys(environment)) { + variables = append(variables, agent_yaml.EnvironmentVariable{ + Name: name, + Value: environment[name], + }) + } + return &variables +} + // structHasKind reports whether the struct carries a non-empty string `kind`, // the marker that an agent definition is present in a service entry's inline or // config properties. @@ -197,7 +241,11 @@ func AgentDefinitionFromService( } } - ca, isHosted, err := agentDefinitionFromStruct(inlineStruct, svc.GetImage()) + ca, isHosted, err := agentDefinitionFromStruct( + inlineStruct, + svc.GetImage(), + svc.GetEnvironment(), + ) return ca, isHosted, true, source, err } @@ -228,11 +276,7 @@ func ServiceConfigProps(svc *azdext.ServiceConfig) *structpb.Struct { return svc.GetConfig() } -// UpsertAgentEnvVars adds or updates environment variables on the agent -// definition carried inline on the service entry, preserving every other key. -// It is used by commands that mutate the definition (e.g. `optimize apply`). -// Returns an error when the service carries no inline definition; callers fall -// back to mutating a legacy on-disk agent.yaml in that case. +// UpsertAgentEnvVars updates the service-level environment map. func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { ca, _, found, source, err := AgentDefinitionFromService(svc) if err != nil { @@ -242,40 +286,19 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { return fmt.Errorf("service %q does not carry an inline agent definition", svc.GetName()) } - envVars := []agent_yaml.EnvironmentVariable{} - if ca.EnvironmentVariables != nil { - envVars = *ca.EnvironmentVariables - } - for key, value := range kv { - idx := -1 - for i := range envVars { - if envVars[i].Name == key { - idx = i - break - } - } - if idx >= 0 { - envVars[idx].Value = value - } else { - envVars = append(envVars, agent_yaml.EnvironmentVariable{Name: key, Value: value}) - } - } - ca.EnvironmentVariables = &envVars - - cfg, err := LoadServiceTargetAgentConfig(svc) - if err != nil { - return err - } - - props, err := AgentDefinitionToServiceProperties(ca, cfg) - if err != nil { - return err + environment := AgentEnvironment(ca) + if environment == nil { + environment = map[string]string{} } + maps.Copy(environment, kv) + svc.Environment = environment + props := svc.GetAdditionalProperties() if source == AgentDefinitionSourceLegacyConfig { - svc.Config = props - } else { - svc.AdditionalProperties = props + props = svc.GetConfig() + } + if props != nil { + delete(props.Fields, "environmentVariables") } return nil } @@ -318,7 +341,11 @@ func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSe // struct that carries the agent definition as service-level properties. coreImage // is the value of the service's `image` field, which is carried on the core // [azdext.ServiceConfig] rather than in the inline property bag. -func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml.ContainerAgent, bool, error) { +func agentDefinitionFromStruct( + s *structpb.Struct, + coreImage string, + environment map[string]string, +) (agent_yaml.ContainerAgent, bool, error) { var inline AgentDefinitionInline if err := UnmarshalStruct(s, &inline); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( @@ -341,7 +368,7 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml ) } - ca := inline.toContainerAgent(cfg.Container, coreImage) + ca := inline.toContainerAgent(cfg.Container, coreImage, environment) // Validate the inline definition with the same rules the on-disk agent.yaml // path uses (kind, name format, policies), so an inline definition cannot diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 821f8ad7e18..376b2a8d4ca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -12,6 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // sampleContainerAgent returns a hosted ContainerAgent with the fields that the @@ -51,11 +52,14 @@ func TestAgentDefinitionRoundTrip(t *testing.T) { props, err := AgentDefinitionToServiceProperties(ca, extra) require.NoError(t, err) + _, hasInlineEnvironment := props.GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) svc := &azdext.ServiceConfig{ Name: "basic-agent", Host: "azure.ai.agent", AdditionalProperties: props, + Environment: AgentEnvironment(ca), } got, isHosted, found, source, err := AgentDefinitionFromService(svc) @@ -110,6 +114,35 @@ func TestAgentDefinitionFromService_LegacyConfigShape(t *testing.T) { require.Equal(t, "basic-agent", got.Name) } +func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { + props, err := AgentDefinitionToServiceProperties( + sampleContainerAgent(), + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "LEGACY_KEY", + "value": "${LEGACY_KEY}", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Config: props, + } + got, _, found, source, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, AgentDefinitionSourceLegacyConfig, source) + require.Equal(t, map[string]string{ + "LEGACY_KEY": "${LEGACY_KEY}", + }, AgentEnvironment(got)) +} + // TestAgentDefinitionFromService_NoDefinition verifies that a service without an // inline definition reports not-found (callers then fall back to disk). func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { @@ -211,9 +244,15 @@ func TestLoadAgentDefinition_DiskFallback(t *testing.T) { // TestUpsertAgentEnvVars verifies that env vars are added/updated on the inline // definition while preserving the other definition keys. func TestUpsertAgentEnvVars(t *testing.T) { - props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + ca := sampleContainerAgent() + props, err := AgentDefinitionToServiceProperties(ca, nil) require.NoError(t, err) - svc := &azdext.ServiceConfig{Name: "basic-agent", Host: "azure.ai.agent", AdditionalProperties: props} + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + Environment: AgentEnvironment(ca), + } require.NoError(t, UpsertAgentEnvVars(svc, map[string]string{ "FOUNDRY_MODEL_DEPLOYMENT_NAME": "gpt-4o", // update existing @@ -225,11 +264,10 @@ func TestUpsertAgentEnvVars(t *testing.T) { require.True(t, found) require.Equal(t, "basic-agent", got.Name) // other keys preserved require.NotNil(t, got.EnvironmentVariables) + _, hasInlineEnvironment := props.GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) - values := map[string]string{} - for _, ev := range *got.EnvironmentVariables { - values[ev.Name] = ev.Value - } + values := AgentEnvironment(got) require.Equal(t, "gpt-4o", values["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) require.Equal(t, "cand-1", values["OPTIMIZATION_CANDIDATE_ID"]) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index b37b190409e..ce5a633a684 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -45,7 +45,6 @@ type ServiceTargetAgentConfig struct { // Foundry project. Its presence is the brownfield signal that makes provision // connect to that project instead of creating a new one. Endpoint string `json:"endpoint,omitempty"` - Environment map[string]string `json:"env,omitempty"` Container *ContainerSettings `json:"container,omitempty"` Deployments []Deployment `json:"deployments,omitempty"` Resources []Resource `json:"resources,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go index 33c8819ff18..ac4fa16b397 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go @@ -147,7 +147,6 @@ func TestServiceTargetAgentConfig_MultipleToolboxes(t *testing.T) { // alongside other ServiceTargetAgentConfig fields. func TestServiceTargetAgentConfig_WithOtherFields(t *testing.T) { original := ServiceTargetAgentConfig{ - Environment: map[string]string{"KEY": "VALUE"}, Deployments: []Deployment{ { Name: "test-deployment", @@ -187,10 +186,6 @@ func TestServiceTargetAgentConfig_WithOtherFields(t *testing.T) { t.Fatalf("UnmarshalStruct failed: %v", err) } - if roundTripped.Environment["KEY"] != "VALUE" { - t.Errorf("Expected env KEY=VALUE, got '%s'", roundTripped.Environment["KEY"]) - } - if len(roundTripped.Deployments) != 1 { t.Fatalf("Expected 1 deployment, got %d", len(roundTripped.Deployments)) } diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index a6ac2d23f0d..1e3acc4b03a 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -4,13 +4,6 @@ "description": "Custom configuration for the Azure AI Agent Service target", "type": "object", "properties": { - "env": { - "type": "object", - "description": "Environment variables as key-value pairs", - "additionalProperties": { - "type": "string" - } - }, "container": { "$ref": "#/definitions/ContainerSettings" }, diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 1f299dd05bb..4889c2f7b03 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml @@ -46,6 +46,8 @@ services: host: azure.ai.connection uses: - ai-project + env: + SEARCH_API_KEY: ${SEARCH_API_KEY} category: CognitiveSearch target: https://my-search.search.windows.net authType: ApiKey @@ -108,7 +110,7 @@ services: - research-tools env: LOG_LEVEL: info - MODEL_ENDPOINT: ${{project.endpoint}} + MODEL_ENDPOINT: $${{project.endpoint}} protocols: - protocol: a2a version: "0.2" @@ -143,6 +145,8 @@ services: host: azure.ai.routine uses: - researcher + env: + DIGEST_TOPIC: ${DIGEST_TOPIC} description: Summarize the day's documents every night. triggers: default: diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml index 38197ca2346..6c51d4ab962 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml @@ -25,3 +25,5 @@ services: kind: hosted name: assistant description: A simple assistant. + env: + LOG_LEVEL: info diff --git a/cli/azd/extensions/azure.ai.connections/extension.yaml b/cli/azd/extensions/azure.ai.connections/extension.yaml index a1d9d0ee90d..39a83f9571f 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -17,4 +17,4 @@ tags: - connection usage: azd ai connection [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.connections/go.mod b/cli/azd/extensions/azure.ai.connections/go.mod index 5b0958a2ee5..0c9a3fa6472 100644 --- a/cli/azd/extensions/azure.ai.connections/go.mod +++ b/cli/azd/extensions/azure.ai.connections/go.mod @@ -2,20 +2,16 @@ module azure.ai.connections go 1.26.4 -// TEMPORARY: local validation against the in-tree azd core for the shared -// pkg/foundry helpers (the $ref resolver and ${VAR}/${{...}} expander). Remove -// before merging — the core change must land first, then bump the azd dependency. -replace github.com/azure/azure-dev/cli/azd => ../../ - require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/azure/azure-dev/cli/azd v1.27.1 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -107,6 +103,5 @@ require ( golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.connections/go.sum b/cli/azd/extensions/azure.ai.connections/go.sum index 63edde352c2..bad69da9451 100644 --- a/cli/azd/extensions/azure.ai.connections/go.sum +++ b/cli/azd/extensions/azure.ai.connections/go.sum @@ -51,6 +51,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.27.1 h1:0SrTTBDSG9huHfgt6m89EminAErKtpmOi9gQ+b/T5aw= +github.com/azure/azure-dev/cli/azd v1.27.1/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go index 975a7bb4de8..faa5b6716af 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go @@ -41,18 +41,21 @@ type connectionServiceConfig struct { // project's connection; the resource name is the service key. Package and Publish are // no-ops because a connection has no build artifact. type connectionServiceTarget struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig + azdClient *azdext.AzdClient } // newConnectionServiceTarget creates the azure.ai.connection service-target provider. -func newConnectionServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { +func newConnectionServiceTarget( + azdClient *azdext.AzdClient, +) azdext.ServiceTargetProvider { return &connectionServiceTarget{azdClient: azdClient} } -// Initialize stores the service configuration; no other setup is required. -func (p *connectionServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *connectionServiceTarget) Initialize( + _ context.Context, + _ *azdext.ServiceConfig, +) error { return nil } @@ -105,8 +108,8 @@ func (p *connectionServiceTarget) Publish( } // Deploy upserts the connection on its project via an idempotent ARM CreateOrUpdate. -// ${VAR} references in the target and credential values resolve against the azd -// environment; Foundry server-side ${{...}} expressions pass through untouched. +// ${VAR} references resolve from the forwarded service environment. +// Foundry server-side ${{...}} expressions pass through untouched. // Removing the service from azure.yaml stops azd managing the connection but does not // delete it (use `azd ai connection delete`). func (p *connectionServiceTarget) Deploy( @@ -122,7 +125,7 @@ func (p *connectionServiceTarget) Deploy( } name := serviceConfig.GetName() - env, err := p.currentEnvValues(ctx) + env, err := p.environmentValues(ctx, serviceConfig) if err != nil { return nil, err } @@ -189,16 +192,27 @@ func parseConnectionServiceConfig(svc *azdext.ServiceConfig) (*connectionService return cfg, nil } -// currentEnvValues loads all key-value pairs from the active azd environment, used to -// resolve ${VAR} references in connection fields at deploy time. -func (p *connectionServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { - current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) +func (p *connectionServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + return environment, nil + } + + current, err := p.azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) if err != nil { return nil, fmt.Errorf("resolving current azd environment: %w", err) } - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }) + resp, err := p.azdClient.Environment().GetValues( + ctx, + &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }, + ) if err != nil { return nil, fmt.Errorf("loading azd environment values: %w", err) } @@ -209,9 +223,7 @@ func (p *connectionServiceTarget) currentEnvValues(ctx context.Context) (map[str return values, nil } -// resolveConnectionEnv expands ${VAR} references against the azd environment while -// preserving Foundry server-side ${{...}} expressions. On expansion error the original -// value is returned unchanged. +// resolveConnectionEnv expands ${VAR} from the service environment. func resolveConnectionEnv(value string, env map[string]string) string { resolved, err := foundry.ExpandEnv(value, func(name string) string { return env[name] }) if err != nil { diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go index 60cf2cfacac..8fc7997528b 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go @@ -87,13 +87,29 @@ func TestConnectionCredentialArgs(t *testing.T) { func TestResolveConnectionEnv(t *testing.T) { t.Parallel() - env := map[string]string{"SEARCH_KEY": "resolved-secret"} + serviceConfig := &azdext.ServiceConfig{ + Environment: map[string]string{"SEARCH_KEY": "resolved-secret"}, + } + environment, err := (&connectionServiceTarget{}).environmentValues( + t.Context(), + serviceConfig, + ) + require.NoError(t, err) - // ${VAR} resolves from the azd env; Foundry ${{...}} passes through untouched. - assert.Equal(t, "resolved-secret", resolveConnectionEnv("${SEARCH_KEY}", env)) + // Foundry server-side expressions pass through untouched. + assert.Equal(t, + "resolved-secret", + resolveConnectionEnv( + "${SEARCH_KEY}", + environment, + ), + ) assert.Equal(t, "${{connections.x.credentials.key}}", - resolveConnectionEnv("${{connections.x.credentials.key}}", env), + resolveConnectionEnv( + "${{connections.x.credentials.key}}", + environment, + ), ) } diff --git a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json index f51ea03067e..c4f76754581 100644 --- a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json +++ b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json @@ -12,7 +12,7 @@ }, "target": { "type": "string", - "description": "Target endpoint URL or ARM resource ID. May contain ${VAR} (azd env, resolved client-side)." + "description": "Target endpoint URL or ARM resource ID. May contain ${VAR}; declare each referenced variable in the service-level env object." }, "authType": { "type": "string", @@ -38,7 +38,7 @@ }, "credentials": { "type": "object", - "description": "Credentials. Values may contain ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side resolution, passed through untouched).", + "description": "Credentials. Values may contain ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution.", "additionalProperties": true }, "metadata": { diff --git a/cli/azd/extensions/azure.ai.routines/extension.yaml b/cli/azd/extensions/azure.ai.routines/extension.yaml index f34400f68da..c23c76f948c 100644 --- a/cli/azd/extensions/azure.ai.routines/extension.yaml +++ b/cli/azd/extensions/azure.ai.routines/extension.yaml @@ -17,4 +17,4 @@ tags: - routine usage: azd ai routine [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.routines/go.mod b/cli/azd/extensions/azure.ai.routines/go.mod index 0f60d383138..bab75ff742b 100644 --- a/cli/azd/extensions/azure.ai.routines/go.mod +++ b/cli/azd/extensions/azure.ai.routines/go.mod @@ -2,15 +2,10 @@ module azure.ai.routines go 1.26.4 -// TEMPORARY: local validation against the in-tree azd core for the shared -// pkg/foundry helpers (the ${VAR}/${{...}} expander). Remove before merging — -// the core change must land first, then bump the azd dependency. -replace github.com/azure/azure-dev/cli/azd => ../../ - require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/azure/azure-dev/cli/azd v1.27.1 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 diff --git a/cli/azd/extensions/azure.ai.routines/go.sum b/cli/azd/extensions/azure.ai.routines/go.sum index fccf35ac970..3988398043a 100644 --- a/cli/azd/extensions/azure.ai.routines/go.sum +++ b/cli/azd/extensions/azure.ai.routines/go.sum @@ -49,6 +49,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.27.1 h1:0SrTTBDSG9huHfgt6m89EminAErKtpmOi9gQ+b/T5aw= +github.com/azure/azure-dev/cli/azd v1.27.1/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go index 9203d8a3f34..8dc8d49092d 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -29,18 +29,21 @@ var _ azdext.ServiceTargetProvider = (*routineServiceTarget)(nil) // model (triggers, action, ...); the routine name is the service key. Package // and Publish are no-ops because a routine has no build artifact. type routineServiceTarget struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig + azdClient *azdext.AzdClient } // newRoutineServiceTarget creates the azure.ai.routine service-target provider. -func newRoutineServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { +func newRoutineServiceTarget( + azdClient *azdext.AzdClient, +) azdext.ServiceTargetProvider { return &routineServiceTarget{azdClient: azdClient} } -// Initialize stores the service configuration; no other setup is required. -func (p *routineServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *routineServiceTarget) Initialize( + _ context.Context, + _ *azdext.ServiceConfig, +) error { return nil } @@ -111,14 +114,16 @@ func (p *routineServiceTarget) Deploy( // The service key is the routine identity; ignore any name in the body. body.Name = serviceConfig.GetName() - // Resolve ${VAR} references in the routine's action input against the azd - // environment, leaving Foundry server-side ${{...}} expressions untouched. + // Resolve ${VAR} against the service environment forwarded by azd. if body.Action != nil { - env, err := p.currentEnvValues(ctx) + environment, err := p.environmentValues(ctx, serviceConfig) if err != nil { return nil, err } - body.Action.Input = expandRoutineValue(body.Action.Input, env) + body.Action.Input = expandRoutineValue( + body.Action.Input, + environment, + ) } if progress != nil { @@ -185,16 +190,27 @@ func newRoutineServiceClient(ctx context.Context) (*routines.Client, error) { ), nil } -// currentEnvValues loads all key-value pairs from the active azd environment, used to -// resolve ${VAR} references in routine fields at deploy time. -func (p *routineServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { - current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) +func (p *routineServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + return environment, nil + } + + current, err := p.azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) if err != nil { return nil, fmt.Errorf("resolving current azd environment: %w", err) } - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }) + resp, err := p.azdClient.Environment().GetValues( + ctx, + &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }, + ) if err != nil { return nil, fmt.Errorf("loading azd environment values: %w", err) } @@ -205,9 +221,7 @@ func (p *routineServiceTarget) currentEnvValues(ctx context.Context) (map[string return values, nil } -// expandRoutineValue recursively expands ${VAR} references in every string within a -// routine value (maps, slices, scalars) against the azd environment, preserving Foundry -// server-side ${{...}} expressions. +// expandRoutineValue expands ${VAR} in nested routine values. func expandRoutineValue(value any, env map[string]string) any { switch typed := value.(type) { case string: diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go index 4df169c2cd9..074fa000190 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go @@ -57,3 +57,25 @@ func TestParseRoutineServiceConfig_ConfigFallback(t *testing.T) { require.NoError(t, err) assert.Equal(t, "legacy", body.Description) } + +func TestExpandRoutineValue(t *testing.T) { + t.Parallel() + + serviceConfig := &azdext.ServiceConfig{ + Environment: map[string]string{"DIGEST_TOPIC": "weekly changes"}, + } + environment, err := (&routineServiceTarget{}).environmentValues( + t.Context(), + serviceConfig, + ) + require.NoError(t, err) + input := map[string]any{ + "topic": "${DIGEST_TOPIC}", + "secret": "${{connections.search.credentials.key}}", + } + + assert.Equal(t, map[string]any{ + "topic": "weekly changes", + "secret": "${{connections.search.credentials.key}}", + }, expandRoutineValue(input, environment)) +} diff --git a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json index 40058c5dfaa..d4f72ec59c7 100644 --- a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json @@ -36,7 +36,7 @@ "properties": { "type": { "type": "string", "description": "Action variant (e.g. invoke_agent_responses_api, invoke_agent_invocations_api)." }, "agent_name": { "type": "string", "description": "Name of the azure.ai.agent service the routine invokes." }, - "input": { "description": "Static JSON input sent to the agent when the routine fires. Values may use ${VAR} or ${{...}}." } + "input": { "description": "Static JSON input sent to the agent when the routine fires. Values may use ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution." } } } } diff --git a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml index 494cbc823d8..84e47842a00 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -17,4 +17,4 @@ tags: - toolbox usage: azd ai toolbox [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.toolboxes/go.mod b/cli/azd/extensions/azure.ai.toolboxes/go.mod index 83c9963b07a..c6acba62c1a 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/go.mod +++ b/cli/azd/extensions/azure.ai.toolboxes/go.mod @@ -2,18 +2,14 @@ module azure.ai.toolboxes go 1.26.4 -// TEMPORARY: local validation against the in-tree azd core for the shared -// pkg/foundry helpers (the $ref resolver and ${VAR}/${{...}} expander). Remove -// before merging — the core change must land first, then bump the azd dependency. -replace github.com/azure/azure-dev/cli/azd => ../../ - require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/azure/azure-dev/cli/azd v1.27.1 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -107,5 +103,4 @@ require ( golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/cli/azd/extensions/azure.ai.toolboxes/go.sum b/cli/azd/extensions/azure.ai.toolboxes/go.sum index fccf35ac970..3988398043a 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/go.sum +++ b/cli/azd/extensions/azure.ai.toolboxes/go.sum @@ -49,6 +49,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.27.1 h1:0SrTTBDSG9huHfgt6m89EminAErKtpmOi9gQ+b/T5aw= +github.com/azure/azure-dev/cli/azd v1.27.1/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go index 69dd7e27075..67329a5b3d3 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -38,19 +38,25 @@ type toolboxServiceConfig struct { // name is the service key. Package and Publish are no-ops because a toolbox has no build // artifact. type toolboxServiceTarget struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig - resolver connectionResolver + azdClient *azdext.AzdClient + resolver connectionResolver } // newToolboxServiceTarget creates the azure.ai.toolbox service-target provider. -func newToolboxServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &toolboxServiceTarget{azdClient: azdClient, resolver: defaultConnectionResolver{}} +func newToolboxServiceTarget( + azdClient *azdext.AzdClient, +) azdext.ServiceTargetProvider { + return &toolboxServiceTarget{ + azdClient: azdClient, + resolver: defaultConnectionResolver{}, + } } -// Initialize stores the service configuration; no other setup is required. -func (p *toolboxServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *toolboxServiceTarget) Initialize( + _ context.Context, + _ *azdext.ServiceConfig, +) error { return nil } @@ -105,8 +111,8 @@ func (p *toolboxServiceTarget) Publish( // Deploy upserts the toolbox by creating a new version from the entry's tools. Tool // entries that name a `connection` are resolved to their project_connection_id (the -// `uses:` edge guarantees the connection is reconciled first). ${VAR} references resolve -// against the azd environment; Foundry ${{...}} expressions pass through untouched. +// `uses:` edge guarantees the connection is reconciled first). ${VAR} +// references resolve from the forwarded service environment. // Removing the service from azure.yaml stops azd managing the toolbox but does not delete // it (use `azd ai toolbox delete`). func (p *toolboxServiceTarget) Deploy( @@ -128,12 +134,16 @@ func (p *toolboxServiceTarget) Deploy( } endpoint := resolved.Endpoint - env, err := p.currentEnvValues(ctx) + environment, err := p.environmentValues(ctx, serviceConfig) if err != nil { return nil, err } - - tools, err := p.buildToolEntries(ctx, endpoint, cfg.Tools, env) + tools, err := p.buildToolEntries( + ctx, + endpoint, + cfg.Tools, + environment, + ) if err != nil { return nil, err } @@ -219,16 +229,27 @@ func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig return cfg, nil } -// currentEnvValues loads all key-value pairs from the active azd environment, used to -// resolve ${VAR} references in tool fields at deploy time. -func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { - current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) +func (p *toolboxServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + return environment, nil + } + + current, err := p.azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) if err != nil { return nil, fmt.Errorf("resolving current azd environment: %w", err) } - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }) + resp, err := p.azdClient.Environment().GetValues( + ctx, + &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }, + ) if err != nil { return nil, fmt.Errorf("loading azd environment values: %w", err) } @@ -239,9 +260,7 @@ func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string return values, nil } -// expandToolboxValue recursively expands ${VAR} references in every string within a tool -// value (maps, slices, scalars) against the azd environment, preserving Foundry -// server-side ${{...}} expressions. +// expandToolboxValue expands ${VAR} in nested toolbox values. func expandToolboxValue(value any, env map[string]string) any { switch typed := value.(type) { case string: diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go index 2c08f4c949c..b91db14c782 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -79,14 +79,26 @@ func TestBuildToolEntries_ResolvesConnectionRef(t *testing.T) { func TestExpandToolboxValue(t *testing.T) { t.Parallel() - env := map[string]string{"MCP_URL": "https://resolved.example.com"} + serviceConfig := &azdext.ServiceConfig{ + Environment: map[string]string{ + "MCP_URL": "https://resolved.example.com", + }, + } + environment, err := (&toolboxServiceTarget{}).environmentValues( + t.Context(), + serviceConfig, + ) + require.NoError(t, err) in := map[string]any{ "type": "mcp", "server_url": "${MCP_URL}", "headers": []any{"x-secret: ${{secrets.token}}"}, } - out, ok := expandToolboxValue(in, env).(map[string]any) + out, ok := expandToolboxValue( + in, + environment, + ).(map[string]any) require.True(t, ok) assert.Equal(t, "https://resolved.example.com", out["server_url"]) // Foundry ${{...}} passes through untouched. diff --git a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json index 46b35dbfd68..807266c82e6 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json @@ -12,7 +12,7 @@ }, "tools": { "type": "array", - "description": "List of tools in the toolbox.", + "description": "List of tools in the toolbox. Values may use ${VAR} declared in the service-level env object.", "items": { "type": "object", "required": ["type"], diff --git a/cli/azd/extensions/microsoft.foundry/extension.yaml b/cli/azd/extensions/microsoft.foundry/extension.yaml index ae8597bfe99..4eb57a9bef7 100644 --- a/cli/azd/extensions/microsoft.foundry/extension.yaml +++ b/cli/azd/extensions/microsoft.foundry/extension.yaml @@ -6,7 +6,7 @@ tags: - ai - foundry version: 1.0.0-beta.1 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.agents version: "~1.0.0-beta.1" From afc5216599d8790e58025f3fdd619cb8ae86fe21 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 10 Jul 2026 18:37:20 +0800 Subject: [PATCH 02/12] fix: document Foundry service env migration --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 4 ++++ cli/azd/extensions/azure.ai.connections/CHANGELOG.md | 6 ++++++ cli/azd/extensions/azure.ai.routines/CHANGELOG.md | 6 ++++++ cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md | 6 ++++++ cli/azd/extensions/microsoft.foundry/CHANGELOG.md | 6 ++++++ 5 files changed, 28 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 941dd97a5f9..832b09da97f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.0.0-beta.6 (Unreleased) +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Store hosted-agent runtime variables in the standard service-level `env` object and read the expanded values forwarded by azd core. `azd ai agent init` no longer writes the unsupported inline `environmentVariables` shape; legacy inline and on-disk agent definitions remain readable. + ### Other Changes - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index 5ecd9a65ff1..c617849f196 100644 --- a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md index bcfd3a84bf3..2cb28a4f83f 100644 --- a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve routine input `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index f6134911adc..70136f9c987 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve toolbox `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Other Changes diff --git a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md index 8c4fe543015..0bea9898bb5 100644 --- a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md +++ b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.2 (Unreleased) + +### Other Changes + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Require azd 1.27.1 so bundled Foundry service targets receive service-level environment values. + ## 1.0.0-beta.1 (2026-06-30) ### Features Added From b634164fc93bf860cb4d0c3eb4acf30dd954791e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:59:15 +0000 Subject: [PATCH 03/12] fix: resolve merge conflicts in azure.ai.connections service target Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com> --- .../internal/cmd/service_target.go | 197 +----------------- .../internal/cmd/service_target_test.go | 68 ------ 2 files changed, 6 insertions(+), 259 deletions(-) diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go index 214e760835a..3f7b4b59e14 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go @@ -21,21 +21,18 @@ var _ azdext.ServiceTargetProvider = (*connectionServiceTarget)(nil) // connection entry in the deploy graph. All lifecycle methods are no-ops; see // Deploy for why. type connectionServiceTarget struct { - azdClient *azdext.AzdClient + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig } // newConnectionServiceTarget creates the azure.ai.connection service-target provider. -func newConnectionServiceTarget( - azdClient *azdext.AzdClient, -) azdext.ServiceTargetProvider { +func newConnectionServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { return &connectionServiceTarget{azdClient: azdClient} } -// Initialize requires no setup. -func (p *connectionServiceTarget) Initialize( - _ context.Context, - _ *azdext.ServiceConfig, -) error { +// Initialize stores the service configuration; no other setup is required. +func (p *connectionServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig return nil } @@ -87,13 +84,6 @@ func (p *connectionServiceTarget) Publish( return &azdext.ServicePublishResult{}, nil } -<<<<<<< HEAD -// Deploy upserts the connection on its project via an idempotent ARM CreateOrUpdate. -// ${VAR} references resolve from the forwarded service environment. -// Foundry server-side ${{...}} expressions pass through untouched. -// Removing the service from azure.yaml stops azd managing the connection but does not -// delete it (use `azd ai connection delete`). -======= // Deploy is a no-op. Connections declared as host: azure.ai.connection // services are created at provision time by the microsoft.foundry provider // (for both greenfield and brownfield projects), so creating them again here @@ -104,7 +94,6 @@ func (p *connectionServiceTarget) Publish( // `uses:` (toolboxes/agents that depend on it). Removing a connection from // azure.yaml stops azd managing it but does not delete it (use // `azd ai connection delete`). ->>>>>>> origin/main func (p *connectionServiceTarget) Deploy( ctx context.Context, serviceConfig *azdext.ServiceConfig, @@ -112,183 +101,9 @@ func (p *connectionServiceTarget) Deploy( targetResource *azdext.TargetResource, progress azdext.ProgressReporter, ) (*azdext.ServiceDeployResult, error) { -<<<<<<< HEAD - cfg, err := parseConnectionServiceConfig(serviceConfig) - if err != nil { - return nil, err - } - name := serviceConfig.GetName() - - env, err := p.environmentValues(ctx, serviceConfig) - if err != nil { - return nil, err - } - expand := func(value string) string { return resolveConnectionEnv(value, env) } - - kebabAuth := normalizeAuthType(strings.TrimSpace(cfg.AuthType)) - // Identity-based and OAuth2 connections are provisioned by `connection create` - // (init/provision), not at deploy time. buildConnectionBody can't build their - // bodies, so upserting here would fail azd deploy. Skip them; the api-key, - // custom-keys, and none types are still upserted to stay current. - if !supportsDeployUpsert(kebabAuth) { - if progress != nil { - progress(fmt.Sprintf("Connection %q uses %s auth provisioned elsewhere; skipping deploy upsert", name, kebabAuth)) - } - return &azdext.ServiceDeployResult{}, nil - } - key, customKeys := connectionCredentialArgs(kebabAuth, cfg.Credentials, expand) - body, err := buildConnectionBody( - cfg.Category, expand(cfg.Target), kebabAuth, key, customKeys, - connectionMetadataPairs(cfg.Metadata, expand), "", "", - ) - if err != nil { - return nil, err - } - -======= ->>>>>>> origin/main if progress != nil { progress(fmt.Sprintf( "Connection %q is provisioned by infrastructure; nothing to deploy", serviceConfig.GetName())) } return &azdext.ServiceDeployResult{}, nil } -<<<<<<< HEAD - -// parseConnectionServiceConfig reads the service-level (inline) connection properties, -// falling back to the deprecated config: shape for azure.yaml files written before the -// per-resource service split. -func parseConnectionServiceConfig(svc *azdext.ServiceConfig) (*connectionServiceConfig, error) { - props := svc.GetAdditionalProperties() - if props == nil || len(props.GetFields()) == 0 { - props = svc.GetConfig() - } - cfg := &connectionServiceConfig{} - if props == nil { - return cfg, nil - } - b, err := json.Marshal(props.AsMap()) - if err != nil { - return nil, fmt.Errorf("encoding connection service %q config: %w", svc.GetName(), err) - } - if err := json.Unmarshal(b, cfg); err != nil { - return nil, fmt.Errorf("parsing connection service %q config: %w", svc.GetName(), err) - } - return cfg, nil -} - -func (p *connectionServiceTarget) environmentValues( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, -) (map[string]string, error) { - if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { - return environment, nil - } - - current, err := p.azdClient.Environment().GetCurrent( - ctx, - &azdext.EmptyRequest{}, - ) - if err != nil { - return nil, fmt.Errorf("resolving current azd environment: %w", err) - } - resp, err := p.azdClient.Environment().GetValues( - ctx, - &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }, - ) - if err != nil { - return nil, fmt.Errorf("loading azd environment values: %w", err) - } - values := make(map[string]string, len(resp.GetKeyValues())) - for _, kv := range resp.GetKeyValues() { - values[kv.GetKey()] = kv.GetValue() - } - return values, nil -} - -// resolveConnectionEnv expands ${VAR} from the service environment. -func resolveConnectionEnv(value string, env map[string]string) string { - resolved, err := foundry.ExpandEnv(value, func(name string) string { return env[name] }) - if err != nil { - return value - } - return resolved -} - -// supportsDeployUpsert reports whether buildConnectionBody can build a body for -// authType at deploy time. Identity-based and OAuth2 types are provisioned by -// `connection create`, so they are skipped during deploy. -func supportsDeployUpsert(authType string) bool { - switch authType { - case "api-key", "custom-keys", "none", "": - return true - default: - return false - } -} - -// connectionCredentialArgs maps the service entry's credentials map to the key / -// custom-keys arguments buildConnectionBody expects, expanding ${VAR} per value. Only -// the auth types buildConnectionBody supports inline (api-key, custom-keys, none) are -// mapped here; other auth types surface buildConnectionBody's own validation error. -func connectionCredentialArgs( - kebabAuth string, - credentials map[string]any, - expand func(string) string, -) (key string, customKeys []string) { - switch kebabAuth { - case "api-key": - key = expand(stringFromAny(credentials["key"])) - case "custom-keys": - for _, k := range sortedKeys(credentials) { - customKeys = append(customKeys, fmt.Sprintf("%s=%s", k, expand(stringFromAny(credentials[k])))) - } - } - return key, customKeys -} - -// connectionMetadataPairs renders the metadata map as sorted key=value pairs with ${VAR} -// expanded, matching the []string shape buildConnectionBody consumes. -func connectionMetadataPairs(metadata map[string]string, expand func(string) string) []string { - if len(metadata) == 0 { - return nil - } - keys := make([]string, 0, len(metadata)) - for k := range metadata { - keys = append(keys, k) - } - sort.Strings(keys) - pairs := make([]string, 0, len(metadata)) - for _, k := range keys { - pairs = append(pairs, fmt.Sprintf("%s=%s", k, expand(metadata[k]))) - } - return pairs -} - -// sortedKeys returns the keys of m in sorted order so generated credential pairs are -// deterministic across deploys. -func sortedKeys(m map[string]any) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// stringFromAny renders a credential or metadata value as a string. Non-string scalars -// are formatted with their default representation; nil becomes empty. -func stringFromAny(v any) string { - switch typed := v.(type) { - case nil: - return "" - case string: - return typed - default: - return fmt.Sprintf("%v", typed) - } -} -======= ->>>>>>> origin/main diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go index 0b9616c52c4..f644710799e 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go @@ -52,73 +52,5 @@ func TestPackagePublish_AreNoOps(t *testing.T) { endpoints, err := target.Endpoints(t.Context(), svc, nil) require.NoError(t, err) -<<<<<<< HEAD - assert.Equal(t, "CustomKeys", cfg.Category) - assert.Equal(t, "CustomKeys", cfg.AuthType) -} - -func TestConnectionCredentialArgs(t *testing.T) { - t.Parallel() - - identity := func(s string) string { return s } - - t.Run("api-key reads the key credential", func(t *testing.T) { - t.Parallel() - key, customKeys := connectionCredentialArgs("api-key", map[string]any{"key": "secret"}, identity) - assert.Equal(t, "secret", key) - assert.Empty(t, customKeys) - }) - - t.Run("custom-keys renders sorted key=value pairs", func(t *testing.T) { - t.Parallel() - key, customKeys := connectionCredentialArgs( - "custom-keys", - map[string]any{"b-token": "2", "a-token": "1"}, - identity, - ) - assert.Empty(t, key) - assert.Equal(t, []string{"a-token=1", "b-token=2"}, customKeys) - }) -} - -func TestResolveConnectionEnv(t *testing.T) { - t.Parallel() - - serviceConfig := &azdext.ServiceConfig{ - Environment: map[string]string{"SEARCH_KEY": "resolved-secret"}, - } - environment, err := (&connectionServiceTarget{}).environmentValues( - t.Context(), - serviceConfig, - ) - require.NoError(t, err) - - // Foundry server-side expressions pass through untouched. - assert.Equal(t, - "resolved-secret", - resolveConnectionEnv( - "${SEARCH_KEY}", - environment, - ), - ) - assert.Equal(t, - "${{connections.x.credentials.key}}", - resolveConnectionEnv( - "${{connections.x.credentials.key}}", - environment, - ), - ) -} - -func TestConnectionMetadataPairs(t *testing.T) { - t.Parallel() - - pairs := connectionMetadataPairs( - map[string]string{"type": "gateway", "team": "search"}, - func(s string) string { return s }, - ) - assert.Equal(t, []string{"team=search", "type=gateway"}, pairs) -======= assert.Nil(t, endpoints) ->>>>>>> origin/main } From f6940b13cf63cd698203fd3a379da2a74e255ed9 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 14 Jul 2026 18:33:26 +0800 Subject: [PATCH 04/12] fix: preserve Foundry service environment migration --- .../internal/project/agent_definition.go | 9 ++++++++- .../internal/project/agent_definition_test.go | 14 ++++++++++++-- cli/azd/extensions/azure.ai.connections/go.mod | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 74ffe9324eb..dad7a2c3d9a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -126,7 +126,14 @@ func (d AgentDefinitionInline) toContainerAgent( ) agent_yaml.ContainerAgent { environmentVariables := d.EnvironmentVariables if len(environment) > 0 { - environmentVariables = environmentVariablesFromMap(environment) + legacyEnvironment := AgentEnvironment(agent_yaml.ContainerAgent{ + EnvironmentVariables: d.EnvironmentVariables, + }) + if legacyEnvironment == nil { + legacyEnvironment = map[string]string{} + } + maps.Copy(legacyEnvironment, environment) + environmentVariables = environmentVariablesFromMap(legacyEnvironment) } ca := agent_yaml.ContainerAgent{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 376b2a8d4ca..27c2f145ed0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -125,14 +125,22 @@ func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { "name": "LEGACY_KEY", "value": "${LEGACY_KEY}", }, + map[string]any{ + "name": "SHARED_KEY", + "value": "legacy", + }, }) require.NoError(t, err) props.Fields["environmentVariables"] = legacyEnvironment svc := &azdext.ServiceConfig{ - Name: "basic-agent", - Host: "azure.ai.agent", + Name: "basic-agent", + Host: "azure.ai.agent", Config: props, + Environment: map[string]string{ + "NEW_KEY": "new", + "SHARED_KEY": "service", + }, } got, _, found, source, err := AgentDefinitionFromService(svc) require.NoError(t, err) @@ -140,6 +148,8 @@ func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { require.Equal(t, AgentDefinitionSourceLegacyConfig, source) require.Equal(t, map[string]string{ "LEGACY_KEY": "${LEGACY_KEY}", + "NEW_KEY": "new", + "SHARED_KEY": "service", }, AgentEnvironment(got)) } diff --git a/cli/azd/extensions/azure.ai.connections/go.mod b/cli/azd/extensions/azure.ai.connections/go.mod index 0658ddc7fac..26fffda7718 100644 --- a/cli/azd/extensions/azure.ai.connections/go.mod +++ b/cli/azd/extensions/azure.ai.connections/go.mod @@ -11,7 +11,6 @@ require ( github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 - google.golang.org/protobuf v1.36.11 ) require ( @@ -102,5 +101,6 @@ require ( golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) From 362c65194dcdf3aed82dd518b5af0e0debfd8a02 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 21 Jul 2026 11:32:06 +0800 Subject: [PATCH 05/12] fix: preserve Foundry environment templates --- .../extensions/azure.ai.agents/CHANGELOG.md | 10 -- .../azure.ai.agents/internal/cmd/helpers.go | 16 +-- .../internal/cmd/optimize_apply.go | 84 ++++++++----- .../internal/cmd/optimize_apply_test.go | 51 ++++++++ .../internal/cmd/resource_services.go | 111 +++++++++++++++--- .../internal/cmd/resource_services_test.go | 20 +++- .../azure.ai.agents/internal/cmd/run.go | 20 +++- .../azure.ai.agents/internal/cmd/run_test.go | 34 +++++- .../internal/project/agent_definition.go | 13 ++ .../internal/project/agent_definition_test.go | 21 +++- .../internal/project/service_target_agent.go | 28 +++-- .../azure.ai.connections/CHANGELOG.md | 6 - .../extensions/azure.ai.routines/CHANGELOG.md | 6 - .../azure.ai.toolboxes/CHANGELOG.md | 6 - .../extensions/microsoft.foundry/CHANGELOG.md | 6 - 15 files changed, 325 insertions(+), 107 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 6de83021694..5b0ce23604f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,15 +1,5 @@ # Release History -## 1.0.0-beta.7 (Unreleased) - -### Bugs Fixed - -- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Store - hosted-agent runtime variables in the standard service-level `env` object and - read the expanded values forwarded by azd core. `azd ai agent init` no longer - writes the unsupported inline `environmentVariables` shape; legacy inline and - on-disk agent definitions remain readable. - ## 1.0.0-beta.6 (2026-07-16) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 1099e1e1971..77ec6fdd5c9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -939,9 +939,10 @@ func resolveAgentServiceFromProject( // ServiceRunContext holds the resolved context needed for local development. type ServiceRunContext struct { - ServiceName string // the resolved service name (from azure.yaml) - ProjectDir string // absolute path to the service source directory - StartupCommand string // startupCommand from AdditionalProperties (may be empty) + ServiceName string // the resolved service name (from azure.yaml) + ProjectDir string // absolute path to the service source directory + StartupCommand string // startupCommand from AdditionalProperties (may be empty) + ServiceEnvironment map[string]string // values already expanded by azd core // Definition is the resolved agent definition (from the inline azure.yaml // entry or a legacy agent.yaml). It is nil when no definition can be resolved. Definition *agent_yaml.ContainerAgent @@ -978,10 +979,11 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, } return &ServiceRunContext{ - ServiceName: svc.Name, - ProjectDir: projectDir, - StartupCommand: startupCmd, - Definition: definition, + ServiceName: svc.Name, + ProjectDir: projectDir, + StartupCommand: startupCmd, + ServiceEnvironment: svc.GetEnvironment(), + Definition: definition, }, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index cb8d97e8100..1834d2d57a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go @@ -174,37 +174,18 @@ func (a *OptimizeApplyAction) apply( "OPTIMIZATION_LOCAL_DIR": agentConfigsDir, "OPTIMIZATION_CANDIDATE_ID": a.flags.candidate, } - if _, _, found, _, err := projectpkg.AgentDefinitionFromService(svc); err != nil { + if _, _, found, source, err := projectpkg.AgentDefinitionFromService(svc); err != nil { return fmt.Errorf("failed to read agent definition: %w", err) } else if found { fmt.Fprintf(out, " Updating agent definition in azure.yaml...\n") - if err := projectpkg.UpsertAgentEnvVars(svc, envUpdates); err != nil { - return fmt.Errorf("failed to update agent definition: %w", err) - } - // Read the current `uses:` value before replacing the whole service entry. - // AddService writes back the proto ServiceConfig shape, which doesn't carry - // the `uses:` field (a core azd-only field). Reading it first and restoring - // it after avoids silently dropping dependency edges that were written by - // `setServiceUses` via SetServiceConfigValue. - prevUses, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ - ServiceName: svc.Name, - Path: "uses", - }) - if err != nil { - return fmt.Errorf("failed to read uses for service %q: %w", svc.Name, err) - } - if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { - return fmt.Errorf("failed to persist agent definition: %w", err) - } - // Restore `uses:` if it was set before the replacement. - if prevUses.GetFound() && prevUses.GetValue() != nil { - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ - ServiceName: svc.Name, - Path: "uses", - Value: prevUses.GetValue(), - }); err != nil { - return fmt.Errorf("failed to restore uses for service %q: %w", svc.Name, err) - } + if err := persistInlineAgentEnvironment( + ctx, + azdClient, + svc, + source, + envUpdates, + ); err != nil { + return err } } else { agentYamlPath := filepath.Join(serviceDir, "agent.yaml") @@ -254,6 +235,53 @@ func (a *OptimizeApplyAction) apply( return nil } +func persistInlineAgentEnvironment( + ctx context.Context, + azdClient *azdext.AzdClient, + svc *azdext.ServiceConfig, + source projectpkg.AgentDefinitionSource, + envUpdates map[string]string, +) error { + migrateLegacyEnvironment := source == projectpkg.AgentDefinitionSourceLegacyConfig && + len(svc.GetEnvironment()) == 0 + if err := projectpkg.UpsertAgentEnvVars(svc, envUpdates); err != nil { + return fmt.Errorf("failed to update agent definition: %w", err) + } + + migratedEnvironment := svc.GetEnvironment() + if migrateLegacyEnvironment { + svc.Environment = nil + } + + // Preserve core-only uses dependency edges. + prevUses, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ + ServiceName: svc.Name, + Path: "uses", + }) + if err != nil { + return fmt.Errorf("failed to read uses for service %q: %w", svc.Name, err) + } + if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { + return fmt.Errorf("failed to persist agent definition: %w", err) + } + if migrateLegacyEnvironment { + if err := setServiceEnvironment(ctx, azdClient, svc.Name, migratedEnvironment); err != nil { + return err + } + } + // Restore `uses:` if it was set before the replacement. + if prevUses.GetFound() && prevUses.GetValue() != nil { + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: svc.Name, + Path: "uses", + Value: prevUses.GetValue(), + }); err != nil { + return fmt.Errorf("failed to restore uses for service %q: %w", svc.Name, err) + } + } + return nil +} + // agentConfigMetadata is the YAML structure written as metadata.yaml in each // agent config version directory (baseline or candidate). // diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go index f84f87e372e..565be3ba02e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go @@ -11,13 +11,16 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/opt_eval" "azureaiagent/internal/pkg/agents/optimize_api" + projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // ---- newOptimizeApplyCommand — command shape ---- @@ -47,6 +50,54 @@ func TestNewOptimizeApplyCommand_CandidateIsRequired(t *testing.T) { assert.Contains(t, err.Error(), "candidate") } +func TestPersistInlineAgentEnvironmentMigratesLegacyTemplates(t *testing.T) { + props, err := projectpkg.AgentDefinitionToServiceProperties( + agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + }, + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "LEGACY_KEY", + "value": "${LEGACY_KEY}", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: AiAgentHost, + Config: props, + } + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + require.NoError(t, persistInlineAgentEnvironment( + t.Context(), + client, + svc, + projectpkg.AgentDefinitionSourceLegacyConfig, + map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.added, 1) + require.Empty(t, server.added[0].GetEnvironment()) + require.Equal(t, map[string]any{ + "LEGACY_KEY": "${LEGACY_KEY}", + "OPTIMIZATION_CANDIDATE_ID": "candidate-1", + }, server.env["basic-agent"]) +} + // ---- printPreviewLines ---- func TestPrintPreviewLines(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 28a7b938634..356eeac050a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "os" - "regexp" "slices" "strings" @@ -36,10 +35,6 @@ const ( aiProjectServiceName = "ai-project" ) -var envReferencePattern = regexp.MustCompile( - `\$\{([A-Za-z_][A-Za-z0-9_]*)\}`, -) - // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one // azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and @@ -325,16 +320,7 @@ func serviceEnvironmentTemplates(cfg *structpb.Struct) map[string]string { func collectEnvironmentTemplates(value any, environment map[string]string) { switch typed := value.(type) { case string: - for _, match := range envReferencePattern.FindAllStringSubmatchIndex( - typed, - -1, - ) { - if match[0] > 0 && typed[match[0]-1] == '$' { - continue - } - name := typed[match[2]:match[3]] - environment[name] = typed[match[0]:match[1]] - } + collectStringEnvironmentTemplates(typed, environment) case map[string]any: for _, nested := range typed { collectEnvironmentTemplates(nested, environment) @@ -346,6 +332,101 @@ func collectEnvironmentTemplates(value any, environment map[string]string) { } } +func collectStringEnvironmentTemplates(value string, environment map[string]string) { + for offset := 0; offset < len(value); { + startOffset := strings.Index(value[offset:], "${") + if startOffset < 0 { + return + } + start := offset + startOffset + if strings.HasPrefix(value[start:], "${{") { + end := strings.Index(value[start+3:], "}}") + if end < 0 { + return + } + offset = start + end + 5 + continue + } + + name, end, found := environmentTemplateAt(value, start) + if !found { + offset = start + 2 + continue + } + environment[name] = value[start:end] + offset = end + } +} + +func environmentTemplateAt(value string, start int) (string, int, bool) { + if start > 0 && value[start-1] == '$' { + return "", 0, false + } + + index := start + 2 + if index >= len(value) || !isEnvironmentNameStart(value[index]) { + return "", 0, false + } + nameStart := index + index++ + for index < len(value) && isEnvironmentNameCharacter(value[index]) { + index++ + } + name := value[nameStart:index] + + if index < len(value) && value[index] == '}' { + return name, index + 1, true + } + if !strings.HasPrefix(value[index:], ":-") { + return "", 0, false + } + + end, found := environmentTemplateEnd(value, index+2) + if !found { + return "", 0, false + } + return name, end, true +} + +// environmentTemplateEnd skips nested Foundry expressions. +func environmentTemplateEnd(value string, index int) (int, bool) { + depth := 1 + for index < len(value) { + if strings.HasPrefix(value[index:], "${{") { + end := strings.Index(value[index+3:], "}}") + if end < 0 { + return 0, false + } + index += end + 5 + continue + } + if strings.HasPrefix(value[index:], "${") { + depth++ + index += 2 + continue + } + if value[index] == '}' { + depth-- + index++ + if depth == 0 { + return index, true + } + continue + } + index++ + } + return 0, false +} + +func isEnvironmentNameStart(value byte) bool { + return value == '_' || value >= 'A' && value <= 'Z' || + value >= 'a' && value <= 'z' +} + +func isEnvironmentNameCharacter(value byte) bool { + return isEnvironmentNameStart(value) || value >= '0' && value <= '9' +} + func setServiceEnvironment( ctx context.Context, azdClient *azdext.AzdClient, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index 0831974f62c..ce0787aefa7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -96,16 +96,20 @@ func TestServiceEnvironmentTemplates(t *testing.T) { "key": "${SEARCH_KEY}", }, Metadata: map[string]string{ - "server": "${SERVER_NAME}", - "token": "${{connections.search.credentials.key}}", - "literal": "$${LITERAL}", + "server": "${SERVER_NAME}", + "default": "${DEFAULT_NAME:-fallback}", + "foundry_default": "${EVENT_BODY:-${{event.body}}}", + "token": "${{connections.search.credentials.key}}", + "literal": "$${LITERAL}", }, }) require.NoError(t, err) assert.Equal(t, map[string]string{ - "SEARCH_KEY": "${SEARCH_KEY}", - "SERVER_NAME": "${SERVER_NAME}", + "SEARCH_KEY": "${SEARCH_KEY}", + "SERVER_NAME": "${SERVER_NAME}", + "DEFAULT_NAME": "${DEFAULT_NAME:-fallback}", + "EVENT_BODY": "${EVENT_BODY:-${{event.body}}}", }, serviceEnvironmentTemplates(cfg)) } @@ -328,6 +332,12 @@ func (s *recordingProjectServer) AddService( return &azdext.EmptyResponse{}, nil } +func (s *recordingProjectServer) GetServiceConfigValue( + _ context.Context, _ *azdext.GetServiceConfigValueRequest, +) (*azdext.GetServiceConfigValueResponse, error) { + return &azdext.GetServiceConfigValueResponse{}, nil +} + func (s *recordingProjectServer) SetServiceConfigValue( _ context.Context, req *azdext.SetServiceConfigValueRequest, ) (*azdext.EmptyResponse, error) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 9e93521ea1f..3ece1e0c63c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -211,7 +211,13 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { // the Foundry data plane). Agent definition env vars do not override // values already present in the process environment. endpoint, _ := resolveAgentEndpoint(ctx, "", "") - defEnv, defErr := resolveAgentDefinitionEnvVars(ctx, runCtx.Definition, azdEnvVars, endpoint) + defEnv, defErr := resolveAgentDefinitionEnvVars( + ctx, + runCtx.Definition, + runCtx.ServiceEnvironment, + azdEnvVars, + endpoint, + ) if defErr != nil { fmt.Fprintf(os.Stderr, "Warning: %s\n", defErr) } @@ -514,6 +520,7 @@ func shouldWarnLoadAzdEnvironmentFailure(err error) bool { func resolveAgentDefinitionEnvVars( ctx context.Context, agentDef *agent_yaml.ContainerAgent, + serviceEnvironment map[string]string, azdEnvVars map[string]string, endpoint string, ) ([]string, error) { @@ -544,8 +551,15 @@ func resolveAgentDefinitionEnvVars( if _, isConn := connRefEnvNames[ev.Name]; isConn { continue } - // ExpandEnv returns the original value on error, so a failed expansion is a no-op. - resolved, _ := project.ExpandEnv(ev.Value, lookup) + resolved, err := project.ResolveAgentEnvironmentVariable( + ev.Name, + ev.Value, + serviceEnvironment, + lookup, + ) + if err != nil { + resolved = ev.Value + } result = append(result, fmt.Sprintf("%s=%s", ev.Name, resolved)) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index c57cee03665..d8f9f1d6b40 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -894,7 +894,7 @@ environment_variables: value: debug `) - result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -918,7 +918,7 @@ environment_variables: azdEnv := map[string]string{ "FOUNDRY_PROJECT_ENDPOINT": "https://example.azure.com", } - result, err := resolveAgentDefinitionEnvVars(t.Context(), def, azdEnv, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, azdEnv, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -939,7 +939,7 @@ environment_variables: value: hello `) - result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -955,7 +955,7 @@ environment_variables: }) t.Run("returns nil for nil definition", func(t *testing.T) { - result, err := resolveAgentDefinitionEnvVars(t.Context(), nil, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), nil, nil, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -967,7 +967,7 @@ environment_variables: t.Run("returns nil for empty environment_variables", func(t *testing.T) { def := parse(t, "name: test-agent\n") - result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -983,7 +983,7 @@ environment_variables: value: ${DOES_NOT_EXIST} `) - result, err := resolveAgentDefinitionEnvVars(t.Context(), def, map[string]string{}, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, map[string]string{}, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -991,6 +991,28 @@ environment_variables: t.Errorf("expected MISSING_REF= (empty), got %v", result) } }) + + t.Run("keeps forwarded core values literal", func(t *testing.T) { + def := parse(t, `name: test-agent +environment_variables: + - name: FORWARDED_VALUE + value: ${FORWARDED_VALUE} +`) + + result, err := resolveAgentDefinitionEnvVars( + t.Context(), + def, + map[string]string{"FORWARDED_VALUE": "literal ${NOT_A_TEMPLATE}"}, + map[string]string{"NOT_A_TEMPLATE": "expanded"}, + "", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Contains(result, "FORWARDED_VALUE=literal ${NOT_A_TEMPLATE}") { + t.Errorf("expected literal forwarded value, got %v", result) + } + }) } func TestVenvPip(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index dad7a2c3d9a..a3ccebc308d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -170,6 +170,19 @@ func AgentEnvironment(ca agent_yaml.ContainerAgent) map[string]string { return environment } +// ResolveAgentEnvironmentVariable preserves values forwarded by core. +func ResolveAgentEnvironmentVariable( + name string, + value string, + serviceEnvironment map[string]string, + mapping func(string) string, +) (string, error) { + if environmentValue, found := serviceEnvironment[name]; found { + return environmentValue, nil + } + return ExpandEnv(value, mapping) +} + func environmentVariablesFromMap( environment map[string]string, ) *[]agent_yaml.EnvironmentVariable { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 27c2f145ed0..2e1bed637cc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -134,8 +134,8 @@ func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { props.Fields["environmentVariables"] = legacyEnvironment svc := &azdext.ServiceConfig{ - Name: "basic-agent", - Host: "azure.ai.agent", + Name: "basic-agent", + Host: "azure.ai.agent", Config: props, Environment: map[string]string{ "NEW_KEY": "new", @@ -153,6 +153,23 @@ func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { }, AgentEnvironment(got)) } +func TestResolveAgentEnvironmentVariable(t *testing.T) { + t.Parallel() + + value, err := ResolveAgentEnvironmentVariable( + "FORWARDED_VALUE", + "${FORWARDED_VALUE}", + map[string]string{ + "FORWARDED_VALUE": "literal ${NOT_A_TEMPLATE}", + }, + func(string) string { + return "expanded" + }, + ) + require.NoError(t, err) + require.Equal(t, "literal ${NOT_A_TEMPLATE}", value) +} + // TestAgentDefinitionFromService_NoDefinition verifies that a service without an // inline definition reports not-found (callers then fall back to disk). func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 610a6d46e22..8fd70198c84 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1420,7 +1420,12 @@ func (p *AgentServiceTargetProvider) prepareDeploy( resolvedEnvVars := make(map[string]string) if agentDef.EnvironmentVariables != nil { for _, envVar := range *agentDef.EnvironmentVariables { - resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) + resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables( + envVar.Name, + envVar.Value, + serviceConfig.GetEnvironment(), + azdEnv, + ) } } @@ -2614,12 +2619,21 @@ func (p *AgentServiceTargetProvider) registerAgentEnvironmentVariables( return nil } -// resolveEnvironmentVariables resolves ${ENV_VAR} style references in value using azd environment variables. -// Supports default values (e.g., "${VAR:-default}") and multiple expressions (e.g., "${VAR1}-${VAR2}"). -func (p *AgentServiceTargetProvider) resolveEnvironmentVariables(value string, azdEnv map[string]string) string { - resolved, err := ExpandEnv(value, func(varName string) string { - return azdEnv[varName] - }) +// resolveEnvironmentVariables expands legacy inline templates. +func (p *AgentServiceTargetProvider) resolveEnvironmentVariables( + name string, + value string, + serviceEnvironment map[string]string, + azdEnv map[string]string, +) string { + resolved, err := ResolveAgentEnvironmentVariable( + name, + value, + serviceEnvironment, + func(varName string) string { + return azdEnv[varName] + }, + ) if err != nil { // If resolution fails, return original value return value diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index 63777cc215c..d1165630ed7 100644 --- a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.4 (Unreleased) - -### Bugs Fixed - -- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. - ## 1.0.0-beta.3 (2026-07-16) ### Breaking Changes diff --git a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md index 582dc5e56fd..175eabe54d1 100644 --- a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.4 (Unreleased) - -### Bugs Fixed - -- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve routine input `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. - ## 1.0.0-beta.3 (2026-07-16) ### Other Changes diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index e45ec88509c..b63dbe79561 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.4 (Unreleased) - -### Bugs Fixed - -- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve toolbox `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. - ## 1.0.0-beta.3 (2026-07-16) ### Features Added diff --git a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md index 0bea9898bb5..8c4fe543015 100644 --- a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md +++ b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.2 (Unreleased) - -### Other Changes - -- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Require azd 1.27.1 so bundled Foundry service targets receive service-level environment values. - ## 1.0.0-beta.1 (2026-06-30) ### Features Added From a41b0abd66cb05cca7cb81effad7e257d6214a6a Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 21 Jul 2026 13:45:16 +0800 Subject: [PATCH 06/12] fix: preserve Foundry env templates when persisting service env --- .../internal/cmd/optimize_apply.go | 78 ++++++++++--- .../internal/cmd/optimize_apply_test.go | 105 +++++++++++++++++- .../internal/cmd/resource_services.go | 30 ++++- .../internal/cmd/resource_services_test.go | 76 ++++++++++++- .../internal/project/agent_definition.go | 21 ++++ .../internal/project/agent_definition_test.go | 39 +++++++ 6 files changed, 329 insertions(+), 20 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index 1834d2d57a5..c8d0604ac89 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "os" "path/filepath" "strings" @@ -174,7 +175,7 @@ func (a *OptimizeApplyAction) apply( "OPTIMIZATION_LOCAL_DIR": agentConfigsDir, "OPTIMIZATION_CANDIDATE_ID": a.flags.candidate, } - if _, _, found, source, err := projectpkg.AgentDefinitionFromService(svc); err != nil { + if _, _, found, _, err := projectpkg.AgentDefinitionFromService(svc); err != nil { return fmt.Errorf("failed to read agent definition: %w", err) } else if found { fmt.Fprintf(out, " Updating agent definition in azure.yaml...\n") @@ -182,7 +183,6 @@ func (a *OptimizeApplyAction) apply( ctx, azdClient, svc, - source, envUpdates, ); err != nil { return err @@ -239,19 +239,35 @@ func persistInlineAgentEnvironment( ctx context.Context, azdClient *azdext.AzdClient, svc *azdext.ServiceConfig, - source projectpkg.AgentDefinitionSource, envUpdates map[string]string, ) error { - migrateLegacyEnvironment := source == projectpkg.AgentDefinitionSourceLegacyConfig && - len(svc.GetEnvironment()) == 0 + // Build the env to persist from raw templates only, never from the + // core-expanded svc.Environment. AddService escapes env values to + // literals, so routing templates through it would freeze a ${VAR} + // template (or snapshot an already-expanded value) into azure.yaml. + legacyEnv, err := projectpkg.InlineAgentEnvironmentVariables(svc) + if err != nil { + return fmt.Errorf("failed to read agent environment: %w", err) + } + existingEnv, err := getRawServiceEnv(ctx, azdClient, svc.Name) + if err != nil { + return err + } + // Merge order mirrors deploy-time precedence in toContainerAgent: + // the top-level env overlays legacy environmentVariables, then the + // new updates win. + mergedEnv := map[string]string{} + maps.Copy(mergedEnv, legacyEnv) + maps.Copy(mergedEnv, existingEnv) + maps.Copy(mergedEnv, envUpdates) + + // UpsertAgentEnvVars drops the deprecated environmentVariables and + // sets svc.Environment; clear it so AddService leaves env alone. The + // merged raw templates are written below via setServiceEnvironment. if err := projectpkg.UpsertAgentEnvVars(svc, envUpdates); err != nil { return fmt.Errorf("failed to update agent definition: %w", err) } - - migratedEnvironment := svc.GetEnvironment() - if migrateLegacyEnvironment { - svc.Environment = nil - } + svc.Environment = nil // Preserve core-only uses dependency edges. prevUses, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ @@ -264,10 +280,8 @@ func persistInlineAgentEnvironment( if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { return fmt.Errorf("failed to persist agent definition: %w", err) } - if migrateLegacyEnvironment { - if err := setServiceEnvironment(ctx, azdClient, svc.Name, migratedEnvironment); err != nil { - return err - } + if err := setServiceEnvironment(ctx, azdClient, svc.Name, mergedEnv); err != nil { + return err } // Restore `uses:` if it was set before the replacement. if prevUses.GetFound() && prevUses.GetValue() != nil { @@ -282,6 +296,42 @@ func persistInlineAgentEnvironment( return nil } +// getRawServiceEnv reads the service's existing env section as raw, +// unexpanded templates. GetServiceConfigValue returns the on-disk +// config, so callers can rewrite env without losing ${VAR}/$${{...}} +// templates. +func getRawServiceEnv( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) (map[string]string, error) { + resp, err := azdClient.Project().GetServiceConfigValue( + ctx, + &azdext.GetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "env", + }, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to read env for service %q: %w", serviceName, err) + } + if !resp.GetFound() || resp.GetValue() == nil { + return nil, nil + } + raw, ok := resp.GetValue().AsInterface().(map[string]any) + if !ok { + return nil, nil + } + env := make(map[string]string, len(raw)) + for key, value := range raw { + if str, ok := value.(string); ok { + env[key] = str + } + } + return env, nil +} + // agentConfigMetadata is the YAML structure written as metadata.yaml in each // agent config version directory (baseline or candidate). // diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go index 565be3ba02e..083ab82c5df 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go @@ -84,7 +84,6 @@ func TestPersistInlineAgentEnvironmentMigratesLegacyTemplates(t *testing.T) { t.Context(), client, svc, - projectpkg.AgentDefinitionSourceLegacyConfig, map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, )) @@ -98,6 +97,110 @@ func TestPersistInlineAgentEnvironmentMigratesLegacyTemplates(t *testing.T) { }, server.env["basic-agent"]) } +// TestPersistInlineAgentEnvironmentPreservesTopLevelEnv verifies a +// modern agent's top-level env templates survive the OPTIMIZATION_* +// update: they are read raw and rewritten via the env section, not +// snapshotted to expanded literals through AddService. +func TestPersistInlineAgentEnvironmentPreservesTopLevelEnv(t *testing.T) { + props, err := projectpkg.AgentDefinitionToServiceProperties( + agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + }, + nil, + ) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: AiAgentHost, + AdditionalProperties: props, + // Core forwards expanded values; the raw templates live on disk. + Environment: map[string]string{ + "LOG_LEVEL": "debug", + "MODEL_ENDPOINT": "https://resolved.example", + }, + } + + server := &recordingProjectServer{ + rawEnv: map[string]map[string]any{ + "basic-agent": { + "LOG_LEVEL": "${AZURE_LOG_LEVEL}", + "MODEL_ENDPOINT": "$${{project.endpoint}}", + }, + }, + } + client := newProjectRecorderClient(t, server) + require.NoError(t, persistInlineAgentEnvironment( + t.Context(), + client, + svc, + map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.added, 1) + require.Empty(t, server.added[0].GetEnvironment()) + require.Equal(t, map[string]any{ + "LOG_LEVEL": "${AZURE_LOG_LEVEL}", + "MODEL_ENDPOINT": "$${{project.endpoint}}", + "OPTIMIZATION_CANDIDATE_ID": "candidate-1", + }, server.env["basic-agent"]) +} + +// TestPersistInlineAgentEnvironmentEscapesLegacyFoundrySpan verifies +// a legacy environmentVariables value carrying a raw Foundry ${{...}} +// span is escaped to $${{...}} when migrated into the env section. +func TestPersistInlineAgentEnvironmentEscapesLegacyFoundrySpan(t *testing.T) { + props, err := projectpkg.AgentDefinitionToServiceProperties( + agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + }, + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "SEARCH_KEY", + "value": "${{connections.search.credentials.key}}", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: AiAgentHost, + AdditionalProperties: props, + } + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + require.NoError(t, persistInlineAgentEnvironment( + t.Context(), + client, + svc, + map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, map[string]any{ + "SEARCH_KEY": "$${{connections.search.credentials.key}}", + "OPTIMIZATION_CANDIDATE_ID": "candidate-1", + }, server.env["basic-agent"]) +} + // ---- printPreviewLines ---- func TestPrintPreviewLines(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 356eeac050a..bc605789481 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -353,7 +353,13 @@ func collectStringEnvironmentTemplates(value string, environment map[string]stri offset = start + 2 continue } - environment[name] = value[start:end] + // Forward the canonical bare ${NAME}, not the full matched span. + // A ${NAME:-default} default is re-applied by the owning + // extension against the raw config at deploy, so the env section + // only needs NAME's resolved base value. Collapsing every form of + // a var to one value also keeps collection deterministic when the + // same var appears with and without a default. + environment[name] = "${" + name + "}" offset = end } } @@ -427,6 +433,26 @@ func isEnvironmentNameCharacter(value byte) bool { return isEnvironmentNameStart(value) || value >= '0' && value <= '9' } +// escapeFoundryTemplates escapes Foundry ${{...}} spans as $${{...}} +// so azd core's envsubst emits a literal ${{...}} for the owning +// extension to resolve. Already-escaped $${{...}} and bare ${VAR} +// are left unchanged, so it is safe on values read back from disk. +func escapeFoundryTemplates(value string) string { + if !strings.Contains(value, "${{") { + return value + } + var b strings.Builder + b.Grow(len(value) + 2) + for i := 0; i < len(value); i++ { + if value[i] == '$' && strings.HasPrefix(value[i:], "${{") && + (i == 0 || value[i-1] != '$') { + b.WriteByte('$') + } + b.WriteByte(value[i]) + } + return b.String() +} + func setServiceEnvironment( ctx context.Context, azdClient *azdext.AzdClient, @@ -439,7 +465,7 @@ func setServiceEnvironment( sectionValues := make(map[string]any, len(environment)) for key, value := range environment { - sectionValues[key] = value + sectionValues[key] = escapeFoundryTemplates(value) } section, err := structpb.NewStruct(sectionValues) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index ce0787aefa7..d04c9f15707 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/structpb" ) func mustMarshalConfig[T any](t *testing.T, in *T) *azdext.ServiceConfig { @@ -108,11 +109,62 @@ func TestServiceEnvironmentTemplates(t *testing.T) { assert.Equal(t, map[string]string{ "SEARCH_KEY": "${SEARCH_KEY}", "SERVER_NAME": "${SERVER_NAME}", - "DEFAULT_NAME": "${DEFAULT_NAME:-fallback}", - "EVENT_BODY": "${EVENT_BODY:-${{event.body}}}", + "DEFAULT_NAME": "${DEFAULT_NAME}", + "EVENT_BODY": "${EVENT_BODY}", }, serviceEnvironmentTemplates(cfg)) } +// TestServiceEnvironmentTemplatesDeterministic verifies a var +// referenced both bare and with a default collapses to the same +// canonical ${VAR}, so field order cannot change the result. +func TestServiceEnvironmentTemplatesDeterministic(t *testing.T) { + t.Parallel() + + cfg, err := project.MarshalStruct(&project.Connection{ + Metadata: map[string]string{ + "bare": "${TOPIC}", + "default": "${TOPIC:-general}", + "default2": "${TOPIC:-other}", + }, + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "TOPIC": "${TOPIC}", + }, serviceEnvironmentTemplates(cfg)) +} + +func TestEscapeFoundryTemplates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + }{ + {"foundry span", "${{event.body}}", "$${{event.body}}"}, + {"already escaped", "$${{event.body}}", "$${{event.body}}"}, + {"single brace untouched", "${VAR}", "${VAR}"}, + {"literal untouched", "info", "info"}, + { + "embedded span", + "prefix-${{connections.x.key}}-suffix", + "prefix-$${{connections.x.key}}-suffix", + }, + { + "span in default", + "${MISSING:-${{event.body}}}", + "${MISSING:-$${{event.body}}}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, escapeFoundryTemplates(tt.value)) + }) + } +} + func TestAddResourceServiceWritesEnvironment(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) @@ -305,6 +357,10 @@ type recordingProjectServer struct { // existing is returned by Get to simulate services already present in the // project (e.g. a prior init's azure.ai.project service). existing map[string]*azdext.ServiceConfig + // rawEnv is returned by GetServiceConfigValue for path "env" to + // simulate a service that already carries an env section (raw, + // on-disk templates). + rawEnv map[string]map[string]any } // configValueRecord captures a single SetServiceConfigValue call. @@ -333,8 +389,22 @@ func (s *recordingProjectServer) AddService( } func (s *recordingProjectServer) GetServiceConfigValue( - _ context.Context, _ *azdext.GetServiceConfigValueRequest, + _ context.Context, req *azdext.GetServiceConfigValueRequest, ) (*azdext.GetServiceConfigValueResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if req.Path == "env" { + if raw, ok := s.rawEnv[req.ServiceName]; ok { + value, err := structpb.NewValue(raw) + if err != nil { + return nil, err + } + return &azdext.GetServiceConfigValueResponse{ + Found: true, + Value: value, + }, nil + } + } return &azdext.GetServiceConfigValueResponse{}, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index a3ccebc308d..3eac73f8de8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -323,6 +323,27 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { return nil } +// InlineAgentEnvironmentVariables returns the deprecated inline +// environmentVariables carried on the agent definition as a raw +// template map, without merging the core-forwarded (already expanded) +// service environment. Values are the templates as authored, suitable +// for migrating into the env section without losing them. +func InlineAgentEnvironmentVariables( + svc *azdext.ServiceConfig, +) (map[string]string, error) { + props := ServiceConfigProps(svc) + if props == nil || len(props.GetFields()) == 0 { + return nil, nil + } + var inline AgentDefinitionInline + if err := UnmarshalStruct(props, &inline); err != nil { + return nil, err + } + return AgentEnvironment(agent_yaml.ContainerAgent{ + EnvironmentVariables: inline.EnvironmentVariables, + }), nil +} + // SetAgentContainerSettings writes the resolved container settings onto the // agent service's inline properties, preserving every other key (the agent // definition and the rest of the deploy/provision config). It mutates whichever diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 2e1bed637cc..ad51140ee6a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -153,6 +153,45 @@ func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { }, AgentEnvironment(got)) } +// TestInlineAgentEnvironmentVariables verifies the raw inline +// environmentVariables are returned as authored, without merging the +// core-forwarded (already expanded) service environment. +func TestInlineAgentEnvironmentVariables(t *testing.T) { + props, err := AgentDefinitionToServiceProperties( + sampleContainerAgent(), + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "LEGACY_KEY", + "value": "${LEGACY_KEY}", + }, + map[string]any{ + "name": "SHARED_KEY", + "value": "legacy", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + // Core-forwarded env must NOT leak into the raw result. + Environment: map[string]string{ + "NEW_KEY": "new", + "SHARED_KEY": "service", + }, + } + got, err := InlineAgentEnvironmentVariables(svc) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "LEGACY_KEY": "${LEGACY_KEY}", + "SHARED_KEY": "legacy", + }, got) +} func TestResolveAgentEnvironmentVariable(t *testing.T) { t.Parallel() From d8c6f4ea3a5c132d9c71195b10b8c8c8a279a72a Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 21 Jul 2026 18:16:29 +0800 Subject: [PATCH 07/12] fix: prioritize service env for local agent runs --- .../azure.ai.agents/internal/cmd/run.go | 152 +++++++++++------- .../azure.ai.agents/internal/cmd/run_test.go | 131 ++++++++++++--- 2 files changed, 209 insertions(+), 74 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 3ece1e0c63c..eac2462eb6d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -186,21 +186,12 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { cmdParts = resolveVenvCommand(projectDir, cmdParts) - env := os.Environ() - env = appendPortEnvVars(env, pt, flags.port) - - // Load azd environment variables (e.g., FOUNDRY_PROJECT_ENDPOINT) - // so the agent can reach Azure services during local development. - // Also translate azd env keys to FOUNDRY_* env vars so the agent code - // works identically whether running locally or in a hosted container - // (where the platform automatically injects FOUNDRY_* env vars). + env := appendPortEnvVars(os.Environ(), pt, flags.port) + + // Load azd values as template inputs and legacy fallback values. var azdEnvVars map[string]string if loaded, err := loadAzdEnvironment(ctx, azdClient); err == nil { azdEnvVars = loaded - for k, v := range azdEnvVars { - env = append(env, fmt.Sprintf("%s=%s", k, v)) - } - env = appendFoundryEnvVars(env, azdEnvVars, runCtx.ServiceName) } else if shouldWarnLoadAzdEnvironmentFailure(err) { fmt.Fprintf(os.Stderr, "Warning: failed to load azd environment values: %s\n", err) } @@ -221,12 +212,13 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { if defErr != nil { fmt.Fprintf(os.Stderr, "Warning: %s\n", defErr) } - for _, entry := range defEnv { - key, _, _ := strings.Cut(entry, "=") - if !envSliceHasKey(env, key) { - env = append(env, entry) - } - } + env = mergeAgentRunEnvironment( + env, + azdEnvVars, + runCtx.ServiceEnvironment, + defEnv, + runCtx.ServiceName, + ) // Activity agents bind IPv4 and are reached at 127.0.0.1 everywhere else // (the port-readiness check and the Playground URL), because `localhost` @@ -984,57 +976,105 @@ func findSystemPython() (pythonInterpreter, error) { return firstCompatiblePython(pythonCandidates(), pythonVersion) } -// appendFoundryEnvVars translates azd environment keys to FOUNDRY_* env vars that hosted -// agent containers receive automatically from the platform. This ensures the agent code -// works identically whether running locally (via azd ai agent run) or in a hosted container. +// mergeAgentRunEnvironment builds the local agent environment. +func mergeAgentRunEnvironment( + baseEnvironment []string, + azdEnvironment map[string]string, + serviceEnvironment map[string]string, + definitionEnvironment []string, + serviceName string, +) []string { + environment := slices.Clone(baseEnvironment) + + // The full azd environment is a compatibility fallback only. + if len(serviceEnvironment) == 0 { + for key, value := range azdEnvironment { + if !envSliceHasKey(baseEnvironment, key) { + environment = append( + environment, + fmt.Sprintf("%s=%s", key, value), + ) + } + } + } + + environment = appendFoundryEnvVars( + environment, + azdEnvironment, + serviceName, + ) + + for _, entry := range definitionEnvironment { + key, _, _ := strings.Cut(entry, "=") + _, serviceScoped := serviceEnvironment[key] + if serviceScoped { + if !envSliceHasKey(baseEnvironment, key) { + environment = append(environment, entry) + } + continue + } + if !envSliceHasKey(environment, key) { + environment = append(environment, entry) + } + } + + return environment +} + +// appendFoundryEnvVars adds values injected by hosted agents. // // The mapping is: // -// AZURE_AI_PROJECT_ID → FOUNDRY_PROJECT_ARM_ID -// AGENT_{SVC}_NAME → FOUNDRY_AGENT_NAME -// AGENT_{SVC}_VERSION → FOUNDRY_AGENT_VERSION -// APPLICATIONINSIGHTS_CONNECTION_STRING (unchanged — already matches platform name) +// FOUNDRY_PROJECT_ENDPOINT -> unchanged +// AZURE_AI_PROJECT_ID -> FOUNDRY_PROJECT_ARM_ID +// AGENT_{SVC}_NAME -> FOUNDRY_AGENT_NAME +// AGENT_{SVC}_VERSION -> FOUNDRY_AGENT_VERSION +// APPLICATIONINSIGHTS_CONNECTION_STRING -> unchanged func appendFoundryEnvVars(env []string, azdEnv map[string]string, serviceName string) []string { - // Static mappings from azd env key names to FOUNDRY_* env var names - staticMappings := []struct { - azdKey string - foundryKey string - }{ - {"AZURE_AI_PROJECT_ID", "FOUNDRY_PROJECT_ARM_ID"}, - } - - for _, m := range staticMappings { - if v := azdEnv[m.azdKey]; v != "" { - if _, exists := azdEnv[m.foundryKey]; !exists && !envSliceHasKey(env, m.foundryKey) { - env = append(env, fmt.Sprintf("%s=%s", m.foundryKey, v)) - } - } + env = appendEnvValue( + env, + "FOUNDRY_PROJECT_ENDPOINT", + azdEnv["FOUNDRY_PROJECT_ENDPOINT"], + ) + + projectArmID := azdEnv["FOUNDRY_PROJECT_ARM_ID"] + if projectArmID == "" { + projectArmID = azdEnv["AZURE_AI_PROJECT_ID"] } + env = appendEnvValue(env, "FOUNDRY_PROJECT_ARM_ID", projectArmID) - // Service-specific mappings (AGENT_{SVC}_NAME → FOUNDRY_AGENT_NAME, etc.) + agentName := "" + agentVersion := "" if serviceName != "" { serviceKey := toServiceKey(serviceName) - agentMappings := []struct { - azdKeyFmt string - foundryKey string - }{ - {"AGENT_%s_NAME", "FOUNDRY_AGENT_NAME"}, - {"AGENT_%s_VERSION", "FOUNDRY_AGENT_VERSION"}, - } - - for _, m := range agentMappings { - azdKey := fmt.Sprintf(m.azdKeyFmt, serviceKey) - if v := azdEnv[azdKey]; v != "" { - if _, exists := azdEnv[m.foundryKey]; !exists && !envSliceHasKey(env, m.foundryKey) { - env = append(env, fmt.Sprintf("%s=%s", m.foundryKey, v)) - } - } - } + agentName = azdEnv[fmt.Sprintf("AGENT_%s_NAME", serviceKey)] + agentVersion = azdEnv[fmt.Sprintf("AGENT_%s_VERSION", serviceKey)] + } + if agentName == "" { + agentName = azdEnv["FOUNDRY_AGENT_NAME"] + } + if agentVersion == "" { + agentVersion = azdEnv["FOUNDRY_AGENT_VERSION"] } + env = appendEnvValue(env, "FOUNDRY_AGENT_NAME", agentName) + env = appendEnvValue(env, "FOUNDRY_AGENT_VERSION", agentVersion) + + env = appendEnvValue( + env, + "APPLICATIONINSIGHTS_CONNECTION_STRING", + azdEnv["APPLICATIONINSIGHTS_CONNECTION_STRING"], + ) return env } +func appendEnvValue(env []string, key string, value string) []string { + if value == "" || envSliceHasKey(env, key) { + return env + } + return append(env, fmt.Sprintf("%s=%s", key, value)) +} + // envSliceHasKey reports whether the env slice already contains an entry for the given key. func envSliceHasKey(env []string, key string) bool { prefix := key + "=" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index d8f9f1d6b40..829d4180c31 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -439,14 +439,15 @@ func createVenv(t *testing.T, projectDir string) string { func TestAppendFoundryEnvVars(t *testing.T) { t.Parallel() - t.Run("does not map FOUNDRY_PROJECT_ENDPOINT to itself", func(t *testing.T) { + t.Run("forwards FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) { t.Parallel() azdEnv := map[string]string{ "FOUNDRY_PROJECT_ENDPOINT": "https://myaccount.services.ai.azure.com/api/projects/myproject", } env := appendFoundryEnvVars(nil, azdEnv, "") - if len(env) != 0 { - t.Errorf("expected no translated env vars, got %v", env) + expected := "FOUNDRY_PROJECT_ENDPOINT=https://myaccount.services.ai.azure.com/api/projects/myproject" + if !slices.Contains(env, expected) { + t.Errorf("expected %q in env, got %v", expected, env) } }) @@ -495,12 +496,12 @@ func TestAppendFoundryEnvVars(t *testing.T) { "AGENT_AGENT1_VERSION": "v1", } env := appendFoundryEnvVars(nil, azdEnv, "agent1") - if len(env) != 3 { - t.Errorf("expected 3 env vars, got %d: %v", len(env), env) + if len(env) != 4 { + t.Errorf("expected 4 env vars, got %d: %v", len(env), env) } }) - t.Run("skips foundry key when already set in azd env", func(t *testing.T) { + t.Run("prefers service-specific agent metadata", func(t *testing.T) { t.Parallel() azdEnv := map[string]string{ "FOUNDRY_PROJECT_ENDPOINT": "https://explicit.services.ai.azure.com", @@ -509,20 +510,26 @@ func TestAppendFoundryEnvVars(t *testing.T) { } env := appendFoundryEnvVars(nil, azdEnv, "my-svc") - // Neither FOUNDRY_PROJECT_ENDPOINT nor FOUNDRY_AGENT_NAME should be - // appended because they already exist in azdEnv (and were thus already - // added to the env slice by the caller's loop over azdEnv). - for _, entry := range env { - if strings.HasPrefix(entry, "FOUNDRY_PROJECT_ENDPOINT=") || - strings.HasPrefix(entry, "FOUNDRY_AGENT_NAME=") { - t.Errorf("should not translate when foundry key already in azdEnv, got %q", entry) - } + if !slices.Contains( + env, + "FOUNDRY_PROJECT_ENDPOINT=https://explicit.services.ai.azure.com", + ) { + t.Errorf("expected project endpoint in env, got %v", env) } + if !slices.Contains(env, "FOUNDRY_AGENT_NAME=my-agent") { + t.Errorf("expected service agent name in env, got %v", env) + } + }) - // AZURE_AI_PROJECT_ID has no explicit FOUNDRY_PROJECT_ARM_ID, so it should still be skipped - // (it's not in azdEnv either, so appendFoundryEnvVars skips it because the source key is empty) - if len(env) != 0 { - t.Errorf("expected no translated env vars, got %v", env) + t.Run("forwards application insights", func(t *testing.T) { + t.Parallel() + azdEnv := map[string]string{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=test", + } + env := appendFoundryEnvVars(nil, azdEnv, "") + expected := "APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=test" + if !slices.Contains(env, expected) { + t.Errorf("expected %q in env, got %v", expected, env) } }) @@ -572,6 +579,94 @@ func TestAppendFoundryEnvVars(t *testing.T) { }) } +func TestMergeAgentRunEnvironment(t *testing.T) { + t.Parallel() + + value := func(environment []string, key string) (string, bool) { + t.Helper() + for i := len(environment) - 1; i >= 0; i-- { + name, entryValue, found := strings.Cut(environment[i], "=") + if found && name == key { + return entryValue, true + } + } + return "", false + } + + t.Run("service env wins without leaking azd values", func(t *testing.T) { + t.Parallel() + environment := mergeAgentRunEnvironment( + []string{"FOO=process", "PORT=8088"}, + map[string]string{ + "FOO": "global", + "BAR": "global", + "UNDECLARED_SECRET": "hidden", + "FOUNDRY_PROJECT_ENDPOINT": "https://project.example", + }, + map[string]string{ + "FOO": "service", + "BAR": "service", + "PORT": "9000", + }, + []string{ + "FOO=service", + "BAR=service", + "PORT=9000", + "LEGACY=declared", + }, + "agent", + ) + + if got, _ := value(environment, "FOO"); got != "process" { + t.Errorf("expected process FOO, got %q", got) + } + if got, _ := value(environment, "BAR"); got != "service" { + t.Errorf("expected service BAR, got %q", got) + } + if got, _ := value(environment, "PORT"); got != "8088" { + t.Errorf("expected command PORT, got %q", got) + } + if _, found := value(environment, "UNDECLARED_SECRET"); found { + t.Errorf("did not expect undeclared azd value in %v", environment) + } + if got, _ := value(environment, "FOUNDRY_PROJECT_ENDPOINT"); got != + "https://project.example" { + t.Errorf("expected Foundry endpoint, got %q", got) + } + if got, _ := value(environment, "LEGACY"); got != "declared" { + t.Errorf("expected declared legacy value, got %q", got) + } + }) + + t.Run("legacy service keeps azd fallback", func(t *testing.T) { + t.Parallel() + environment := mergeAgentRunEnvironment( + []string{"FOO=process"}, + map[string]string{ + "FOO": "global", + "BAR": "global", + "PROJECT_ID": "project", + }, + nil, + []string{"BAR=inline", "BAZ=inline"}, + "agent", + ) + + if got, _ := value(environment, "FOO"); got != "process" { + t.Errorf("expected process FOO, got %q", got) + } + if got, _ := value(environment, "BAR"); got != "global" { + t.Errorf("expected global BAR, got %q", got) + } + if got, _ := value(environment, "PROJECT_ID"); got != "project" { + t.Errorf("expected legacy azd value, got %q", got) + } + if got, _ := value(environment, "BAZ"); got != "inline" { + t.Errorf("expected inline BAZ, got %q", got) + } + }) +} + func TestAppendPortEnvVars(t *testing.T) { t.Parallel() From 2618e5729b56b8e1f41c33c5f03c4bfddafaf14c Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 21 Jul 2026 21:03:01 +0800 Subject: [PATCH 08/12] fix: forward service env through Foundry deployment paths --- .../azure.ai.agents/internal/cmd/run.go | 12 ++- .../azure.ai.agents/internal/cmd/run_test.go | 14 ++- .../internal/project/service_target_agent.go | 11 ++- .../project/service_target_agent_test.go | 45 +++++++++ .../internal/synthesis/synthesizer.go | 98 ++++++++++++++----- .../internal/synthesis/synthesizer_test.go | 10 +- .../foundry_provisioning_provider.go | 97 +++++++++++++----- .../foundry_provisioning_provider_test.go | 95 ++++++++++++++++++ .../internal/synthesis/synthesizer.go | 98 ++++++++++++++----- .../internal/synthesis/synthesizer_test.go | 76 +++++++++++++- 10 files changed, 465 insertions(+), 91 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index eac2462eb6d..ea6921886ec 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -996,6 +996,15 @@ func mergeAgentRunEnvironment( ) } } + } else { + for key, value := range serviceEnvironment { + if !envSliceHasKey(baseEnvironment, key) { + environment = append( + environment, + fmt.Sprintf("%s=%s", key, value), + ) + } + } } environment = appendFoundryEnvVars( @@ -1008,9 +1017,6 @@ func mergeAgentRunEnvironment( key, _, _ := strings.Cut(entry, "=") _, serviceScoped := serviceEnvironment[key] if serviceScoped { - if !envSliceHasKey(baseEnvironment, key) { - environment = append(environment, entry) - } continue } if !envSliceHasKey(environment, key) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index 829d4180c31..591c6193fcd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -604,9 +604,11 @@ func TestMergeAgentRunEnvironment(t *testing.T) { "FOUNDRY_PROJECT_ENDPOINT": "https://project.example", }, map[string]string{ - "FOO": "service", - "BAR": "service", - "PORT": "9000", + "FOO": "service", + "BAR": "service", + "EMPTY": "", + "PORT": "9000", + "SERVICE_ONLY": "service-only", }, []string{ "FOO=service", @@ -636,6 +638,12 @@ func TestMergeAgentRunEnvironment(t *testing.T) { if got, _ := value(environment, "LEGACY"); got != "declared" { t.Errorf("expected declared legacy value, got %q", got) } + if got, _ := value(environment, "SERVICE_ONLY"); got != "service-only" { + t.Errorf("expected service-only value, got %q", got) + } + if got, found := value(environment, "EMPTY"); !found || got != "" { + t.Errorf("expected empty service value, got %q, found %v", got, found) + } }) t.Run("legacy service keeps azd fallback", func(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 8fd70198c84..fa82728ad96 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -15,6 +15,7 @@ import ( "io" "io/fs" "log" + "maps" "net/http" "net/url" "os" @@ -1416,10 +1417,16 @@ func (p *AgentServiceTargetProvider) prepareDeploy( fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["FOUNDRY_PROJECT_ENDPOINT"]) fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - // Resolve environment variables from YAML using azd environment values - resolvedEnvVars := make(map[string]string) + // Seed core-expanded values before resolving legacy variables. + resolvedEnvVars := maps.Clone(serviceConfig.GetEnvironment()) + if resolvedEnvVars == nil { + resolvedEnvVars = make(map[string]string) + } if agentDef.EnvironmentVariables != nil { for _, envVar := range *agentDef.EnvironmentVariables { + if _, found := resolvedEnvVars[envVar.Name]; found { + continue + } resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables( envVar.Name, envVar.Value, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index af7f0f8cc3a..ba684db9b6e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -993,6 +993,51 @@ func TestLoadContainerAgentDefinition_MalformedYAMLReturnsError(t *testing.T) { require.Contains(t, err.Error(), "agent.yaml is not valid") } +func TestPrepareDeployIncludesServiceEnvironment(t *testing.T) { + t.Parallel() + + agentDef := sampleContainerAgent() + *agentDef.EnvironmentVariables = append( + *agentDef.EnvironmentVariables, + agent_yaml.EnvironmentVariable{ + Name: "LEGACY_ONLY", + Value: "${GLOBAL_VALUE}", + }, + agent_yaml.EnvironmentVariable{ + Name: "SHARED", + Value: "${SHARED}", + }, + ) + serviceConfig := &azdext.ServiceConfig{ + Name: "basic-agent", + Environment: map[string]string{ + "SERVICE_ONLY": "literal ${NOT_A_TEMPLATE}", + "SHARED": "service", + }, + } + + prep, err := (&AgentServiceTargetProvider{}).prepareDeploy( + serviceConfig, + agentDef, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://project.example", + "GLOBAL_VALUE": "legacy", + "SHARED": "global", + }, + []agent_yaml.AgentBuildOption{ + agent_yaml.WithImageURL("registry.example/agent:latest"), + }, + ) + require.NoError(t, err) + require.Equal( + t, + "literal ${NOT_A_TEMPLATE}", + prep.resolvedEnvVars["SERVICE_ONLY"], + ) + require.Equal(t, "service", prep.resolvedEnvVars["SHARED"]) + require.Equal(t, "legacy", prep.resolvedEnvVars["LEGACY_ONLY"]) +} + func TestLoadContainerAgentDefinition_EnvPathOverridesInlineDefinition(t *testing.T) { t.Parallel() 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 688911a9737..b3ccd87c8a1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -52,12 +52,16 @@ type Input struct { // value is not checked (only existence and endpoint: are). AcceptedHosts []string - // Env maps azd environment variable names to values. Used to resolve - // ${VAR} references in network fields (subnet vnet ids, dns.subscription). - // When a referenced variable is absent here, the synthesizer falls back - // to the process environment before failing. May be nil. + // Env maps project-wide azd values. + // Network fields always use it. + // Legacy connection services use it when service env is absent. + // Missing values may fall back to the process environment. Env map[string]string + // ServiceEnvironments contains core-expanded values by service. + // Connection fields prefer these values over the legacy Env map. + ServiceEnvironments map[string]map[string]string + // PreserveVarRefs keeps ${VAR} references verbatim instead of resolving // them. Used by the eject path, where the synthesized main.parameters.json // must stay environment-portable: the on-disk provision flow resolves @@ -283,6 +287,7 @@ func Synthesize(in Input) (*Result, error) { connections, err := collectConnections( root.Services, in.Env, + in.ServiceEnvironments, !in.PreserveVarRefs, in.ProjectRoot, ) @@ -354,6 +359,7 @@ func BrownfieldDeployments( func BrownfieldConnections( raw []byte, env map[string]string, + serviceEnvironments map[string]map[string]string, projectRoot string, ) ([]Connection, error) { if len(raw) == 0 { @@ -365,7 +371,13 @@ func BrownfieldConnections( return nil, fmt.Errorf("parse azure.yaml: %w", err) } - return collectConnections(root.Services, env, true, projectRoot) + return collectConnections( + root.Services, + env, + serviceEnvironments, + true, + projectRoot, + ) } // ProjectEndpoint returns the endpoint configured on a Foundry project service. @@ -504,12 +516,13 @@ func agentNeedsAcr(a agentBlock) bool { // (the service key is the connection name) and returns them sorted by name so // the synthesized parameter is deterministic regardless of YAML map order. // -// ${VAR} in target/credentials/metadata is expanded from env when resolve is -// true (provision path) and kept verbatim when false (eject path); Foundry -// ${{...}} expressions are always preserved, mirroring synthesizeNetwork. +// Provisioning resolves ${VAR} from service env when present. +// Legacy services use project and process values. +// Eject keeps references, and Foundry ${{...}} remains unchanged. func collectConnections( services map[string]yaml.Node, env map[string]string, + serviceEnvironments map[string]map[string]string, resolve bool, projectRoot string, ) ([]Connection, error) { @@ -534,17 +547,25 @@ func collectConnections( return nil, fmt.Errorf("services.%s: decode connection: %w", name, err) } - target, err := maybeExpand(svc.Target, env, resolve) + mapping := connectionEnvironmentMapping( + env, + serviceEnvironments[name], + ) + target, err := maybeExpand(svc.Target, mapping, resolve) if err != nil { return nil, fmt.Errorf("services.%s.target: %w", name, err) } - credentials, err := expandCredentials(svc.Credentials, env, resolve) + credentials, err := expandCredentials( + svc.Credentials, + mapping, + resolve, + ) if err != nil { return nil, fmt.Errorf("services.%s.credentials: %w", name, err) } - metadata, err := expandMetadata(svc.Metadata, env, resolve) + metadata, err := expandMetadata(svc.Metadata, mapping, resolve) if err != nil { return nil, fmt.Errorf("services.%s.metadata: %w", name, err) } @@ -565,20 +586,39 @@ func collectConnections( return connections, nil } +// Use scoped values when the service declares env. +// Legacy services use the project and process environments. +func connectionEnvironmentMapping( + env map[string]string, + serviceEnvironment map[string]string, +) func(string) string { + if len(serviceEnvironment) > 0 { + return func(name string) string { + return serviceEnvironment[name] + } + } + + return func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } +} + // maybeExpand expands ${VAR} references in s when resolve is true, preserving // Foundry ${{...}} expressions; when resolve is false it returns s unchanged so // the eject path keeps references verbatim. -func maybeExpand(s string, env map[string]string, resolve bool) (string, error) { +func maybeExpand( + s string, + mapping func(string) string, + resolve bool, +) (string, error) { if !resolve || s == "" { return s, nil } - return foundry.ExpandEnv(s, func(name string) string { - if v, ok := env[name]; ok { - return v - } - v, _ := os.LookupEnv(name) - return v - }) + return foundry.ExpandEnv(s, mapping) } // expandCredentials deep-copies a credentials map, expanding ${VAR} in every @@ -587,7 +627,7 @@ func maybeExpand(s string, env map[string]string, resolve bool) (string, error) // credentials entirely (e.g. None / identity auth). func expandCredentials( creds map[string]any, - env map[string]string, + mapping func(string) string, resolve bool, ) (map[string]any, error) { if creds == nil { @@ -595,7 +635,7 @@ func expandCredentials( } out := make(map[string]any, len(creds)) for k, v := range creds { - expanded, err := expandValue(v, env, resolve) + expanded, err := expandValue(v, mapping, resolve) if err != nil { return nil, err } @@ -606,14 +646,18 @@ func expandCredentials( // expandValue recursively expands ${VAR} in string values, map values, and // slice elements, leaving other types untouched. -func expandValue(v any, env map[string]string, resolve bool) (any, error) { +func expandValue( + v any, + mapping func(string) string, + resolve bool, +) (any, error) { switch val := v.(type) { case string: - return maybeExpand(val, env, resolve) + return maybeExpand(val, mapping, resolve) case map[string]any: out := make(map[string]any, len(val)) for k, inner := range val { - expanded, err := expandValue(inner, env, resolve) + expanded, err := expandValue(inner, mapping, resolve) if err != nil { return nil, err } @@ -623,7 +667,7 @@ func expandValue(v any, env map[string]string, resolve bool) (any, error) { case []any: out := make([]any, len(val)) for i, inner := range val { - expanded, err := expandValue(inner, env, resolve) + expanded, err := expandValue(inner, mapping, resolve) if err != nil { return nil, err } @@ -639,7 +683,7 @@ func expandValue(v any, env map[string]string, resolve bool) (any, error) { // A nil map returns nil so the connection omits metadata entirely. func expandMetadata( metadata map[string]string, - env map[string]string, + mapping func(string) string, resolve bool, ) (map[string]string, error) { if metadata == nil { @@ -647,7 +691,7 @@ func expandMetadata( } out := make(map[string]string, len(metadata)) for k, v := range metadata { - expanded, err := maybeExpand(v, env, resolve) + expanded, err := maybeExpand(v, mapping, resolve) if err != nil { return nil, err } 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 1ce676d260a..5e8d68d4c7a 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 @@ -671,6 +671,7 @@ services: conns, err := BrownfieldConnections( []byte(yaml), map[string]string{"SEARCH_API_KEY": "secret"}, + nil, "", ) require.NoError(t, err) @@ -688,13 +689,18 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil, "") + conns, err := BrownfieldConnections( + []byte(noConns), + nil, + nil, + "", + ) require.NoError(t, err) assert.Empty(t, conns) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil, "") + _, err := BrownfieldConnections(nil, nil, nil, "") require.Error(t, err) }) } 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 0e4aadc7fa1..fa1287ff23a 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 @@ -10,6 +10,7 @@ import ( "fmt" "hash/fnv" "log" + "maps" "net/url" "os" "path/filepath" @@ -63,19 +64,20 @@ type FoundryProvisioningProvider struct { azdClient *azdext.AzdClient // Populated by Initialize. - projectPath string - synthResult *synthesis.Result // nil when onDiskSource != nil - envName string - subID string - location string - rgName string - rgExplicit bool // AZURE_RESOURCE_GROUP came from env, not the rg- default - foundryName string - principalID string - credential azcore.TokenCredential - tenantID string // resolved lazily by ensureCredential; surfaced as AZURE_TENANT_ID - armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set - onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists + projectPath string + synthResult *synthesis.Result // nil when onDiskSource != nil + serviceEnvironments map[string]map[string]string + envName string + subID string + location string + rgName string + rgExplicit bool // AZURE_RESOURCE_GROUP came from env, not the rg- default + foundryName string + principalID string + credential azcore.TokenCredential + tenantID string // resolved lazily by ensureCredential; surfaced as AZURE_TENANT_ID + armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set + onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists // brownfieldEndpoint is the existing project endpoint when the foundry // service sets endpoint: (bring-your-own). When non-empty the provider skips @@ -107,12 +109,11 @@ func NewFoundryProvisioningProvider(azdClient *azdext.AzdClient) azdext.Provisio // and the on-disk Bicep path, and resolves required env values. It rejects // brownfield (endpoint:) and missing services with structured errors. // -// Initialize is cheap by contract: it does no network I/O and builds no -// credential. Tenant lookup and credential construction happen lazily in -// [FoundryProvisioningProvider.ensureCredential]; the bicep CLI is built -// only when an on-disk template actually needs compiling. azd-core may -// call Initialize on providers it never deploys with, so keeping it cheap -// lets pure metadata calls (Parameters, PlannedOutputs) succeed without auth. +// Initialize is cheap and performs no Azure network I/O. +// Credentials are created lazily by ensureCredential. +// The bicep CLI is built only when an on-disk template needs it. +// azd-core may initialize providers it never deploys with, so this +// keeps metadata calls unauthenticated. func (p *FoundryProvisioningProvider) Initialize( ctx context.Context, projectPath string, @@ -144,6 +145,11 @@ func (p *FoundryProvisioningProvider) Initialize( return err } + p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) + if err != nil { + return err + } + // Detect on-disk Bicep before synthesizing. Stat-only; no compile here. if p.onDiskTemplatePresent() { log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ @@ -176,11 +182,12 @@ func (p *FoundryProvisioningProvider) Initialize( } res, err := synthesis.Synthesize(synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - ProjectRoot: projectPath, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: p.networkEnvMap(ctx), + ServiceEnvironments: p.serviceEnvironments, + ProjectRoot: projectPath, }) switch { case errors.Is(err, synthesis.ErrEndpointBrownfield): @@ -251,6 +258,7 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str log.Printf("[debug] foundry provider: no azd client; network ${VAR} uses process env only") return nil } + envClient := p.azdClient.Environment() if envClient == nil { log.Printf("[debug] foundry provider: no environment client; network ${VAR} uses process env only") @@ -276,6 +284,46 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str return out } +// projectServiceEnvironments reads core-expanded service values. +// It keeps service scopes separate for connection synthesis. +func (p *FoundryProvisioningProvider) projectServiceEnvironments( + ctx context.Context, +) (map[string]map[string]string, error) { + if p.azdClient == nil { + return nil, exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "read project service environments: azd client is unavailable", + "restart azd and retry", + ) + } + + response, err := p.azdClient.Project().Get( + ctx, + &azdext.EmptyRequest{}, + ) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeAzdClientFailed, + fmt.Sprintf("read project service environments: %s", err), + "verify the azd project is accessible, then retry", + ) + } + if response.GetProject() == nil { + return nil, exterrors.Internal( + exterrors.CodeInvalidServiceConfig, + "read project service environments: project is missing", + ) + } + + environments := map[string]map[string]string{} + for name, service := range response.GetProject().GetServices() { + if len(service.GetEnvironment()) > 0 { + environments[name] = maps.Clone(service.GetEnvironment()) + } + } + return environments, nil +} + // warnNetworkIgnoredInBrownfield logs a warning when a service declares both // endpoint: (brownfield) and network:. The account's network posture is fixed // by whoever created it, so the network: block has no effect. @@ -749,6 +797,7 @@ func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( connections, err := synthesis.BrownfieldConnections( rawYAML, p.networkEnvMap(ctx), + p.serviceEnvironments, p.projectPath, ) if err != nil { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 6d572aa5c41..3ce84302fd4 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -183,6 +183,101 @@ func TestFoundryProvider_ImplementsContract(t *testing.T) { assert.NotNil(t, p) } +func TestProjectServiceEnvironments(t *testing.T) { + t.Parallel() + + projectServer := &validateStubProjectServer{ + project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + "connection": { + Environment: map[string]string{ + "ENDPOINT": "https://service.example", + }, + }, + "legacy": {}, + }, + }, + } + client := newValidateTestClient( + t, + projectServer, + &validateStubEnvServer{}, + ) + provider := &FoundryProvisioningProvider{azdClient: client} + + environments, err := provider.projectServiceEnvironments(t.Context()) + require.NoError(t, err) + require.Equal( + t, + map[string]map[string]string{ + "connection": { + "ENDPOINT": "https://service.example", + }, + }, + environments, + ) +} + +func TestInitializeUsesConnectionServiceEnvironment(t *testing.T) { + t.Parallel() + + projectPath := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(projectPath, "azure.yaml"), + []byte(` +services: + project: + host: azure.ai.project + connection: + host: azure.ai.connection + uses: [project] + env: + ENDPOINT: ${SEARCH_ENDPOINT} + category: CognitiveSearch + target: ${ENDPOINT} + authType: None +`), + 0o600, + )) + + projectServer := &validateStubProjectServer{ + project: &azdext.ProjectConfig{ + Path: projectPath, + Services: map[string]*azdext.ServiceConfig{ + "connection": { + Environment: map[string]string{ + "ENDPOINT": "https://service.example", + }, + }, + }, + }, + } + client := newValidateTestClient( + t, + projectServer, + &validateStubEnvServer{ + envName: "test", + get: map[string]string{ + envKeySubscriptionID: "00000000-0000-0000-0000-000000000000", + envKeyLocation: "eastus", + }, + }, + ) + provider := &FoundryProvisioningProvider{azdClient: client} + + err := provider.Initialize( + t.Context(), + projectPath, + &azdext.ProvisioningOptions{Provider: FoundryProviderName}, + ) + require.NoError(t, err) + require.NotNil(t, provider.synthResult) + connections, ok := provider.synthResult.Parameters["connections"].([]synthesis.Connection) + require.True(t, ok) + require.Len(t, connections, 1) + require.Equal(t, "https://service.example", connections[0].Target) +} + func TestArmOutputsToProto(t *testing.T) { tests := []struct { name string 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 688911a9737..b3ccd87c8a1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -52,12 +52,16 @@ type Input struct { // value is not checked (only existence and endpoint: are). AcceptedHosts []string - // Env maps azd environment variable names to values. Used to resolve - // ${VAR} references in network fields (subnet vnet ids, dns.subscription). - // When a referenced variable is absent here, the synthesizer falls back - // to the process environment before failing. May be nil. + // Env maps project-wide azd values. + // Network fields always use it. + // Legacy connection services use it when service env is absent. + // Missing values may fall back to the process environment. Env map[string]string + // ServiceEnvironments contains core-expanded values by service. + // Connection fields prefer these values over the legacy Env map. + ServiceEnvironments map[string]map[string]string + // PreserveVarRefs keeps ${VAR} references verbatim instead of resolving // them. Used by the eject path, where the synthesized main.parameters.json // must stay environment-portable: the on-disk provision flow resolves @@ -283,6 +287,7 @@ func Synthesize(in Input) (*Result, error) { connections, err := collectConnections( root.Services, in.Env, + in.ServiceEnvironments, !in.PreserveVarRefs, in.ProjectRoot, ) @@ -354,6 +359,7 @@ func BrownfieldDeployments( func BrownfieldConnections( raw []byte, env map[string]string, + serviceEnvironments map[string]map[string]string, projectRoot string, ) ([]Connection, error) { if len(raw) == 0 { @@ -365,7 +371,13 @@ func BrownfieldConnections( return nil, fmt.Errorf("parse azure.yaml: %w", err) } - return collectConnections(root.Services, env, true, projectRoot) + return collectConnections( + root.Services, + env, + serviceEnvironments, + true, + projectRoot, + ) } // ProjectEndpoint returns the endpoint configured on a Foundry project service. @@ -504,12 +516,13 @@ func agentNeedsAcr(a agentBlock) bool { // (the service key is the connection name) and returns them sorted by name so // the synthesized parameter is deterministic regardless of YAML map order. // -// ${VAR} in target/credentials/metadata is expanded from env when resolve is -// true (provision path) and kept verbatim when false (eject path); Foundry -// ${{...}} expressions are always preserved, mirroring synthesizeNetwork. +// Provisioning resolves ${VAR} from service env when present. +// Legacy services use project and process values. +// Eject keeps references, and Foundry ${{...}} remains unchanged. func collectConnections( services map[string]yaml.Node, env map[string]string, + serviceEnvironments map[string]map[string]string, resolve bool, projectRoot string, ) ([]Connection, error) { @@ -534,17 +547,25 @@ func collectConnections( return nil, fmt.Errorf("services.%s: decode connection: %w", name, err) } - target, err := maybeExpand(svc.Target, env, resolve) + mapping := connectionEnvironmentMapping( + env, + serviceEnvironments[name], + ) + target, err := maybeExpand(svc.Target, mapping, resolve) if err != nil { return nil, fmt.Errorf("services.%s.target: %w", name, err) } - credentials, err := expandCredentials(svc.Credentials, env, resolve) + credentials, err := expandCredentials( + svc.Credentials, + mapping, + resolve, + ) if err != nil { return nil, fmt.Errorf("services.%s.credentials: %w", name, err) } - metadata, err := expandMetadata(svc.Metadata, env, resolve) + metadata, err := expandMetadata(svc.Metadata, mapping, resolve) if err != nil { return nil, fmt.Errorf("services.%s.metadata: %w", name, err) } @@ -565,20 +586,39 @@ func collectConnections( return connections, nil } +// Use scoped values when the service declares env. +// Legacy services use the project and process environments. +func connectionEnvironmentMapping( + env map[string]string, + serviceEnvironment map[string]string, +) func(string) string { + if len(serviceEnvironment) > 0 { + return func(name string) string { + return serviceEnvironment[name] + } + } + + return func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } +} + // maybeExpand expands ${VAR} references in s when resolve is true, preserving // Foundry ${{...}} expressions; when resolve is false it returns s unchanged so // the eject path keeps references verbatim. -func maybeExpand(s string, env map[string]string, resolve bool) (string, error) { +func maybeExpand( + s string, + mapping func(string) string, + resolve bool, +) (string, error) { if !resolve || s == "" { return s, nil } - return foundry.ExpandEnv(s, func(name string) string { - if v, ok := env[name]; ok { - return v - } - v, _ := os.LookupEnv(name) - return v - }) + return foundry.ExpandEnv(s, mapping) } // expandCredentials deep-copies a credentials map, expanding ${VAR} in every @@ -587,7 +627,7 @@ func maybeExpand(s string, env map[string]string, resolve bool) (string, error) // credentials entirely (e.g. None / identity auth). func expandCredentials( creds map[string]any, - env map[string]string, + mapping func(string) string, resolve bool, ) (map[string]any, error) { if creds == nil { @@ -595,7 +635,7 @@ func expandCredentials( } out := make(map[string]any, len(creds)) for k, v := range creds { - expanded, err := expandValue(v, env, resolve) + expanded, err := expandValue(v, mapping, resolve) if err != nil { return nil, err } @@ -606,14 +646,18 @@ func expandCredentials( // expandValue recursively expands ${VAR} in string values, map values, and // slice elements, leaving other types untouched. -func expandValue(v any, env map[string]string, resolve bool) (any, error) { +func expandValue( + v any, + mapping func(string) string, + resolve bool, +) (any, error) { switch val := v.(type) { case string: - return maybeExpand(val, env, resolve) + return maybeExpand(val, mapping, resolve) case map[string]any: out := make(map[string]any, len(val)) for k, inner := range val { - expanded, err := expandValue(inner, env, resolve) + expanded, err := expandValue(inner, mapping, resolve) if err != nil { return nil, err } @@ -623,7 +667,7 @@ func expandValue(v any, env map[string]string, resolve bool) (any, error) { case []any: out := make([]any, len(val)) for i, inner := range val { - expanded, err := expandValue(inner, env, resolve) + expanded, err := expandValue(inner, mapping, resolve) if err != nil { return nil, err } @@ -639,7 +683,7 @@ func expandValue(v any, env map[string]string, resolve bool) (any, error) { // A nil map returns nil so the connection omits metadata entirely. func expandMetadata( metadata map[string]string, - env map[string]string, + mapping func(string) string, resolve bool, ) (map[string]string, error) { if metadata == nil { @@ -647,7 +691,7 @@ func expandMetadata( } out := make(map[string]string, len(metadata)) for k, v := range metadata { - expanded, err := maybeExpand(v, env, resolve) + expanded, err := maybeExpand(v, mapping, resolve) if err != nil { return nil, err } 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 ebd272c0bf2..63223f1b3a3 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 @@ -566,6 +566,51 @@ services: assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) }) + t.Run("service env takes precedence and isolates lookup", func(t *testing.T) { + const serviceEnvYAML = ` +services: + my-project: + host: azure.ai.project + mcp-conn: + host: azure.ai.connection + uses: [my-project] + env: + ENDPOINT: ${MCP_URL} + KEY: ${MCP_KEY} + category: RemoteTool + target: ${ENDPOINT} + authType: CustomKeys + credentials: + keys: + x-api-key: ${KEY} + metadata: + owner: ${OWNER:-service-default} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(serviceEnvYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{ + "ENDPOINT": "https://wrong.example/mcp", + "KEY": "wrong-secret", + "OWNER": "wrong-owner", + }, + ServiceEnvironments: map[string]map[string]string{ + "mcp-conn": { + "ENDPOINT": "https://service.example/mcp", + "KEY": "service-secret", + }, + }, + }) + require.NoError(t, err) + + c := getConn(t, res) + assert.Equal(t, "https://service.example/mcp", c.Target) + keys := c.Credentials["keys"].(map[string]any) + assert.Equal(t, "service-secret", keys["x-api-key"]) + assert.Equal(t, "service-default", c.Metadata["owner"]) + }) + t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), @@ -661,6 +706,7 @@ services: conns, err := BrownfieldConnections( []byte(yaml), map[string]string{"SEARCH_API_KEY": "secret"}, + nil, "", ) require.NoError(t, err) @@ -671,6 +717,20 @@ services: assert.Equal(t, "secret", conns[1].Credentials["key"]) }) + t.Run("service environment takes precedence", func(t *testing.T) { + conns, err := BrownfieldConnections( + []byte(yaml), + map[string]string{"SEARCH_API_KEY": "global"}, + map[string]map[string]string{ + "search-conn": {"SEARCH_API_KEY": "service"}, + }, + "", + ) + require.NoError(t, err) + require.Len(t, conns, 2) + assert.Equal(t, "service", conns[1].Credentials["key"]) + }) + t.Run("no connection services yields empty slice", func(t *testing.T) { const noConns = ` services: @@ -678,13 +738,18 @@ services: host: azure.ai.project endpoint: https://existing.services.ai.azure.com/api/projects/p1 ` - conns, err := BrownfieldConnections([]byte(noConns), nil, "") + conns, err := BrownfieldConnections( + []byte(noConns), + nil, + nil, + "", + ) require.NoError(t, err) assert.Empty(t, conns) }) t.Run("empty raw errors", func(t *testing.T) { - _, err := BrownfieldConnections(nil, nil, "") + _, err := BrownfieldConnections(nil, nil, nil, "") require.Error(t, err) }) } @@ -971,7 +1036,12 @@ services: require.Len(t, deployments, 1) assert.Equal(t, "gpt-4o", deployments[0].Name) - connections, err := BrownfieldConnections([]byte(yaml), nil, root) + connections, err := BrownfieldConnections( + []byte(yaml), + nil, + nil, + root, + ) require.NoError(t, err) require.Len(t, connections, 1) assert.Equal(t, "CognitiveSearch", connections[0].Category) From 2b33122bee153097fcfabd3c7bf3fa207a47cb14 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 22 Jul 2026 16:03:31 +0800 Subject: [PATCH 09/12] fix: restore Bicep modules before linting --- eng/scripts/Test-BicepLint.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/eng/scripts/Test-BicepLint.ps1 b/eng/scripts/Test-BicepLint.ps1 index b8bbcfffa5e..f3b8fd93ffc 100644 --- a/eng/scripts/Test-BicepLint.ps1 +++ b/eng/scripts/Test-BicepLint.ps1 @@ -30,6 +30,20 @@ function parseErrorLine($line) { $bicepFiles = Get-ChildItem "$Path/*.bicep" -Recurse -Force | Where-Object { $_.FullName -notlike "*cli/azd/test/functional/testdata/snaps*" } +# Restore modules before parallel builds to prevent BCP190. +foreach ($file in $bicepFiles) { + Write-Verbose "Restoring modules for $file..." -Verbose:$Verbose + $restoreErrors = $( bicep restore $file ) 2>&1 + $restoreExitCode = $LASTEXITCODE + if ($restoreExitCode -eq 0) { + continue + } + + Write-Host "Failed to restore modules for $file" + $restoreErrors | ForEach-Object { Write-Host $_ } + exit $restoreExitCode +} + # Running bicep in parallel reduce run time from ~52 seconds to ~11 seconds on a # machine with 4 cores with hyper threading. No significant improvements seen # when increasing `-ThrottleLimit`. From d67f4a3e620d34b379133b4af6b0b9c61b0ba68b Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 23 Jul 2026 18:11:12 +0800 Subject: [PATCH 10/12] ci: drop unrelated Bicep restore change --- eng/scripts/Test-BicepLint.ps1 | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/eng/scripts/Test-BicepLint.ps1 b/eng/scripts/Test-BicepLint.ps1 index f3b8fd93ffc..b8bbcfffa5e 100644 --- a/eng/scripts/Test-BicepLint.ps1 +++ b/eng/scripts/Test-BicepLint.ps1 @@ -30,20 +30,6 @@ function parseErrorLine($line) { $bicepFiles = Get-ChildItem "$Path/*.bicep" -Recurse -Force | Where-Object { $_.FullName -notlike "*cli/azd/test/functional/testdata/snaps*" } -# Restore modules before parallel builds to prevent BCP190. -foreach ($file in $bicepFiles) { - Write-Verbose "Restoring modules for $file..." -Verbose:$Verbose - $restoreErrors = $( bicep restore $file ) 2>&1 - $restoreExitCode = $LASTEXITCODE - if ($restoreExitCode -eq 0) { - continue - } - - Write-Host "Failed to restore modules for $file" - $restoreErrors | ForEach-Object { Write-Host $_ } - exit $restoreExitCode -} - # Running bicep in parallel reduce run time from ~52 seconds to ~11 seconds on a # machine with 4 cores with hyper threading. No significant improvements seen # when increasing `-ThrottleLimit`. From fea0f4988cd963d03e82d9e8f75685516041995a Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 23 Jul 2026 18:41:38 +0800 Subject: [PATCH 11/12] fix: preserve Foundry environment migration semantics --- .../internal/cmd/optimize_apply.go | 87 ++++++++----- .../internal/cmd/optimize_apply_test.go | 121 +++++++++++++++++- .../internal/cmd/resource_services.go | 6 +- .../internal/cmd/resource_services_test.go | 21 ++- .../azure.ai.agents/internal/cmd/run.go | 77 +++++++++-- .../azure.ai.agents/internal/cmd/run_test.go | 43 +++++++ .../internal/project/agent_definition.go | 11 +- .../internal/project/agent_definition_test.go | 42 ++++-- .../schemas/examples/complex.azure.yaml | 1 + .../schemas/azure.ai.connection.json | 2 +- .../internal/synthesis/synthesizer_test.go | 39 ++++-- 11 files changed, 382 insertions(+), 68 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index bc323af6b07..cdd2ee2ccab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go @@ -25,6 +25,7 @@ import ( "azureaiagent/internal/pkg/agents/opt_eval" "azureaiagent/internal/pkg/agents/optimize_api" "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/pkg/projectconfig" projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -259,6 +260,17 @@ func persistInlineAgentEnvironment( svc *azdext.ServiceConfig, envUpdates map[string]string, ) error { + _, _, found, source, err := projectpkg.AgentDefinitionFromService(svc) + if err != nil { + return fmt.Errorf("failed to read agent definition: %w", err) + } + if !found { + return fmt.Errorf( + "service %q does not carry an inline agent definition", + svc.GetName(), + ) + } + // Build the env to persist from raw templates only, never from the // core-expanded svc.Environment. AddService escapes env values to // literals, so routing templates through it would freeze a ${VAR} @@ -267,7 +279,7 @@ func persistInlineAgentEnvironment( if err != nil { return fmt.Errorf("failed to read agent environment: %w", err) } - existingEnv, err := getRawServiceEnv(ctx, azdClient, svc.Name) + existingEnv, err := getRawServiceEnv(ctx, azdClient, svc) if err != nil { return err } @@ -279,37 +291,26 @@ func persistInlineAgentEnvironment( maps.Copy(mergedEnv, existingEnv) maps.Copy(mergedEnv, envUpdates) - // UpsertAgentEnvVars drops the deprecated environmentVariables and - // sets svc.Environment; clear it so AddService leaves env alone. The - // merged raw templates are written below via setServiceEnvironment. - if err := projectpkg.UpsertAgentEnvVars(svc, envUpdates); err != nil { - return fmt.Errorf("failed to update agent definition: %w", err) - } - svc.Environment = nil - - // Preserve core-only uses dependency edges. - prevUses, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ - ServiceName: svc.Name, - Path: "uses", - }) - if err != nil { - return fmt.Errorf("failed to read uses for service %q: %w", svc.Name, err) - } - if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { - return fmt.Errorf("failed to persist agent definition: %w", err) - } if err := setServiceEnvironment(ctx, azdClient, svc.Name, mergedEnv); err != nil { return err } - // Restore `uses:` if it was set before the replacement. - if prevUses.GetFound() && prevUses.GetValue() != nil { - if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + + environmentPath := "environmentVariables" + if source == projectpkg.AgentDefinitionSourceLegacyConfig { + environmentPath = "config.environmentVariables" + } + if _, err := azdClient.Project().UnsetServiceConfig( + ctx, + &azdext.UnsetServiceConfigRequest{ ServiceName: svc.Name, - Path: "uses", - Value: prevUses.GetValue(), - }); err != nil { - return fmt.Errorf("failed to restore uses for service %q: %w", svc.Name, err) - } + Path: environmentPath, + }, + ); err != nil { + return fmt.Errorf( + "removing deprecated environmentVariables from service %q: %w", + svc.Name, + err, + ) } return nil } @@ -321,8 +322,9 @@ func persistInlineAgentEnvironment( func getRawServiceEnv( ctx context.Context, azdClient *azdext.AzdClient, - serviceName string, + svc *azdext.ServiceConfig, ) (map[string]string, error) { + serviceName := svc.GetName() resp, err := azdClient.Project().GetServiceConfigValue( ctx, &azdext.GetServiceConfigValueRequest{ @@ -341,11 +343,36 @@ func getRawServiceEnv( if !ok { return nil, nil } + originalRaw := maps.Clone(raw) + properties := map[string]any{"env": raw} + if err := projectconfig.NormalizeEnvironment(properties); err != nil { + return nil, fmt.Errorf( + "normalizing env for service %q: %w", + serviceName, + err, + ) + } env := make(map[string]string, len(raw)) for key, value := range raw { - if str, ok := value.(string); ok { + if original, wasString := originalRaw[key].(string); wasString { + env[key] = original + continue + } + if forwarded, found := svc.GetEnvironment()[key]; found { + env[key] = forwarded + continue + } + str, ok := value.(string) + if ok { env[key] = str + continue } + return nil, fmt.Errorf( + "normalizing env %q for service %q produced %T", + key, + serviceName, + value, + ) } return env, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go index 083ab82c5df..c03ab66de2d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply_test.go @@ -89,8 +89,12 @@ func TestPersistInlineAgentEnvironmentMigratesLegacyTemplates(t *testing.T) { server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - require.Empty(t, server.added[0].GetEnvironment()) + require.Empty(t, server.added) + require.Equal( + t, + []string{"config.environmentVariables"}, + server.unsetPaths, + ) require.Equal(t, map[string]any{ "LEGACY_KEY": "${LEGACY_KEY}", "OPTIMIZATION_CANDIDATE_ID": "candidate-1", @@ -144,8 +148,12 @@ func TestPersistInlineAgentEnvironmentPreservesTopLevelEnv(t *testing.T) { server.mu.Lock() defer server.mu.Unlock() - require.Len(t, server.added, 1) - require.Empty(t, server.added[0].GetEnvironment()) + require.Empty(t, server.added) + require.Equal( + t, + []string{"environmentVariables"}, + server.unsetPaths, + ) require.Equal(t, map[string]any{ "LOG_LEVEL": "${AZURE_LOG_LEVEL}", "MODEL_ENDPOINT": "$${{project.endpoint}}", @@ -195,12 +203,117 @@ func TestPersistInlineAgentEnvironmentEscapesLegacyFoundrySpan(t *testing.T) { server.mu.Lock() defer server.mu.Unlock() + require.Equal( + t, + []string{"environmentVariables"}, + server.unsetPaths, + ) require.Equal(t, map[string]any{ "SEARCH_KEY": "$${{connections.search.credentials.key}}", "OPTIMIZATION_CANDIDATE_ID": "candidate-1", }, server.env["basic-agent"]) } +func TestPersistInlineAgentEnvironmentNormalizesScalars(t *testing.T) { + props, err := projectpkg.AgentDefinitionToServiceProperties( + agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + }, + nil, + ) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: AiAgentHost, + AdditionalProperties: props, + Environment: map[string]string{ + "LARGE": "9007199254740993", + }, + } + server := &recordingProjectServer{ + rawEnv: map[string]map[string]any{ + "basic-agent": { + "ENABLED": true, + "RETRIES": float64(3), + "EMPTY": nil, + "LARGE": float64(9007199254740992), + }, + }, + } + + client := newProjectRecorderClient(t, server) + require.NoError(t, persistInlineAgentEnvironment( + t.Context(), + client, + svc, + map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, map[string]any{ + "ENABLED": "true", + "RETRIES": "3", + "EMPTY": "", + "LARGE": "9007199254740993", + "OPTIMIZATION_CANDIDATE_ID": "candidate-1", + }, server.env["basic-agent"]) +} + +func TestPersistInlineAgentEnvironmentKeepsLegacyOnEnvFailure( + t *testing.T, +) { + props, err := projectpkg.AgentDefinitionToServiceProperties( + agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + }, + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "LEGACY_KEY", + "value": "${LEGACY_KEY}", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: AiAgentHost, + AdditionalProperties: props, + } + server := &recordingProjectServer{ + setEnvironmentErr: fmt.Errorf("write failed"), + } + + client := newProjectRecorderClient(t, server) + err = persistInlineAgentEnvironment( + t.Context(), + client, + svc, + map[string]string{"OPTIMIZATION_CANDIDATE_ID": "candidate-1"}, + ) + + require.ErrorContains(t, err, "write failed") + server.mu.Lock() + defer server.mu.Unlock() + require.Empty(t, server.unsetPaths) + require.Empty(t, server.added) +} + // ---- printPreviewLines ---- func TestPrintPreviewLines(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index b5e18018962..2aa7017fa1a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -306,6 +306,8 @@ func addResourceService( return nil } +// serviceEnvironmentTemplates discovers client-side templates in the +// generic nested resource config emitted to azure.yaml. func serviceEnvironmentTemplates(cfg *structpb.Struct) map[string]string { if cfg == nil { return nil @@ -355,7 +357,7 @@ func collectStringEnvironmentTemplates(value string, environment map[string]stri offset = start + 2 continue } - // Forward the canonical bare ${NAME}, not the full matched span. + // env is keyed by name, so store one canonical ${NAME}. // A ${NAME:-default} default is re-applied by the owning // extension against the raw config at deploy, so the env section // only needs NAME's resolved base value. Collapsing every form of @@ -455,6 +457,8 @@ func escapeFoundryTemplates(value string) string { return b.String() } +// setServiceEnvironment writes raw templates through the config RPC. +// AddService treats ServiceConfig.Environment as expanded literals. func setServiceEnvironment( ctx context.Context, azdClient *azdext.AzdClient, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index ad3f4dabaff..a479a247155 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -425,7 +425,10 @@ type recordingProjectServer struct { // rawEnv is returned by GetServiceConfigValue for path "env" to // simulate a service that already carries an env section (raw, // on-disk templates). - rawEnv map[string]map[string]any + rawEnv map[string]map[string]any + unsetPaths []string + setEnvironmentErr error + unsetServiceConfigErr error } // configValueRecord captures a single SetServiceConfigValue call. @@ -511,6 +514,9 @@ func (s *recordingProjectServer) SetServiceConfigSection( ) (*azdext.EmptyResponse, error) { s.mu.Lock() defer s.mu.Unlock() + if s.setEnvironmentErr != nil { + return nil, s.setEnvironmentErr + } if s.env == nil { s.env = map[string]map[string]any{} } @@ -520,6 +526,19 @@ func (s *recordingProjectServer) SetServiceConfigSection( return &azdext.EmptyResponse{}, nil } +func (s *recordingProjectServer) UnsetServiceConfig( + _ context.Context, + req *azdext.UnsetServiceConfigRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.unsetPaths = append(s.unsetPaths, req.Path) + if s.unsetServiceConfigErr != nil { + return nil, s.unsetServiceConfigErr + } + return &azdext.EmptyResponse{}, nil +} + // newProjectRecorderClient spins up an in-process gRPC server backed by the // supplied project server stub and returns a client wired to its address. func newProjectRecorderClient(t *testing.T, server azdext.ProjectServiceServer) *azdext.AzdClient { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index dbe7a8663d2..cbfbd7bc787 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log" + "maps" "net" "net/http" "os" @@ -197,10 +198,19 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { } endpoint, _ := resolveAgentEndpoint(ctx, "", "") + endpoint = localProjectEndpoint( + env, + runCtx.ServiceEnvironment, + endpoint, + ) + serviceEnvironment := resolveLocalServiceEnvironment( + runCtx.ServiceEnvironment, + endpoint, + ) defEnv, defErr := resolveAgentDefinitionEnvVars( ctx, runCtx.Definition, - runCtx.ServiceEnvironment, + serviceEnvironment, azdEnvVars, endpoint, ) @@ -210,7 +220,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { env = mergeAgentRunEnvironment( env, azdEnvVars, - runCtx.ServiceEnvironment, + serviceEnvironment, defEnv, runCtx.ServiceName, ) @@ -562,6 +572,41 @@ func resolveAgentDefinitionEnvVars( return result, nil } +func resolveLocalServiceEnvironment( + environment map[string]string, + endpoint string, +) map[string]string { + resolved := maps.Clone(environment) + if endpoint == "" { + return resolved + } + for key, value := range resolved { + resolved[key] = strings.ReplaceAll( + value, + "${{project.endpoint}}", + endpoint, + ) + } + return resolved +} + +func localProjectEndpoint( + baseEnvironment []string, + serviceEnvironment map[string]string, + fallback string, +) string { + if value, found := envSliceValue( + baseEnvironment, + "FOUNDRY_PROJECT_ENDPOINT", + ); found { + return value + } + if value, found := serviceEnvironment["FOUNDRY_PROJECT_ENDPOINT"]; found { + return value + } + return fallback +} + // findAgentYaml locates the agent definition file in the given directory. // After `azd ai agent init`, agent.yaml (and azure.yaml) are the sources of // truth for the agent configuration. We intentionally do not look at @@ -972,6 +1017,11 @@ func findSystemPython() (pythonInterpreter, error) { } // mergeAgentRunEnvironment builds the local agent environment. +// +// baseEnvironment contains process and command-owned values. +// azdEnvironment is the full active environment for legacy fallback. +// serviceEnvironment is core-expanded services..env. +// definitionEnvironment comes from legacy agent definitions. func mergeAgentRunEnvironment( baseEnvironment []string, azdEnvironment map[string]string, @@ -1078,16 +1128,27 @@ func appendEnvValue(env []string, key string, value string) []string { // envSliceHasKey reports whether env contains an entry for key. func envSliceHasKey(env []string, key string) bool { - return slices.ContainsFunc(env, func(entry string) bool { - entryKey, _, found := strings.Cut(entry, "=") + _, found := envSliceValue(env, key) + return found +} + +func envSliceValue(env []string, key string) (string, bool) { + for _, entry := range env { + entryKey, value, found := strings.Cut(entry, "=") if !found { - return false + continue } if runtime.GOOS == "windows" { - return strings.EqualFold(entryKey, key) + if strings.EqualFold(entryKey, key) { + return value, true + } + continue } - return entryKey == key - }) + if entryKey == key { + return value, true + } + } + return "", false } // loadAzdEnvironment reads all key-value pairs from the current azd environment. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index e230106ab5c..83fef235114 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -675,6 +675,49 @@ func TestMergeAgentRunEnvironment(t *testing.T) { }) } +func TestResolveLocalServiceEnvironment(t *testing.T) { + t.Parallel() + + original := map[string]string{ + "MODEL_ENDPOINT": "${{project.endpoint}}/models", + "LITERAL": "literal ${NOT_A_TEMPLATE}", + } + endpoint := localProjectEndpoint( + []string{"FOUNDRY_PROJECT_ENDPOINT=https://process.example"}, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://service.example", + }, + "https://azd.example", + ) + resolved := resolveLocalServiceEnvironment( + original, + endpoint, + ) + + if got := resolved["MODEL_ENDPOINT"]; got != + "https://process.example/models" { + t.Errorf("expected resolved endpoint, got %q", got) + } + if got := resolved["LITERAL"]; got != "literal ${NOT_A_TEMPLATE}" { + t.Errorf("expected literal value, got %q", got) + } + if got := original["MODEL_ENDPOINT"]; got != + "${{project.endpoint}}/models" { + t.Errorf("expected original map to stay unchanged, got %q", got) + } + + serviceEndpoint := localProjectEndpoint( + nil, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://service.example", + }, + "https://azd.example", + ) + if serviceEndpoint != "https://service.example" { + t.Errorf("expected service endpoint, got %q", serviceEndpoint) + } +} + func TestEnvSliceHasKeyUsesPlatformCasing(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 514cc247e9b..92de5663f76 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -181,7 +181,15 @@ func ResolveAgentEnvironmentVariable( if environmentValue, found := serviceEnvironment[name]; found { return environmentValue, nil } - return ExpandEnv(value, mapping) + return ExpandEnv(value, func(variableName string) string { + if environmentValue, found := serviceEnvironment[variableName]; found { + return environmentValue + } + if mapping == nil { + return "" + } + return mapping(variableName) + }) } func environmentVariablesFromMap( @@ -533,6 +541,7 @@ func validateRootRefCoreFields( return err } for _, field := range []string{ + "env", "project", "language", "image", diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index e4e0aa6f4d1..f10e818124e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -195,18 +195,35 @@ func TestInlineAgentEnvironmentVariables(t *testing.T) { func TestResolveAgentEnvironmentVariable(t *testing.T) { t.Parallel() - value, err := ResolveAgentEnvironmentVariable( - "FORWARDED_VALUE", - "${FORWARDED_VALUE}", - map[string]string{ - "FORWARDED_VALUE": "literal ${NOT_A_TEMPLATE}", - }, - func(string) string { - return "expanded" - }, - ) - require.NoError(t, err) - require.Equal(t, "literal ${NOT_A_TEMPLATE}", value) + t.Run("preserves same-name core value", func(t *testing.T) { + value, err := ResolveAgentEnvironmentVariable( + "FORWARDED_VALUE", + "${FORWARDED_VALUE}", + map[string]string{ + "FORWARDED_VALUE": "literal ${NOT_A_TEMPLATE}", + }, + func(string) string { + return "expanded" + }, + ) + require.NoError(t, err) + require.Equal(t, "literal ${NOT_A_TEMPLATE}", value) + }) + + t.Run("resolves aliases from service env first", func(t *testing.T) { + value, err := ResolveAgentEnvironmentVariable( + "TARGET", + "${SERVICE_ENDPOINT}", + map[string]string{ + "SERVICE_ENDPOINT": "https://service.example", + }, + func(string) string { + return "https://project.example" + }, + ) + require.NoError(t, err) + require.Equal(t, "https://service.example", value) + }) } func TestLoadAgentDefinition_UnrelatedInlineFallsBackToConfig( @@ -513,6 +530,7 @@ func TestResolveServiceConfigInPlaceRejectsCoreFieldsFromRootRef(t *testing.T) { name string value string }{ + {name: "env", value: "env:\n LOG_LEVEL: info\n"}, {name: "project", value: "project: src/agent\n"}, {name: "language", value: "language: docker\n"}, {name: "image", value: "image: registry.example/agent:v1\n"}, diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 4889c2f7b03..b4165873bb3 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml @@ -110,6 +110,7 @@ services: - research-tools env: LOG_LEVEL: info + # The extra $ preserves this Foundry expression through azd. MODEL_ENDPOINT: $${{project.endpoint}} protocols: - protocol: a2a diff --git a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json index c4f76754581..831ee68087f 100644 --- a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json +++ b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json @@ -38,7 +38,7 @@ }, "credentials": { "type": "object", - "description": "Credentials. Values may contain ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution.", + "description": "Credentials. Use ${VAR} for azd service env substitution or ${{...}} for Foundry server-side resolution. In a service env value, use $${{...}} so azd forwards the Foundry expression unchanged.", "additionalProperties": true }, "metadata": { 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 9299650e8fe..1f8e0bcad6e 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 @@ -543,6 +543,14 @@ services: require.Len(t, conns, 1) return conns[0] } + getKeys := func(t *testing.T, c Connection) map[string]any { + t.Helper() + value, found := c.Credentials["keys"] + require.True(t, found, "credentials should contain keys") + keys, ok := value.(map[string]any) + require.True(t, ok, "keys should be a map, got %T", value) + return keys + } t.Run("provision path resolves ${VAR}", func(t *testing.T) { res, err := Synthesize(Input{ @@ -555,15 +563,27 @@ services: c := getConn(t, res) assert.Equal(t, "https://mcp.example.com/mcp", c.Target) - keys, ok := c.Credentials["keys"].(map[string]any) - require.True(t, ok, "keys should be a nested map, got %T", c.Credentials["keys"]) + keys := getKeys(t, c) assert.Equal(t, "secret-value", keys["x-api-key"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) - publicConnections := res.Parameters["connections"].([]Connection) + publicValue, found := res.Parameters["connections"] + require.True(t, found) + publicConnections, ok := publicValue.([]Connection) + require.True(t, ok, "connections should be []Connection") + require.Len(t, publicConnections, 1) assert.Nil(t, publicConnections[0].Credentials) - secureCredentials := res.Parameters["connectionCredentials"].(map[string]map[string]any) - assert.Equal(t, "secret-value", secureCredentials["mcp-conn"]["keys"].(map[string]any)["x-api-key"]) + secureValue, found := res.Parameters["connectionCredentials"] + require.True(t, found) + secureCredentials, ok := secureValue.(map[string]map[string]any) + require.True(t, ok, "connectionCredentials should be a map") + connectionCredentials, found := secureCredentials["mcp-conn"] + require.True(t, found) + keyValue, found := connectionCredentials["keys"] + require.True(t, found) + secureKeys, ok := keyValue.(map[string]any) + require.True(t, ok, "secure keys should be a map") + assert.Equal(t, "secret-value", secureKeys["x-api-key"]) }) t.Run("service env takes precedence and isolates lookup", func(t *testing.T) { @@ -606,7 +626,7 @@ services: c := getConn(t, res) assert.Equal(t, "https://service.example/mcp", c.Target) - keys := c.Credentials["keys"].(map[string]any) + keys := getKeys(t, c) assert.Equal(t, "service-secret", keys["x-api-key"]) assert.Equal(t, "service-default", c.Metadata["owner"]) }) @@ -623,8 +643,7 @@ services: c := getConn(t, res) assert.Equal(t, "${MCP_URL}", c.Target) - keys, ok := c.Credentials["keys"].(map[string]any) - require.True(t, ok) + keys := getKeys(t, c) assert.Equal(t, "${MCP_KEY}", keys["x-api-key"]) assert.Equal(t, "${MCP_OWNER}", c.Metadata["owner"]) }) @@ -653,7 +672,7 @@ services: require.NoError(t, err) c := getConn(t, res) - keys := c.Credentials["keys"].(map[string]any) + keys := getKeys(t, c) assert.Equal(t, "${{connections.other.credentials.key}}", keys["x-api-key"]) }) @@ -672,7 +691,7 @@ services: c := getConn(t, res) assert.Equal(t, "", c.Target) - keys := c.Credentials["keys"].(map[string]any) + keys := getKeys(t, c) assert.Equal(t, "", keys["x-api-key"]) }) } From ce4d70aed9e957d1e35377e84db9f4d4ccc5ddf5 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 23 Jul 2026 21:15:21 +0800 Subject: [PATCH 12/12] fix: honor explicit empty service env as an isolated scope --- .../azure.ai.agents/internal/cmd/helpers.go | 28 +++++++++++----- .../azure.ai.agents/internal/cmd/run.go | 6 +++- .../azure.ai.agents/internal/cmd/run_test.go | 20 +++++++++++ .../internal/synthesis/synthesizer.go | 19 ++++++++++- .../internal/synthesis/synthesizer.go | 19 ++++++++++- .../internal/synthesis/synthesizer_test.go | 33 +++++++++++++++++++ .../internal/cmd/service_target.go | 29 +++++++++++++++- .../internal/cmd/service_target_test.go | 16 +++++++++ .../extensions/azure.ai.toolboxes/README.md | 7 ++-- .../internal/cmd/service_target.go | 29 +++++++++++++++- .../internal/cmd/service_target_test.go | 15 +++++++++ .../schemas/azure.ai.toolbox.json | 2 +- 12 files changed, 206 insertions(+), 17 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 66e98464935..86507fe8ee8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -939,10 +939,11 @@ func resolveAgentServiceFromProject( // ServiceRunContext holds the resolved context needed for local development. type ServiceRunContext struct { - ServiceName string // the resolved service name (from azure.yaml) - ProjectDir string // absolute path to the service source directory - StartupCommand string // startupCommand from AdditionalProperties (may be empty) - ServiceEnvironment map[string]string // values already expanded by azd core + ServiceName string // the resolved service name (from azure.yaml) + ProjectDir string // absolute path to the service source directory + StartupCommand string // startupCommand from AdditionalProperties (may be empty) + ServiceEnvironment map[string]string // values already expanded by azd core + HasServiceEnvironment bool // service declares env: even when empty // Definition is the resolved agent definition (from the inline azure.yaml // entry or a legacy agent.yaml). It is nil when no definition can be resolved. Definition *agent_yaml.ContainerAgent @@ -994,12 +995,21 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, } } + hasServiceEnvironment := false + if resp, envErr := azdClient.Project().GetServiceConfigValue( + ctx, + &azdext.GetServiceConfigValueRequest{ServiceName: svc.Name, Path: "env"}, + ); envErr == nil { + hasServiceEnvironment = resp.GetFound() + } + return &ServiceRunContext{ - ServiceName: svc.Name, - ProjectDir: projectDir, - StartupCommand: startupCmd, - ServiceEnvironment: svc.GetEnvironment(), - Definition: definition, + ServiceName: svc.Name, + ProjectDir: projectDir, + StartupCommand: startupCmd, + ServiceEnvironment: svc.GetEnvironment(), + HasServiceEnvironment: hasServiceEnvironment, + Definition: definition, }, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index cbfbd7bc787..07d250df570 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -223,6 +223,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { serviceEnvironment, defEnv, runCtx.ServiceName, + runCtx.HasServiceEnvironment, ) // Activity agents bind IPv4 and are reached at 127.0.0.1 everywhere else @@ -1022,17 +1023,20 @@ func findSystemPython() (pythonInterpreter, error) { // azdEnvironment is the full active environment for legacy fallback. // serviceEnvironment is core-expanded services..env. // definitionEnvironment comes from legacy agent definitions. +// hasServiceEnvironment reports whether the service declares an +// env: block (even an empty one). func mergeAgentRunEnvironment( baseEnvironment []string, azdEnvironment map[string]string, serviceEnvironment map[string]string, definitionEnvironment []string, serviceName string, + hasServiceEnvironment bool, ) []string { environment := slices.Clone(baseEnvironment) // The full azd environment is a compatibility fallback only. - if len(serviceEnvironment) == 0 { + if !hasServiceEnvironment { for key, value := range azdEnvironment { if !envSliceHasKey(baseEnvironment, key) { environment = append( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index 83fef235114..0baebe95529 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -617,6 +617,7 @@ func TestMergeAgentRunEnvironment(t *testing.T) { "LEGACY=declared", }, "agent", + true, ) if got, _ := value(environment, "FOO"); got != "process" { @@ -646,6 +647,24 @@ func TestMergeAgentRunEnvironment(t *testing.T) { } }) + t.Run("explicit empty env stays isolated", func(t *testing.T) { + t.Parallel() + environment := mergeAgentRunEnvironment( + []string{"FOO=process"}, + map[string]string{"SECRET": "leaked", "OTHER": "leaked"}, + map[string]string{}, + nil, + "agent", + true, + ) + if _, found := value(environment, "SECRET"); found { + t.Errorf("did not expect azd value in isolated env %v", environment) + } + if got, _ := value(environment, "FOO"); got != "process" { + t.Errorf("expected process FOO, got %q", got) + } + }) + t.Run("legacy service keeps azd fallback", func(t *testing.T) { t.Parallel() environment := mergeAgentRunEnvironment( @@ -658,6 +677,7 @@ func TestMergeAgentRunEnvironment(t *testing.T) { nil, []string{"BAR=inline", "BAZ=inline"}, "agent", + false, ) if got, _ := value(environment, "FOO"); got != "process" { 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 50539149ed1..41781ecd0b5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -673,9 +673,11 @@ func collectConnections( 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 != nil { @@ -712,13 +714,28 @@ func collectConnections( return connections, nil } +// connectionEnvDeclared reports whether the service node +// declares an env: key, including an empty env: {}. Core +// collapses an empty env to an omitted one, so the raw node +// is the only signal that a service opted into an isolated +// (possibly empty) scope. +func connectionEnvDeclared(node yaml.Node) bool { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return false + } + _, ok := fields["env"] + return ok +} + // Use scoped values when the service declares env. // Legacy services use the project and process environments. func connectionEnvironmentMapping( env map[string]string, serviceEnvironment map[string]string, + declared bool, ) func(string) string { - if len(serviceEnvironment) > 0 { + if declared { return func(name string) string { return serviceEnvironment[name] } 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 50539149ed1..41781ecd0b5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -673,9 +673,11 @@ func collectConnections( 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 != nil { @@ -712,13 +714,28 @@ func collectConnections( return connections, nil } +// connectionEnvDeclared reports whether the service node +// declares an env: key, including an empty env: {}. Core +// collapses an empty env to an omitted one, so the raw node +// is the only signal that a service opted into an isolated +// (possibly empty) scope. +func connectionEnvDeclared(node yaml.Node) bool { + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return false + } + _, ok := fields["env"] + return ok +} + // Use scoped values when the service declares env. // Legacy services use the project and process environments. func connectionEnvironmentMapping( env map[string]string, serviceEnvironment map[string]string, + declared bool, ) func(string) string { - if len(serviceEnvironment) > 0 { + if declared { return func(name string) string { return serviceEnvironment[name] } 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 1f8e0bcad6e..901697b8430 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 @@ -631,6 +631,39 @@ services: assert.Equal(t, "service-default", c.Metadata["owner"]) }) + t.Run("explicit empty env isolates the connection", func(t *testing.T) { + const emptyEnvYAML = ` +services: + my-project: + host: azure.ai.project + mcp-conn: + host: azure.ai.connection + uses: [my-project] + env: {} + category: RemoteTool + target: ${MCP_URL} + authType: CustomKeys + credentials: + keys: + x-api-key: ${MCP_KEY} +` + res, err := Synthesize(Input{ + RawAzureYAML: []byte(emptyEnvYAML), + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + Env: map[string]string{ + "MCP_URL": "https://leak.example/mcp", + "MCP_KEY": "leaked-secret", + }, + }) + require.NoError(t, err) + + c := getConn(t, res) + assert.Equal(t, "", c.Target) + keys := getKeys(t, c) + assert.Equal(t, "", keys["x-api-key"]) + }) + t.Run("eject path preserves ${VAR} verbatim", func(t *testing.T) { res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go index 8dc8d49092d..52b2305f0ab 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -190,11 +190,38 @@ func newRoutineServiceClient(ctx context.Context) (*routines.Client, error) { ), nil } +var serviceEnvDeclared = func( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) (bool, error) { + resp, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "env", + }) + if err != nil { + return false, fmt.Errorf("reading env for service %q: %w", serviceName, err) + } + return resp.GetFound(), nil +} + func (p *routineServiceTarget) environmentValues( ctx context.Context, serviceConfig *azdext.ServiceConfig, ) (map[string]string, error) { - if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + environment := serviceConfig.GetEnvironment() + if len(environment) > 0 { + return environment, nil + } + // An explicit empty env: {} declares an isolated scope. + // Core forwards it as an empty map, indistinguishable from + // an omitted env, so consult the raw config before falling + // back to the full azd environment. + declared, err := serviceEnvDeclared(ctx, p.azdClient, serviceConfig.GetName()) + if err != nil { + return nil, err + } + if declared { return environment, nil } diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go index 074fa000190..b4ca8135baf 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -79,3 +80,18 @@ func TestExpandRoutineValue(t *testing.T) { "secret": "${{connections.search.credentials.key}}", }, expandRoutineValue(input, environment)) } + +func TestRoutineEnvironmentValuesEmptyDeclaredIsolates(t *testing.T) { + orig := serviceEnvDeclared + t.Cleanup(func() { serviceEnvDeclared = orig }) + serviceEnvDeclared = func(context.Context, *azdext.AzdClient, string) (bool, error) { + return true, nil + } + + env, err := (&routineServiceTarget{}).environmentValues( + t.Context(), + &azdext.ServiceConfig{Name: "nightly-digest"}, + ) + require.NoError(t, err) + require.Empty(t, env) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/README.md b/cli/azd/extensions/azure.ai.toolboxes/README.md index 406294ec0ee..dd3acc39ab0 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/README.md +++ b/cli/azd/extensions/azure.ai.toolboxes/README.md @@ -16,10 +16,13 @@ services: research-tools: host: azure.ai.toolbox endpoint: ${RESEARCH_TOOLBOX_ENDPOINT} + env: + RESEARCH_TOOLBOX_ENDPOINT: ${RESEARCH_TOOLBOX_ENDPOINT} ``` Get the endpoint value from `azd ai toolbox show ` (the `Endpoint:` line). -The value may contain `${VAR}` references, which resolve against the azd -environment. Because a toolbox version is immutable, `endpoint` cannot be +The value may contain `${VAR}` references. Declare each referenced variable +in the service-level `env` object; azd falls back to the active environment +only when the service declares no `env`. Because a toolbox version is immutable, `endpoint` cannot be combined with `tools` or `description`. diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go index 8ee2a6c9f84..23eef91ef86 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -334,11 +334,38 @@ func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig return cfg, nil } +var serviceEnvDeclared = func( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) (bool, error) { + resp, err := azdClient.Project().GetServiceConfigValue(ctx, &azdext.GetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "env", + }) + if err != nil { + return false, fmt.Errorf("reading env for service %q: %w", serviceName, err) + } + return resp.GetFound(), nil +} + func (p *toolboxServiceTarget) environmentValues( ctx context.Context, serviceConfig *azdext.ServiceConfig, ) (map[string]string, error) { - if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + environment := serviceConfig.GetEnvironment() + if len(environment) > 0 { + return environment, nil + } + // An explicit empty env: {} declares an isolated scope. + // Core forwards it as an empty map, indistinguishable from + // an omitted env, so consult the raw config before falling + // back to the full azd environment. + declared, err := serviceEnvDeclared(ctx, p.azdClient, serviceConfig.GetName()) + if err != nil { + return nil, err + } + if declared { return environment, nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go index efa9f2b0b70..b9e0678e68c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -223,3 +223,18 @@ func TestExpandToolboxValue(t *testing.T) { // Foundry ${{...}} passes through untouched. assert.Equal(t, []any{"x-secret: ${{secrets.token}}"}, out["headers"]) } + +func TestToolboxEnvironmentValuesEmptyDeclaredIsolates(t *testing.T) { + orig := serviceEnvDeclared + t.Cleanup(func() { serviceEnvDeclared = orig }) + serviceEnvDeclared = func(context.Context, *azdext.AzdClient, string) (bool, error) { + return true, nil + } + + env, err := (&toolboxServiceTarget{}).environmentValues( + t.Context(), + &azdext.ServiceConfig{Name: "research-tools"}, + ) + require.NoError(t, err) + require.Empty(t, env) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json index 8f48945a852..08c300a8254 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json @@ -8,7 +8,7 @@ "properties": { "endpoint": { "type": "string", - "description": "MCP endpoint URL of an existing Foundry toolbox version, as shown by 'azd ai toolbox show' (e.g. https://my-account.services.ai.azure.com/api/projects/my-project/toolboxes/research/versions/3/mcp?api-version=v1). When set, azd reuses this existing toolbox and publishes the endpoint for agents instead of creating a new version, so 'tools' and 'description' must be omitted. May contain ${VAR} (azd env, resolved client-side)." + "description": "MCP endpoint URL of an existing Foundry toolbox version, as shown by 'azd ai toolbox show' (e.g. https://my-account.services.ai.azure.com/api/projects/my-project/toolboxes/research/versions/3/mcp?api-version=v1). When set, azd reuses this existing toolbox and publishes the endpoint for agents instead of creating a new version, so 'tools' and 'description' must be omitted. May contain ${VAR} declared in the service-level env object." }, "description": { "type": "string",