Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 6 additions & 29 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,7 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args
for _, svc := range args.Project.Services {
switch svc.Host {
case AiAgentHost:
if err := prepareContainerSettings(
ctx,
azdClient,
svc,
args.Project.Path,
); err != nil {
if err := prepareContainerSettings(svc, args.Project.Path); err != nil {
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
}
if err := envUpdate(
Expand Down Expand Up @@ -246,12 +241,7 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
return err
}

if err := prepareContainerSettings(
ctx,
azdClient,
svc,
args.Project.Path,
); err != nil {
if err := prepareContainerSettings(svc, args.Project.Path); err != nil {
return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err)
}
if err := envUpdate(
Expand Down Expand Up @@ -761,8 +751,6 @@ func setEnvVar(ctx context.Context, azdClient *azdext.AzdClient, envName string,
}

func prepareContainerSettings(
ctx context.Context,
azdClient *azdext.AzdClient,
svc *azdext.ServiceConfig,
projectRoot string,
) error {
Expand Down Expand Up @@ -811,26 +799,15 @@ func prepareContainerSettings(
result.Cpu = project.DefaultCpu
}

// Persist the resolved container settings back onto the service's inline
// properties, preserving the agent definition and other config keys.
containerPath, containerValue, err := project.SetAgentContainerSettings(
// Defaults are runtime values. Do not persist them here:
// lifecycle hooks must not rewrite user-authored azure.yaml.
if err := project.SetAgentContainerSettings(
svc,
&project.ContainerSettings{Resources: result},
)
if err != nil {
); err != nil {
return fmt.Errorf("failed to update agent container settings: %w", err)
}

if !hasRootFileRef {
if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{
ServiceName: svc.GetName(),
Path: containerPath,
Value: containerValue,
}); err != nil {
return fmt.Errorf("persisting agent container settings: %w", err)
}
}

return nil
}

Expand Down
139 changes: 58 additions & 81 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package cmd

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
Expand All @@ -21,50 +20,51 @@ import (
"google.golang.org/protobuf/types/known/structpb"
)

type containerSettingsProjectServer struct {
azdext.UnimplementedProjectServiceServer

mu sync.Mutex
addServiceCalls int
setServiceRequests []*azdext.SetServiceConfigValueRequest
}

func (s *containerSettingsProjectServer) AddService(
_ context.Context,
_ *azdext.AddServiceRequest,
) (*azdext.EmptyResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.addServiceCalls++
return &azdext.EmptyResponse{}, nil
}

func (s *containerSettingsProjectServer) SetServiceConfigValue(
_ context.Context,
req *azdext.SetServiceConfigValueRequest,
) (*azdext.EmptyResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.setServiceRequests = append(s.setServiceRequests, req)
return &azdext.EmptyResponse{}, nil
}

func TestPrepareContainerSettings_UsesTargetedConfigUpdate(t *testing.T) {
func TestPrepareContainerSettings_AppliesSettingsInMemory(t *testing.T) {
t.Parallel()

tests := []struct {
name string
legacy bool
wantPath string
name string
legacy bool
resources map[string]any
wantCPU string
wantMemory string
}{
{
name: "inline service properties",
wantPath: "container",
name: "inline explicit resources",
resources: map[string]any{
"cpu": "0.25",
"memory": "0.5Gi",
},
wantCPU: "0.25",
wantMemory: "0.5Gi",
},
{
name: "legacy explicit resources",
legacy: true,
resources: map[string]any{
"cpu": "0.25",
"memory": "0.5Gi",
},
wantCPU: "0.25",
wantMemory: "0.5Gi",
},
{
name: "legacy config properties",
legacy: true,
wantPath: "config.container",
name: "inline missing memory",
resources: map[string]any{
"cpu": "1",
},
wantCPU: "1",
wantMemory: project.DefaultMemory,
},
{
name: "legacy missing memory",
legacy: true,
resources: map[string]any{
"cpu": "1",
},
wantCPU: "1",
wantMemory: project.DefaultMemory,
},
}

Expand All @@ -77,9 +77,7 @@ func TestPrepareContainerSettings_UsesTargetedConfigUpdate(t *testing.T) {
"name": "my-chat-agent",
"customField": "preserved",
"container": map[string]any{
"resources": map[string]any{
"cpu": "1",
},
"resources": tt.resources,
},
})
require.NoError(t, err)
Expand All @@ -95,28 +93,14 @@ func TestPrepareContainerSettings_UsesTargetedConfigUpdate(t *testing.T) {
svc.AdditionalProperties = props
}

server := &containerSettingsProjectServer{}
client := newProjectRecorderClient(t, server)

require.NoError(t, prepareContainerSettings(t.Context(), client, svc, t.TempDir()))

server.mu.Lock()
defer server.mu.Unlock()

require.Zero(t, server.addServiceCalls,
"full service replacement would drop fields that are not modeled by the extension")
require.Len(t, server.setServiceRequests, 1)

req := server.setServiceRequests[0]
require.Equal(t, "agent", req.ServiceName)
require.Equal(t, tt.wantPath, req.Path)
require.Equal(t, map[string]any{
"resources": map[string]any{
"cpu": "1",
"memory": project.DefaultMemory,
},
}, req.Value.AsInterface())
require.NoError(t, prepareContainerSettings(svc, t.TempDir()))

cfg, err := project.LoadServiceTargetAgentConfig(svc)
require.NoError(t, err)
require.NotNil(t, cfg.Container)
require.NotNil(t, cfg.Container.Resources)
require.Equal(t, tt.wantCPU, cfg.Container.Resources.Cpu)
require.Equal(t, tt.wantMemory, cfg.Container.Resources.Memory)
require.Equal(t, "myregistry.azurecr.io/my-agent:${MY_TAG}", svc.Image)
require.Equal(t, "preserved",
project.ServiceConfigProps(svc).GetFields()["customField"].GetStringValue())
Expand Down Expand Up @@ -235,7 +219,7 @@ func TestIsHostedAgentServiceRejectsTraversal(t *testing.T) {
}
}

func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef(
func TestPrepareContainerSettings_ResolvesFileRefInMemory(
t *testing.T,
) {
t.Parallel()
Expand Down Expand Up @@ -263,10 +247,7 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef(
RelativePath: "src/echo",
AdditionalProperties: props,
}
server := &containerSettingsProjectServer{}
client := newProjectRecorderClient(t, server)

err = prepareContainerSettings(t.Context(), client, svc, root)
err = prepareContainerSettings(svc, root)

require.NoError(t, err)
require.Equal(t, "src/echo", svc.GetRelativePath())
Expand All @@ -276,7 +257,6 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef(
require.NotNil(t, cfg.Container.Resources)
require.Equal(t, "2", cfg.Container.Resources.Cpu)
require.Equal(t, "4Gi", cfg.Container.Resources.Memory)
require.Empty(t, server.setServiceRequests)
}

func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) {
Expand All @@ -295,9 +275,7 @@ func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) {
Host: AiAgentHost,
AdditionalProperties: props,
}
client := newProjectRecorderClient(t, &containerSettingsProjectServer{})

err = prepareContainerSettings(t.Context(), client, svc, t.TempDir())
err = prepareContainerSettings(svc, t.TempDir())

require.NoError(t, err)
deployments, ok := svc.GetAdditionalProperties().
Expand Down Expand Up @@ -335,9 +313,7 @@ func TestPrepareContainerSettings_NormalizesInlineEnvironment(t *testing.T) {
Host: AiAgentHost,
AdditionalProperties: props,
}
client := newProjectRecorderClient(t, &containerSettingsProjectServer{})

err = prepareContainerSettings(t.Context(), client, svc, t.TempDir())
err = prepareContainerSettings(svc, t.TempDir())

require.NoError(t, err)
require.Equal(
Expand All @@ -352,15 +328,16 @@ func TestPrepareContainerSettings_NormalizesInlineEnvironment(t *testing.T) {
func TestPrepareContainerSettings_WithoutProperties(t *testing.T) {
t.Parallel()

client := newProjectRecorderClient(t, &containerSettingsProjectServer{})
err := prepareContainerSettings(
t.Context(),
client,
&azdext.ServiceConfig{Name: "echo", Host: AiAgentHost},
t.TempDir(),
)
svc := &azdext.ServiceConfig{Name: "echo", Host: AiAgentHost}
err := prepareContainerSettings(svc, t.TempDir())

require.NoError(t, err)
cfg, err := project.LoadServiceTargetAgentConfig(svc)
require.NoError(t, err)
require.NotNil(t, cfg.Container)
require.NotNil(t, cfg.Container.Resources)
require.Equal(t, project.DefaultCpu, cfg.Container.Resources.Cpu)
require.Equal(t, project.DefaultMemory, cfg.Container.Resources.Memory)
}

func TestKindEnvUpdateRejectsTraversal(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,18 +548,13 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error {
// agent service's inline properties, preserving every other key (the agent
// definition and the rest of the deploy/provision config). It mutates whichever
// shape the service uses (the unified AdditionalProperties, or — for older
// projects — the config-nested struct). The returned path and value identify
// the exact mutation for callers that need to persist it through the azd host.
// projects — the config-nested struct).
func SetAgentContainerSettings(
svc *azdext.ServiceConfig,
container *ContainerSettings,
) (string, *structpb.Value, error) {
) error {
props := ServiceConfigProps(svc)
legacy := props != nil && props == svc.GetConfig()
containerPath := "container"
if legacy {
containerPath = "config.container"
}
if props == nil {
props = &structpb.Struct{}
}
Expand All @@ -569,17 +564,16 @@ func SetAgentContainerSettings(

containerStruct, err := MarshalStruct(container)
if err != nil {
return "", nil, fmt.Errorf("marshaling container settings: %w", err)
return fmt.Errorf("marshaling container settings: %w", err)
}
containerValue := structpb.NewStructValue(containerStruct)
props.Fields["container"] = containerValue
props.Fields["container"] = structpb.NewStructValue(containerStruct)

if legacy {
svc.Config = props
} else {
svc.AdditionalProperties = props
}
return containerPath, containerValue, nil
return nil
}

// agentDefinitionFromStruct builds the ContainerAgent from an inline/config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,29 +157,25 @@ func TestAgentDefinitionFromService_NoDefinition(t *testing.T) {
require.False(t, found)
}

func TestSetAgentContainerSettings_ReturnsPersistenceTarget(t *testing.T) {
func TestSetAgentContainerSettings_PreservesServiceProperties(t *testing.T) {
t.Parallel()

tests := []struct {
name string
legacy bool
unrelatedInline bool
wantPath string
}{
{
name: "inline service properties",
wantPath: "container",
name: "inline service properties",
},
{
name: "legacy config properties",
legacy: true,
wantPath: "config.container",
name: "legacy config properties",
legacy: true,
},
{
name: "legacy config properties with unrelated inline properties",
legacy: true,
unrelatedInline: true,
wantPath: "config.container",
},
}

Expand All @@ -206,21 +202,19 @@ func TestSetAgentContainerSettings_ReturnsPersistenceTarget(t *testing.T) {
require.NoError(t, err)
}

path, value, err := SetAgentContainerSettings(svc, &ContainerSettings{
err = SetAgentContainerSettings(svc, &ContainerSettings{
Resources: &ResourceSettings{Cpu: "1", Memory: "2Gi"},
})
require.NoError(t, err)
require.Equal(t, tt.wantPath, path)

storedProps := ServiceConfigProps(svc)
require.Equal(t, "preserved", storedProps.GetFields()["customField"].GetStringValue())
require.Equal(t, map[string]any{
"resources": map[string]any{
"cpu": "1",
"memory": "2Gi",
},
}, value.AsInterface())

storedProps := ServiceConfigProps(svc)
require.Equal(t, "preserved", storedProps.GetFields()["customField"].GetStringValue())
require.Same(t, value, storedProps.GetFields()["container"])
}, storedProps.GetFields()["container"].AsInterface())
})
}
}
Expand Down
Loading