Skip to content
Open
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
57 changes: 20 additions & 37 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,22 +1123,24 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
infraProvider = p
}

// `--infra` on a directory that already has an azd agent project
// is a standalone eject: synthesize infra (Bicep or Terraform) from
// `--infra` within an existing azd agent project is a standalone
// eject: synthesize infra (Bicep or Terraform) from
// the existing azure.yaml, write ./infra/, and return without
// prompting.
if infraProvider != "" && fileExists("azure.yaml") {
// Reject inputs the eject path would silently ignore (a
// positional arg, -m, or --src) instead of pretending they
// were honored.
if err := validateStandaloneEjectArgs(args, flags); err != nil {
return err
if infraProvider != "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is the fix for scenario 3 in #9287 (standalone eject from a subdirectory), and nothing covers it.

I reverted it locally to fileExists("azure.yaml") plus os.Getwd() and ran the whole package: ok azureaiagent/internal/cmd. The four new TestEjectInfraAfterInit_* tests all call ejectInfraAfterInit directly, which is what the post-init paths use, so none of them reach this code.

This isn't the same situation as the bare-definition and unified-manifest branches I said needed a mock azd gRPC server. This one returns on line 1142, before azdext.NewAzdClient() on line 1148, so there's no client, no prompting, and no network. Driving the real cobra command covers it:

func TestInitInfra_StandaloneEjectResolvesParentProject(t *testing.T) {
	t.Setenv("AZD_EXEC_PROJECT_DIR", "")
	projectRoot := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "azure.yaml"), []byte(`name: test
services:
  ai-project:
    host: azure.ai.project
`), 0600))
	nested := filepath.Join(projectRoot, "src", "agent")
	require.NoError(t, os.MkdirAll(nested, 0750))
	t.Chdir(nested)

	cmd := newInitCommand(&azdext.ExtensionContext{})
	cmd.SetArgs([]string{"--infra"})
	cmd.SetOut(io.Discard)
	cmd.SetErr(io.Discard)

	var execErr error
	withCapturedStdout(t, func() {
		execErr = cmd.Execute()
	})

	require.NoError(t, execErr)
	assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep"))
	assert.NoDirExists(t, filepath.Join(nested, "infra"))
}

Passes at 976d2f2 in 0.11s. With the old cwd-only lookup it fails, because the command falls through into the interactive init flow instead of ejecting. The passing path never reaches the azd client, so it stays hermetic.

projectRoot, projectRootErr := azdext.GetProjectDir()
if projectRootErr != nil && !errors.Is(projectRootErr, azdext.ErrProjectNotFound) {
return fmt.Errorf("resolve azd project directory: %w", projectRootErr)
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("resolve current directory: %w", err)
if projectRootErr == nil {
// Reject inputs the eject path would silently ignore (a
// positional arg, -m, or --src) instead of pretending they
// were honored.
if err := validateStandaloneEjectArgs(args, flags); err != nil {
return err
}
return ejectInfra(projectRoot, infraProvider)
}
return ejectInfra(cwd, infraProvider)
}

ctx := azdext.WithAccessToken(cmd.Context())
Expand Down Expand Up @@ -1285,7 +1287,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
if flags.src == "" {
flags.src = checkDir
}
return runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing)
if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil {
return err
}
return ejectInfraAfterInit(infraProvider)
Comment thread
hund030 marked this conversation as resolved.
}
}
}
Expand All @@ -1311,7 +1316,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
}
return err
}
return nil
return ejectInfraAfterInit(infraProvider)
}

// Resolve the agent name BEFORE creating the project folder
Expand Down Expand Up @@ -1514,29 +1519,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
// wrote azure.yaml, chain the eject step. Skip silently when init
// didn't produce a foundry-bearing azure.yaml (cancelled or
// non-foundry flow) to avoid a confusing "nothing to eject" error.
if infraProvider != "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("resolve current directory: %w", err)
}
//nolint:gosec // G304: azure.yaml in the current azd project directory
rawYAML, readErr := os.ReadFile(filepath.Join(cwd, "azure.yaml"))
switch {
case errors.Is(readErr, fs.ErrNotExist):
// Init didn't write azure.yaml; nothing to eject.
case readErr != nil:
return fmt.Errorf("read azure.yaml after init: %w", readErr)
default:
// Skip silently when no foundry service is present.
if _, svcErr := findFoundryServiceForEject(rawYAML); svcErr == nil {
if err := ejectInfra(cwd, infraProvider); err != nil {
return err
}
}
}
}

