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 0b48d8a78fb..206f867445c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -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()) @@ -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) } } } @@ -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 @@ -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) }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 42055aa6ae5..1fd07aeafe0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -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" ) @@ -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 + } + 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[=]` diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index 7ec57ddd899..5e39953a2eb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go @@ -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()