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 != "" {
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)
}
}
}
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

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.

Optional, non-blocking: the added tests cover the propagate path (multiple foundry services) and the no-project path, but not this silent-skip branch where azure.yaml exists with no foundry service. A small test here would lock in that post-init eject skips silently while standalone eject still surfaces the same case as an error through ejectInfra.

}
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,51 @@ 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)

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_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