return nil
return ejectInfraAfterInit(infraProvider)
},
}

Expand Down
32 changes: 32 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"azureaiagent/internal/project"
"azureaiagent/internal/synthesis"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/fatih/color"
"go.yaml.in/yaml/v3"
)
Expand Down Expand Up @@ -67,6 +68,37 @@ func parseInfraProvider(value string) (string, error) {
}
}

// ejectInfraAfterInit ejects from the azd project containing the current
// directory. Init may create or discover a project above cwd, so use the same
// upward project resolution as the rest of azd.
func ejectInfraAfterInit(provider string) error {
if provider == "" {
return nil
}

projectRoot, err := azdext.GetProjectDir()
if errors.Is(err, azdext.ErrProjectNotFound) {
return nil
}
if err != nil {
return fmt.Errorf("resolve azd project directory after init: %w", err)
}

rawYAML, err := os.ReadFile(filepath.Join(projectRoot, "azure.yaml")) //nolint:gosec // resolved azd project file
if err != nil {
return fmt.Errorf("read azure.yaml after init: %w", err)
}
if _, err := findFoundryServiceForEject(rawYAML); err != nil {
if localErr, ok := errors.AsType[*azdext.LocalError](err); ok &&
localErr.Code == exterrors.CodeInfraEjectNoFoundryService {
return nil
Comment thread
hund030 marked this conversation as resolved.
}
return err
}

return ejectInfra(projectRoot, provider)
}

// ejectInfra synthesizes the embedded Bicep templates from azure.yaml and
// ejectInfra synthesizes infrastructure templates from azure.yaml and writes
// them into projectRoot/infra/. Invoked by `azd ai agent init --infra[=<provider>]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,67 @@ func TestParseInfraProvider(t *testing.T) {
}
}

func TestEjectInfraAfterInit_ResolvesParentProject(t *testing.T) {
t.Setenv("AZD_EXEC_PROJECT_DIR", "")
projectRoot := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "azure.yaml"), []byte(`name: test
services:
ai-project:
host: azure.ai.project
`), 0600))
nestedDir := filepath.Join(projectRoot, "src", "agent")
require.NoError(t, os.MkdirAll(nestedDir, 0750))
t.Chdir(nestedDir)

withCapturedStdout(t, func() {
require.NoError(t, ejectInfraAfterInit("bicep"))
})

assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep"))
assert.NoDirExists(t, filepath.Join(nestedDir, "infra"))
}

func TestEjectInfraAfterInit_NoProject(t *testing.T) {
t.Setenv("AZD_EXEC_PROJECT_DIR", "")
t.Chdir(t.TempDir())

assert.NoError(t, ejectInfraAfterInit("bicep"))
}

func TestEjectInfraAfterInit_SkipsProjectWithoutFoundryService(t *testing.T) {
t.Setenv("AZD_EXEC_PROJECT_DIR", "")
projectRoot := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "azure.yaml"), []byte(`name: test
services:
web:
host: containerapp
`), 0600))
t.Chdir(projectRoot)

assert.NoError(t, ejectInfraAfterInit("bicep"))
assert.NoDirExists(t, filepath.Join(projectRoot, "infra"))
}

func TestEjectInfraAfterInit_PropagatesInvalidFoundryConfiguration(t *testing.T) {
t.Setenv("AZD_EXEC_PROJECT_DIR", "")
projectRoot := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "azure.yaml"), []byte(`name: test
services:
first-project:
host: azure.ai.project
second-project:
host: azure.ai.project
`), 0600))
t.Chdir(projectRoot)

err := ejectInfraAfterInit("bicep")
require.Error(t, err)
localErr, ok := errors.AsType[*azdext.LocalError](err)
require.True(t, ok, "expected *azdext.LocalError, got %T", err)
assert.Equal(t, exterrors.CodeInfraEjectMultipleFoundryServices, localErr.Code)
assert.NoDirExists(t, filepath.Join(projectRoot, "infra"))
}

func TestEjectInfra_Terraform_HappyPath_WritesExpectedFiles(t *testing.T) {
// Not parallel: captures os.Stdout (see TestEjectInfra_HappyPath_WritesExpectedFiles).
dir := t.TempDir()
Expand Down
Loading