From a4eb5769abb2e25148e3bbf25bf842369dea4f26 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 10 Jul 2026 18:35:00 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 )