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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 175 additions & 9 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1163,6 +1168,7 @@ func agentDefiningFlagsSet(flags *initFlags, srcBlocksReuse bool) bool {
flags.modelDeployment != "" ||
flags.projectResourceId != "" ||
flags.image != "" ||
flags.registryConnection != "" ||
srcBlocksReuse ||
len(flags.protocols) > 0
}
Expand Down Expand Up @@ -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 "<resource-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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. "+
Expand Down Expand Up @@ -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)
}
Expand All @@ -2025,26 +2057,32 @@ 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
}
}
}

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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <registry/image:tag> 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)
}
Expand Down Expand Up @@ -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 <registry/image:tag> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading