From 05865118a1ea0bede3a751a3850e505ad347b4a8 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Fri, 14 Aug 2026 17:45:55 +0800 Subject: [PATCH 01/12] feat(agents): support private registry connections Adds registry-neutral hosted-agent authoring, non-interactive init support, Foundry connection validation, and registry_connection_id REST mapping.\n\nFixes #9582 --- .../azure.ai.agents/internal/cmd/init.go | 184 +++++++++++++++++- .../cmd/init_foundry_resources_helpers.go | 39 ++++ .../init_foundry_resources_helpers_test.go | 38 ++++ .../cmd/init_reuse_project_agent_test.go | 1 + .../azure.ai.agents/internal/cmd/init_test.go | 169 ++++++++++++++-- .../internal/pkg/agents/agent_api/models.go | 3 +- .../pkg/agents/agent_api/models_test.go | 43 +++- .../internal/pkg/agents/agent_yaml/map.go | 7 +- .../pkg/agents/agent_yaml/map_test.go | 63 ++++++ .../pkg/agents/agent_yaml/parse_test.go | 15 +- .../internal/pkg/agents/agent_yaml/yaml.go | 1 + .../internal/project/agent_definition.go | 15 +- .../internal/project/agent_definition_test.go | 7 +- .../internal/project/doc_examples_test.go | 34 ++++ .../internal/project/foundry_dependencies.go | 59 ++++++ .../project/foundry_dependencies_test.go | 55 ++++++ .../internal/project/service_target_agent.go | 71 +++++-- .../project/service_target_agent_test.go | 75 +++++++ .../internal/synthesis/synthesizer_test.go | 10 +- .../schemas/azure.ai.agent.json | 21 ++ 20 files changed, 855 insertions(+), 55 deletions(-) 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 af9bce3a2b0..12a48802b0c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -73,6 +73,10 @@ type initFlags struct { // connection prompts. Requires --agent-name when no --manifest is given. Incompatible // with --deploy-mode code. image string + // registryConnection identifies an existing Foundry project connection used + // to pull a private pre-built image. The value is passed through as a generic + // connection name or ID; azd does not inspect registry-specific configuration. + registryConnection string // kind selects the agent kind to initialize non-interactively, bypassing the // interactive init-mode/template prompts. Currently the only accepted value is // "prompt-voice", which synthesizes a declarative (managed) voice agent @@ -143,19 +147,20 @@ type InitAction struct { // This happens when: // - Code deploy mode is selected (ZIP upload, no container build) // - Pre-built image is provided via --image flag (user manages their own registry) +// - A registry connection is provided for a pre-built image // - The manifest is a prompt-voice agent (managed, no container image) func (a *InitAction) skipACR() bool { - return a.isCodeDeploy || a.flags.image != "" || a.isVoiceAgent + return a.isCodeDeploy || a.flags.image != "" || a.flags.registryConnection != "" || a.isVoiceAgent } // isHostedAgent reports whether the agent is deployed as an azd hosted agent -// (code deploy or a pre-built --image). Hosted agents must land in a Foundry +// (code deploy or a pre-built image). Hosted agents must land in a Foundry // project whose region supports hosted agents, so this gates the region filter // in selectFoundryProject. It is deliberately distinct from skipACR: a // prompt-voice agent also skips ACR, but is managed rather than hosted and must // not be constrained to hosted-agent regions. func (a *InitAction) isHostedAgent() bool { - return a.isCodeDeploy || a.flags.image != "" + return a.isCodeDeploy || a.flags.image != "" || a.flags.registryConnection != "" } // modelSelector encapsulates the dependencies needed for model selection and @@ -1163,6 +1168,7 @@ func agentDefiningFlagsSet(flags *initFlags, srcBlocksReuse bool) bool { flags.modelDeployment != "" || flags.projectResourceId != "" || flags.image != "" || + flags.registryConnection != "" || srcBlocksReuse || len(flags.protocols) > 0 } @@ -1223,7 +1229,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, # Bring your own pre-built image (no template/language selection, Dockerfile, or ACR setup) azd ai agent init --no-prompt --agent-name my-agent \ - --image myacr.azurecr.io/agents/my-agent:v1`, + --image myacr.azurecr.io/agents/my-agent:v1 + + # Use an existing Foundry connection for a private pre-built image + azd ai agent init --no-prompt --agent-name my-agent --project-id "" \ + --image registry.example.com/agents/my-agent:v1 --registry-connection production-registry`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { flags.noPrompt = extCtx.NoPrompt @@ -1316,6 +1326,17 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // when a template adds a subfolder to an existing project. existingProject := fileExists("azure.yaml") + if err := validateRegistryConnectionFlag( + flags.registryConnection, + flags.image, + flags.manifestPointer != "", + flags.deployMode, + flags.kind, + ); err != nil { + return err + } + flags.registryConnection = strings.TrimSpace(flags.registryConnection) + // Validate --kind and its incompatible options before either synthesis // branch. The image and prompt-voice fast paths both mutate // flags.manifestPointer, so validating inside one branch is unreachable @@ -1915,6 +1936,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Dockerfile generation, and ACR setup, and requires --agent-name. "+ "Incompatible with --deploy-mode code.") + cmd.Flags().StringVar(&flags.registryConnection, "registry-connection", "", + "Name or ID of an existing Foundry project connection used to pull a private pre-built container image. "+ + "Requires a pre-built image and is incompatible with code deploy.") + cmd.Flags().StringVar(&flags.kind, "kind", "", "Agent kind to initialize non-interactively. Currently supports 'prompt-voice' to create a "+ "declarative (managed) voice agent, skipping template/language selection and code scaffolding. "+ @@ -1998,9 +2023,16 @@ func (a *InitAction) Run(ctx context.Context) error { // Prompt for deploy mode (code vs container) for hosted agents. // Code deploy is supported for Python and .NET projects. - if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { + if hostedAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { showCodeDeploy := supportsCodeDeploy(targetDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode, a.userProvidedManifest) + requestedDeployMode := a.flags.deployMode + if requestedDeployMode == "" && + (a.flags.registryConnection != "" || strings.TrimSpace(hostedAgent.RegistryConnectionID) != "") { + requestedDeployMode = "container" + } + deployMode, err := promptDeployMode( + ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, requestedDeployMode, a.userProvidedManifest, + ) if err != nil { return fmt.Errorf("prompting for deploy mode: %w", err) } @@ -2025,13 +2057,11 @@ func (a *InitAction) Run(ctx context.Context) error { removeContainerFiles(targetDir) } - hostedAgent := agentManifest.Template.(agent_yaml.ContainerAgent) hostedAgent.CodeConfiguration = codeConfig agentManifest.Template = hostedAgent } else { // Container mode: ensure any pre-existing code_configuration is removed // (e.g. when switching from code deploy back to container) - hostedAgent := agentManifest.Template.(agent_yaml.ContainerAgent) if hostedAgent.CodeConfiguration != nil { hostedAgent.CodeConfiguration = nil agentManifest.Template = hostedAgent @@ -2039,12 +2069,20 @@ func (a *InitAction) Run(ctx context.Context) error { } } + if err := a.applyAndValidateRegistryConnection(agentManifest); err != nil { + return err + } + // Model configuration: prompt user for "use existing" vs "deploy new" agentManifest, err = a.configureModelChoice(ctx, agentManifest) if err != nil { return fmt.Errorf("configuring model choice: %w", err) } + if err := a.verifyRegistryConnection(ctx); err != nil { + return err + } + // For hosted agents, prompt for container resources before writing agent.yaml // so the selected values are persisted into the definition file. if hostedAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { @@ -3361,6 +3399,9 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if err := yaml.Unmarshal(templateYAML, &containerDef); err != nil { return fmt.Errorf("parsing agent definition: %w", err) } + if a.flags.registryConnection != "" { + containerDef.RegistryConnectionID = a.flags.registryConnection + } agentProps, err := project.AgentDefinitionToServiceProperties(containerDef, &agentConfig) if err != nil { @@ -4424,13 +4465,94 @@ func extractConnectionConfigs( return connections, credentialEnvVars, nil } +// applyAndValidateRegistryConnection resolves the effective registry connection +// from an explicit flag or a hosted-agent manifest and applies it to the manifest. +func (a *InitAction) applyAndValidateRegistryConnection(agentManifest *agent_yaml.AgentManifest) error { + containerAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent) + rawConnectionRef := a.flags.registryConnection + if rawConnectionRef == "" && ok { + rawConnectionRef = containerAgent.RegistryConnectionID + } + connectionRef := strings.TrimSpace(rawConnectionRef) + if rawConnectionRef != "" && connectionRef == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "registry connection cannot be empty or whitespace", + "Provide the name or ID of an existing Foundry project connection", + ) + } + if connectionRef == "" { + return nil + } + + if !ok { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection is only valid for hosted container agents", + "Use a registry connection with a hosted agent that supplies a pre-built image", + ) + } + if a.isCodeDeploy || containerAgent.CodeConfiguration != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection cannot be used with code deploy", + "Use the registry connection with a pre-built image or remove it", + ) + } + if preBuiltImageForInit(agentManifest, a.flags.image) == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection requires a pre-built image", + "Pass --image or provide an image in the hosted-agent manifest", + ) + } + + containerAgent.RegistryConnectionID = connectionRef + agentManifest.Template = containerAgent + a.flags.registryConnection = connectionRef + return nil +} + +// verifyRegistryConnection checks an explicitly selected existing project for +// the generic connection name or ID. Foundry remains authoritative for the +// connection's registry vendor, authentication fields, and token exchange. +func (a *InitAction) verifyRegistryConnection(ctx context.Context) error { + if a.flags.registryConnection == "" || a.selectedFoundryProject == nil { + return nil + } + + if err := verifyFoundryProjectConnection( + ctx, + a.credential, + *a.selectedFoundryProject, + a.flags.registryConnection, + listFoundryProjectConnections, + ); err != nil { + return exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("failed to verify registry connection %q: %s", a.flags.registryConnection, err), + "Create the connection on the selected Foundry project or pass the name or ID of an existing connection", + ) + } + return nil +} + // validateCodeDeployFlags checks that required flags are present when using // --deploy-mode code in --no-prompt mode. func (a *InitAction) validateCodeDeployFlags() error { - // First validate image flag (it has incompatibilities with other flags) + // First validate image and registry flags (they have incompatibilities with other flags). if err := validateImageFlag(a.flags.image, a.flags.deployMode); err != nil { return err } + if err := validateRegistryConnectionFlag( + a.flags.registryConnection, + a.flags.image, + a.flags.manifestPointer != "", + a.flags.deployMode, + a.flags.kind, + ); err != nil { + return err + } return validateCodeDeployInput( a.flags.noPrompt, a.flags.deployMode, a.flags.runtime, a.flags.entryPoint, a.flags.depResolution) } @@ -4475,6 +4597,50 @@ func validateImageFlag(image, deployMode string) error { return nil } +// validateRegistryConnectionFlag validates combinations that can be resolved +// before a manifest is loaded. Manifest-backed image validation is deferred +// until the effective hosted-agent definition is available. +func validateRegistryConnectionFlag( + connectionRef string, + image string, + hasManifest bool, + deployMode string, + kind string, +) error { + if connectionRef == "" { + return nil + } + if strings.TrimSpace(connectionRef) == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--registry-connection cannot be empty", + "Pass the name or ID of an existing Foundry project connection", + ) + } + if deployMode == "code" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--registry-connection cannot be used with --deploy-mode code", + "Use --registry-connection with a pre-built image or remove the option", + ) + } + if kind != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--registry-connection is only valid for hosted container agents", + "Remove --kind or omit --registry-connection", + ) + } + if image == "" && !hasManifest { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--registry-connection requires --image when no manifest is provided", + "Pass --image or provide a hosted-agent manifest with an image", + ) + } + return nil +} + // validateCodeDeployInput is the shared validation logic for code deploy flags. // Used by both InitAction and InitFromCodeAction. func validateCodeDeployInput(noPrompt bool, deployMode, runtime, entryPoint, depResolution string) error { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 4dbf273c034..57288f2b198 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -50,6 +50,45 @@ func (p *FoundryProjectInfo) Endpoint() string { return fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", p.AccountName, p.ProjectName) } +type foundryConnectionsLoader func( + context.Context, + azcore.TokenCredential, + string, + string, +) ([]azure.Connection, error) + +func listFoundryProjectConnections( + ctx context.Context, + credential azcore.TokenCredential, + accountName string, + projectName string, +) ([]azure.Connection, error) { + client, err := azure.NewFoundryProjectsClient(accountName, projectName, credential) + if err != nil { + return nil, fmt.Errorf("creating Foundry projects client: %w", err) + } + return client.GetAllConnections(ctx) +} + +func verifyFoundryProjectConnection( + ctx context.Context, + credential azcore.TokenCredential, + project FoundryProjectInfo, + connectionRef string, + load foundryConnectionsLoader, +) error { + connections, err := load(ctx, credential, project.AccountName, project.ProjectName) + if err != nil { + return fmt.Errorf("listing connections on project %q: %w", project.ProjectName, err) + } + for _, connection := range connections { + if connection.Name == connectionRef || connection.ID == connectionRef { + return nil + } + } + return fmt.Errorf("connection %q was not found on project %q", connectionRef, project.ProjectName) +} + // FoundryDeploymentInfo holds information about an existing model deployment in a Foundry project. type FoundryDeploymentInfo struct { Name string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go index 124cbf71442..0d1635ce6b1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go @@ -45,6 +45,44 @@ func TestFoundryProjectInfo_Endpoint(t *testing.T) { } } +func TestVerifyFoundryProjectConnection(t *testing.T) { + t.Parallel() + + project := FoundryProjectInfo{AccountName: "account", ProjectName: "project"} + connections := []azure.Connection{ + {Name: "private-registry", ID: "/connections/private-registry", Type: azure.ConnectionTypeCustomKeys}, + } + loader := func( + _ context.Context, + _ azcore.TokenCredential, + accountName string, + projectName string, + ) ([]azure.Connection, error) { + require.Equal(t, "account", accountName) + require.Equal(t, "project", projectName) + return connections, nil + } + + require.NoError(t, verifyFoundryProjectConnection( + t.Context(), nil, project, "private-registry", loader, + )) + require.NoError(t, verifyFoundryProjectConnection( + t.Context(), nil, project, "/connections/private-registry", loader, + )) + + err := verifyFoundryProjectConnection(t.Context(), nil, project, "missing", loader) + require.ErrorContains(t, err, "was not found") + + loadErr := errors.New("service unavailable") + err = verifyFoundryProjectConnection( + t.Context(), nil, project, "private-registry", + func(context.Context, azcore.TokenCredential, string, string) ([]azure.Connection, error) { + return nil, loadErr + }, + ) + require.ErrorIs(t, err, loadErr) +} + func TestExtractProjectDetails(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go index 0b74aca6412..f5853e712a0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -258,6 +258,7 @@ func TestAgentDefiningFlagsSet(t *testing.T) { {name: "model-deployment", flags: &initFlags{modelDeployment: "my-deployment"}, want: true}, {name: "project-id", flags: &initFlags{projectResourceId: "/subscriptions/x"}, want: true}, {name: "image", flags: &initFlags{image: "myacr.azurecr.io/agent:1"}, want: true}, + {name: "registry connection", flags: &initFlags{registryConnection: "private-registry"}, want: true}, {name: "protocol", flags: &initFlags{protocols: []string{"responses"}}, want: true}, // An explicit --src names where a new agent's source goes, so it opts 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 083ac34a231..951982af848 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 @@ -250,6 +250,104 @@ func TestValidateImageFlag(t *testing.T) { } } +func TestValidateRegistryConnectionFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + connection string + image string + hasManifest bool + deployMode string + kind string + wantContains string + }{ + {name: "unset"}, + {name: "generic registry image", connection: "private-registry", image: "registry.example.com/org/agent:v1"}, + {name: "manifest image deferred", connection: "private-registry", hasManifest: true}, + {name: "missing image", connection: "private-registry", wantContains: "requires --image"}, + { + name: "code deploy", connection: "private-registry", image: "registry.example.com/agent:v1", + deployMode: "code", wantContains: "code", + }, + { + name: "managed kind", connection: "private-registry", image: "registry.example.com/agent:v1", + kind: "prompt-voice", wantContains: "hosted container", + }, + {name: "whitespace", connection: " ", image: "registry.example.com/agent:v1", wantContains: "cannot be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateRegistryConnectionFlag( + tt.connection, tt.image, tt.hasManifest, tt.deployMode, tt.kind, + ) + if tt.wantContains == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantContains) + }) + } +} + +func TestApplyAndValidateRegistryConnection(t *testing.T) { + t.Parallel() + + manifest := func(image, connection string) *agent_yaml.AgentManifest { + return &agent_yaml.AgentManifest{Template: agent_yaml.ContainerAgent{ + Image: image, RegistryConnectionID: connection, + }} + } + + t.Run("flag overrides manifest and preserves arbitrary registry", func(t *testing.T) { + t.Parallel() + agentManifest := manifest("registry.example.com/org/agent:v1", "manifest-connection") + action := &InitAction{flags: &initFlags{registryConnection: "flag-connection"}} + require.NoError(t, action.applyAndValidateRegistryConnection(agentManifest)) + require.Equal(t, "flag-connection", action.flags.registryConnection) + require.Equal(t, "flag-connection", + agentManifest.Template.(agent_yaml.ContainerAgent).RegistryConnectionID) + }) + + t.Run("manifest connection is preserved", func(t *testing.T) { + t.Parallel() + agentManifest := manifest("registry.example.com/org/agent:v1", "manifest-connection") + action := &InitAction{flags: &initFlags{}} + require.NoError(t, action.applyAndValidateRegistryConnection(agentManifest)) + require.Equal(t, "manifest-connection", action.flags.registryConnection) + }) + + t.Run("missing image is rejected", func(t *testing.T) { + t.Parallel() + action := &InitAction{flags: &initFlags{registryConnection: "private-registry"}} + require.ErrorContains(t, + action.applyAndValidateRegistryConnection(manifest("", "")), + "requires a pre-built image") + }) + + t.Run("code deploy is rejected", func(t *testing.T) { + t.Parallel() + action := &InitAction{ + flags: &initFlags{registryConnection: "private-registry"}, + isCodeDeploy: true, + } + require.ErrorContains(t, + action.applyAndValidateRegistryConnection(manifest("registry.example.com/agent:v1", "")), + "code deploy") + }) +} + +func TestInitCommandRegistersRegistryConnectionFlag(t *testing.T) { + t.Parallel() + + cmd := newInitCommand(&azdext.ExtensionContext{}) + flag := cmd.Flags().Lookup("registry-connection") + require.NotNil(t, flag) + require.Equal(t, "", flag.DefValue) +} + func TestPreBuiltImageForInit(t *testing.T) { t.Parallel() @@ -298,11 +396,12 @@ func TestSkipACR(t *testing.T) { t.Parallel() tests := []struct { - name string - isCodeDeploy bool - image string - isVoiceAgent bool - want bool + name string + isCodeDeploy bool + image string + registryConnection string + isVoiceAgent bool + want bool }{ { name: "code deploy skips ACR", @@ -322,6 +421,11 @@ func TestSkipACR(t *testing.T) { image: "myacr.azurecr.io/agent:v1", want: true, }, + { + name: "registry connection skips ACR", + registryConnection: "private-registry", + want: true, + }, { name: "voice agent skips ACR", isCodeDeploy: false, @@ -344,7 +448,10 @@ func TestSkipACR(t *testing.T) { action := &InitAction{ isCodeDeploy: tt.isCodeDeploy, isVoiceAgent: tt.isVoiceAgent, - flags: &initFlags{image: tt.image}, + flags: &initFlags{ + image: tt.image, + registryConnection: tt.registryConnection, + }, } require.Equal(t, tt.want, action.skipACR()) @@ -359,14 +466,16 @@ func TestIsHostedAgent(t *testing.T) { t.Parallel() tests := []struct { - name string - isCodeDeploy bool - image string - isVoiceAgent bool - want bool + name string + isCodeDeploy bool + image string + registryConnection string + isVoiceAgent bool + want bool }{ {name: "code deploy is hosted", isCodeDeploy: true, want: true}, {name: "image is hosted", image: "myacr.azurecr.io/agent:v1", want: true}, + {name: "registry connection is hosted", registryConnection: "private-registry", want: true}, {name: "voice is not hosted", isVoiceAgent: true, want: false}, {name: "plain container is not hosted", want: false}, } @@ -378,7 +487,10 @@ func TestIsHostedAgent(t *testing.T) { action := &InitAction{ isCodeDeploy: tt.isCodeDeploy, isVoiceAgent: tt.isVoiceAgent, - flags: &initFlags{image: tt.image}, + flags: &initFlags{ + image: tt.image, + registryConnection: tt.registryConnection, + }, } require.Equal(t, tt.want, action.isHostedAgent()) @@ -502,13 +614,18 @@ func TestSynthesizeImageManifestFile_AcceptsActivityProtocol(t *testing.T) { } func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { - const image = "myacr.azurecr.io/agents/my-agent:v1" + const image = "registry.example.com/agents/my-agent:v1" + const registryConnection = "production-registry" server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) action := &InitAction{ - azdClient: client, - environment: &azdext.Environment{Name: "test-env"}, - flags: &initFlags{image: image, noPrompt: true}, + azdClient: client, + environment: &azdext.Environment{Name: "test-env"}, + flags: &initFlags{ + image: image, + registryConnection: registryConnection, + noPrompt: true, + }, serviceNameOverride: "my-agent", } description := "Hosted container agent using a pre-built image" @@ -556,6 +673,10 @@ func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { _, hasInlineImage := agentService.GetAdditionalProperties().GetFields()["image"] require.False(t, hasInlineImage, "pre-built image must ride on the top-level service image field") + require.Equal(t, registryConnection, + agentService.GetAdditionalProperties().GetFields()["registryConnectionId"].GetStringValue()) + require.NotContains(t, agentService.GetUses(), registryConnection, + "an external connection must not be added to uses") _, hasInlineEnvironment := agentService.GetAdditionalProperties(). GetFields()["environmentVariables"] require.False(t, hasInlineEnvironment) @@ -3034,6 +3155,22 @@ func TestCodeDeployFlagValidation(t *testing.T) { flags: initFlags{noPrompt: true, deployMode: "container"}, wantErr: false, }, + { + name: "registry connection with pre-built image passes", + flags: initFlags{ + noPrompt: true, deployMode: "container", + image: "registry.example.com/agent:v1", registryConnection: "private-registry", + }, + }, + { + name: "registry connection with code deploy fails", + flags: initFlags{ + noPrompt: true, deployMode: "code", runtime: "python_3_13", entryPoint: "app.py", + registryConnection: "private-registry", manifestPointer: "agent.yaml", + }, + wantErr: true, + wantErrContain: "registry-connection", + }, { name: "code deploy without noPrompt skips validation", flags: initFlags{noPrompt: false, deployMode: "code"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index c94b857c6b0..cd540b50915 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -241,7 +241,8 @@ type CodeConfigurationAPI struct { // ContainerConfigurationAPI represents the container_configuration block in the API request. // Used for container deploy mode to specify the pre-built container image. type ContainerConfigurationAPI struct { - Image string `json:"image"` + Image string `json:"image"` + RegistryConnectionID string `json:"registry_connection_id,omitempty"` } // HostedAgentDefinition represents a hosted agent that can be either container-based diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go index 7c3686514f5..e58e59b682c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -161,7 +161,8 @@ func TestHostedAgentDefinition_ContainerImage_RoundTrip(t *testing.T) { CPU: "0.5", Memory: "1Gi", ContainerConfiguration: &ContainerConfigurationAPI{ - Image: "myregistry.azurecr.io/agent:latest", + Image: "registry.example.com/agent:latest", + RegistryConnectionID: "private-registry", }, } @@ -175,8 +176,19 @@ func TestHostedAgentDefinition_ContainerImage_RoundTrip(t *testing.T) { if err := json.Unmarshal(data, &rawMap); err != nil { t.Fatalf("unmarshal to map: %v", err) } - if _, ok := rawMap["container_configuration"]; !ok { - t.Error("expected top-level \"container_configuration\" key") + containerRaw, ok := rawMap["container_configuration"] + if !ok { + t.Fatal("expected top-level \"container_configuration\" key") + } + var containerMap map[string]json.RawMessage + if err := json.Unmarshal(containerRaw, &containerMap); err != nil { + t.Fatalf("unmarshal container configuration: %v", err) + } + if _, ok := containerMap["registry_connection_id"]; !ok { + t.Error("expected nested \"registry_connection_id\" key") + } + if _, ok := rawMap["registry_connection_id"]; ok { + t.Error("unexpected top-level \"registry_connection_id\" key") } if _, ok := rawMap["protocol_versions"]; !ok { t.Error("expected top-level \"protocol_versions\" key") @@ -197,11 +209,36 @@ func TestHostedAgentDefinition_ContainerImage_RoundTrip(t *testing.T) { if got.ContainerConfiguration == nil || got.ContainerConfiguration.Image != original.ContainerConfiguration.Image { t.Errorf("ContainerConfiguration.Image = %v, want %q", got.ContainerConfiguration, original.ContainerConfiguration.Image) } + if got.ContainerConfiguration.RegistryConnectionID != original.ContainerConfiguration.RegistryConnectionID { + t.Errorf("ContainerConfiguration.RegistryConnectionID = %q, want %q", + got.ContainerConfiguration.RegistryConnectionID, original.ContainerConfiguration.RegistryConnectionID) + } if got.CPU != "0.5" { t.Errorf("CPU = %q, want %q", got.CPU, "0.5") } } +func TestHostedAgentDefinition_ContainerImage_OmitsEmptyRegistryConnection(t *testing.T) { + t.Parallel() + + definition := HostedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, + CPU: "0.5", + Memory: "1Gi", + ContainerConfiguration: &ContainerConfigurationAPI{ + Image: "registry.example.com/agent:latest", + }, + } + + data, err := json.Marshal(definition) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "registry_connection_id") { + t.Errorf("empty registry connection should be omitted: %s", data) + } +} + func TestHostedAgentDefinition_LegacyUnmarshal(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 486fefad11e..f82c59ae6a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -438,6 +438,10 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB if imageURL == "" { return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") } + registryConnectionID := strings.TrimSpace(hostedAgent.RegistryConnectionID) + if hostedAgent.RegistryConnectionID != "" && registryConnectionID == "" { + return nil, fmt.Errorf("registryConnectionId cannot be empty or whitespace") + } imageDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ @@ -449,7 +453,8 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB Memory: memory, EnvironmentVariables: envVars, ContainerConfiguration: &agent_api.ContainerConfigurationAPI{ - Image: imageURL, + Image: imageURL, + RegistryConnectionID: registryConnectionID, }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index 124b2d4ad91..1f34a04b6e4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -859,6 +859,7 @@ func TestCreateHostedAgentAPIRequest_FullConfig(t *testing.T) { {Protocol: "responses", Version: "2.0.0"}, {Protocol: "invocations", Version: "1.0.0"}, }, + RegistryConnectionID: "private-registry", } buildConfig := &AgentBuildConfig{ @@ -889,6 +890,10 @@ func TestCreateHostedAgentAPIRequest_FullConfig(t *testing.T) { if imgDef.ContainerConfiguration == nil || imgDef.ContainerConfiguration.Image != "myregistry.azurecr.io/agent:v1" { t.Errorf("ContainerConfiguration.Image = %v", imgDef.ContainerConfiguration) } + if imgDef.ContainerConfiguration.RegistryConnectionID != "private-registry" { + t.Errorf("ContainerConfiguration.RegistryConnectionID = %q", + imgDef.ContainerConfiguration.RegistryConnectionID) + } if imgDef.CPU != "4" { t.Errorf("CPU = %q", imgDef.CPU) } @@ -983,6 +988,38 @@ func TestCreateHostedAgentAPIRequest_UsesAgentImage(t *testing.T) { } } +func TestCreateHostedAgentAPIRequest_ImagesWithoutRegistryConnectionRemainUnchanged(t *testing.T) { + t.Parallel() + + for _, image := range []string{ + "docker.io/example/public-agent:v1", + "example.azurecr.io/private-agent:v1", + } { + t.Run(image, func(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted, Name: "agent"}, + Image: image, + } + req, err := CreateHostedAgentAPIRequest(agent, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + definition := req.Definition.(agent_api.HostedAgentDefinition) + if definition.ContainerConfiguration.RegistryConnectionID != "" { + t.Errorf("unexpected registry connection for %q", image) + } + serialized, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(serialized), "registry_connection_id") { + t.Errorf("unexpected registry connection field: %s", serialized) + } + }) + } +} + func TestCreateHostedAgentAPIRequest_BuildConfigImageOverridesAgentImage(t *testing.T) { t.Parallel() agent := ContainerAgent{ @@ -1025,6 +1062,21 @@ func TestCreateHostedAgentAPIRequest_MissingImageURL(t *testing.T) { } } +func TestCreateHostedAgentAPIRequest_WhitespaceRegistryConnectionRejected(t *testing.T) { + t.Parallel() + + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted, Name: "agent"}, + Image: "registry.example.com/agent:v1", + RegistryConnectionID: " ", + } + + _, err := CreateHostedAgentAPIRequest(agent, nil) + if err == nil || !strings.Contains(err.Error(), "registryConnectionId") { + t.Fatalf("expected registry connection validation error, got %v", err) + } +} + func TestCreateHostedAgentAPIRequest_NilBuildConfig(t *testing.T) { t.Parallel() agent := ContainerAgent{ @@ -1362,6 +1414,7 @@ func TestCreateAgentAPIRequest_CodeDeploy_DotnetRuntime(t *testing.T) { Protocols: []ProtocolVersionRecord{ {Protocol: "responses", Version: "1.0.0"}, }, + RegistryConnectionID: "must-not-be-emitted", CodeConfiguration: &CodeConfiguration{ Runtime: "dotnet_9", EntryPoint: "MyAgent.dll", @@ -1378,6 +1431,16 @@ func TestCreateAgentAPIRequest_CodeDeploy_DotnetRuntime(t *testing.T) { if !ok { t.Fatalf("expected CodeBasedHostedAgentDefinition, got %T", req.Definition) } + if codeDef.ContainerConfiguration != nil { + t.Fatalf("code deploy unexpectedly emitted container configuration: %+v", codeDef.ContainerConfiguration) + } + serialized, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal code deploy request: %v", err) + } + if strings.Contains(string(serialized), "registry_connection_id") { + t.Errorf("code deploy unexpectedly emitted registry connection: %s", serialized) + } // Verify entry_point is ["dotnet", "MyAgent.dll"] wantEntryPoint := []string{"dotnet", "MyAgent.dll"} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go index 6129008d05b..593e91fdb49 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go @@ -117,7 +117,8 @@ name: image-agent template: kind: hosted name: image-agent - image: myregistry.azurecr.io/myimage:v1 + image: registry.example.com/myimage:v1 + registryConnectionId: private-registry protocols: - protocol: invocations version: 1.0.0 @@ -145,8 +146,11 @@ template: t.Fatalf("Expected ContainerAgent, got %T", agent) } - if containerAgent.Image != "myregistry.azurecr.io/myimage:v1" { - t.Errorf("Expected image 'myregistry.azurecr.io/myimage:v1', got '%s'", containerAgent.Image) + if containerAgent.Image != "registry.example.com/myimage:v1" { + t.Errorf("Expected image 'registry.example.com/myimage:v1', got '%s'", containerAgent.Image) + } + if containerAgent.RegistryConnectionID != "private-registry" { + t.Errorf("Expected registry connection 'private-registry', got '%s'", containerAgent.RegistryConnectionID) } if containerAgent.AgentEndpoint == nil { @@ -167,9 +171,12 @@ template: t.Fatalf("Failed to marshal ContainerAgent: %v", err) } - if !strings.Contains(string(marshaled), "myregistry.azurecr.io/myimage:v1") { + if !strings.Contains(string(marshaled), "registry.example.com/myimage:v1") { t.Errorf("Marshaled YAML should contain image value, got:\n%s", string(marshaled)) } + if !strings.Contains(string(marshaled), "registryConnectionId: private-registry") { + t.Errorf("Marshaled YAML should contain registry connection, got:\n%s", string(marshaled)) + } } // TestExtractAgentDefinition_WithoutResources tests that ContainerAgent without resources still parses diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 1832ae35474..12a04d9b530 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -271,6 +271,7 @@ type Policy struct { type ContainerAgent struct { AgentDefinition `json:",inline" yaml:",inline"` Image string `json:"image,omitempty" yaml:"image,omitempty"` + RegistryConnectionID string `json:"registryConnectionId,omitempty" yaml:"registryConnectionId,omitempty"` Protocols []ProtocolVersionRecord `json:"protocols" yaml:"protocols"` Resources *ContainerResources `json:"resources,omitempty" yaml:"resources,omitempty"` EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty" yaml:"environment_variables,omitempty"` 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 8d308b392f8..a1e0f9c5290 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 @@ -128,6 +128,7 @@ func orphanedConfigEnvNames(svc *azdext.ServiceConfig) []string { type AgentDefinitionInline struct { agent_yaml.AgentDefinition `json:",inline"` Protocols []agent_yaml.ProtocolVersionRecord `json:"protocols,omitempty"` + RegistryConnectionID string `json:"registryConnectionId,omitempty"` // EnvironmentVariables reads the deprecated inline shape. EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` @@ -175,12 +176,13 @@ func (d AgentDefinitionInline) toVoiceAgent() agent_yaml.VoiceAgent { // 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, - AgentEndpoint: ca.AgentEndpoint, - AgentCard: ca.AgentCard, - CodeConfiguration: ca.CodeConfiguration, - Policies: ca.Policies, + AgentDefinition: ca.AgentDefinition, + Protocols: ca.Protocols, + RegistryConnectionID: ca.RegistryConnectionID, + AgentEndpoint: ca.AgentEndpoint, + AgentCard: ca.AgentCard, + CodeConfiguration: ca.CodeConfiguration, + Policies: ca.Policies, } var container *ContainerSettings @@ -216,6 +218,7 @@ func (d AgentDefinitionInline) toContainerAgent( ca := agent_yaml.ContainerAgent{ AgentDefinition: d.AgentDefinition, Image: image, + RegistryConnectionID: d.RegistryConnectionID, Protocols: d.Protocols, EnvironmentVariables: environmentVariables, AgentEndpoint: d.AgentEndpoint, 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 1afdbef6bcd..f87f56729a9 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 @@ -28,6 +28,7 @@ func sampleContainerAgent() agent_yaml.ContainerAgent { Protocols: []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, }, + RegistryConnectionID: "private-registry", EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ {Name: "FOUNDRY_MODEL_DEPLOYMENT_NAME", Value: "gpt-4.1-mini"}, }, @@ -55,6 +56,7 @@ func TestAgentDefinitionRoundTrip(t *testing.T) { require.NoError(t, err) _, hasInlineEnvironment := props.GetFields()["environmentVariables"] require.False(t, hasInlineEnvironment) + require.Equal(t, "private-registry", props.GetFields()["registryConnectionId"].GetStringValue()) svc := &azdext.ServiceConfig{ Name: "basic-agent", @@ -74,6 +76,7 @@ func TestAgentDefinitionRoundTrip(t *testing.T) { require.NotNil(t, got.Description) require.Equal(t, "A basic agent hosted by Foundry.", *got.Description) require.Equal(t, ca.Protocols, got.Protocols) + require.Equal(t, ca.RegistryConnectionID, got.RegistryConnectionID) require.NotNil(t, got.EnvironmentVariables) require.Equal(t, *ca.EnvironmentVariables, *got.EnvironmentVariables) // CPU/memory round-trips through the `container` config. @@ -515,7 +518,8 @@ func TestLoadAgentDefinition_ToolboxServiceReference(t *testing.T) { // fallback used during the migration window. func TestLoadAgentDefinition_DiskFallback(t *testing.T) { dir := t.TempDir() - yaml := "kind: hosted\nname: disk-agent\nprotocols:\n - protocol: responses\n version: \"1.0.0\"\n" + yaml := "kind: hosted\nname: disk-agent\nregistryConnectionId: private-registry\n" + + "protocols:\n - protocol: responses\n version: \"1.0.0\"\n" require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0o600)) svc := &azdext.ServiceConfig{Name: "disk-agent", Host: "azure.ai.agent", RelativePath: "."} @@ -525,6 +529,7 @@ func TestLoadAgentDefinition_DiskFallback(t *testing.T) { require.Equal(t, AgentDefinitionSourceDisk, source) require.True(t, source.IsLegacy()) require.Equal(t, "disk-agent", got.Name) + require.Equal(t, "private-registry", got.RegistryConnectionID) } func TestLoadAgentDefinition_FileRef(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go index cd678554ba9..7bbc26db20e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go @@ -813,6 +813,40 @@ func TestDocSchemaValidatesConstraints(t *testing.T) { } } +func TestDocSchemaRegistryConnectionID(t *testing.T) { + t.Parallel() + + schema := loadDocSchema(t, extensionRoot(t)) + property, exists := schema.property("registryConnectionId") + require.True(t, exists) + require.Equal(t, "string", property["type"]) + + require.NoError(t, schema.validate(map[string]any{ + "kind": "hosted", + "registryConnectionId": "private-registry", + })) + require.Error(t, schema.validate(map[string]any{ + "kind": "hosted", + "registryConnectionId": "", + })) + require.Error(t, schema.validate(map[string]any{ + "kind": "hosted", + "registryConnectionId": 42, + })) + require.Error(t, schema.validate(map[string]any{ + "kind": "prompt-voice", + "registryConnectionId": "private-registry", + "model": map[string]any{"id": "gpt-realtime"}, + })) + require.Error(t, schema.validate(map[string]any{ + "kind": "hosted", + "registryConnectionId": "private-registry", + "codeConfiguration": map[string]any{ + "runtime": "python_3_13", "entryPoint": "app.py", + }, + })) +} + func TestActiveDocAgentConfig(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 65faf5b730c..56904b63d28 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -39,6 +39,65 @@ type foundryDependencyFailure struct { requiresMigration bool } +// validateRegistryConnectionDependency ensures a registry connection declared +// as a sibling azd service is wired through uses. References that do not match a +// local service are external Foundry connection names or IDs and are left to the +// service to resolve. +func validateRegistryConnectionDependency( + ctx context.Context, + agent *azdext.ServiceConfig, + connectionRef string, + services map[string]*azdext.ServiceConfig, + isEnabled dependencyEnabled, +) error { + connectionRef = strings.TrimSpace(connectionRef) + if connectionRef == "" { + return nil + } + + dependency, exists := services[connectionRef] + if !exists { + return nil + } + if dependency.GetHost() != foundryConnectionHost { + return exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf( + "registry connection %s resolves to service host %s instead of %s", + strconv.Quote(connectionRef), + strconv.Quote(dependency.GetHost()), + strconv.Quote(foundryConnectionHost), + ), + fmt.Sprintf("change the %s service host to %s or use an external Foundry connection reference", + strconv.Quote(connectionRef), strconv.Quote(foundryConnectionHost)), + ) + } + if !slices.Contains(agent.GetUses(), connectionRef) { + return exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("registry connection service %s is not declared in %s uses", + strconv.Quote(connectionRef), strconv.Quote(agent.GetName())), + fmt.Sprintf("add %s to the %s service uses list, run 'azd provision', then retry the agent deployment", + strconv.Quote(connectionRef), strconv.Quote(agent.GetName())), + ) + } + if isEnabled != nil { + enabled, err := isEnabled(ctx, connectionRef) + if err != nil { + return err + } + if !enabled { + return exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("registry connection service %s is disabled by its deployment condition", + strconv.Quote(connectionRef)), + "enable the registry connection dependency or use an external Foundry connection reference", + ) + } + } + return nil +} + func validateFoundryDependencies( ctx context.Context, agent *azdext.ServiceConfig, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go index a9b3be98f11..71a69fb8332 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies_test.go @@ -15,6 +15,61 @@ import ( "github.com/stretchr/testify/require" ) +func TestValidateRegistryConnectionDependency(t *testing.T) { + t.Parallel() + + agent := func(uses ...string) *azdext.ServiceConfig { + return &azdext.ServiceConfig{Name: "agent", Host: foundryAgentHost, Uses: uses} + } + connection := &azdext.ServiceConfig{Name: "private-registry", Host: foundryConnectionHost} + + t.Run("external connection reference does not require uses", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateRegistryConnectionDependency( + t.Context(), agent(), "/connections/external-registry", nil, nil, + )) + }) + + t.Run("sibling connection requires uses", func(t *testing.T) { + t.Parallel() + err := validateRegistryConnectionDependency( + t.Context(), agent(), "private-registry", + map[string]*azdext.ServiceConfig{"private-registry": connection}, nil, + ) + require.ErrorContains(t, err, "is not declared") + require.ErrorContains(t, err, "uses") + }) + + t.Run("sibling connection with uses is valid", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateRegistryConnectionDependency( + t.Context(), agent("private-registry"), "private-registry", + map[string]*azdext.ServiceConfig{"private-registry": connection}, nil, + )) + }) + + t.Run("sibling service must be a Foundry connection", func(t *testing.T) { + t.Parallel() + err := validateRegistryConnectionDependency( + t.Context(), agent("private-registry"), "private-registry", + map[string]*azdext.ServiceConfig{ + "private-registry": {Name: "private-registry", Host: foundryToolboxHost}, + }, nil, + ) + require.ErrorContains(t, err, foundryConnectionHost) + }) + + t.Run("disabled sibling connection is rejected", func(t *testing.T) { + t.Parallel() + err := validateRegistryConnectionDependency( + t.Context(), agent("private-registry"), "private-registry", + map[string]*azdext.ServiceConfig{"private-registry": connection}, + func(context.Context, string) (bool, error) { return false, nil }, + ) + require.ErrorContains(t, err, "disabled") + }) +} + func TestValidateFoundryDependencies(t *testing.T) { t.Parallel() 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 3f4039392a2..ade3db224bb 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 @@ -677,8 +677,19 @@ func (p *AgentServiceTargetProvider) Package( return nil, err } serviceConfig = p.serviceConfig - // Code deploy: ZIP the source directory - if p.isCodeDeployAgent() { + agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() + if err != nil { + return nil, err + } + if !isContainerAgent { + return &azdext.ServicePackageResult{}, nil + } + if err := validateRegistryConnectionDefinition(agentDef); err != nil { + return nil, err + } + + // Code deploy: ZIP the source directory. + if agentDef.CodeConfiguration != nil { progress("Packaging code") zipPath, sha256Hex, err := p.packageCodeDeploy(ctx, serviceConfig) if err != nil { @@ -700,14 +711,6 @@ func (p *AgentServiceTargetProvider) Package( }, nil } - agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() - if err != nil { - return nil, err - } - if !isContainerAgent { - return &azdext.ServicePackageResult{}, nil - } - usePreBuiltImage, err := p.shouldUsePreBuiltImage(ctx, agentDef) if err != nil { return nil, err @@ -1314,6 +1317,9 @@ func (p *AgentServiceTargetProvider) Deploy( ); err != nil { return nil, err } + if err := validateRegistryConnectionDefinition(agentDef); err != nil { + return nil, err + } } // Ensure Foundry project is loaded @@ -1360,6 +1366,11 @@ func (p *AgentServiceTargetProvider) Deploy( } progress("Validating service dependencies") + if err := validateRegistryConnectionDependency( + ctx, serviceConfig, agentDef.RegistryConnectionID, p.projectServices, p.dependencyEnabled, + ); err != nil { + return nil, err + } if err := validateFoundryDependencies( ctx, serviceConfig, serviceTargetConfig, p.projectServices, azdEnv, p.dependencyEnabled, ); err != nil { @@ -1756,9 +1767,40 @@ func memoryStoreOptionsEmpty(options *MemoryStoreOptions) bool { options.UserProfileDetails == "" } +func validateRegistryConnectionDefinition(agentDef agent_yaml.ContainerAgent) error { + rawConnectionRef := agentDef.RegistryConnectionID + connectionRef := strings.TrimSpace(rawConnectionRef) + if rawConnectionRef == "" { + return nil + } + if connectionRef == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId cannot be empty or whitespace", + "set registryConnectionId to a Foundry project connection name or ID, or remove it", + ) + } + if agentDef.CodeConfiguration != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId cannot be used with codeConfiguration", + "use registryConnectionId with a pre-built image or remove it for code deploy", + ) + } + if strings.TrimSpace(agentDef.Image) == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId requires a pre-built container image", + "set image on the azure.ai.agent service or remove registryConnectionId", + ) + } + return nil +} + // shouldUsePreBuiltImage determines whether to use a pre-built image. // // Behavior: +// - A registry connection requires an image and always selects that pre-built image. // - If no image is configured in the loaded agent definition, always build from Dockerfile. // The image usually comes from the azure.yaml service image field, but can come from // a legacy agent.yaml fallback. @@ -1771,7 +1813,14 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( ctx context.Context, agentDef agent_yaml.ContainerAgent, ) (bool, error) { - imageURL := agentDef.Image + imageURL := strings.TrimSpace(agentDef.Image) + if agentDef.RegistryConnectionID != "" { + if err := validateRegistryConnectionDefinition(agentDef); err != nil { + return false, err + } + log.Printf("registryConnectionId is configured: using pre-built image from agent definition") + return true, nil + } if imageURL == "" { return false, nil } 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 9e48effc3c0..9f4a0438f60 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 @@ -1478,6 +1478,7 @@ func TestPrepareDeployIncludesServiceEnvironment(t *testing.T) { t.Parallel() agentDef := sampleContainerAgent() + agentDef.RegistryConnectionID = "private-registry" *agentDef.EnvironmentVariables = append( *agentDef.EnvironmentVariables, agent_yaml.EnvironmentVariable{ @@ -1517,6 +1518,10 @@ func TestPrepareDeployIncludesServiceEnvironment(t *testing.T) { ) require.Equal(t, "service", prep.resolvedEnvVars["SHARED"]) require.Equal(t, "legacy", prep.resolvedEnvVars["LEGACY_ONLY"]) + hostedDefinition, ok := prep.request.Definition.(agent_api.HostedAgentDefinition) + require.True(t, ok) + require.NotNil(t, hostedDefinition.ContainerConfiguration) + require.Equal(t, "private-registry", hostedDefinition.ContainerConfiguration.RegistryConnectionID) } func TestLoadContainerAgentDefinition_EnvPathOverridesInlineDefinition(t *testing.T) { @@ -1646,6 +1651,52 @@ func TestPrepareDeployAppliesDefaultResources(t *testing.T) { require.Equal(t, DefaultMemory, definition.Memory) } +func TestValidateRegistryConnectionDefinition(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent agent_yaml.ContainerAgent + wantContain string + }{ + {name: "unset"}, + { + name: "valid", + agent: agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", RegistryConnectionID: "private-registry", + }, + }, + { + name: "missing image", agent: agent_yaml.ContainerAgent{RegistryConnectionID: "private-registry"}, + wantContain: "requires a pre-built container image", + }, + { + name: "code deploy", + agent: agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", RegistryConnectionID: "private-registry", + CodeConfiguration: &agent_yaml.CodeConfiguration{}, + }, + wantContain: "codeConfiguration", + }, + { + name: "whitespace", agent: agent_yaml.ContainerAgent{RegistryConnectionID: " "}, + wantContain: "empty or whitespace", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := validateRegistryConnectionDefinition(test.agent) + if test.wantContain == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantContain) + }) + } +} + func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { t.Parallel() @@ -1656,6 +1707,30 @@ func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { require.False(t, result, "should default to build when no image is configured") } +func TestShouldUsePreBuiltImage_RegistryConnectionForcesPreBuilt(t *testing.T) { + t.Parallel() + + promptStub := &stubPromptServer{selectedIndex: 0} + provider := &AgentServiceTargetProvider{azdClient: newPromptTestClient(t, promptStub)} + result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", + RegistryConnectionID: "private-registry", + }) + require.NoError(t, err) + require.True(t, result) + require.Equal(t, int32(0), promptStub.selectCalls.Load(), "registry-backed images must not prompt to build") +} + +func TestShouldUsePreBuiltImage_RegistryConnectionRequiresImage(t *testing.T) { + t.Parallel() + + provider := &AgentServiceTargetProvider{} + _, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ + RegistryConnectionID: "private-registry", + }) + require.ErrorContains(t, err, "requires a pre-built container image") +} + func TestShouldUsePreBuiltImage_SelectsPreBuiltImage(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 5a725848dde..2c2b66d01cd 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 @@ -560,13 +560,15 @@ services: credentials: keys: x-api-key: ${MCP_KEY} + body.provider: ${REGISTRY_PROVIDER} metadata: owner: ${MCP_OWNER} ` env := map[string]string{ - "MCP_URL": "https://mcp.example.com/mcp", - "MCP_KEY": "secret-value", - "MCP_OWNER": "team-ai", + "MCP_URL": "https://mcp.example.com/mcp", + "MCP_KEY": "secret-value", + "MCP_OWNER": "team-ai", + "REGISTRY_PROVIDER": "generic-provider", } getConn := func(t *testing.T, res *Result) Connection { @@ -590,6 +592,7 @@ services: keys, ok := c.Credentials["keys"].(map[string]any) require.True(t, ok, "keys should be a nested map, got %T", c.Credentials["keys"]) assert.Equal(t, "secret-value", keys["x-api-key"]) + assert.Equal(t, "generic-provider", keys["body.provider"]) assert.Equal(t, "team-ai", c.Metadata["owner"]) publicConnections := res.Parameters["connections"].([]Connection) @@ -613,6 +616,7 @@ services: keys, ok := c.Credentials["keys"].(map[string]any) require.True(t, ok) assert.Equal(t, "${MCP_KEY}", keys["x-api-key"]) + assert.Equal(t, "${REGISTRY_PROVIDER}", keys["body.provider"]) assert.Equal(t, "${MCP_OWNER}", c.Metadata["owner"]) }) 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 b029ef3a5d2..9e8a179153b 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 @@ -94,6 +94,12 @@ "description": "Invocation protocols the agent implements (e.g., responses, invocations, invocations_ws, a2a).", "items": { "$ref": "#/definitions/ProtocolVersionRecord" } }, + "registryConnectionId": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Name or ID of a Foundry project connection used to pull a private pre-built container image." + }, "agentEndpoint": { "type": "object", "description": "Agent endpoint configuration (protocols, version selection, auth).", @@ -136,6 +142,21 @@ "then": { "required": ["model"] } + }, + { + "$comment": "A registry connection applies only to a hosted container agent, not code or managed voice deploy.", + "if": { + "required": ["registryConnectionId"] + }, + "then": { + "properties": { + "kind": { "const": "hosted" } + }, + "required": ["kind"], + "not": { + "required": ["codeConfiguration"] + } + } } ], "definitions": { From eef1a86a52c89f0dfbeb2f0c5a2326233288a570 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Mon, 17 Aug 2026 19:13:44 +0800 Subject: [PATCH 02/12] feat(agents): use image passthrough for pre-built images --- .../azure.ai.agents/internal/cmd/init.go | 5 + .../azure.ai.agents/internal/cmd/init_test.go | 139 ++++++++++-------- .../internal/project/image_passthrough.go | 54 +++++++ .../internal/project/service_target_agent.go | 39 +++-- .../project/service_target_agent_test.go | 100 ++++++++++++- 5 files changed, 264 insertions(+), 73 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go 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 12a48802b0c..9dc87723a21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3428,6 +3428,11 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa strings.HasPrefix(ca.CodeConfiguration.Runtime, "dotnet_") { serviceConfig.Language = "csharp" } + } else if preBuiltImage != "" { + // Keep pre-built images in their source registry. This applies to both public + // BYO images and private images pulled through a Foundry registry connection. + serviceConfig.Docker = &azdext.DockerProjectOptions{} + project.EnableDockerImagePassthrough(serviceConfig.Docker) } else { // Disable remote build when the Foundry account is VNET-injected; remote // build runs on worker IPs that can't reach a registry in the VNET. 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 951982af848..9c65059501b 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 @@ -613,73 +613,90 @@ func TestSynthesizeImageManifestFile_AcceptsActivityProtocol(t *testing.T) { }, containerAgent.Protocols) } -func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { +func TestAddToProjectPreBuiltImageEnablesPassthrough(t *testing.T) { const image = "registry.example.com/agents/my-agent:v1" - const registryConnection = "production-registry" - server := &recordingProjectServer{} - client := newProjectRecorderClient(t, server) - action := &InitAction{ - azdClient: client, - environment: &azdext.Environment{Name: "test-env"}, - flags: &initFlags{ - image: image, - registryConnection: registryConnection, - noPrompt: true, - }, - serviceNameOverride: "my-agent", - } - description := "Hosted container agent using a pre-built image" - manifest := &agent_yaml.AgentManifest{ - Template: agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindHosted, - Name: "my-agent", - Description: &description, - }, - Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "responses", Version: "2.0.0"}, - }, - EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ - {Name: "LOG_LEVEL", Value: "info"}, - }, - }, + tests := []struct { + name string + flagImage string + manifestImage string + registryConnection string + }{ + {name: "BYO image flag", flagImage: image}, + {name: "manifest BYO image", manifestImage: image}, + {name: "private registry image", flagImage: image, registryConnection: "production-registry"}, } - output, err := captureStdout(t, func() error { - return action.addToProject(t.Context(), "src/my-agent", manifest) - }) - require.NoError(t, err) - require.Contains(t, output, "\nAdded agent 'my-agent' to azure.yaml.\n") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + action := &InitAction{ + azdClient: client, + environment: &azdext.Environment{Name: "test-env"}, + flags: &initFlags{ + image: test.flagImage, + registryConnection: test.registryConnection, + noPrompt: true, + }, + serviceNameOverride: "my-agent", + } + description := "Hosted container agent using a pre-built image" + manifest := &agent_yaml.AgentManifest{ + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "my-agent", + Description: &description, + }, + Image: test.manifestImage, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + {Name: "LOG_LEVEL", Value: "info"}, + }, + }, + } - server.mu.Lock() - defer server.mu.Unlock() + output, err := captureStdout(t, func() error { + return action.addToProject(t.Context(), "src/my-agent", manifest) + }) + require.NoError(t, err) + require.Contains(t, output, "\nAdded agent 'my-agent' to azure.yaml.\n") - var agentService *azdext.ServiceConfig - for _, service := range server.added { - if service.GetName() == "my-agent" { - agentService = service - break - } + server.mu.Lock() + defer server.mu.Unlock() + + var agentService *azdext.ServiceConfig + for _, service := range server.added { + if service.GetName() == "my-agent" { + agentService = service + break + } + } + require.NotNil(t, agentService) + require.Equal(t, image, agentService.GetImage()) + require.Equal(t, "docker", agentService.GetLanguage()) + require.True(t, project.DockerImagePassthrough(agentService.GetDocker())) + require.False(t, agentService.GetDocker().GetRemoteBuild()) + require.NotNil(t, agentService.GetAdditionalProperties()) + require.Empty(t, agentService.GetEnvironment()) + require.Equal(t, map[string]any{"LOG_LEVEL": "info"}, server.env["my-agent"]) + + properties := agentService.GetAdditionalProperties().GetFields() + _, hasInlineImage := properties["image"] + require.False(t, hasInlineImage, "pre-built image must ride on the top-level service image field") + if test.registryConnection == "" { + require.NotContains(t, properties, "registryConnectionId") + } else { + require.Equal(t, test.registryConnection, properties["registryConnectionId"].GetStringValue()) + require.NotContains(t, agentService.GetUses(), test.registryConnection, + "an external connection must not be added to uses") + } + _, hasInlineEnvironment := properties["environmentVariables"] + require.False(t, hasInlineEnvironment) + }) } - require.NotNil(t, agentService) - require.Equal(t, image, agentService.GetImage()) - 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") - require.Equal(t, registryConnection, - agentService.GetAdditionalProperties().GetFields()["registryConnectionId"].GetStringValue()) - require.NotContains(t, agentService.GetUses(), registryConnection, - "an external connection must not be added to uses") - _, hasInlineEnvironment := agentService.GetAdditionalProperties(). - GetFields()["environmentVariables"] - require.False(t, hasInlineEnvironment) } func TestValidateInitAgentName(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go b/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go new file mode 100644 index 00000000000..7891c8b79b9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +// cSpell:ignore protowire + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/encoding/protowire" +) + +const imagePassthroughFieldNumber = 11 + +// EnableDockerImagePassthrough enables the core azd image passthrough option. +// +// The extension currently builds against an azd SDK version that predates this +// protobuf field. Encoding it as an unknown field keeps the extension compatible +// with that SDK while allowing newer azd hosts to consume the option. +func EnableDockerImagePassthrough(options *azdext.DockerProjectOptions) { + unknown := options.ProtoReflect().GetUnknown() + unknown = protowire.AppendTag(unknown, imagePassthroughFieldNumber, protowire.VarintType) + unknown = protowire.AppendVarint(unknown, 1) + options.ProtoReflect().SetUnknown(unknown) +} + +// DockerImagePassthrough reports whether the core azd image passthrough option is enabled. +func DockerImagePassthrough(options *azdext.DockerProjectOptions) bool { + if options == nil { + return false + } + + unknown := options.ProtoReflect().GetUnknown() + for len(unknown) > 0 { + number, wireType, tagLength := protowire.ConsumeTag(unknown) + if tagLength < 0 { + return false + } + unknown = unknown[tagLength:] + + if number == imagePassthroughFieldNumber && wireType == protowire.VarintType { + value, valueLength := protowire.ConsumeVarint(unknown) + return valueLength >= 0 && value != 0 + } + + fieldLength := protowire.ConsumeFieldValue(number, wireType, unknown) + if fieldLength < 0 { + return false + } + unknown = unknown[fieldLength:] + } + + return false +} 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 ade3db224bb..abe202b7569 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 @@ -688,6 +688,17 @@ func (p *AgentServiceTargetProvider) Package( return nil, err } + // Core image passthrough owns the artifact lifecycle for all pre-built images, + // whether the source registry is public or accessed through a Foundry connection. + if DockerImagePassthrough(serviceConfig.GetDocker()) { + progress("Packaging pre-built container image") + artifacts, err := p.packageContainer(ctx, serviceConfig, serviceContext) + if err != nil { + return nil, err + } + return &azdext.ServicePackageResult{Artifacts: artifacts}, nil + } + // Code deploy: ZIP the source directory. if agentDef.CodeConfiguration != nil { progress("Packaging code") @@ -756,18 +767,12 @@ func (p *AgentServiceTargetProvider) Package( serviceContext.Build = append(serviceContext.Build, buildResponse.Result.Artifacts...) } - packageRequest := &azdext.ContainerPackageRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - } - packageResponse, err := p.azdClient. - Container(). - Package(ctx, packageRequest) + artifacts, err := p.packageContainer(ctx, serviceConfig, serviceContext) if err != nil { - return nil, exterrors.FromHost(err, exterrors.OpContainerPackage, "container package failed") + return nil, err } - newArtifacts = append(newArtifacts, packageResponse.Result.Artifacts...) + newArtifacts = append(newArtifacts, artifacts...) } return &azdext.ServicePackageResult{ @@ -775,6 +780,22 @@ func (p *AgentServiceTargetProvider) Package( }, nil } +func (p *AgentServiceTargetProvider) packageContainer( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, +) ([]*azdext.Artifact, error) { + packageResponse, err := p.azdClient.Container().Package(ctx, &azdext.ContainerPackageRequest{ + ServiceName: serviceConfig.Name, + ServiceContext: serviceContext, + }) + if err != nil { + return nil, exterrors.FromHost(err, exterrors.OpContainerPackage, "container package failed") + } + + return packageResponse.Result.Artifacts, nil +} + // Publish performs the publish operation for the agent service func (p *AgentServiceTargetProvider) Publish( ctx context.Context, 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 9f4a0438f60..afce4cea991 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 @@ -287,6 +287,8 @@ type stubContainerServer struct { buildRequest *azdext.ContainerBuildRequest packRequest *azdext.ContainerPackageRequest pubRequest *azdext.ContainerPublishRequest + packageImage string + publishImage string publishErr error } @@ -312,11 +314,16 @@ func (s *stubContainerServer) Package( ) (*azdext.ContainerPackageResponse, error) { s.packageCalls.Add(1) s.packRequest = request + image := s.packageImage + if image == "" { + image = "myregistry.azurecr.io/test-image:latest" + } return &azdext.ContainerPackageResponse{ Result: &azdext.ServicePackageResult{ Artifacts: []*azdext.Artifact{{ - Kind: azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER, - Location: "myregistry.azurecr.io/test-image:latest", + Kind: azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER, + Location: image, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, }}, }, }, nil @@ -332,11 +339,15 @@ func (s *stubContainerServer) Publish( return nil, s.publishErr } + image := s.publishImage + if image == "" { + image = "myregistry.azurecr.io/test-image:latest" + } return &azdext.ContainerPublishResponse{ Result: &azdext.ServicePublishResult{ Artifacts: []*azdext.Artifact{{ Kind: azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER, - Location: "myregistry.azurecr.io/test-image:latest", + Location: image, LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, }}, }, @@ -1819,6 +1830,54 @@ func TestShouldUsePreBuiltImage_PromptErrorCanRetry(t *testing.T) { require.Equal(t, int32(2), promptStub.selectCalls.Load()) } +func TestPackage_DelegatesImagePassthroughToCore(t *testing.T) { + const image = "registry.example.com/agents/my-agent:v1" + tests := []struct { + name string + registryConnection string + }{ + {name: "BYO image"}, + {name: "private registry image", registryConnection: "production-registry"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + content := fmt.Sprintf("kind: hosted\nname: test-agent\nimage: %s\n", image) + if test.registryConnection != "" { + content += fmt.Sprintf("registryConnectionId: %s\n", test.registryConnection) + } + require.NoError(t, os.WriteFile(agentPath, []byte(content), 0o600)) + + containerStub := &stubContainerServer{packageImage: image} + promptStub := &stubPromptServer{selectedIndex: 0} + dockerOptions := &azdext.DockerProjectOptions{} + EnableDockerImagePassthrough(dockerOptions) + provider := &AgentServiceTargetProvider{ + azdClient: newServiceTargetTestClient(t, containerStub, promptStub), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + } + + result, err := provider.Package( + t.Context(), + &azdext.ServiceConfig{Name: "test-svc", Docker: dockerOptions}, + &azdext.ServiceContext{}, + func(string) {}, + ) + + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, image, result.Artifacts[0].Location) + require.Equal(t, azdext.LocationKind_LOCATION_KIND_REMOTE, result.Artifacts[0].LocationKind) + require.Equal(t, int32(0), containerStub.buildCalls.Load()) + require.Equal(t, int32(1), containerStub.packageCalls.Load()) + require.Equal(t, int32(0), promptStub.selectCalls.Load()) + }) + } +} + func TestPackage_SkipsWhenPreBuiltImageChosen(t *testing.T) { t.Parallel() @@ -1882,6 +1941,41 @@ func TestPackage_BuildsWhenUserChoseDockerfile(t *testing.T) { require.Equal(t, int32(1), containerStub.packageCalls.Load()) } +func TestPublish_DelegatesImagePassthroughToCore(t *testing.T) { + t.Parallel() + + const image = "registry.example.com/agents/my-agent:v1" + dir := t.TempDir() + agentPath := writeHostedAgentYAMLWithImage(t, dir, image) + containerStub := &stubContainerServer{publishImage: image} + dockerOptions := &azdext.DockerProjectOptions{} + EnableDockerImagePassthrough(dockerOptions) + provider := &AgentServiceTargetProvider{ + azdClient: newContainerTestClient(t, containerStub), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + } + + result, err := provider.Publish( + t.Context(), + &azdext.ServiceConfig{Name: "test-svc", Docker: dockerOptions}, + &azdext.ServiceContext{Package: []*azdext.Artifact{{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER, + Location: image, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{"imagePassthrough": "true"}, + }}}, + &azdext.TargetResource{}, + &azdext.PublishOptions{}, + func(string) {}, + ) + + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, image, result.Artifacts[0].Location) + require.Equal(t, int32(1), containerStub.publishCalls.Load()) +} + func TestPublish_SkipsWhenPreBuiltImageChosen(t *testing.T) { t.Parallel() From 6480662a8ef8fbc8713734cd74918a5c53430a05 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 14:13:47 +0800 Subject: [PATCH 03/12] fix(agents): complete pre-built image passthrough migration --- cli/azd/docs/environment-variables.md | 2 +- .../docs/private-networking.md | 6 ++ .../azure.ai.agents/internal/cmd/helpers.go | 2 +- .../internal/cmd/hosted_container_config.go | 42 +++++++++ .../cmd/hosted_container_config_test.go | 54 +++++++++++ .../azure.ai.agents/internal/cmd/init.go | 33 +++---- .../internal/cmd/init_adopt.go | 57 ++++++------ .../cmd/init_adopt_deploymode_test.go | 93 ++++++++++--------- .../internal/cmd/init_from_code.go | 17 ++-- .../azure.ai.agents/internal/cmd/init_test.go | 18 +++- .../internal/pkg/containerref/reference.go | 22 +++++ .../pkg/containerref/reference_test.go | 38 ++++++++ .../internal/project/doc_examples_test.go | 21 +++-- .../internal/project/service_target_agent.go | 29 ++++-- .../project/service_target_agent_test.go | 84 +++++++++++++++++ 15 files changed, 397 insertions(+), 121 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 9a22784c156..219602f936f 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -158,7 +158,7 @@ specific version of the tool installed on the machine. | `AZURE_AI_PROJECT_ACR_CONNECTION_NAME` | The Azure Container Registry connection name used by the extension for hosted agents. | | `AI_PROJECT_DEPLOYMENTS` | JSON-encoded deployment metadata populated by the extension for agent workflows. | | `AI_PROJECT_DEPENDENT_RESOURCES` | JSON-encoded dependent resource metadata populated by the extension for agent workflows. | -| `AZD_AGENT_SKIP_ACR` | If `true`, signals the Bicep template to skip Azure Container Registry creation during provisioning. Automatically set by `azd agent init` for code-deploy scenarios (where no container image is built). | +| `AZD_AGENT_SKIP_ACR` | If `true`, signals legacy agent Bicep templates to skip Azure Container Registry creation during provisioning. Automatically set by `azd ai agent init` for code deploy and pre-built-image scenarios. It does not select the container build or deployment mode for new configurations; the agents extension recognizes it only as a compatibility marker for pre-image-passthrough projects. | | `ENABLE_HOSTED_AGENTS` | If set, indicates that hosted agents are enabled for the current azd environment. | | `ENABLE_CONTAINER_AGENTS` | If set, indicates that container agents are enabled for the current azd environment. | | `AGENT_DEFINITION_PATH` | Path to an agent definition file for AI agent workflows. | diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index b1c1f6780b6..70fc441d63d 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -20,6 +20,8 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + imagePassthrough: true ai-project: host: azure.ai.project @@ -111,6 +113,8 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + imagePassthrough: true ai-project: host: azure.ai.project @@ -153,6 +157,8 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 + docker: + imagePassthrough: true ai-project: host: azure.ai.project 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 fe50486b22e..f53e01a62d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -477,7 +477,7 @@ func resolveConversationID( // setACREnvVar sets the AZD_AGENT_SKIP_ACR environment variable based on whether ACR // should be skipped. ACR is skipped when: // - Code deploy mode (no container registry needed) -// - Pre-built image provided via --image flag (user manages their own registry) +// - Pre-built image provided by a flag, manifest, or detected source definition // // This env var is consumed by the Bicep template in Azure-Samples/azd-ai-starter-basic // (infra/main.bicep) as `param skipAcr bool` to conditionally skip ACR resource creation. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go new file mode 100644 index 00000000000..541092e18e8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +type hostedContainerDockerConfig struct { + imagePassthrough bool + remoteBuild bool +} + +func resolveHostedContainerDockerConfig(image string, networkInjected bool) hostedContainerDockerConfig { + if strings.TrimSpace(image) != "" { + return hostedContainerDockerConfig{imagePassthrough: true} + } + + return hostedContainerDockerConfig{remoteBuild: !networkInjected} +} + +func dockerProjectOptionsForHostedContainer(image string, networkInjected bool) *azdext.DockerProjectOptions { + config := resolveHostedContainerDockerConfig(image, networkInjected) + options := &azdext.DockerProjectOptions{RemoteBuild: config.remoteBuild} + if config.imagePassthrough { + project.EnableDockerImagePassthrough(options) + } + return options +} + +func dockerProjectMapForHostedContainer(image string, networkInjected bool) map[string]any { + config := resolveHostedContainerDockerConfig(image, networkInjected) + if config.imagePassthrough { + return map[string]any{"imagePassthrough": true} + } + return map[string]any{"remoteBuild": config.remoteBuild} +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go new file mode 100644 index 00000000000..8e98d9964c8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/project" + + "github.com/stretchr/testify/require" +) + +func TestDockerProjectOptionsForHostedContainer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + image string + networkInjected bool + wantPassthrough bool + wantRemoteBuild bool + }{ + {name: "source build", wantRemoteBuild: true}, + {name: "network injected source build", networkInjected: true}, + {name: "pre-built image", image: "registry.example.com/team/agent:v1", wantPassthrough: true}, + { + name: "network injected pre-built image", + image: "registry.example.com/team/agent:v1", + networkInjected: true, + wantPassthrough: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + options := dockerProjectOptionsForHostedContainer(test.image, test.networkInjected) + require.Equal(t, test.wantPassthrough, project.DockerImagePassthrough(options)) + require.Equal(t, test.wantRemoteBuild, options.GetRemoteBuild()) + }) + } +} + +func TestDockerProjectMapForHostedContainer(t *testing.T) { + t.Parallel() + + require.Equal(t, map[string]any{"imagePassthrough": true}, + dockerProjectMapForHostedContainer("registry.example.com/team/agent:v1", false)) + require.Equal(t, map[string]any{"remoteBuild": true}, + dockerProjectMapForHostedContainer("", false)) + require.Equal(t, map[string]any{"remoteBuild": false}, + dockerProjectMapForHostedContainer("", true)) +} 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 9dc87723a21..c4ff6003916 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3,6 +3,8 @@ package cmd +// cSpell:ignore containerref + import ( "context" "crypto/rand" @@ -29,6 +31,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azdignore" + "azureaiagent/internal/pkg/containerref" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/project" @@ -135,6 +138,7 @@ type InitAction struct { // addToProject can disable remote build for VNET-injected accounts // without issuing a second account read. selectedFoundryProject *FoundryProjectInfo + usesPreBuiltImage bool // userProvidedManifest is true when the init flow is driven by a manifest — // either explicitly via the -m flag/positional argument, or when the user @@ -146,11 +150,12 @@ type InitAction struct { // skipACR returns true when ACR provisioning and configuration should be skipped. // This happens when: // - Code deploy mode is selected (ZIP upload, no container build) -// - Pre-built image is provided via --image flag (user manages their own registry) +// - Pre-built image is provided via a flag or manifest (user manages their own registry) // - A registry connection is provided for a pre-built image // - The manifest is a prompt-voice agent (managed, no container image) func (a *InitAction) skipACR() bool { - return a.isCodeDeploy || a.flags.image != "" || a.flags.registryConnection != "" || a.isVoiceAgent + return a.isCodeDeploy || a.usesPreBuiltImage || a.flags.image != "" || + a.flags.registryConnection != "" || a.isVoiceAgent } // isHostedAgent reports whether the agent is deployed as an azd hosted agent @@ -160,7 +165,7 @@ func (a *InitAction) skipACR() bool { // prompt-voice agent also skips ACR, but is managed rather than hosted and must // not be constrained to hosted-agent regions. func (a *InitAction) isHostedAgent() bool { - return a.isCodeDeploy || a.flags.image != "" || a.flags.registryConnection != "" + return a.isCodeDeploy || a.usesPreBuiltImage || a.flags.image != "" || a.flags.registryConnection != "" } // modelSelector encapsulates the dependencies needed for model selection and @@ -2072,6 +2077,7 @@ func (a *InitAction) Run(ctx context.Context) error { if err := a.applyAndValidateRegistryConnection(agentManifest); err != nil { return err } + a.usesPreBuiltImage = preBuiltImageForInit(agentManifest, a.flags.image) != "" // Model configuration: prompt user for "use existing" vs "deploy new" agentManifest, err = a.configureModelChoice(ctx, agentManifest) @@ -3428,16 +3434,11 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa strings.HasPrefix(ca.CodeConfiguration.Runtime, "dotnet_") { serviceConfig.Language = "csharp" } - } else if preBuiltImage != "" { - // Keep pre-built images in their source registry. This applies to both public - // BYO images and private images pulled through a Foundry registry connection. - serviceConfig.Docker = &azdext.DockerProjectOptions{} - project.EnableDockerImagePassthrough(serviceConfig.Docker) } else { - // Disable remote build when the Foundry account is VNET-injected; remote - // build runs on worker IPs that can't reach a registry in the VNET. + // Pre-built images stay in their source registry. Source builds use ACR Tasks + // unless the Foundry account is VNET-injected. networkInjected := a.selectedFoundryProject != nil && a.selectedFoundryProject.NetworkInjected - serviceConfig.Docker = &azdext.DockerProjectOptions{RemoteBuild: !networkInjected} + serviceConfig.Docker = dockerProjectOptionsForHostedContainer(preBuiltImage, networkInjected) } } @@ -4562,14 +4563,6 @@ func (a *InitAction) validateCodeDeployFlags() error { a.flags.noPrompt, a.flags.deployMode, a.flags.runtime, a.flags.entryPoint, a.flags.depResolution) } -var initImageRefRe = regexp.MustCompile( - `^(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]+)?|` + - `localhost(?::[0-9]+)?|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?:[0-9]+)/` + - `[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*` + - `(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*` + - `(?::[\w][\w.-]{0,127}|@sha256:[0-9a-fA-F]{64})?$`, -) - // validateImageFlag checks that --image is valid when provided. // Returns an error if: // - --image is used with --deploy-mode code (incompatible) @@ -4591,7 +4584,7 @@ func validateImageFlag(image, deployMode string) error { // Require a fully-qualified image reference with an explicit registry host, // e.g. "myacr.azurecr.io/agent", "docker.io/myorg/agent:v1", or // "localhost:5000/agent@sha256:". - if !initImageRefRe.MatchString(image) { + if !containerref.IsFullyQualified(image) { return exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("invalid image URL %q: must be in format registry/image[:tag]", image), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 092d5bee299..2f6cd76b64f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -1049,15 +1049,14 @@ func runInitFromAzureYaml( // resolved deploy mode: a container agent on an existing project // needs AZURE_CONTAINER_REGISTRY_ENDPOINT set here, while a code // agent (or a user-supplied --image) does not. - usesContainer, err := applyDeployModeToAdoptedProject(ctx, flags, azdClient) + needsACR, err := applyDeployModeToAdoptedProject(ctx, flags, azdClient) if err != nil { return err } - // skipACR is false only for a container deploy whose registry azd - // manages. Code deploy and --image (bring your own registry) both - // skip ACR. - skipACR := !usesContainer || flags.image != "" + // Only source-container deploys require an ACR. Code deploy and pre-built + // images skip it. + skipACR := !needsACR // The adopt path only supports hosted agents today. Hosted-region filtering // is independent from ACR setup; prompt-voice has its own region/onboarding // constraints and does not flow through this path. @@ -1540,10 +1539,8 @@ func printAdoptionNextSteps(ctx context.Context, azdClient *azdext.AzdClient, fo // explicit flag is passed and the service already has a codeConfiguration or // docker property, the service is left unchanged (the sample is pre-configured). // -// It reports whether any agent service resolved to a container -// (Docker) deploy so the caller can decide whether an Azure -// Container Registry must be wired (existing project) or created -// on provision. +// It reports whether any agent service requires an Azure Container Registry +// for a source-container build. func applyDeployModeToAdoptedProject( ctx context.Context, flags *initFlags, @@ -1575,11 +1572,11 @@ func applyDeployModeToAdoptedProject( return false, nil } - // Apply configuration to each agent service, tracking whether any - // resolves to a container deploy so the caller can wire an ACR. - usesContainer := false + // Apply configuration to each agent service, tracking whether any source + // container requires an ACR. + needsACR := false for _, agent := range agentServices { - container, err := applyDeployModeToService( + requiresACR, err := applyDeployModeToService( ctx, flags, azdClient, @@ -1590,19 +1587,16 @@ func applyDeployModeToAdoptedProject( if err != nil { return false, err } - if container { - usesContainer = true + if requiresACR { + needsACR = true } } - return usesContainer, nil + return needsACR, nil } // applyDeployModeToService applies deploy-mode configuration to a -// single agent service and reports whether the resolved mode is a -// container (Docker) deploy. A container deploy that azd builds -// requires an Azure Container Registry; a code (ZIP) deploy does -// not. A user-provided --image is a container deploy but uses the -// caller's own registry, so callers treat --image as skip-ACR. +// single agent service and reports whether the resolved mode requires an ACR. +// Source-container builds require one; code deploy and pre-built images do not. func applyDeployModeToService( ctx context.Context, flags *initFlags, @@ -1626,11 +1620,20 @@ func applyDeployModeToService( } log.Printf("Applied --image %q to agent service %q", flags.image, serviceName) - // --image implies container deploy; apply container config and return. - if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc); err != nil { + // --image implies container deploy; apply image passthrough and return. + if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc, flags.image); err != nil { return false, err } - return true, nil + return false, nil + } + + // An adopted service that already declares an image also uses passthrough, + // even when --image was not supplied during this init. + if strings.TrimSpace(svc.GetImage()) != "" { + if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc, svc.GetImage()); err != nil { + return false, err + } + return false, nil } // Check whether the service already specifies its deploy mode. @@ -1671,7 +1674,7 @@ func applyDeployModeToService( ctx, flags, azdClient, serviceName, serviceDir, svc, ) } - if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc); err != nil { + if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc, ""); err != nil { return false, err } return true, nil @@ -1800,9 +1803,9 @@ func applyContainerDeployToService( azdClient *azdext.AzdClient, serviceName string, svc *azdext.ServiceConfig, + image string, ) error { - // Set docker property with remote build enabled. - dockerMap := map[string]any{"remoteBuild": true} + dockerMap := dockerProjectMapForHostedContainer(image, false) dockerValue, err := structpb.NewValue(dockerMap) if err != nil { return fmt.Errorf("encoding docker configuration: %w", err) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go index a606fa3364d..39b7f20dee1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go @@ -85,11 +85,8 @@ func agentServiceConfig(t *testing.T, name string, props map[string]any) *azdext return sc } -// TestApplyDeployModeToAdoptedProject verifies that the adopt flow -// reports whether the resolved deploy mode is a container (Docker) -// deploy so the caller can wire an Azure Container Registry. A -// container agent that never reported itself would leave -// AZURE_CONTAINER_REGISTRY_ENDPOINT unset and fail deploy. +// TestApplyDeployModeToAdoptedProject verifies that the adopt flow reports +// whether the resolved deploy mode requires an Azure Container Registry. func TestApplyDeployModeToAdoptedProject(t *testing.T) { const svcName = "agent" @@ -97,51 +94,61 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { codeProps := map[string]any{ "codeConfiguration": map[string]any{"runtime": "python_3_13", "entryPoint": "app.py"}, } + preBuiltService := agentServiceConfig(t, svcName, dockerProps) + preBuiltService.Image = "registry.example.com/team/agent:v1" tests := []struct { - name string - flags *initFlags - service *azdext.ServiceConfig - wantContainer bool - wantLanguage any - wantDockerSet bool - wantCodeSet bool + name string + flags *initFlags + service *azdext.ServiceConfig + wantNeedsACR bool + wantLanguage any + wantDocker map[string]any + wantCodeSet bool }{ { - name: "explicit container flag wires ACR", - flags: &initFlags{deployMode: "container"}, - service: agentServiceConfig(t, svcName, nil), - wantContainer: true, - wantLanguage: "docker", - wantDockerSet: true, + name: "explicit container flag wires ACR", + flags: &initFlags{deployMode: "container"}, + service: agentServiceConfig(t, svcName, nil), + wantNeedsACR: true, + wantLanguage: "docker", + wantDocker: map[string]any{"remoteBuild": true}, }, { - name: "explicit code flag skips ACR", - flags: &initFlags{deployMode: "code", runtime: "python_3_13", entryPoint: "app.py"}, - service: agentServiceConfig(t, svcName, nil), - wantContainer: false, - wantLanguage: "python", - wantCodeSet: true, + name: "explicit code flag skips ACR", + flags: &initFlags{deployMode: "code", runtime: "python_3_13", entryPoint: "app.py"}, + service: agentServiceConfig(t, svcName, nil), + wantNeedsACR: false, + wantLanguage: "python", + wantCodeSet: true, }, { - name: "prebuilt image is container deploy", - flags: &initFlags{image: "myacr.azurecr.io/agent:v1"}, - service: agentServiceConfig(t, svcName, nil), - wantContainer: true, - wantLanguage: "docker", - wantDockerSet: true, + name: "prebuilt image uses passthrough", + flags: &initFlags{image: "myacr.azurecr.io/agent:v1"}, + service: agentServiceConfig(t, svcName, nil), + wantNeedsACR: false, + wantLanguage: "docker", + wantDocker: map[string]any{"imagePassthrough": true}, }, { - name: "respects sample docker config", - flags: &initFlags{}, - service: agentServiceConfig(t, svcName, dockerProps), - wantContainer: true, + name: "existing image uses passthrough", + flags: &initFlags{}, + service: preBuiltService, + wantNeedsACR: false, + wantLanguage: "docker", + wantDocker: map[string]any{"imagePassthrough": true}, }, { - name: "respects sample code config", - flags: &initFlags{}, - service: agentServiceConfig(t, svcName, codeProps), - wantContainer: false, + name: "respects sample docker config", + flags: &initFlags{}, + service: agentServiceConfig(t, svcName, dockerProps), + wantNeedsACR: true, + }, + { + name: "respects sample code config", + flags: &initFlags{}, + service: agentServiceConfig(t, svcName, codeProps), + wantNeedsACR: false, }, } @@ -153,22 +160,22 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { } client := newProjectRecorderClient(t, server) - usesContainer, err := applyDeployModeToAdoptedProject(t.Context(), tc.flags, client) + needsACR, err := applyDeployModeToAdoptedProject(t.Context(), tc.flags, client) require.NoError(t, err) - assert.Equal(t, tc.wantContainer, usesContainer) + assert.Equal(t, tc.wantNeedsACR, needsACR) sets := server.sets[svcName] if tc.wantLanguage != nil { assert.Equal(t, tc.wantLanguage, sets["language"]) } - if tc.wantDockerSet { - assert.Contains(t, sets, "docker") + if tc.wantDocker != nil { + assert.Equal(t, tc.wantDocker, sets["docker"]) } if tc.wantCodeSet { assert.Contains(t, sets, "codeConfiguration") } // A respected sample config must not be rewritten. - if !tc.wantDockerSet && !tc.wantCodeSet && tc.wantLanguage == nil { + if tc.wantDocker == nil && !tc.wantCodeSet && tc.wantLanguage == nil { assert.Empty(t, sets) } }) 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 a37a34a1beb..1ca01691088 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 @@ -814,8 +814,9 @@ func (a *InitFromCodeAction) addToProject( agentConfig.Deployments = a.deploymentDetails - // Detect startup command (container deploy only; code deploy does not use startupCommand) - if !isCodeDeploy { + // Detect startup command only for source-container deploys. Code deploy and + // pre-built images do not use it. + if !isCodeDeploy && strings.TrimSpace(definition.Image) == "" { startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) if err != nil { return err @@ -855,17 +856,17 @@ func (a *InitFromCodeAction) addToProject( AdditionalProperties: agentProps, } - // For hosted container-based agents, enable remote build by default. It is - // silently disabled when the target Foundry account has VNET network injection - // configured, since it cannot reach a registry in the VNET. + // Pre-built images stay in their source registry. Source builds use ACR Tasks + // unless the Foundry account is VNET-injected. if !isCodeDeploy { networkInjected := a.selectedFoundryProject != nil && a.selectedFoundryProject.NetworkInjected - serviceConfig.Docker = &azdext.DockerProjectOptions{RemoteBuild: !networkInjected} + serviceConfig.Docker = dockerProjectOptionsForHostedContainer(definition.Image, networkInjected) } - // Set AZD_AGENT_SKIP_ACR so Bicep knows whether to create a container registry. + // Set AZD_AGENT_SKIP_ACR so legacy Bicep knows whether to create a container registry. // Set before AddService so env state is consistent even if AddService fails. - if err := setACREnvVar(ctx, a.azdClient, a.environment.Name, isCodeDeploy); err != nil { + skipACR := isCodeDeploy || strings.TrimSpace(definition.Image) != "" + if err := setACREnvVar(ctx, a.azdClient, a.environment.Name, skipACR); err != nil { return err } 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 9c65059501b..4a47c9b54f2 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 @@ -400,6 +400,7 @@ func TestSkipACR(t *testing.T) { isCodeDeploy bool image string registryConnection string + usesPreBuiltImage bool isVoiceAgent bool want bool }{ @@ -426,6 +427,11 @@ func TestSkipACR(t *testing.T) { registryConnection: "private-registry", want: true, }, + { + name: "manifest image skips ACR", + usesPreBuiltImage: true, + want: true, + }, { name: "voice agent skips ACR", isCodeDeploy: false, @@ -446,8 +452,9 @@ func TestSkipACR(t *testing.T) { t.Parallel() action := &InitAction{ - isCodeDeploy: tt.isCodeDeploy, - isVoiceAgent: tt.isVoiceAgent, + isCodeDeploy: tt.isCodeDeploy, + usesPreBuiltImage: tt.usesPreBuiltImage, + isVoiceAgent: tt.isVoiceAgent, flags: &initFlags{ image: tt.image, registryConnection: tt.registryConnection, @@ -470,12 +477,14 @@ func TestIsHostedAgent(t *testing.T) { isCodeDeploy bool image string registryConnection string + usesPreBuiltImage bool isVoiceAgent bool want bool }{ {name: "code deploy is hosted", isCodeDeploy: true, want: true}, {name: "image is hosted", image: "myacr.azurecr.io/agent:v1", want: true}, {name: "registry connection is hosted", registryConnection: "private-registry", want: true}, + {name: "manifest image is hosted", usesPreBuiltImage: true, want: true}, {name: "voice is not hosted", isVoiceAgent: true, want: false}, {name: "plain container is not hosted", want: false}, } @@ -485,8 +494,9 @@ func TestIsHostedAgent(t *testing.T) { t.Parallel() action := &InitAction{ - isCodeDeploy: tt.isCodeDeploy, - isVoiceAgent: tt.isVoiceAgent, + isCodeDeploy: tt.isCodeDeploy, + usesPreBuiltImage: tt.usesPreBuiltImage, + isVoiceAgent: tt.isVoiceAgent, flags: &initFlags{ image: tt.image, registryConnection: tt.registryConnection, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go new file mode 100644 index 00000000000..25afcb3be9f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package containerref validates container image references used by hosted agents. +package containerref + +// cSpell:ignore containerref + +import "regexp" + +var fullyQualifiedReference = regexp.MustCompile( + `^(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]+)?|` + + `localhost(?::[0-9]+)?|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?:[0-9]+)/` + + `[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*` + + `(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*` + + `(?::[\w][\w.-]{0,127}|@sha256:[0-9a-fA-F]{64})?$`, +) + +// IsFullyQualified reports whether image contains an explicit registry host and repository. +func IsFullyQualified(image string) bool { + return fullyQualifiedReference.MatchString(image) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go new file mode 100644 index 00000000000..3368e4cbe7f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package containerref + +// cSpell:ignore containerref + +import "testing" + +func TestIsFullyQualified(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + image string + want bool + }{ + {name: "registry and tag", image: "registry.example.com/team/agent:v1", want: true}, + {name: "localhost and port", image: "localhost:5000/agent:latest", want: true}, + { + name: "digest", + image: "registry.example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + want: true, + }, + {name: "unqualified", image: "agent:v1", want: false}, + {name: "URL scheme", image: "https://registry.example.com/agent:v1", want: false}, + {name: "empty", image: "", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := IsFullyQualified(test.image); got != test.want { + t.Errorf("IsFullyQualified(%q) = %t, want %t", test.image, got, test.want) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go index 7bbc26db20e..20a850567c0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go @@ -278,16 +278,17 @@ func dedent(line string, n int) string { // coreDockerFields mirrors project.DockerProjectOptions without importing the // full project package into this test-only validator. type coreDockerFields struct { - Path string `yaml:"path"` - Context string `yaml:"context"` - Platform string `yaml:"platform"` - Target string `yaml:"target"` - Registry string `yaml:"registry"` - Image string `yaml:"image"` - Tag string `yaml:"tag"` - RemoteBuild bool `yaml:"remoteBuild"` - Network string `yaml:"network"` - BuildArgs []string `yaml:"buildArgs"` + Path string `yaml:"path"` + Context string `yaml:"context"` + Platform string `yaml:"platform"` + Target string `yaml:"target"` + Registry string `yaml:"registry"` + Image string `yaml:"image"` + Tag string `yaml:"tag"` + RemoteBuild bool `yaml:"remoteBuild"` + ImagePassthrough bool `yaml:"imagePassthrough"` + Network string `yaml:"network"` + BuildArgs []string `yaml:"buildArgs"` } // coreK8sFields mirrors the azure.yaml-facing portion of project.AksOptions. 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 abe202b7569..172c1c8489b 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 @@ -3,6 +3,8 @@ package project +// cSpell:ignore containerref + import ( "archive/zip" "bytes" @@ -34,6 +36,7 @@ import ( "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/azure" "azureaiagent/internal/pkg/botservice" + "azureaiagent/internal/pkg/containerref" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/pkg/paths" @@ -1808,13 +1811,21 @@ func validateRegistryConnectionDefinition(agentDef agent_yaml.ContainerAgent) er "use registryConnectionId with a pre-built image or remove it for code deploy", ) } - if strings.TrimSpace(agentDef.Image) == "" { + image := strings.TrimSpace(agentDef.Image) + if image == "" { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, "registryConnectionId requires a pre-built container image", "set image on the azure.ai.agent service or remove registryConnectionId", ) } + if !containerref.IsFullyQualified(image) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId requires an image with an explicit registry host and repository", + "set image to a fully qualified reference such as registry.example.com/team/agent:v1", + ) + } return nil } @@ -1846,15 +1857,19 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return false, nil } - if p.shouldSkipACRForEnvironment(ctx) { - log.Printf("AZD_AGENT_SKIP_ACR=true: using pre-built image from agent definition") + // Releases before docker.imagePassthrough represented init --image as a docker + // service plus AZD_AGENT_SKIP_ACR=true. Honor that exact legacy shape during + // the compatibility window without using the provisioning variable for new + // or hand-authored image configurations. + if p.serviceConfig.GetDocker() != nil && + !DockerImagePassthrough(p.serviceConfig.GetDocker()) && + p.shouldSkipACRForEnvironment(ctx) { + log.Printf("legacy pre-built image configuration detected: using configured image") return true, nil } - // Default to build so the pre-built path requires an explicit choice. - // In non-interactive mode (--no-prompt), the framework returns the default - // selection (index 0 = build) automatically unless AZD_AGENT_SKIP_ACR=true - // was set by init --image. + // Default to build so the legacy pre-built path requires an explicit choice. + // New projects use docker.imagePassthrough and do not enter this fallback. choices := []*azdext.SelectChoice{ {Value: "build", Label: "Build a new image for me"}, {Value: "prebuilt", Label: fmt.Sprintf("Create hosted agent from %s", imageURL)}, 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 afce4cea991..210bf3cede0 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 @@ -516,6 +516,38 @@ func newPromptTestClient(t *testing.T, promptSrv azdext.PromptServiceServer) *az return newServiceTargetTestClient(t, nil, promptSrv) } +type legacyPreBuiltEnvironmentServer struct { + azdext.UnimplementedEnvironmentServiceServer +} + +func (s *legacyPreBuiltEnvironmentServer) GetValue( + _ context.Context, + _ *azdext.GetEnvRequest, +) (*azdext.KeyValueResponse, error) { + return &azdext.KeyValueResponse{Value: "true"}, nil +} + +func newLegacyPreBuiltTestClient(t *testing.T, promptSrv azdext.PromptServiceServer) *azdext.AzdClient { + t.Helper() + + srv := grpc.NewServer() + azdext.RegisterPromptServiceServer(srv, promptSrv) + azdext.RegisterEnvironmentServiceServer(srv, &legacyPreBuiltEnvironmentServer{}) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + }) + + client, err := azdext.NewAzdClient(azdext.WithAddress(lis.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + return client +} + func TestInitializeIsCheapAndSideEffectFree(t *testing.T) { // azd-core calls ServiceTargetProvider.Initialize for every service on // every action (provision, deploy, env refresh, show, ...). Initialize @@ -1681,6 +1713,20 @@ func TestValidateRegistryConnectionDefinition(t *testing.T) { name: "missing image", agent: agent_yaml.ContainerAgent{RegistryConnectionID: "private-registry"}, wantContain: "requires a pre-built container image", }, + { + name: "unqualified image", + agent: agent_yaml.ContainerAgent{ + Image: "agent:v1", RegistryConnectionID: "private-registry", + }, + wantContain: "explicit registry host and repository", + }, + { + name: "image URL scheme", + agent: agent_yaml.ContainerAgent{ + Image: "https://registry.example.com/agent:v1", RegistryConnectionID: "private-registry", + }, + wantContain: "explicit registry host and repository", + }, { name: "code deploy", agent: agent_yaml.ContainerAgent{ @@ -1718,6 +1764,44 @@ func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { require.False(t, result, "should default to build when no image is configured") } +func TestShouldUsePreBuiltImage_LegacyInitImageUsesCompatibilityMarker(t *testing.T) { + t.Parallel() + + promptStub := &stubPromptServer{selectedIndex: 0} + provider := &AgentServiceTargetProvider{ + azdClient: newLegacyPreBuiltTestClient(t, promptStub), + env: &azdext.Environment{Name: "test-env"}, + serviceConfig: &azdext.ServiceConfig{ + Docker: &azdext.DockerProjectOptions{RemoteBuild: true}, + }, + } + + result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", + }) + require.NoError(t, err) + require.True(t, result) + require.Equal(t, int32(0), promptStub.selectCalls.Load()) +} + +func TestShouldUsePreBuiltImage_SkipACRDoesNotSelectHandAuthoredImage(t *testing.T) { + t.Parallel() + + promptStub := &stubPromptServer{selectedIndex: 0} + provider := &AgentServiceTargetProvider{ + azdClient: newLegacyPreBuiltTestClient(t, promptStub), + env: &azdext.Environment{Name: "test-env"}, + serviceConfig: &azdext.ServiceConfig{}, + } + + result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", + }) + require.NoError(t, err) + require.False(t, result) + require.Equal(t, int32(1), promptStub.selectCalls.Load()) +} + func TestShouldUsePreBuiltImage_RegistryConnectionForcesPreBuilt(t *testing.T) { t.Parallel() From f03d7336995829ddd1aac005c4502ce65d990363 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 18:28:36 +0800 Subject: [PATCH 04/12] fix(agents): preserve deployment mode during adoption --- .../azure.ai.agents/internal/cmd/init.go | 18 ++- .../internal/cmd/init_adopt.go | 95 +++++++++++++-- .../cmd/init_adopt_deploymode_test.go | 64 +++++++++- .../internal/project/service_target_agent.go | 82 ++++++------- .../project/service_target_agent_test.go | 111 +++++++++++++++++- 5 files changed, 312 insertions(+), 58 deletions(-) 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 c4ff6003916..77413b48bcd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -4527,16 +4527,30 @@ func (a *InitAction) verifyRegistryConnection(ctx context.Context) error { return nil } - if err := verifyFoundryProjectConnection( + return verifyRegistryConnectionOnProject( ctx, a.credential, *a.selectedFoundryProject, a.flags.registryConnection, + ) +} + +func verifyRegistryConnectionOnProject( + ctx context.Context, + credential azcore.TokenCredential, + foundryProject FoundryProjectInfo, + connectionRef string, +) error { + if err := verifyFoundryProjectConnection( + ctx, + credential, + foundryProject, + connectionRef, listFoundryProjectConnections, ); err != nil { return exterrors.Dependency( exterrors.CodeFoundryDependencyNotReady, - fmt.Sprintf("failed to verify registry connection %q: %s", a.flags.registryConnection, err), + fmt.Sprintf("failed to verify registry connection %q: %s", connectionRef, err), "Create the connection on the selected Foundry project or pass the name or ID of an existing connection", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 2f6cd76b64f..c6ae654ed98 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -1079,6 +1079,16 @@ func runInitFromAzureYaml( // azure.ai.project service so the provisioning provider recognizes the // brownfield signal and reuses the project instead of creating a new one. if result.FoundryProject != nil { + if flags.registryConnection != "" { + if err := verifyRegistryConnectionOnProject( + ctx, + result.Credential, + *result.FoundryProject, + strings.TrimSpace(flags.registryConnection), + ); err != nil { + return err + } + } if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { return err } @@ -1605,6 +1615,65 @@ func applyDeployModeToService( serviceName string, svc *azdext.ServiceConfig, ) (bool, error) { + resolvedAgent, isHosted, hasDefinition, _, err := project.AgentDefinitionFromResolvedService(svc, projectPath) + if err != nil { + return false, fmt.Errorf("reading adopted agent service %q: %w", serviceName, err) + } + hasCodeConfig := adoptedServiceHasCodeConfig(svc) || + (hasDefinition && resolvedAgent.CodeConfiguration != nil) + + connectionRef := strings.TrimSpace(flags.registryConnection) + if flags.registryConnection != "" { + if connectionRef == "" { + return false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "registry connection cannot be empty or whitespace", + "provide the name or ID of an existing Foundry project connection", + ) + } + if hasDefinition && !isHosted { + return false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection is only valid for hosted container agents", + "use a registry connection with a hosted agent that supplies a pre-built image", + ) + } + if flags.image == "" && hasCodeConfig { + return false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection cannot be used with code deploy", + "use the registry connection with a pre-built image or remove it", + ) + } + + effectiveImage := strings.TrimSpace(flags.image) + if effectiveImage == "" { + effectiveImage = strings.TrimSpace(svc.GetImage()) + } + if effectiveImage == "" && hasDefinition { + effectiveImage = strings.TrimSpace(resolvedAgent.Image) + } + if effectiveImage == "" { + return false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "a registry connection requires a pre-built image", + "pass --image or provide an image in the hosted-agent manifest", + ) + } + + connectionValue, err := structpb.NewValue(connectionRef) + if err != nil { + return false, fmt.Errorf("encoding registry connection value: %w", err) + } + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "registryConnectionId", + Value: connectionValue, + }); err != nil { + return false, fmt.Errorf("writing registry connection to agent service %q: %w", serviceName, err) + } + } + // Apply --image override to the agent service when provided. if flags.image != "" { imageValue, err := structpb.NewValue(flags.image) @@ -1627,24 +1696,28 @@ func applyDeployModeToService( return false, nil } + // Check whether the service already specifies its deploy mode. Code deploy + // takes precedence over stale image or docker properties when no override + // is requested. + hasDocker := adoptedServiceHasDocker(svc) + if flags.deployMode == "" && hasCodeConfig { + return false, nil + } + // An adopted service that already declares an image also uses passthrough, - // even when --image was not supplied during this init. - if strings.TrimSpace(svc.GetImage()) != "" { + // even when --image was not supplied during this init. An explicit code mode + // overrides a leftover image. + if strings.TrimSpace(svc.GetImage()) != "" && flags.deployMode != "code" { if err := applyContainerDeployToService(ctx, azdClient, serviceName, svc, svc.GetImage()); err != nil { return false, err } return false, nil } - // Check whether the service already specifies its deploy mode. - hasCodeConfig := adoptedServiceHasCodeConfig(svc) - hasDocker := adoptedServiceHasDocker(svc) - - // When no explicit --deploy-mode flag is passed and the service - // is already configured, respect the sample's existing config. A - // pre-configured docker property means container deploy. - if flags.deployMode == "" && (hasCodeConfig || hasDocker) { - return hasDocker, nil + // When no explicit --deploy-mode flag is passed and the service is already + // configured for a source-container build, respect that configuration. + if flags.deployMode == "" && hasDocker { + return true, nil } // Use the service's subdirectory for language detection (not project root). diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go index 39b7f20dee1..eabbbe163d3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deploymode_test.go @@ -96,6 +96,8 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { } preBuiltService := agentServiceConfig(t, svcName, dockerProps) preBuiltService.Image = "registry.example.com/team/agent:v1" + mixedCodeService := agentServiceConfig(t, svcName, codeProps) + mixedCodeService.Image = "registry.example.com/team/agent:v1" tests := []struct { name string @@ -105,6 +107,7 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { wantLanguage any wantDocker map[string]any wantCodeSet bool + wantRegistry string }{ { name: "explicit container flag wires ACR", @@ -130,6 +133,18 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { wantLanguage: "docker", wantDocker: map[string]any{"imagePassthrough": true}, }, + { + name: "prebuilt image applies registry connection", + flags: &initFlags{ + image: "registry.example.com/team/agent:v1", + registryConnection: "private-registry", + }, + service: agentServiceConfig(t, svcName, nil), + wantNeedsACR: false, + wantLanguage: "docker", + wantDocker: map[string]any{"imagePassthrough": true}, + wantRegistry: "private-registry", + }, { name: "existing image uses passthrough", flags: &initFlags{}, @@ -150,6 +165,12 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { service: agentServiceConfig(t, svcName, codeProps), wantNeedsACR: false, }, + { + name: "code config takes precedence over existing image", + flags: &initFlags{}, + service: mixedCodeService, + wantNeedsACR: false, + }, } for _, tc := range tests { @@ -174,14 +195,55 @@ func TestApplyDeployModeToAdoptedProject(t *testing.T) { if tc.wantCodeSet { assert.Contains(t, sets, "codeConfiguration") } + if tc.wantRegistry != "" { + assert.Equal(t, tc.wantRegistry, sets["registryConnectionId"]) + } // A respected sample config must not be rewritten. - if tc.wantDocker == nil && !tc.wantCodeSet && tc.wantLanguage == nil { + if tc.wantDocker == nil && !tc.wantCodeSet && tc.wantLanguage == nil && tc.wantRegistry == "" { assert.Empty(t, sets) } }) } } +func TestApplyDeployModeToAdoptedProject_ValidatesRegistryConnection(t *testing.T) { + const svcName = "agent" + + tests := []struct { + name string + service *azdext.ServiceConfig + wantContain string + }{ + { + name: "requires image", + service: agentServiceConfig(t, svcName, nil), + wantContain: "requires a pre-built image", + }, + { + name: "rejects code deploy", + service: agentServiceConfig(t, svcName, map[string]any{ + "codeConfiguration": map[string]any{"runtime": "python_3_13", "entryPoint": "app.py"}, + }), + wantContain: "cannot be used with code deploy", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := &deployModeProjectServer{ + path: t.TempDir(), + services: map[string]*azdext.ServiceConfig{svcName: test.service}, + } + client := newProjectRecorderClient(t, server) + + _, err := applyDeployModeToAdoptedProject(t.Context(), &initFlags{ + registryConnection: "private-registry", + }, client) + require.ErrorContains(t, err, test.wantContain) + }) + } +} + // TestApplyDeployModeToAdoptedProject_NoAgentServices verifies that // a project without any azure.ai.agent service reports no container // deploy (so ACR is skipped) rather than erroring. 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 172c1c8489b..0a13e1adce4 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 @@ -691,18 +691,7 @@ func (p *AgentServiceTargetProvider) Package( return nil, err } - // Core image passthrough owns the artifact lifecycle for all pre-built images, - // whether the source registry is public or accessed through a Foundry connection. - if DockerImagePassthrough(serviceConfig.GetDocker()) { - progress("Packaging pre-built container image") - artifacts, err := p.packageContainer(ctx, serviceConfig, serviceContext) - if err != nil { - return nil, err - } - return &azdext.ServicePackageResult{Artifacts: artifacts}, nil - } - - // Code deploy: ZIP the source directory. + // Code deploy takes precedence over stale or mixed image configuration. if agentDef.CodeConfiguration != nil { progress("Packaging code") zipPath, sha256Hex, err := p.packageCodeDeploy(ctx, serviceConfig) @@ -725,6 +714,17 @@ func (p *AgentServiceTargetProvider) Package( }, nil } + // Core image passthrough owns the artifact lifecycle for all pre-built images, + // whether the source registry is public or accessed through a Foundry connection. + if DockerImagePassthrough(serviceConfig.GetDocker()) { + progress("Packaging pre-built container image") + artifacts, err := p.packageContainer(ctx, serviceConfig, serviceContext) + if err != nil { + return nil, err + } + return &azdext.ServicePackageResult{Artifacts: artifacts}, nil + } + usePreBuiltImage, err := p.shouldUsePreBuiltImage(ctx, agentDef) if err != nil { return nil, err @@ -808,34 +808,30 @@ func (p *AgentServiceTargetProvider) Publish( publishOptions *azdext.PublishOptions, progress azdext.ProgressReporter, ) (*azdext.ServicePublishResult, error) { - // A pre-built image does not start a container publish operation. Preserve - // this fast path; Activity Bot selection still runs in Deploy because the - // deployed agent identity is required to prefer an already-bound bot. - if preBuiltArtifact := findPreBuiltImageArtifact(serviceContext.Package); preBuiltArtifact != nil { - progress("Using pre-built container image, skipping publish") - return &azdext.ServicePublishResult{ - Artifacts: []*azdext.Artifact{preBuiltArtifact}, - }, nil - } - p.adoptServiceConfig(serviceConfig) if err := p.ensureDeployContext(ctx); err != nil { return nil, err } serviceConfig = p.serviceConfig - // Code deploy skips Publish (no ACR needed) - if p.isCodeDeployAgent() { - return &azdext.ServicePublishResult{}, nil - } - _, isContainerAgent, err := p.loadContainerAgentDefinition() + agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() if err != nil { return nil, err } - if !isContainerAgent { + if !isContainerAgent || agentDef.CodeConfiguration != nil { return &azdext.ServicePublishResult{}, nil } + // A pre-built image does not start a container publish operation. Preserve + // this fast path; Activity Bot selection still runs in Deploy because the + // deployed agent identity is required to prefer an already-bound bot. + if preBuiltArtifact := findPreBuiltImageArtifact(serviceContext.Package); preBuiltArtifact != nil { + progress("Using pre-built container image, skipping publish") + return &azdext.ServicePublishResult{ + Artifacts: []*azdext.Artifact{preBuiltArtifact}, + }, nil + } + progress("Publishing container") publishRequest := &azdext.ContainerPublishRequest{ ServiceName: serviceConfig.Name, @@ -1861,8 +1857,9 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( // service plus AZD_AGENT_SKIP_ACR=true. Honor that exact legacy shape during // the compatibility window without using the provisioning variable for new // or hand-authored image configurations. - if p.serviceConfig.GetDocker() != nil && - !DockerImagePassthrough(p.serviceConfig.GetDocker()) && + dockerOptions := p.serviceConfig.GetDocker() + if hasConfiguredDockerOptions(dockerOptions) && + !DockerImagePassthrough(dockerOptions) && p.shouldSkipACRForEnvironment(ctx) { log.Printf("legacy pre-built image configuration detected: using configured image") return true, nil @@ -1889,6 +1886,21 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return resp.Value != nil && choices[*resp.Value].Value == "prebuilt", nil } +func hasConfiguredDockerOptions(options *azdext.DockerProjectOptions) bool { + return options != nil && + (options.GetPath() != "" || + options.GetContext() != "" || + options.GetPlatform() != "" || + options.GetTarget() != "" || + options.GetRegistry() != "" || + options.GetImage() != "" || + options.GetTag() != "" || + options.GetRemoteBuild() || + len(options.GetBuildArgs()) > 0 || + options.GetNetwork() != "" || + DockerImagePassthrough(options)) +} + func (p *AgentServiceTargetProvider) shouldSkipACRForEnvironment(ctx context.Context) bool { if p.env == nil || p.env.Name == "" { return false @@ -1905,16 +1917,6 @@ func (p *AgentServiceTargetProvider) shouldSkipACRForEnvironment(ctx context.Con return strings.EqualFold(strings.TrimSpace(resp.Value), "true") } -// isCodeDeployAgent returns true if the agent definition has code_configuration (code deploy mode) -func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { - agentDef, isHosted, err := p.loadContainerAgentDefinition() - if err != nil || !isHosted { - return false - } - - return agentDef.CodeConfiguration != nil -} - // deployPrepResult holds the common outputs from prepareDeploy, used by both // container and code deploy paths. type deployPrepResult struct { 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 210bf3cede0..244dfdf318f 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 @@ -1754,6 +1754,29 @@ func TestValidateRegistryConnectionDefinition(t *testing.T) { } } +func TestHasConfiguredDockerOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options *azdext.DockerProjectOptions + want bool + }{ + {name: "nil"}, + {name: "mapped zero value", options: &azdext.DockerProjectOptions{}}, + {name: "remote build", options: &azdext.DockerProjectOptions{RemoteBuild: true}, want: true}, + {name: "path", options: &azdext.DockerProjectOptions{Path: "Dockerfile"}, want: true}, + {name: "build args", options: &azdext.DockerProjectOptions{BuildArgs: []string{"MODE=release"}}, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, hasConfiguredDockerOptions(test.options)) + }) + } +} + func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { t.Parallel() @@ -1789,9 +1812,10 @@ func TestShouldUsePreBuiltImage_SkipACRDoesNotSelectHandAuthoredImage(t *testing promptStub := &stubPromptServer{selectedIndex: 0} provider := &AgentServiceTargetProvider{ - azdClient: newLegacyPreBuiltTestClient(t, promptStub), - env: &azdext.Environment{Name: "test-env"}, - serviceConfig: &azdext.ServiceConfig{}, + azdClient: newLegacyPreBuiltTestClient(t, promptStub), + env: &azdext.Environment{Name: "test-env"}, + // Core maps an absent docker property to a non-nil zero-value message. + serviceConfig: &azdext.ServiceConfig{Docker: &azdext.DockerProjectOptions{}}, } result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ @@ -1962,6 +1986,45 @@ func TestPackage_DelegatesImagePassthroughToCore(t *testing.T) { } } +func TestPackage_CodeDeployTakesPrecedenceOverImagePassthrough(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile(agentPath, []byte(`kind: hosted +name: test-agent +image: registry.example.com/agents/test-agent:v1 +code_configuration: + runtime: python_3_13 + entry_point: app.py +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.py"), []byte("print('hello')\n"), 0o600)) + + containerStub := &stubContainerServer{} + dockerOptions := &azdext.DockerProjectOptions{} + EnableDockerImagePassthrough(dockerOptions) + provider := &AgentServiceTargetProvider{ + azdClient: newContainerTestClient(t, containerStub), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + } + + result, err := provider.Package( + t.Context(), + &azdext.ServiceConfig{Name: "test-svc", Docker: dockerOptions}, + &azdext.ServiceContext{}, + func(string) {}, + ) + + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, result.Artifacts[0].Kind) + require.Equal(t, "code-zip", result.Artifacts[0].Metadata["type"]) + require.Equal(t, int32(0), containerStub.buildCalls.Load()) + require.Equal(t, int32(0), containerStub.packageCalls.Load()) + t.Cleanup(func() { require.NoError(t, os.Remove(result.Artifacts[0].Location)) }) +} + func TestPackage_SkipsWhenPreBuiltImageChosen(t *testing.T) { t.Parallel() @@ -2060,13 +2123,53 @@ func TestPublish_DelegatesImagePassthroughToCore(t *testing.T) { require.Equal(t, int32(1), containerStub.publishCalls.Load()) } +func TestPublish_CodeDeployTakesPrecedenceOverPreBuiltArtifact(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile(agentPath, []byte(`kind: hosted +name: test-agent +image: registry.example.com/agents/test-agent:v1 +code_configuration: + runtime: python_3_13 + entry_point: app.py +`), 0o600)) + + containerStub := &stubContainerServer{} + provider := &AgentServiceTargetProvider{ + azdClient: newContainerTestClient(t, containerStub), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + } + + result, err := provider.Publish( + t.Context(), + &azdext.ServiceConfig{Name: "test-svc"}, + &azdext.ServiceContext{Package: []*azdext.Artifact{ + preBuiltImageArtifact("registry.example.com/agents/test-agent:v1"), + }}, + &azdext.TargetResource{}, + &azdext.PublishOptions{}, + func(string) {}, + ) + + require.NoError(t, err) + require.Empty(t, result.Artifacts) + require.Equal(t, int32(0), containerStub.publishCalls.Load()) +} + func TestPublish_SkipsWhenPreBuiltImageChosen(t *testing.T) { t.Parallel() imageURL := "myregistry.azurecr.io/myimage:v1" + dir := t.TempDir() + agentPath := writeHostedAgentYAMLWithImage(t, dir, imageURL) provider := &AgentServiceTargetProvider{ - env: &azdext.Environment{Name: "test-env"}, + azdClient: newContainerTestClient(t, &stubContainerServer{}), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, } var progressMessages []string From 335f86fa0fb4a1ed936769cc1ae96de7d7360a56 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 18:39:46 +0800 Subject: [PATCH 05/12] fix(agents): require image passthrough host support --- cli/azd/extensions/azure.ai.agents/extension.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index d5cb29556a2..9d9625c059d 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.10 -requiredAzdVersion: ">=1.31.1" +requiredAzdVersion: ">=1.32.0-beta.1" dependencies: - id: azure.ai.inspector version: "~1.0.0-beta.1" From c688aa677f2b789f5d9cbe0679d2cccb97fae190 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 18:52:25 +0800 Subject: [PATCH 06/12] test(projects): update agents host requirement --- .../extensions/azure.ai.projects/internal/cmd/ownership_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go index fedd1940aea..6cafd790ca5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/ownership_test.go @@ -46,7 +46,7 @@ func TestProvisioningOwnershipMetadata(t *testing.T) { assert.NotContains(t, agents.Capabilities, "provisioning-provider") assert.NotContains(t, agents.Capabilities, "validation-provider") - assert.Equal(t, ">=1.31.1", agents.RequiredAzdVersion) + assert.Equal(t, ">=1.32.0-beta.1", agents.RequiredAzdVersion) assert.False(t, manifestHasProvider( agents, "microsoft.foundry", From dc3346ca9b5ec78c425adfb58ae8507cf62044d8 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 19:20:56 +0800 Subject: [PATCH 07/12] fix(agents): accept image tags with digests --- .../azure.ai.agents/internal/pkg/containerref/reference.go | 2 +- .../internal/pkg/containerref/reference_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go index 25afcb3be9f..bd022c6d8c2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go @@ -13,7 +13,7 @@ var fullyQualifiedReference = regexp.MustCompile( `localhost(?::[0-9]+)?|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?:[0-9]+)/` + `[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*` + `(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*` + - `(?::[\w][\w.-]{0,127}|@sha256:[0-9a-fA-F]{64})?$`, + `(?::[\w][\w.-]{0,127})?(?:@sha256:[0-9a-fA-F]{64})?$`, ) // IsFullyQualified reports whether image contains an explicit registry host and repository. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go index 3368e4cbe7f..582b044c032 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go @@ -22,6 +22,12 @@ func TestIsFullyQualified(t *testing.T) { image: "registry.example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", want: true, }, + { + name: "tag and digest", + image: "registry.example.com/agent:v1@sha256:" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + want: true, + }, {name: "unqualified", image: "agent:v1", want: false}, {name: "URL scheme", image: "https://registry.example.com/agent:v1", want: false}, {name: "empty", image: "", want: false}, From e4f096c5f85aef6728f71b38ce44973e85b99e27 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 21:40:19 +0800 Subject: [PATCH 08/12] fix(agents): preserve legacy pre-built image projects --- .../docs/private-networking.md | 6 -- .../internal/project/service_target_agent.go | 27 ++----- .../project/service_target_agent_test.go | 78 ++++++------------- 3 files changed, 30 insertions(+), 81 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 70fc441d63d..b1c1f6780b6 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -20,8 +20,6 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 - docker: - imagePassthrough: true ai-project: host: azure.ai.project @@ -113,8 +111,6 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 - docker: - imagePassthrough: true ai-project: host: azure.ai.project @@ -157,8 +153,6 @@ services: uses: - ai-project image: myprivacr.azurecr.io/agents/my-agent:v1 - docker: - imagePassthrough: true ai-project: host: azure.ai.project 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 0a13e1adce4..9f6071149af 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 @@ -1853,13 +1853,11 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return false, nil } - // Releases before docker.imagePassthrough represented init --image as a docker - // service plus AZD_AGENT_SKIP_ACR=true. Honor that exact legacy shape during - // the compatibility window without using the provisioning variable for new - // or hand-authored image configurations. - dockerOptions := p.serviceConfig.GetDocker() - if hasConfiguredDockerOptions(dockerOptions) && - !DockerImagePassthrough(dockerOptions) && + // Releases before docker.imagePassthrough represented init --image with a + // top-level image plus AZD_AGENT_SKIP_ACR=true. The docker property may be + // absent, so preserve that environment marker as the legacy compatibility + // contract. New projects use docker.imagePassthrough and do not enter this fallback. + if !DockerImagePassthrough(p.serviceConfig.GetDocker()) && p.shouldSkipACRForEnvironment(ctx) { log.Printf("legacy pre-built image configuration detected: using configured image") return true, nil @@ -1886,21 +1884,6 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return resp.Value != nil && choices[*resp.Value].Value == "prebuilt", nil } -func hasConfiguredDockerOptions(options *azdext.DockerProjectOptions) bool { - return options != nil && - (options.GetPath() != "" || - options.GetContext() != "" || - options.GetPlatform() != "" || - options.GetTarget() != "" || - options.GetRegistry() != "" || - options.GetImage() != "" || - options.GetTag() != "" || - options.GetRemoteBuild() || - len(options.GetBuildArgs()) > 0 || - options.GetNetwork() != "" || - DockerImagePassthrough(options)) -} - func (p *AgentServiceTargetProvider) shouldSkipACRForEnvironment(ctx context.Context) bool { if p.env == nil || p.env.Name == "" { return false 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 244dfdf318f..d63cba12915 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 @@ -1754,29 +1754,6 @@ func TestValidateRegistryConnectionDefinition(t *testing.T) { } } -func TestHasConfiguredDockerOptions(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - options *azdext.DockerProjectOptions - want bool - }{ - {name: "nil"}, - {name: "mapped zero value", options: &azdext.DockerProjectOptions{}}, - {name: "remote build", options: &azdext.DockerProjectOptions{RemoteBuild: true}, want: true}, - {name: "path", options: &azdext.DockerProjectOptions{Path: "Dockerfile"}, want: true}, - {name: "build args", options: &azdext.DockerProjectOptions{BuildArgs: []string{"MODE=release"}}, want: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, test.want, hasConfiguredDockerOptions(test.options)) - }) - } -} - func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { t.Parallel() @@ -1790,40 +1767,35 @@ func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { func TestShouldUsePreBuiltImage_LegacyInitImageUsesCompatibilityMarker(t *testing.T) { t.Parallel() - promptStub := &stubPromptServer{selectedIndex: 0} - provider := &AgentServiceTargetProvider{ - azdClient: newLegacyPreBuiltTestClient(t, promptStub), - env: &azdext.Environment{Name: "test-env"}, - serviceConfig: &azdext.ServiceConfig{ - Docker: &azdext.DockerProjectOptions{RemoteBuild: true}, - }, + tests := []struct { + name string + docker *azdext.DockerProjectOptions + }{ + {name: "docker absent"}, + {name: "mapped zero-value docker", docker: &azdext.DockerProjectOptions{}}, + {name: "configured docker", docker: &azdext.DockerProjectOptions{RemoteBuild: true}}, } - result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ - Image: "registry.example.com/agent:v1", - }) - require.NoError(t, err) - require.True(t, result) - require.Equal(t, int32(0), promptStub.selectCalls.Load()) -} - -func TestShouldUsePreBuiltImage_SkipACRDoesNotSelectHandAuthoredImage(t *testing.T) { - t.Parallel() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + promptStub := &stubPromptServer{selectedIndex: 0} + provider := &AgentServiceTargetProvider{ + azdClient: newLegacyPreBuiltTestClient(t, promptStub), + env: &azdext.Environment{Name: "test-env"}, + serviceConfig: &azdext.ServiceConfig{ + Docker: test.docker, + }, + } - promptStub := &stubPromptServer{selectedIndex: 0} - provider := &AgentServiceTargetProvider{ - azdClient: newLegacyPreBuiltTestClient(t, promptStub), - env: &azdext.Environment{Name: "test-env"}, - // Core maps an absent docker property to a non-nil zero-value message. - serviceConfig: &azdext.ServiceConfig{Docker: &azdext.DockerProjectOptions{}}, + result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ + Image: "registry.example.com/agent:v1", + }) + require.NoError(t, err) + require.True(t, result) + require.Equal(t, int32(0), promptStub.selectCalls.Load()) + }) } - - result, err := provider.shouldUsePreBuiltImage(t.Context(), agent_yaml.ContainerAgent{ - Image: "registry.example.com/agent:v1", - }) - require.NoError(t, err) - require.False(t, result) - require.Equal(t, int32(1), promptStub.selectCalls.Load()) } func TestShouldUsePreBuiltImage_RegistryConnectionForcesPreBuilt(t *testing.T) { From bfe7a484c904c62700411a3bcf98604853ce4d60 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 21:51:20 +0800 Subject: [PATCH 09/12] fix(agents): reuse core passthrough package artifact --- .../internal/project/service_target_agent.go | 20 +++++++++++ .../project/service_target_agent_test.go | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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 9f6071149af..fa9b4e50fa2 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 @@ -717,6 +717,13 @@ func (p *AgentServiceTargetProvider) Package( // Core image passthrough owns the artifact lifecycle for all pre-built images, // whether the source registry is public or accessed through a Foundry connection. if DockerImagePassthrough(serviceConfig.GetDocker()) { + // The core Docker framework packages before this target and adds its artifact + // to the shared context. Do not return it again from the target. + if findImagePassthroughArtifact(serviceContext.Package) != nil { + return &azdext.ServicePackageResult{}, nil + } + + // Keep direct extension callers compatible when core has not packaged first. progress("Packaging pre-built container image") artifacts, err := p.packageContainer(ctx, serviceConfig, serviceContext) if err != nil { @@ -1124,6 +1131,19 @@ func findPreBuiltImageArtifact(artifacts []*azdext.Artifact) *azdext.Artifact { return nil } +func findImagePassthroughArtifact(artifacts []*azdext.Artifact) *azdext.Artifact { + for _, artifact := range artifacts { + if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER && + artifact.LocationKind == azdext.LocationKind_LOCATION_KIND_REMOTE && + artifact.Location != "" && + artifact.Metadata["imagePassthrough"] == "true" { + return artifact + } + } + + return nil +} + func findPreBuiltImageArtifactInContext(serviceContext *azdext.ServiceContext) *azdext.Artifact { if serviceContext == nil { return nil 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 d63cba12915..3098f773c9d 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 @@ -1958,6 +1958,40 @@ func TestPackage_DelegatesImagePassthroughToCore(t *testing.T) { } } +func TestPackage_ReusesCoreImagePassthroughArtifact(t *testing.T) { + t.Parallel() + + const image = "registry.example.com/agents/my-agent:v1" + dir := t.TempDir() + agentPath := writeHostedAgentYAMLWithImage(t, dir, image) + containerStub := &stubContainerServer{packageImage: image} + dockerOptions := &azdext.DockerProjectOptions{} + EnableDockerImagePassthrough(dockerOptions) + provider := &AgentServiceTargetProvider{ + azdClient: newContainerTestClient(t, containerStub), + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + } + serviceContext := &azdext.ServiceContext{Package: []*azdext.Artifact{{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER, + Location: image, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{"imagePassthrough": "true"}, + }}} + + result, err := provider.Package( + t.Context(), + &azdext.ServiceConfig{Name: "test-svc", Docker: dockerOptions}, + serviceContext, + func(string) {}, + ) + + require.NoError(t, err) + require.Empty(t, result.Artifacts, "core already added the passthrough artifact to the shared context") + require.Len(t, serviceContext.Package, 1) + require.Equal(t, int32(0), containerStub.packageCalls.Load()) +} + func TestPackage_CodeDeployTakesPrecedenceOverImagePassthrough(t *testing.T) { t.Parallel() From 11f4c5ec87c4d41c98f593d29e2dff140a7b8f8b Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 22:12:13 +0800 Subject: [PATCH 10/12] fix(agents): validate registry lifecycle before build --- .../internal/project/service_target_agent.go | 33 ++++++++ .../project/service_target_agent_test.go | 75 +++++++++++++++++++ 2 files changed, 108 insertions(+) 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 fa9b4e50fa2..2fd34f6a5d1 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 @@ -205,6 +205,39 @@ func NewAgentServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTa // only when a deploy-time entrypoint needs it. func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { p.adoptServiceConfig(serviceConfig) + return validateRegistryConnectionServiceConfig(serviceConfig) +} + +func validateRegistryConnectionServiceConfig(serviceConfig *azdext.ServiceConfig) error { + props := ServiceConfigProps(serviceConfig) + if props == nil || props.GetFields()["registryConnectionId"] == nil { + return nil + } + + dockerOptions := serviceConfig.GetDocker() + if !DockerImagePassthrough(dockerOptions) { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId requires docker.imagePassthrough: true", + "enable docker.imagePassthrough for the private pre-built image", + ) + } + if dockerOptions.GetRemoteBuild() { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "registryConnectionId cannot be combined with docker.remoteBuild", + "remove docker.remoteBuild and use image passthrough", + ) + } + + agentDef, _, found, _, err := AgentDefinitionFromService(serviceConfig) + if err != nil { + return err + } + if found { + return validateRegistryConnectionDefinition(agentDef) + } + return nil } 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 3098f773c9d..f3e98f7afb1 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 @@ -573,6 +573,81 @@ func TestInitializeIsCheapAndSideEffectFree(t *testing.T) { require.NoError(t, provider.Initialize(t.Context(), &azdext.ServiceConfig{Name: "echo", RelativePath: "svc"})) } +func TestInitializeValidatesRegistryConnectionLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registry bool + docker bool + passthrough bool + remoteBuild bool + wantContains string + }{ + {name: "registry with passthrough", registry: true, passthrough: true}, + { + name: "registry without docker", + registry: true, + wantContains: "requires docker.imagePassthrough: true", + }, + { + name: "registry with zero-value docker", + registry: true, + docker: true, + wantContains: "requires docker.imagePassthrough: true", + }, + { + name: "registry with remote build", + registry: true, + passthrough: true, + remoteBuild: true, + wantContains: "cannot be combined with docker.remoteBuild", + }, + {name: "legacy image without docker"}, + {name: "legacy image with remote build", docker: true, remoteBuild: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + agentDef := sampleContainerAgent() + agentDef.Image = "registry.example.com/agents/my-agent:v1" + agentDef.RegistryConnectionID = "" + if test.registry { + agentDef.RegistryConnectionID = "private-registry" + } + props, err := AgentDefinitionToServiceProperties(agentDef, nil) + require.NoError(t, err) + + var dockerOptions *azdext.DockerProjectOptions + if test.docker || test.passthrough || test.remoteBuild { + dockerOptions = &azdext.DockerProjectOptions{RemoteBuild: test.remoteBuild} + if test.passthrough { + EnableDockerImagePassthrough(dockerOptions) + } + } + provider := &AgentServiceTargetProvider{} + err = provider.Initialize(t.Context(), &azdext.ServiceConfig{ + Name: "my-agent", + Host: "azure.ai.agent", + Image: agentDef.Image, + Docker: dockerOptions, + AdditionalProperties: props, + }) + + if test.wantContains != "" { + require.ErrorContains(t, err, test.wantContains) + } else { + require.NoError(t, err) + } + require.Empty(t, provider.agentDefinitionPath) + require.Nil(t, provider.credential) + require.Empty(t, provider.tenantId) + }) + } +} + func TestInitializeAcceptsProjectLocalAgentYaml(t *testing.T) { t.Setenv("AGENT_DEFINITION_PATH", "") From 66f7056db4ab2741e2fc2875f2a0a3398a768207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wei=20Meng=E2=9A=94=EF=B8=8F?= Date: Thu, 20 Aug 2026 11:34:39 +0800 Subject: [PATCH 11/12] fix(agents): address registry review feedback --- .../extensions/azure.ai.agents/cspell.yaml | 2 + cli/azd/extensions/azure.ai.agents/go.mod | 2 + cli/azd/extensions/azure.ai.agents/go.sum | 4 + .../azure.ai.agents/internal/cmd/init.go | 2 - .../internal/cmd/init_adopt.go | 18 ++--- .../internal/pkg/containerref/reference.go | 39 +++++++--- .../pkg/containerref/reference_test.go | 77 ++++++++++++++----- .../internal/project/agent_definition.go | 5 +- .../internal/project/agent_definition_test.go | 29 ++++++- .../internal/project/image_passthrough.go | 2 - .../internal/project/service_target_agent.go | 8 -- 11 files changed, 131 insertions(+), 57 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 5d2d9e0950c..cb88b3a5983 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -55,6 +55,7 @@ words: - azuresdk - bicepless - CLIENTSECRET + - containerref - curr - dataagent - envkey @@ -79,6 +80,7 @@ words: - projectconfig - projectpkg - protocolversionrecord + - protowire - Qdrant - schedulerun - Toolsets diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 571a6db8721..55afe63bbfb 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -14,6 +14,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/azure/azure-dev/cli/azd v1.28.0 github.com/braydonk/yaml v0.9.0 + github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 // indirect github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 @@ -96,6 +97,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 6b3b1b91d9f..7e15d41a637 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -115,6 +115,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 h1:0nsrg//Dc7xC74H/TZ5sYR8uk4UQRNjsw8zejqH5a4Q= github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817/go.mod h1:C/+sI4IFnEpCn6VQ3GIPEp+FrQnQw+YQP3+n+GdGq7o= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= @@ -205,6 +207,8 @@ github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/g github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 77413b48bcd..da0c73bfcd7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -3,8 +3,6 @@ package cmd -// cSpell:ignore containerref - import ( "context" "crypto/rand" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index c6ae654ed98..01e15ade93a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -1049,14 +1049,14 @@ func runInitFromAzureYaml( // resolved deploy mode: a container agent on an existing project // needs AZURE_CONTAINER_REGISTRY_ENDPOINT set here, while a code // agent (or a user-supplied --image) does not. - needsACR, err := applyDeployModeToAdoptedProject(ctx, flags, azdClient) + projectNeedsACR, err := applyDeployModeToAdoptedProject(ctx, flags, azdClient) if err != nil { return err } // Only source-container deploys require an ACR. Code deploy and pre-built // images skip it. - skipACR := !needsACR + skipACR := !projectNeedsACR // The adopt path only supports hosted agents today. Hosted-region filtering // is independent from ACR setup; prompt-voice has its own region/onboarding // constraints and does not flow through this path. @@ -1582,11 +1582,11 @@ func applyDeployModeToAdoptedProject( return false, nil } - // Apply configuration to each agent service, tracking whether any source - // container requires an ACR. - needsACR := false + // Apply configuration to each agent service, tracking whether the project + // contains any source container that requires an ACR. + projectNeedsACR := false for _, agent := range agentServices { - requiresACR, err := applyDeployModeToService( + serviceNeedsACR, err := applyDeployModeToService( ctx, flags, azdClient, @@ -1597,11 +1597,9 @@ func applyDeployModeToAdoptedProject( if err != nil { return false, err } - if requiresACR { - needsACR = true - } + projectNeedsACR = projectNeedsACR || serviceNeedsACR } - return needsACR, nil + return projectNeedsACR, nil } // applyDeployModeToService applies deploy-mode configuration to a diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go index bd022c6d8c2..7dd6d1090b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference.go @@ -4,19 +4,36 @@ // Package containerref validates container image references used by hosted agents. package containerref -// cSpell:ignore containerref +import ( + "strings" -import "regexp" - -var fullyQualifiedReference = regexp.MustCompile( - `^(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]+)?|` + - `localhost(?::[0-9]+)?|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?:[0-9]+)/` + - `[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*` + - `(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*` + - `(?::[\w][\w.-]{0,127})?(?:@sha256:[0-9a-fA-F]{64})?$`, + "github.com/distribution/reference" ) -// IsFullyQualified reports whether image contains an explicit registry host and repository. +// IsValid reports whether image is a syntactically valid named container image reference. +func IsValid(image string) bool { + _, ok := parseNamed(image) + return ok +} + +// IsFullyQualified reports whether image is valid and contains an explicit registry host and repository. func IsFullyQualified(image string) bool { - return fullyQualifiedReference.MatchString(image) + named, ok := parseNamed(image) + if !ok { + return false + } + + registry := reference.Domain(named) + return registry != "" && + (strings.Contains(registry, ".") || strings.Contains(registry, ":") || registry == "localhost") +} + +func parseNamed(image string) (reference.Named, bool) { + parsed, err := reference.Parse(image) + if err != nil { + return nil, false + } + + named, ok := parsed.(reference.Named) + return named, ok } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go index 582b044c032..02e395248d5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/containerref/reference_test.go @@ -3,41 +3,76 @@ package containerref -// cSpell:ignore containerref +import ( + "strings" + "testing" +) -import "testing" - -func TestIsFullyQualified(t *testing.T) { +func TestImageReferences(t *testing.T) { t.Parallel() tests := []struct { - name string - image string - want bool + name string + image string + wantValid bool + wantFullyQualified bool }{ - {name: "registry and tag", image: "registry.example.com/team/agent:v1", want: true}, - {name: "localhost and port", image: "localhost:5000/agent:latest", want: true}, { - name: "digest", - image: "registry.example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - want: true, + name: "registry and tag", + image: "registry.example.com/team/agent:v1", + wantValid: true, + wantFullyQualified: true, + }, + { + name: "registry port", + image: "registry:5000/team/agent:v1", + wantValid: true, + wantFullyQualified: true, + }, + { + name: "localhost and port", + image: "localhost:5000/agent:latest", + wantValid: true, + wantFullyQualified: true, }, { - name: "tag and digest", - image: "registry.example.com/agent:v1@sha256:" + - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - want: true, + name: "IPv6 registry", + image: "[2001:db8::1]:5000/team/agent:v1", + wantValid: true, + wantFullyQualified: true, }, - {name: "unqualified", image: "agent:v1", want: false}, - {name: "URL scheme", image: "https://registry.example.com/agent:v1", want: false}, - {name: "empty", image: "", want: false}, + { + name: "digest", + image: "registry.example.com/agent@sha256:" + strings.Repeat("a", 64), + wantValid: true, + wantFullyQualified: true, + }, + { + name: "tag and digest", + image: "registry.example.com/agent:v1@sha256:" + strings.Repeat("a", 64), + wantValid: true, + wantFullyQualified: true, + }, + {name: "unqualified path", image: "team/agent:v1", wantValid: true}, + {name: "unqualified", image: "agent:v1", wantValid: true}, + {name: "URL scheme", image: "https://registry.example.com/agent:v1"}, + {name: "uppercase repository", image: "registry.example.com/Team/agent:v1"}, + {name: "empty"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - if got := IsFullyQualified(test.image); got != test.want { - t.Errorf("IsFullyQualified(%q) = %t, want %t", test.image, got, test.want) + if got := IsValid(test.image); got != test.wantValid { + t.Errorf("IsValid(%q) = %t, want %t", test.image, got, test.wantValid) + } + if got := IsFullyQualified(test.image); got != test.wantFullyQualified { + t.Errorf( + "IsFullyQualified(%q) = %t, want %t", + test.image, + got, + test.wantFullyQualified, + ) } }) } 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 a1e0f9c5290..fedfed2bb3c 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 @@ -14,6 +14,7 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/agentkind" + "azureaiagent/internal/pkg/containerref" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/pkg/projectconfig" @@ -770,7 +771,7 @@ func agentDefinitionFromStruct( return agent_yaml.ContainerAgent{}, false, err } - if ca.Image != "" && !containerImageRefRe.MatchString(ca.Image) { + if ca.Image != "" && !containerref.IsValid(ca.Image) { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("invalid container image reference in agent service config: %q", ca.Image), @@ -876,7 +877,7 @@ func parseContainerAgentYAML(data []byte) (agent_yaml.ContainerAgent, bool, erro ) } - if agentDef.Image != "" && !containerImageRefRe.MatchString(agentDef.Image) { + if agentDef.Image != "" && !containerref.IsValid(agentDef.Image) { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("invalid container image reference in agent.yaml: %q", agentDef.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 f87f56729a9..13b1bf36bb4 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 @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -377,6 +378,30 @@ func TestAgentDefinition_ImageRidesOnCoreServiceField(t *testing.T) { require.Empty(t, gotNoImage.Image) } +func TestAgentDefinitionFromService_ValidImages(t *testing.T) { + props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + require.NoError(t, err) + + images := []string{ + "localhost:5000/agent:v1", + "[2001:db8::1]:5000/team/agent:v1", + "registry.example.com/agent:v1@sha256:" + strings.Repeat("a", 64), + } + for _, image := range images { + t.Run(image, func(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Image: image, + AdditionalProperties: props, + } + got, _, _, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.Equal(t, image, got.Image) + }) + } +} + // TestAgentDefinitionFromService_InvalidImage verifies the image reference (from // the core service field) is still validated for the inline shape. func TestAgentDefinitionFromService_InvalidImage(t *testing.T) { @@ -518,7 +543,8 @@ func TestLoadAgentDefinition_ToolboxServiceReference(t *testing.T) { // fallback used during the migration window. func TestLoadAgentDefinition_DiskFallback(t *testing.T) { dir := t.TempDir() - yaml := "kind: hosted\nname: disk-agent\nregistryConnectionId: private-registry\n" + + image := "registry.example.com/agent:v1@sha256:" + strings.Repeat("a", 64) + yaml := "kind: hosted\nname: disk-agent\nimage: " + image + "\nregistryConnectionId: private-registry\n" + "protocols:\n - protocol: responses\n version: \"1.0.0\"\n" require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0o600)) @@ -529,6 +555,7 @@ func TestLoadAgentDefinition_DiskFallback(t *testing.T) { require.Equal(t, AgentDefinitionSourceDisk, source) require.True(t, source.IsLegacy()) require.Equal(t, "disk-agent", got.Name) + require.Equal(t, image, got.Image) require.Equal(t, "private-registry", got.RegistryConnectionID) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go b/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go index 7891c8b79b9..f2ccd10ebf7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go @@ -3,8 +3,6 @@ package project -// cSpell:ignore protowire - import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "google.golang.org/protobuf/encoding/protowire" 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 2fd34f6a5d1..015d27aa1fe 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 @@ -3,8 +3,6 @@ package project -// cSpell:ignore containerref - import ( "archive/zip" "bytes" @@ -186,12 +184,6 @@ const ( preBuiltImageArtifactSource = "agent.yaml" ) -// containerImageRefRe is a basic pattern for container image references: -// [registry/]repository[:tag|@digest] -var containerImageRefRe = regexp.MustCompile( - `^[a-zA-Z0-9]([a-zA-Z0-9._-]*/)*[a-zA-Z0-9][a-zA-Z0-9._-]*(:[a-zA-Z0-9._-]+|@sha256:[0-9a-fA-F]{64})?$`, -) - // NewAgentServiceTargetProvider creates a new AgentServiceTargetProvider instance func NewAgentServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { return &AgentServiceTargetProvider{ From c8b9884f91986d367a6c461c61c2e9739f11ee11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wei=20Meng=E2=9A=94=EF=B8=8F?= Date: Thu, 20 Aug 2026 15:15:01 +0800 Subject: [PATCH 12/12] refactor(agents): use image passthrough SDK field --- .../extensions/azure.ai.agents/cspell.yaml | 1 - cli/azd/extensions/azure.ai.agents/go.mod | 2 +- .../internal/cmd/hosted_container_config.go | 4 +- .../cmd/hosted_container_config_test.go | 4 +- .../azure.ai.agents/internal/cmd/init_test.go | 2 +- .../internal/project/image_passthrough.go | 52 ------------------- .../internal/project/service_target_agent.go | 6 +-- .../project/service_target_agent_test.go | 18 +++---- 8 files changed, 14 insertions(+), 75 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index cb88b3a5983..bfd4a132c29 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -80,7 +80,6 @@ words: - projectconfig - projectpkg - protocolversionrecord - - protowire - Qdrant - schedulerun - Toolsets diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 55afe63bbfb..cf097f46642 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -12,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.28.0 + github.com/azure/azure-dev/cli/azd v1.32.0-beta.1 github.com/braydonk/yaml v0.9.0 github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go index 541092e18e8..8ab51bd8d25 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config.go @@ -6,8 +6,6 @@ package cmd import ( "strings" - "azureaiagent/internal/project" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) @@ -28,7 +26,7 @@ func dockerProjectOptionsForHostedContainer(image string, networkInjected bool) config := resolveHostedContainerDockerConfig(image, networkInjected) options := &azdext.DockerProjectOptions{RemoteBuild: config.remoteBuild} if config.imagePassthrough { - project.EnableDockerImagePassthrough(options) + options.ImagePassthrough = true } return options } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go index 8e98d9964c8..372e1aeb158 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted_container_config_test.go @@ -6,8 +6,6 @@ package cmd import ( "testing" - "azureaiagent/internal/project" - "github.com/stretchr/testify/require" ) @@ -36,7 +34,7 @@ func TestDockerProjectOptionsForHostedContainer(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() options := dockerProjectOptionsForHostedContainer(test.image, test.networkInjected) - require.Equal(t, test.wantPassthrough, project.DockerImagePassthrough(options)) + require.Equal(t, test.wantPassthrough, options.GetImagePassthrough()) require.Equal(t, test.wantRemoteBuild, options.GetRemoteBuild()) }) } 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 4a47c9b54f2..6be546eb5f6 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 @@ -687,7 +687,7 @@ func TestAddToProjectPreBuiltImageEnablesPassthrough(t *testing.T) { require.NotNil(t, agentService) require.Equal(t, image, agentService.GetImage()) require.Equal(t, "docker", agentService.GetLanguage()) - require.True(t, project.DockerImagePassthrough(agentService.GetDocker())) + require.True(t, agentService.GetDocker().GetImagePassthrough()) require.False(t, agentService.GetDocker().GetRemoteBuild()) require.NotNil(t, agentService.GetAdditionalProperties()) require.Empty(t, agentService.GetEnvironment()) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go b/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go deleted file mode 100644 index f2ccd10ebf7..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/image_passthrough.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "google.golang.org/protobuf/encoding/protowire" -) - -const imagePassthroughFieldNumber = 11 - -// EnableDockerImagePassthrough enables the core azd image passthrough option. -// -// The extension currently builds against an azd SDK version that predates this -// protobuf field. Encoding it as an unknown field keeps the extension compatible -// with that SDK while allowing newer azd hosts to consume the option. -func EnableDockerImagePassthrough(options *azdext.DockerProjectOptions) { - unknown := options.ProtoReflect().GetUnknown() - unknown = protowire.AppendTag(unknown, imagePassthroughFieldNumber, protowire.VarintType) - unknown = protowire.AppendVarint(unknown, 1) - options.ProtoReflect().SetUnknown(unknown) -} - -// DockerImagePassthrough reports whether the core azd image passthrough option is enabled. -func DockerImagePassthrough(options *azdext.DockerProjectOptions) bool { - if options == nil { - return false - } - - unknown := options.ProtoReflect().GetUnknown() - for len(unknown) > 0 { - number, wireType, tagLength := protowire.ConsumeTag(unknown) - if tagLength < 0 { - return false - } - unknown = unknown[tagLength:] - - if number == imagePassthroughFieldNumber && wireType == protowire.VarintType { - value, valueLength := protowire.ConsumeVarint(unknown) - return valueLength >= 0 && value != 0 - } - - fieldLength := protowire.ConsumeFieldValue(number, wireType, unknown) - if fieldLength < 0 { - return false - } - unknown = unknown[fieldLength:] - } - - return false -} 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 015d27aa1fe..731502e12d5 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 @@ -207,7 +207,7 @@ func validateRegistryConnectionServiceConfig(serviceConfig *azdext.ServiceConfig } dockerOptions := serviceConfig.GetDocker() - if !DockerImagePassthrough(dockerOptions) { + if !dockerOptions.GetImagePassthrough() { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, "registryConnectionId requires docker.imagePassthrough: true", @@ -741,7 +741,7 @@ func (p *AgentServiceTargetProvider) Package( // Core image passthrough owns the artifact lifecycle for all pre-built images, // whether the source registry is public or accessed through a Foundry connection. - if DockerImagePassthrough(serviceConfig.GetDocker()) { + if serviceConfig.GetDocker().GetImagePassthrough() { // The core Docker framework packages before this target and adds its artifact // to the shared context. Do not return it again from the target. if findImagePassthroughArtifact(serviceContext.Package) != nil { @@ -1902,7 +1902,7 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( // top-level image plus AZD_AGENT_SKIP_ACR=true. The docker property may be // absent, so preserve that environment marker as the legacy compatibility // contract. New projects use docker.imagePassthrough and do not enter this fallback. - if !DockerImagePassthrough(p.serviceConfig.GetDocker()) && + if !p.serviceConfig.GetDocker().GetImagePassthrough() && p.shouldSkipACRForEnvironment(ctx) { log.Printf("legacy pre-built image configuration detected: using configured image") return true, nil 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 f3e98f7afb1..5dd7ce0975c 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 @@ -622,9 +622,9 @@ func TestInitializeValidatesRegistryConnectionLifecycle(t *testing.T) { var dockerOptions *azdext.DockerProjectOptions if test.docker || test.passthrough || test.remoteBuild { - dockerOptions = &azdext.DockerProjectOptions{RemoteBuild: test.remoteBuild} - if test.passthrough { - EnableDockerImagePassthrough(dockerOptions) + dockerOptions = &azdext.DockerProjectOptions{ + RemoteBuild: test.remoteBuild, + ImagePassthrough: test.passthrough, } } provider := &AgentServiceTargetProvider{} @@ -2007,8 +2007,7 @@ func TestPackage_DelegatesImagePassthroughToCore(t *testing.T) { containerStub := &stubContainerServer{packageImage: image} promptStub := &stubPromptServer{selectedIndex: 0} - dockerOptions := &azdext.DockerProjectOptions{} - EnableDockerImagePassthrough(dockerOptions) + dockerOptions := &azdext.DockerProjectOptions{ImagePassthrough: true} provider := &AgentServiceTargetProvider{ azdClient: newServiceTargetTestClient(t, containerStub, promptStub), agentDefinitionPath: agentPath, @@ -2040,8 +2039,7 @@ func TestPackage_ReusesCoreImagePassthroughArtifact(t *testing.T) { dir := t.TempDir() agentPath := writeHostedAgentYAMLWithImage(t, dir, image) containerStub := &stubContainerServer{packageImage: image} - dockerOptions := &azdext.DockerProjectOptions{} - EnableDockerImagePassthrough(dockerOptions) + dockerOptions := &azdext.DockerProjectOptions{ImagePassthrough: true} provider := &AgentServiceTargetProvider{ azdClient: newContainerTestClient(t, containerStub), agentDefinitionPath: agentPath, @@ -2082,8 +2080,7 @@ code_configuration: require.NoError(t, os.WriteFile(filepath.Join(dir, "app.py"), []byte("print('hello')\n"), 0o600)) containerStub := &stubContainerServer{} - dockerOptions := &azdext.DockerProjectOptions{} - EnableDockerImagePassthrough(dockerOptions) + dockerOptions := &azdext.DockerProjectOptions{ImagePassthrough: true} provider := &AgentServiceTargetProvider{ azdClient: newContainerTestClient(t, containerStub), agentDefinitionPath: agentPath, @@ -2176,8 +2173,7 @@ func TestPublish_DelegatesImagePassthroughToCore(t *testing.T) { dir := t.TempDir() agentPath := writeHostedAgentYAMLWithImage(t, dir, image) containerStub := &stubContainerServer{publishImage: image} - dockerOptions := &azdext.DockerProjectOptions{} - EnableDockerImagePassthrough(dockerOptions) + dockerOptions := &azdext.DockerProjectOptions{ImagePassthrough: true} provider := &AgentServiceTargetProvider{ azdClient: newContainerTestClient(t, containerStub), agentDefinitionPath: agentPath,