From c6db56f8f283753c9e08a1d2f6d4635e32152368 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 3 Aug 2026 12:57:06 -0400 Subject: [PATCH 1/3] Fix `azd ai agent init --infra` on an existing project without an agent service `--infra` treated any existing azure.yaml as a standalone eject, so running it in a project azd already managed but that had no Foundry service failed with "no foundry provisioning service found in azure.yaml ... nothing to eject". Getting infra files for such a project was therefore impossible in one step. The gate now only ejects standalone when azure.yaml already declares a Foundry service. Otherwise it falls through to the normal init flow and the existing trailing ejectInfraAfterInit call synthesizes ./infra/ once init has added the service. Because that fall-through opens a "run the whole init flow, then fail on the last step" window, the pre-existing ./infra/ refusal is now checked up front instead of only inside ejectInfra. That refusal is also reworded. Eject writes no marker, so nothing can tell its own prior output apart from IaC the user authored for the project's other services, and the old suggestion told users to delete the directory unconditionally. It now leads with the non-destructive option (run init without --infra) and conditions the delete on a previous --infra run having generated the tree. Fixes #9124 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48 --- .../azure.ai.agents/internal/cmd/init.go | 25 +- .../internal/cmd/init_infra.go | 160 +++++++++--- .../internal/cmd/init_infra_test.go | 230 +++++++++++++++++- 3 files changed, 377 insertions(+), 38 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 884ca71f428..4163da76573 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1123,23 +1123,29 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, infraProvider = p } - // `--infra` within an existing azd agent project is a standalone - // eject: synthesize infra (Bicep or Terraform) from + // `--infra` inside a project that already declares a Foundry service + // is a standalone eject: synthesize infra (Bicep or Terraform) from // the existing azure.yaml, write ./infra/, and return without // prompting. + // + // Any other project — including one azd already manages that has no + // Foundry service yet — has nothing to eject, so `--infra` falls + // through to the normal init flow and ejects afterwards via + // ejectInfraAfterInit. See resolveInfraGate. if infraProvider != "" { - projectRoot, projectRootErr := azdext.GetProjectDir() - if projectRootErr != nil && !errors.Is(projectRootErr, azdext.ErrProjectNotFound) { - return fmt.Errorf("resolve azd project directory: %w", projectRootErr) + gate, gateErr := resolveInfraGate() + if gateErr != nil { + return gateErr } - if projectRootErr == nil { + if gate.standaloneEject { // Reject inputs the eject path would silently ignore (a // positional arg, -m, or --src) instead of pretending they - // were honored. + // were honored. They stay valid on the init fall-through, + // where they do drive the flow. if err := validateStandaloneEjectArgs(args, flags); err != nil { return err } - return ejectInfra(projectRoot, infraProvider) + return ejectInfra(gate.projectRoot, infraProvider) } } @@ -1575,7 +1581,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Eject infrastructure-as-code from azure.yaml into ./infra/. "+ "A bare --infra ejects Bicep; --infra=terraform ejects Terraform and sets "+ "infra.provider: terraform; --infra=bicep is explicit Bicep. "+ - "When azure.yaml already exists, runs as a standalone eject and skips the init prompts.") + "When azure.yaml already declares a Foundry project service, runs as a standalone "+ + "eject and skips the init prompts; otherwise init runs first and the eject follows it.") // NoOptDefVal makes a bare `--infra` resolve to "bicep" while still allowing // `--infra=terraform` / `--infra=bicep`. Absent flag stays "" (no eject). cmd.Flags().Lookup("infra").NoOptDefVal = project.BicepProviderName 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 1fd07aeafe0..abf0859011f 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 @@ -68,6 +68,130 @@ func parseInfraProvider(value string) (string, error) { } } +// readProjectAzureYAML reads azure.yaml from projectRoot, reporting a missing +// file as the structured CodeInfraEjectAzureYamlMissing refusal so every eject +// entry point surfaces the same code for the same condition. +func readProjectAzureYAML(projectRoot string) ([]byte, error) { + //nolint:gosec // G304: azure.yaml under the caller-supplied azd project root + raw, err := os.ReadFile(filepath.Join(projectRoot, "azure.yaml")) + if err != nil { + if os.IsNotExist(err) { + return nil, exterrors.Validation( + exterrors.CodeInfraEjectAzureYamlMissing, + "azure.yaml not found in the current directory; "+ + "`azd ai agent init --infra` requires an existing azd agent project", + "run `azd ai agent init` first to create azure.yaml, then re-run with --infra", + ) + } + return nil, fmt.Errorf("read azure.yaml: %w", err) + } + + return raw, nil +} + +// hasFoundryServiceForEject reports whether azure.yaml at projectRoot already +// declares the Foundry provisioning service that eject synthesizes from. +// +// "No Foundry service" is reported as (false, nil) rather than an error: it +// means the project simply has nothing to eject yet, which callers resolve by +// running the normal init flow first. Malformed YAML and ambiguous projects +// (several Foundry services) still surface as errors. +func hasFoundryServiceForEject(projectRoot string) (bool, error) { + rawYAML, err := readProjectAzureYAML(projectRoot) + if err != nil { + return false, err + } + + if _, err := findFoundryServiceForEject(rawYAML); err != nil { + if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && + localErr.Code == exterrors.CodeInfraEjectNoFoundryService { + return false, nil + } + return false, err + } + + return true, nil +} + +// ensureInfraDirAbsent refuses when projectRoot already contains ./infra/. +// Eject writes the whole tree or nothing, so it never merges into or overwrites +// what is already there. +// +// Eject leaves no marker behind, so nothing at this point can tell prior eject +// output apart from infrastructure the user authored for the project's other +// services — a plain "delete ./infra/" would be destructive advice half the +// time. The suggestion therefore covers both cases and leads with the +// non-destructive one. +func ensureInfraDirAbsent(projectRoot string) error { + // A plain file at ./infra counts too: os.Stat cannot tell the caller's + // intent apart, and silently overwriting a user-owned file is never correct. + if _, err := os.Stat(filepath.Join(projectRoot, "infra")); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat infra directory: %w", err) + } + + return exterrors.Validation( + exterrors.CodeInfraEjectExists, + "`./infra/` already exists", + "if you authored ./infra/, keep it and run `azd ai agent init` without --infra: "+ + "--infra synthesizes a self-contained Foundry template and cannot merge into "+ + "infrastructure you already own. If a previous --infra run generated it, "+ + "delete ./infra/ and run the command again to regenerate it from azure.yaml", + ) +} + +// infraGate is how `azd ai agent init --infra` should behave for the azd +// project (if any) that contains the current directory. +type infraGate struct { + // standaloneEject is true when an existing project already declares a + // Foundry service, so eject runs on its own and skips the init prompts. + standaloneEject bool + // projectRoot is the resolved azd project root. Empty when the current + // directory is not inside a project yet. + projectRoot string +} + +// resolveInfraGate decides between a standalone eject and the normal init flow +// for a `--infra` run. +// +// A project that already declares a Foundry service ejects standalone. Anything +// else — no project at all, or a project azd already manages that has no Foundry +// service yet — runs init first and ejects afterwards, so "add an agent to my +// existing project and give me its IaC" stays a single step instead of failing +// with "nothing to eject". +// +// The one refusal kept up front is a pre-existing ./infra/: init cannot clear +// it, so failing here beats mutating azure.yaml and then refusing on the +// trailing eject. +func resolveInfraGate() (infraGate, error) { + projectRoot, err := azdext.GetProjectDir() + if errors.Is(err, azdext.ErrProjectNotFound) { + return infraGate{}, nil + } + if err != nil { + return infraGate{}, fmt.Errorf("resolve azd project directory: %w", err) + } + + hasFoundry, err := hasFoundryServiceForEject(projectRoot) + if err != nil { + return infraGate{}, err + } + if hasFoundry { + return infraGate{standaloneEject: true, projectRoot: projectRoot}, nil + } + + // The trailing eject writes ./infra/, and init cannot clear a directory that + // is already there. Refuse now rather than mutating azure.yaml and adding an + // azd environment first and only then refusing. + if err := ensureInfraDirAbsent(projectRoot); err != nil { + return infraGate{}, err + } + + return infraGate{projectRoot: projectRoot}, nil +} + // 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. @@ -84,17 +208,13 @@ func ejectInfraAfterInit(provider string) error { 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 + hasFoundry, err := hasFoundryServiceForEject(projectRoot) 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 } + if !hasFoundry { + return nil + } return ejectInfra(projectRoot, provider) } @@ -120,19 +240,9 @@ func ejectInfraAfterInit(provider string) error { // // On success it prints the summary block and returns nil. func ejectInfra(projectRoot, provider string) error { - yamlPath := filepath.Join(projectRoot, "azure.yaml") - //nolint:gosec // G304: azure.yaml under the caller-supplied azd project root - rawYAML, err := os.ReadFile(yamlPath) + rawYAML, err := readProjectAzureYAML(projectRoot) if err != nil { - if os.IsNotExist(err) { - return exterrors.Validation( - exterrors.CodeInfraEjectAzureYamlMissing, - "azure.yaml not found in the current directory; "+ - "`azd ai agent init --infra` requires an existing azd agent project", - "run `azd ai agent init` first to create azure.yaml, then re-run with --infra", - ) - } - return fmt.Errorf("read azure.yaml: %w", err) + return err } svcName, err := findFoundryServiceForEject(rawYAML) @@ -141,14 +251,8 @@ func ejectInfra(projectRoot, provider string) error { } infraDir := filepath.Join(projectRoot, "infra") - if _, err := os.Stat(infraDir); err == nil { - return exterrors.Validation( - exterrors.CodeInfraEjectExists, - "`./infra/` already exists", - "to regenerate from azure.yaml, delete the infra directory and run the command again", - ) - } else if !os.IsNotExist(err) { - return fmt.Errorf("stat infra directory: %w", err) + if err := ensureInfraDirAbsent(projectRoot); err != nil { + return err } res, err := synthesis.Synthesize(synthesis.Input{ 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 7ed897af679..281d2db32de 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 @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "azureaiagent/internal/exterrors" @@ -77,7 +78,7 @@ func TestEjectInfra_RefusesWhenInfraExists(t *testing.T) { require.True(t, ok, "expected structured azdext.LocalError, got %T", err) assert.Equal(t, exterrors.CodeInfraEjectExists, localErr.Code) assert.Contains(t, localErr.Message, "./infra/") - assert.Contains(t, localErr.Suggestion, "delete the infra directory") + assert.Contains(t, localErr.Suggestion, "delete ./infra/") // Pre-existing infra/ must not be wiped by the refusal. info, err := os.Stat(filepath.Join(dir, "infra")) @@ -713,6 +714,233 @@ services: assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) } +func TestHasFoundryServiceForEject(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + omitYAML bool + want bool + wantErrCode string + }{ + {name: "foundry project service", yaml: validFoundryAzureYAML, want: true}, + { + name: "legacy foundry host", + yaml: `name: my-project +services: + agent: + host: azure.ai.agent +`, + want: true, + }, + { + name: "non-foundry services only", + yaml: `name: my-project +services: + web: + host: containerapp + project: src/web +`, + want: false, + }, + {name: "no services block", yaml: "name: my-project\n", want: false}, + { + name: "multiple foundry services", + yaml: `name: my-project +services: + first: + host: azure.ai.project + second: + host: azure.ai.project +`, + wantErrCode: exterrors.CodeInfraEjectMultipleFoundryServices, + }, + {name: "azure.yaml missing", omitYAML: true, wantErrCode: exterrors.CodeInfraEjectAzureYamlMissing}, + { + name: "malformed yaml", + yaml: "name: my-project\nservices: [oops\n", + wantErrCode: exterrors.CodeInvalidAzureYaml, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if !tt.omitYAML { + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), tt.yaml) + } + + got, err := hasFoundryServiceForEject(dir) + if tt.wantErrCode != "" { + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + assert.Equal(t, tt.wantErrCode, localErr.Code) + assert.False(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestEnsureInfraDirAbsent(t *testing.T) { + t.Parallel() + + t.Run("absent", func(t *testing.T) { + t.Parallel() + assert.NoError(t, ensureInfraDirAbsent(t.TempDir())) + }) + + t.Run("directory present", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "infra"), 0o750)) + + err := ensureInfraDirAbsent(dir) + 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.CodeInfraEjectExists, localErr.Code) + }) + + t.Run("file present", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "infra"), "not a dir") + + err := ensureInfraDirAbsent(dir) + 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.CodeInfraEjectExists, localErr.Code) + }) +} + +// TestResolveInfraGate_ExistingProjectWithoutFoundryServiceRunsInit is the +// regression test for #9124: `azd ai agent init --infra` inside an azd project +// that has no Foundry service must fall through to the normal init flow rather +// than refusing with CodeInfraEjectNoFoundryService ("nothing to eject"). +func TestResolveInfraGate_ExistingProjectWithoutFoundryServiceRunsInit(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + web: + host: containerapp + project: src/web +`) + t.Chdir(projectRoot) + + gate, err := resolveInfraGate() + require.NoError(t, err, "an existing project without a Foundry service must not refuse") + assert.False(t, gate.standaloneEject, "init runs first, then the trailing eject") + assert.Equal(t, projectRoot, gate.projectRoot) +} + +func TestResolveInfraGate_ExistingFoundryProjectEjectsStandalone(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), validFoundryAzureYAML) + t.Chdir(projectRoot) + + gate, err := resolveInfraGate() + require.NoError(t, err) + assert.True(t, gate.standaloneEject) + assert.Equal(t, projectRoot, gate.projectRoot) +} + +func TestResolveInfraGate_NoProjectRunsInit(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + t.Chdir(t.TempDir()) + + gate, err := resolveInfraGate() + require.NoError(t, err) + assert.False(t, gate.standaloneEject) + assert.Empty(t, gate.projectRoot, "no project root to eject from yet") +} + +// A project with no Foundry service now runs the whole init flow before +// ejecting, so the ./infra/ conflict has to surface up front instead of after +// the user has answered every prompt. The existing tree usually belongs to the +// project's own services, so the refusal must offer the non-destructive path. +func TestResolveInfraGate_RefusesExistingInfraBeforeRunningInit(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + web: + host: containerapp +`) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + t.Chdir(projectRoot) + + _, err := resolveInfraGate() + 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.CodeInfraEjectExists, localErr.Code) + assert.Contains(t, localErr.Suggestion, "without --infra", + "the non-destructive path has to be offered, and offered first") + assert.Less(t, + strings.Index(localErr.Suggestion, "without --infra"), + strings.Index(localErr.Suggestion, "delete ./infra/"), + "never lead with deleting IaC the extension may not have authored") +} + +func TestResolveInfraGate_PropagatesInvalidFoundryConfiguration(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + first: + host: azure.ai.project + second: + host: azure.ai.project +`) + t.Chdir(projectRoot) + + _, err := resolveInfraGate() + 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) +} + +// End-to-end through cobra: before #9124 this returned +// CodeInfraEjectNoFoundryService. It now gets past that gate and reports the +// ./infra/ conflict instead, which also keeps the command from touching the azd +// client or prompting. +func TestInitInfra_ExistingProjectWithoutFoundryServiceSkipsNothingToEject(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + web: + host: containerapp +`) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) + t.Chdir(projectRoot) + + 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.Error(t, execErr) + localErr, ok := errors.AsType[*azdext.LocalError](execErr) + require.True(t, ok, "expected *azdext.LocalError, got %T", execErr) + assert.NotEqual(t, exterrors.CodeInfraEjectNoFoundryService, localErr.Code, + "--infra must no longer refuse a project that simply has no agent service yet") + assert.Equal(t, exterrors.CodeInfraEjectExists, localErr.Code) +} + func TestEjectInfra_Terraform_HappyPath_WritesExpectedFiles(t *testing.T) { // Not parallel: captures os.Stdout (see TestEjectInfra_HappyPath_WritesExpectedFiles). dir := t.TempDir() From b4ad415de3726f7bcce132adc1f244549ee8b187 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 4 Aug 2026 09:51:27 -0400 Subject: [PATCH 2/3] Refuse eject on a custom infra.path and cover the init fall-through Addresses PR review on #9407. - Refuse `--infra` when azure.yaml declares a non-default `infra.path`. ensureInfraDirAbsent only stats /infra, so a project that keeps its IaC elsewhere passed the gate; on --infra=terraform the trailing stampInfraProvider then set `provider: terraform` and removed `infra.path`, aiming provisioning at the Foundry-only module and leaving the user's real IaC orphaned. New CodeInfraEjectCustomInfraPath refusal fires in resolveInfraGate (both branches) and in ejectInfra, before anything is written. - Scope the validateStandaloneEjectArgs refusal text and doc comment to "a project that already declares a Foundry service"; the old wording described the pre-PR scope and contradicted the fall-through, where a positional path, -m, --src and --image are all accepted. - Strengthen fall-through coverage: the regression test no longer pre-creates ./infra/, so it exercises the real fall-through instead of exiting at the gate, and a new test drives gate -> init adds the Foundry service -> trailing ejectInfraAfterInit and asserts infra/main.bicep is generated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../internal/cmd/init_infra.go | 100 +++++++- .../internal/cmd/init_infra_test.go | 235 +++++++++++++++++- .../internal/exterrors/codes.go | 1 + 3 files changed, 322 insertions(+), 14 deletions(-) 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 abf0859011f..e1c50165359 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 @@ -33,17 +33,21 @@ type ejectArtifact struct { } // validateStandaloneEjectArgs refuses init-driving inputs that the -// standalone-eject branch would silently drop. `--infra` on an existing -// project runs eject only; honoring a positional path, -m, or --src would -// falsely imply the input was acted upon. +// standalone-eject branch would silently drop. `--infra` on a project that +// already declares a Foundry service runs eject only; honoring a positional +// path, -m, or --src would falsely imply the input was acted upon. +// +// Scoped to that branch only: on the init fall-through (a project without a +// Foundry service, or no project at all) the same inputs are accepted, because +// there they genuinely drive init. func validateStandaloneEjectArgs(args []string, flags *initFlags) error { if len(args) == 0 && flags.manifestPointer == "" && flags.src == "" && flags.image == "" { return nil } return exterrors.Validation( exterrors.CodeInfraEjectConflictingArguments, - "`--infra` on an existing project runs eject only and does not "+ - "accept a positional path, -m/--manifest, --src, or --image", + "`--infra` on a project that already declares a Foundry service runs eject only "+ + "and does not accept a positional path, -m/--manifest, --src, or --image", "drop the extra argument and run `azd ai agent init --infra` from the project root, "+ "or remove --infra to run the normal init flow", ) @@ -89,6 +93,10 @@ func readProjectAzureYAML(projectRoot string) ([]byte, error) { return raw, nil } +// defaultInfraDirName is the directory eject writes into, and the directory an +// azd project uses when it does not declare `infra.path`. +const defaultInfraDirName = "infra" + // hasFoundryServiceForEject reports whether azure.yaml at projectRoot already // declares the Foundry provisioning service that eject synthesizes from. // @@ -102,6 +110,12 @@ func hasFoundryServiceForEject(projectRoot string) (bool, error) { return false, err } + return hasFoundryServiceInYAML(rawYAML) +} + +// hasFoundryServiceInYAML is hasFoundryServiceForEject for callers that have +// already read azure.yaml and need it for more than the service scan. +func hasFoundryServiceInYAML(rawYAML []byte) (bool, error) { if _, err := findFoundryServiceForEject(rawYAML); err != nil { if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && localErr.Code == exterrors.CodeInfraEjectNoFoundryService { @@ -113,6 +127,54 @@ func hasFoundryServiceForEject(projectRoot string) (bool, error) { return true, nil } +// declaredInfraPath returns the `infra.path` declared in azure.yaml, or "" when +// the project does not declare one. +// +// A parse failure deliberately yields "": azure.yaml is parsed again — with the +// established CodeInvalidAzureYaml classification — by findFoundryServiceForEject +// and stampInfraProvider, and this helper must not displace those codes. +func declaredInfraPath(rawYAML []byte) string { + var doc struct { + Infra struct { + Path string `yaml:"path"` + } `yaml:"infra"` + } + if err := yaml.Unmarshal(rawYAML, &doc); err != nil { + return "" + } + + return strings.TrimSpace(doc.Infra.Path) +} + +// ensureDefaultInfraPath refuses when azure.yaml points the project's +// infrastructure somewhere other than ./infra/ via `infra.path`. +// +// Eject always writes ./infra/, and the Terraform path additionally stamps +// `infra.provider: terraform` and drops `infra.path` so azd-core provisions the +// generated module. On a project that declares its own `infra.path`, that +// combination aims provisioning at the Foundry-only template and leaves the +// directory the user actually maintains orphaned on disk. +// +// The refusal is about the declaration, not the directory: ./infra/ being absent +// proves nothing when the project's IaC deliberately lives elsewhere, so +// ensureInfraDirAbsent alone would wave this project through. +func ensureDefaultInfraPath(rawYAML []byte) error { + declared := declaredInfraPath(rawYAML) + if declared == "" || filepath.Clean(declared) == defaultInfraDirName { + return nil + } + + return exterrors.Validation( + exterrors.CodeInfraEjectCustomInfraPath, + fmt.Sprintf("azure.yaml points this project's infrastructure at %q via `infra.path`; "+ + "`--infra` writes a self-contained Foundry template to ./infra/ and cannot take "+ + "over infrastructure the project already owns", declared), + fmt.Sprintf("run `azd ai agent init` without --infra to add the agent while %q stays the "+ + "project's infrastructure, or remove `infra.path` from azure.yaml first if you want "+ + "the generated ./infra/ to replace it", declared), + ) +} + // ensureInfraDirAbsent refuses when projectRoot already contains ./infra/. // Eject writes the whole tree or nothing, so it never merges into or overwrites // what is already there. @@ -125,7 +187,7 @@ func hasFoundryServiceForEject(projectRoot string) (bool, error) { func ensureInfraDirAbsent(projectRoot string) error { // A plain file at ./infra counts too: os.Stat cannot tell the caller's // intent apart, and silently overwriting a user-owned file is never correct. - if _, err := os.Stat(filepath.Join(projectRoot, "infra")); err != nil { + if _, err := os.Stat(filepath.Join(projectRoot, defaultInfraDirName)); err != nil { if os.IsNotExist(err) { return nil } @@ -164,7 +226,8 @@ type infraGate struct { // // The one refusal kept up front is a pre-existing ./infra/: init cannot clear // it, so failing here beats mutating azure.yaml and then refusing on the -// trailing eject. +// trailing eject. A project that declares its own `infra.path` is refused on +// both branches for the same reason — see ensureDefaultInfraPath. func resolveInfraGate() (infraGate, error) { projectRoot, err := azdext.GetProjectDir() if errors.Is(err, azdext.ErrProjectNotFound) { @@ -174,10 +237,22 @@ func resolveInfraGate() (infraGate, error) { return infraGate{}, fmt.Errorf("resolve azd project directory: %w", err) } - hasFoundry, err := hasFoundryServiceForEject(projectRoot) + rawYAML, err := readProjectAzureYAML(projectRoot) + if err != nil { + return infraGate{}, err + } + + // Scanned before the infra.path check so a malformed azure.yaml keeps its + // established CodeInvalidAzureYaml classification. + hasFoundry, err := hasFoundryServiceInYAML(rawYAML) if err != nil { return infraGate{}, err } + + if err := ensureDefaultInfraPath(rawYAML); err != nil { + return infraGate{}, err + } + if hasFoundry { return infraGate{standaloneEject: true, projectRoot: projectRoot}, nil } @@ -236,6 +311,7 @@ func ejectInfraAfterInit(provider string) error { // // - azure.yaml is missing -> CodeInfraEjectAzureYamlMissing // - no service has a Foundry host -> CodeInfraEjectNoFoundryService +// - azure.yaml declares a non-default infra.path -> CodeInfraEjectCustomInfraPath // - ./infra/ already exists -> CodeInfraEjectExists // // On success it prints the summary block and returns nil. @@ -250,7 +326,13 @@ func ejectInfra(projectRoot, provider string) error { return err } - infraDir := filepath.Join(projectRoot, "infra") + // Checked before anything is written: the Terraform path drops infra.path on + // its way out, which would orphan IaC the project points at elsewhere. + if err := ensureDefaultInfraPath(rawYAML); err != nil { + return err + } + + infraDir := filepath.Join(projectRoot, defaultInfraDirName) if err := ensureInfraDirAbsent(projectRoot); err != nil { return err } 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 281d2db32de..d9e0078cee2 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 @@ -818,6 +818,121 @@ func TestEnsureInfraDirAbsent(t *testing.T) { }) } +// A project can point azd at IaC outside ./infra/ with `infra.path`. Eject +// always writes ./infra/, and the Terraform path drops `infra.path` on its way +// out, so such a project has to be refused before anything is written — an +// absent ./infra/ says nothing about whether the project owns infrastructure. +func TestEnsureDefaultInfraPath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + wantErr bool + }{ + {name: "no infra block", yaml: "name: my-project\n"}, + {name: "infra block without path", yaml: "name: my-project\ninfra:\n provider: bicep\n"}, + {name: "explicit default path", yaml: "name: my-project\ninfra:\n path: infra\n"}, + {name: "explicit default path dot-prefixed", yaml: "name: my-project\ninfra:\n path: ./infra\n"}, + {name: "empty path", yaml: "name: my-project\ninfra:\n path: \"\"\n"}, + {name: "custom path", yaml: "name: my-project\ninfra:\n path: myinfra\n", wantErr: true}, + {name: "nested path", yaml: "name: my-project\ninfra:\n path: deploy/infra\n", wantErr: true}, + // Malformed YAML must keep the CodeInvalidAzureYaml classification the + // service scan and the provider stamp already give it. + {name: "malformed yaml defers classification", yaml: "name: [unterminated\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ensureDefaultInfraPath([]byte(tt.yaml)) + if !tt.wantErr { + assert.NoError(t, err) + return + } + 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.CodeInfraEjectCustomInfraPath, localErr.Code) + assert.Contains(t, localErr.Suggestion, "without --infra", + "the non-destructive path has to be offered") + }) + } +} + +// The gate refuses a project that keeps its IaC elsewhere on both branches: +// standalone eject and the init fall-through both end in ejectInfra. +func TestResolveInfraGate_RefusesCustomInfraPath(t *testing.T) { + tests := []struct { + name string + yaml string + }{ + { + name: "project without a foundry service", + yaml: `name: my-project +infra: + path: myinfra +services: + web: + host: containerapp +`, + }, + { + name: "project that already declares a foundry service", + yaml: `name: my-project +infra: + path: myinfra +services: + ai-project: + host: azure.ai.project +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), tt.yaml) + // The declared directory exists; ./infra/ deliberately does not, so + // only the infra.path check can catch this. + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "myinfra"), 0o750)) + t.Chdir(projectRoot) + + _, err := resolveInfraGate() + 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.CodeInfraEjectCustomInfraPath, localErr.Code) + assert.Contains(t, localErr.Message, "myinfra") + }) + } +} + +// Terraform eject stamps infra.provider and removes infra.path. Refusing before +// anything is written is what keeps a project's own IaC from being orphaned. +func TestEjectInfra_Terraform_RefusesCustomInfraPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + azureYAML := `name: my-project +infra: + path: myinfra +services: + my-foundry: + host: azure.ai.project +` + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), azureYAML) + + err := ejectInfra(dir, "terraform") + 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.CodeInfraEjectCustomInfraPath, localErr.Code) + + assert.NoDirExists(t, filepath.Join(dir, "infra")) + raw, readErr := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec // G304: test path from t.TempDir() + require.NoError(t, readErr) + assert.Equal(t, azureYAML, string(raw), + "a refused eject must not stamp infra.provider or drop the declared infra.path") +} + // TestResolveInfraGate_ExistingProjectWithoutFoundryServiceRunsInit is the // regression test for #9124: `azd ai agent init --infra` inside an azd project // that has no Foundry service must fall through to the normal init flow rather @@ -908,11 +1023,121 @@ services: assert.Equal(t, exterrors.CodeInfraEjectMultipleFoundryServices, localErr.Code) } -// End-to-end through cobra: before #9124 this returned -// CodeInfraEjectNoFoundryService. It now gets past that gate and reports the -// ./infra/ conflict instead, which also keeps the command from touching the azd -// client or prompting. -func TestInitInfra_ExistingProjectWithoutFoundryServiceSkipsNothingToEject(t *testing.T) { +// End-to-end through cobra with nothing in the way: before #9124 this returned +// CodeInfraEjectNoFoundryService before the command did anything else. The gate +// now falls through, so the run reaches the normal init flow and only stops +// there, on the azd host RPCs this test deliberately leaves unreachable. +// +// The interesting assertions are negative — no eject-gate refusal fires, and a +// refused-at-the-gate run cannot be mistaken for a fall-through because it never +// reaches the azd host at all. +func TestInitInfra_ExistingProjectWithoutFoundryServiceFallsThroughToInit(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + // No azd host, and no `azd` on PATH for the auth probe, so the run fails at + // a fixed point inside init instead of depending on the developer's machine. + t.Setenv("AZD_SERVER", "") + t.Setenv("PATH", "") + projectRoot := t.TempDir() + azureYAML := `name: my-project +services: + web: + host: containerapp +` + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), azureYAML) + t.Chdir(projectRoot) + + 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.Error(t, execErr, "init cannot complete without an azd host") + if localErr, ok := errors.AsType[*azdext.LocalError](execErr); ok { + assert.NotContains(t, []string{ + exterrors.CodeInfraEjectNoFoundryService, + exterrors.CodeInfraEjectExists, + exterrors.CodeInfraEjectCustomInfraPath, + exterrors.CodeInfraEjectConflictingArguments, + }, localErr.Code, "--infra must not refuse a project that simply has no agent service yet") + } + assert.Contains(t, execErr.Error(), "rpc error", + "the run has to get far enough into init to talk to the azd host") + + // A refusal at the gate is the only thing that could have stopped the run + // earlier, and it would have left these untouched too — so also assert init + // did not half-write anything on its way out. + assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) + raw, err := os.ReadFile(filepath.Join(projectRoot, "azure.yaml")) //nolint:gosec // G304: test path from t.TempDir() + require.NoError(t, err) + assert.Equal(t, azureYAML, string(raw)) +} + +// The fall-through only pays off if the trailing eject still runs: every init +// exit path ends in ejectInfraAfterInit. Drive that seam end to end — gate, +// then the azure.yaml mutation init performs when it adds the agent service, +// then the trailing eject — so a regression that stopped generating IaC after +// init on an existing project cannot pass. +// +// The init flow in between is exercised by its own tests; reproducing it here +// would mean standing up the whole azd host (prompts, model catalog, template +// download), which belongs in the functional suite. +func TestInitInfra_FallThroughEjectsAfterInitAddsFoundryService(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + web: + host: containerapp +`) + t.Chdir(projectRoot) + + gate, err := resolveInfraGate() + require.NoError(t, err) + require.False(t, gate.standaloneEject, "no Foundry service yet, so init has to run first") + require.Equal(t, projectRoot, gate.projectRoot) + + // Stand in for the init flow: add the Foundry project service the same way + // init does before it hands off to ejectInfraAfterInit. + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +services: + web: + host: containerapp + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4-1-mini + model: + name: gpt-4.1-mini + format: OpenAI + version: "2024-07-18" + sku: + name: GlobalStandard + capacity: 50 +`) + + withCapturedStdout(t, func() { + require.NoError(t, ejectInfraAfterInit("bicep")) + }) + + assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.bicep")) + assert.FileExists(t, filepath.Join(projectRoot, "infra", "main.parameters.json")) + + // The project's pre-existing service has to survive the round trip. + raw, err := os.ReadFile(filepath.Join(projectRoot, "azure.yaml")) //nolint:gosec // G304: test path from t.TempDir() + require.NoError(t, err) + assert.Contains(t, string(raw), "host: containerapp") + assert.Contains(t, string(raw), "host: azure.ai.project") +} + +// The pre-existing ./infra/ refusal moved into the gate, so it now fires before +// the user is walked through init. Asserted at the command level because the +// ordering is what the gate exists for. +func TestInitInfra_ExistingProjectWithPreexistingInfraRefusesUpFront(t *testing.T) { t.Setenv("AZD_EXEC_PROJECT_DIR", "") projectRoot := t.TempDir() mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index bfe363d1b52..ab626b971f8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -231,6 +231,7 @@ const ( CodeInfraEjectConflictingArguments = "infra_eject_conflicting_arguments" CodeInfraEjectNetworkUnsupported = "infra_eject_network_unsupported" CodeInfraEjectBrownfieldUnsupported = "infra_eject_brownfield_unsupported" + CodeInfraEjectCustomInfraPath = "infra_eject_custom_infra_path" ) // Operation names for the microsoft.foundry provisioning provider. From 521392f12dfdec8f71dc9ecccbe5e8a24c4f7bad Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Wed, 5 Aug 2026 11:41:25 -0400 Subject: [PATCH 3/3] fix(agents): reject unsupported infra eject configuration Standalone eject now rejects every changed init-only flag instead of reporting success after silently ignoring it. Cobra's changed local and inherited flags are inspected, while global execution and tracing controls remain valid. Protect existing project configuration before init or eject by refusing layered infra, custom modules, and Bicep output under an incompatible provider. Harden path handling to match core normalization, preserve literal path whitespace, and treat dangling ./infra symlinks as existing user-owned paths. Add regression coverage for scalar, slice, boolean and inherited flags; explicit and inherited layer providers; Bicep/Terraform module behavior; provider mismatches; invalid config types; and cross-platform path edge cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48 --- .../azure.ai.agents/internal/cmd/init.go | 11 +- .../internal/cmd/init_infra.go | 223 +++++++- .../internal/cmd/init_infra_test.go | 523 ++++++++++++++++-- .../internal/exterrors/codes.go | 3 + 4 files changed, 685 insertions(+), 75 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 4163da76573..436e7f4533c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1133,16 +1133,15 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // through to the normal init flow and ejects afterwards via // ejectInfraAfterInit. See resolveInfraGate. if infraProvider != "" { - gate, gateErr := resolveInfraGate() + gate, gateErr := resolveInfraGate(infraProvider) if gateErr != nil { return gateErr } if gate.standaloneEject { - // Reject inputs the eject path would silently ignore (a - // positional arg, -m, or --src) instead of pretending they - // were honored. They stay valid on the init fall-through, - // where they do drive the flow. - if err := validateStandaloneEjectArgs(args, flags); err != nil { + // Reject init inputs the eject path would silently ignore + // instead of pretending they were honored. They stay valid + // on the init fall-through, where they do drive the flow. + if err := validateStandaloneEjectArgs(cmd, args); err != nil { return err } return ejectInfra(gate.projectRoot, 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 e1c50165359..7807b2a6a04 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 @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io/fs" + "maps" "os" "path/filepath" "slices" @@ -21,6 +22,8 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/pflag" "go.yaml.in/yaml/v3" ) @@ -35,24 +38,85 @@ type ejectArtifact struct { // validateStandaloneEjectArgs refuses init-driving inputs that the // standalone-eject branch would silently drop. `--infra` on a project that // already declares a Foundry service runs eject only; honoring a positional -// path, -m, or --src would falsely imply the input was acted upon. +// path or an explicitly changed init flag would falsely imply the input was +// acted upon. // // Scoped to that branch only: on the init fall-through (a project without a // Foundry service, or no project at all) the same inputs are accepted, because // there they genuinely drive init. -func validateStandaloneEjectArgs(args []string, flags *initFlags) error { - if len(args) == 0 && flags.manifestPointer == "" && flags.src == "" && flags.image == "" { +func validateStandaloneEjectArgs(cmd *cobra.Command, args []string) error { + conflicts := standaloneEjectConflictingInputs(cmd, args) + if len(conflicts) == 0 { return nil } + return exterrors.Validation( exterrors.CodeInfraEjectConflictingArguments, "`--infra` on a project that already declares a Foundry service runs eject only "+ - "and does not accept a positional path, -m/--manifest, --src, or --image", - "drop the extra argument and run `azd ai agent init --infra` from the project root, "+ + fmt.Sprintf("and cannot use these init inputs: %s", strings.Join(conflicts, ", ")), + "drop the init inputs and run `azd ai agent init --infra` from the project root, "+ "or remove --infra to run the normal init flow", ) } +// ensureDefaultInfraModule refuses a custom infra.module. Eject writes +// main.bicep/main.parameters.json or main.tfvars.json, while azd derives those +// filenames from infra.module and would ignore the generated entry point or +// parameter file. +func ensureDefaultInfraModule(rawYAML []byte) error { + config, err := readDeclaredInfraConfig(rawYAML) + if err != nil { + return err + } + + if config.Module == "" || config.Module == "main" || config.Module == "./main" { + return nil + } + + return exterrors.Validation( + exterrors.CodeInfraEjectCustomModule, + fmt.Sprintf("azure.yaml selects infrastructure module %q via `infra.module`; `--infra` "+ + "generates the default main module and cannot preserve that entry point", config.Module), + fmt.Sprintf("run `azd ai agent init` without --infra to keep module %q, or remove "+ + "`infra.module` first if you want the generated main module to replace it", config.Module), + ) +} + +// standaloneEjectConflictingInputs returns every changed input the standalone +// eject branch cannot honor. Global execution controls remain valid; everything +// else is assumed to belong to init so newly-added flags fail safe instead of +// being silently ignored. +func standaloneEjectConflictingInputs(cmd *cobra.Command, args []string) []string { + allowed := map[string]struct{}{ + "cwd": {}, + "debug": {}, + "infra": {}, + "no-prompt": {}, + "output": {}, + "trace-log-file": {}, + "trace-log-url": {}, + } + seen := map[string]struct{}{} + if len(args) > 0 { + seen["positional path"] = struct{}{} + } + + collect := func(flags *pflag.FlagSet) { + flags.Visit(func(flag *pflag.Flag) { + if _, ok := allowed[flag.Name]; ok { + return + } + seen["--"+flag.Name] = struct{}{} + }) + } + collect(cmd.Flags()) + collect(cmd.InheritedFlags()) + + conflicts := slices.Collect(maps.Keys(seen)) + slices.Sort(conflicts) + return conflicts +} + // parseInfraProvider normalizes the --infra flag value into a supported // provider name. A bare `--infra` arrives as "bicep" (the flag's NoOptDefVal), // so the accepted values are "bicep" and "terraform" (case-insensitive). The @@ -127,23 +191,43 @@ func hasFoundryServiceInYAML(rawYAML []byte) (bool, error) { return true, nil } -// declaredInfraPath returns the `infra.path` declared in azure.yaml, or "" when -// the project does not declare one. -// -// A parse failure deliberately yields "": azure.yaml is parsed again — with the -// established CodeInvalidAzureYaml classification — by findFoundryServiceForEject -// and stampInfraProvider, and this helper must not displace those codes. -func declaredInfraPath(rawYAML []byte) string { +type declaredInfraConfig struct { + Provider string `yaml:"provider"` + Path string `yaml:"path"` + Module string `yaml:"module"` + Layers []struct { + Name string `yaml:"name"` + Provider string `yaml:"provider"` + Path string `yaml:"path"` + } `yaml:"layers"` +} + +// readDeclaredInfraConfig returns the infra configuration declared in +// azure.yaml. Type-invalid infra fields use the same invalid-azure.yaml +// classification as the service scan; otherwise a value such as +// `infra.layers: unexpected` would look like no layers and bypass the guard. +func readDeclaredInfraConfig(rawYAML []byte) (declaredInfraConfig, error) { var doc struct { - Infra struct { - Path string `yaml:"path"` - } `yaml:"infra"` + Infra declaredInfraConfig `yaml:"infra"` } if err := yaml.Unmarshal(rawYAML, &doc); err != nil { - return "" + return declaredInfraConfig{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("parse azure.yaml: %s", err), + "verify azure.yaml is valid YAML", + ) } - return strings.TrimSpace(doc.Infra.Path) + return doc.Infra, nil +} + +// normalizeInfraPath mirrors azd core's project-path normalization before +// comparing a declared path with the default ./infra directory. +func normalizeInfraPath(path string) string { + if strings.Contains(path, "\\") && !strings.Contains(path, "/") { + path = strings.ReplaceAll(path, "\\", "/") + } + return filepath.Clean(filepath.FromSlash(path)) } // ensureDefaultInfraPath refuses when azure.yaml points the project's @@ -159,8 +243,13 @@ func declaredInfraPath(rawYAML []byte) string { // proves nothing when the project's IaC deliberately lives elsewhere, so // ensureInfraDirAbsent alone would wave this project through. func ensureDefaultInfraPath(rawYAML []byte) error { - declared := declaredInfraPath(rawYAML) - if declared == "" || filepath.Clean(declared) == defaultInfraDirName { + config, err := readDeclaredInfraConfig(rawYAML) + if err != nil { + return err + } + + declared := config.Path + if declared == "" || normalizeInfraPath(declared) == defaultInfraDirName { return nil } @@ -175,6 +264,63 @@ func ensureDefaultInfraPath(rawYAML []byte) error { ) } +// ensureNoInfraLayers refuses projects that use infra.layers. Eject generates +// one self-contained Foundry module under ./infra and cannot preserve layer +// paths, dependency ordering, hooks, or provider inheritance. +func ensureNoInfraLayers(rawYAML []byte) error { + config, err := readDeclaredInfraConfig(rawYAML) + if err != nil { + return err + } + if len(config.Layers) == 0 { + return nil + } + + return exterrors.Validation( + exterrors.CodeInfraEjectLayersUnsupported, + fmt.Sprintf("azure.yaml declares %d infrastructure layer(s); `--infra` generates one "+ + "self-contained Foundry template and cannot preserve layered provisioning", len(config.Layers)), + "run `azd ai agent init` without --infra to keep the existing layers, or remove "+ + "`infra.layers` first if you want the generated ./infra/ template to replace them", + ) +} + +// ensureCompatibleInfraProvider refuses a Bicep eject when azure.yaml selects a +// different provisioning provider. Bicep eject intentionally leaves +// infra.provider unchanged, so azd would continue dispatching to that provider +// and ignore the generated files. +func ensureCompatibleInfraProvider(rawYAML []byte, requestedProvider string) error { + config, err := readDeclaredInfraConfig(rawYAML) + if err != nil { + return err + } + + if requestedProvider != project.BicepProviderName { + return nil + } + + declared := config.Provider + if declared == "" || + declared == project.BicepProviderName || + declared == project.FoundryProviderName { + return nil + } + + suggestion := "change or remove `infra.provider` before ejecting Bicep" + if declared == project.TerraformProviderName { + suggestion = "use `azd ai agent init --infra=terraform` to generate Terraform, " + + "or change/remove `infra.provider` before ejecting Bicep" + } + + return exterrors.Validation( + exterrors.CodeInfraEjectProviderConflict, + fmt.Sprintf("azure.yaml uses `infra.provider: %s`, but `--infra=bicep` generates Bicep "+ + "without changing the provider; azd would continue using %s and ignore the generated files", + declared, declared), + suggestion, + ) +} + // ensureInfraDirAbsent refuses when projectRoot already contains ./infra/. // Eject writes the whole tree or nothing, so it never merges into or overwrites // what is already there. @@ -185,9 +331,10 @@ func ensureDefaultInfraPath(rawYAML []byte) error { // time. The suggestion therefore covers both cases and leads with the // non-destructive one. func ensureInfraDirAbsent(projectRoot string) error { - // A plain file at ./infra counts too: os.Stat cannot tell the caller's - // intent apart, and silently overwriting a user-owned file is never correct. - if _, err := os.Stat(filepath.Join(projectRoot, defaultInfraDirName)); err != nil { + // Lstat makes any directory entry count, including a dangling symlink. + // Eject must never replace a user-owned path whose target happens to be + // missing. + if _, err := os.Lstat(filepath.Join(projectRoot, defaultInfraDirName)); err != nil { if os.IsNotExist(err) { return nil } @@ -226,9 +373,9 @@ type infraGate struct { // // The one refusal kept up front is a pre-existing ./infra/: init cannot clear // it, so failing here beats mutating azure.yaml and then refusing on the -// trailing eject. A project that declares its own `infra.path` is refused on -// both branches for the same reason — see ensureDefaultInfraPath. -func resolveInfraGate() (infraGate, error) { +// trailing eject. Projects that use a custom infra.path, infra.layers, or an +// incompatible provider are refused on both branches for the same reason. +func resolveInfraGate(provider string) (infraGate, error) { projectRoot, err := azdext.GetProjectDir() if errors.Is(err, azdext.ErrProjectNotFound) { return infraGate{}, nil @@ -249,9 +396,18 @@ func resolveInfraGate() (infraGate, error) { return infraGate{}, err } + if err := ensureNoInfraLayers(rawYAML); err != nil { + return infraGate{}, err + } if err := ensureDefaultInfraPath(rawYAML); err != nil { return infraGate{}, err } + if err := ensureDefaultInfraModule(rawYAML); err != nil { + return infraGate{}, err + } + if err := ensureCompatibleInfraProvider(rawYAML, provider); err != nil { + return infraGate{}, err + } if hasFoundry { return infraGate{standaloneEject: true, projectRoot: projectRoot}, nil @@ -311,7 +467,10 @@ func ejectInfraAfterInit(provider string) error { // // - azure.yaml is missing -> CodeInfraEjectAzureYamlMissing // - no service has a Foundry host -> CodeInfraEjectNoFoundryService +// - azure.yaml declares infra.layers -> CodeInfraEjectLayersUnsupported // - azure.yaml declares a non-default infra.path -> CodeInfraEjectCustomInfraPath +// - azure.yaml declares a non-default infra.module -> CodeInfraEjectCustomModule +// - Bicep eject conflicts with infra.provider -> CodeInfraEjectProviderConflict // - ./infra/ already exists -> CodeInfraEjectExists // // On success it prints the summary block and returns nil. @@ -326,11 +485,21 @@ func ejectInfra(projectRoot, provider string) error { return err } - // Checked before anything is written: the Terraform path drops infra.path on - // its way out, which would orphan IaC the project points at elsewhere. + // Checked before anything is written: eject cannot preserve layers, the + // Terraform path drops infra.path, and Bicep leaves an existing provider + // unchanged. Any of those could make azd ignore or orphan user-owned IaC. + if err := ensureNoInfraLayers(rawYAML); err != nil { + return err + } if err := ensureDefaultInfraPath(rawYAML); err != nil { return err } + if err := ensureDefaultInfraModule(rawYAML); err != nil { + return err + } + if err := ensureCompatibleInfraProvider(rawYAML, provider); err != nil { + return err + } infraDir := filepath.Join(projectRoot, defaultInfraDirName) if err := ensureInfraDirAbsent(projectRoot); err != nil { 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 d9e0078cee2..09295c1a635 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 @@ -6,15 +6,18 @@ package cmd import ( "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" + "runtime" "strings" "testing" "azureaiagent/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" @@ -538,42 +541,83 @@ func TestEjectInfra_RefusesWhenInfraIsAFile(t *testing.T) { } func TestValidateStandaloneEjectArgs(t *testing.T) { - // The standalone-eject branch in init.go runs after positional-arg - // resolution, so by the time validateStandaloneEjectArgs is called - // flags.manifestPointer / flags.src may have been set by - // applyPositionalArg even if the user never passed a `-m` or `--src`. - // Either way: any of args, manifestPointer, or src being set means - // init-driving input that standalone eject cannot honor. tests := []struct { name string args []string - manifest string - src string - image string + changed map[string]string wantError bool + wantInput string }{ - {name: "no extras: ok", args: nil, manifest: "", src: "", wantError: false}, - {name: "positional arg: refuse", args: []string{"./foo"}, wantError: true}, - {name: "manifest flag: refuse", manifest: "./agent.yaml", wantError: true}, - {name: "src flag: refuse", src: "./src/agent", wantError: true}, - {name: "image flag: refuse", image: "myacr.azurecr.io/agent:1", wantError: true}, + {name: "no extras: ok"}, + {name: "positional arg: refuse", args: []string{"./foo"}, wantError: true, wantInput: "positional path"}, { - name: "all three set: refuse", + name: "manifest flag: refuse", + changed: map[string]string{"manifest": "./agent.yaml"}, + wantError: true, + wantInput: "--manifest", + }, + { + name: "scalar init flag: refuse", + changed: map[string]string{"model": "gpt-5.4-mini"}, + wantError: true, + wantInput: "--model", + }, + { + name: "slice init flag: refuse", + changed: map[string]string{"protocol": "responses"}, + wantError: true, + wantInput: "--protocol", + }, + { + name: "boolean init flag: refuse", + changed: map[string]string{"force": "true"}, + wantError: true, + wantInput: "--force", + }, + { + name: "environment flag: refuse", + changed: map[string]string{"environment": "dev"}, + wantError: true, + wantInput: "--environment", + }, + { + name: "global execution controls: ok", + changed: map[string]string{ + "cwd": ".", + "debug": "true", + "infra": "bicep", + "no-prompt": "true", + "output": "json", + }, + }, + { + name: "multiple inputs: refuse", args: []string{"./pos"}, - manifest: "./agent.yaml", - src: "./src", + changed: map[string]string{"manifest": "./agent.yaml", "src": "./src"}, wantError: true, + wantInput: "--manifest", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - flags := &initFlags{ - manifestPointer: tt.manifest, - src: tt.src, - image: tt.image, + + cmd := newInitCommand(&azdext.ExtensionContext{}) + // These are inherited global flags on the real extension command. + // Register them locally here so the standalone helper sees the same + // changed-flag state without constructing the full command tree. + if cmd.Flags().Lookup("cwd") == nil { + cmd.Flags().String("cwd", "", "") + cmd.Flags().Bool("debug", false, "") + cmd.Flags().String("environment", "", "") + cmd.Flags().Bool("no-prompt", false, "") + cmd.Flags().String("output", "default", "") } - err := validateStandaloneEjectArgs(tt.args, flags) + for name, value := range tt.changed { + require.NoError(t, cmd.Flags().Set(name, value)) + } + + err := validateStandaloneEjectArgs(cmd, tt.args) if !tt.wantError { assert.NoError(t, err) return @@ -584,13 +628,39 @@ func TestValidateStandaloneEjectArgs(t *testing.T) { assert.Equal(t, exterrors.CodeInfraEjectConflictingArguments, localErr.Code) assert.Equal(t, azdext.LocalErrorCategoryValidation, localErr.Category, "the conflict is bad-user-input, classified Validation") + assert.Contains(t, localErr.Message, tt.wantInput) // Suggestion must point at both ways out: drop the arg, or drop --infra. - assert.Contains(t, localErr.Suggestion, "drop the extra argument") + assert.Contains(t, localErr.Suggestion, "drop the init inputs") assert.Contains(t, localErr.Suggestion, "remove --infra") }) } } +func TestValidateStandaloneEjectArgs_AllowsSDKTraceFlags(t *testing.T) { + // Not parallel: NewRootCommand enables Cobra's package-level traverse-run + // hooks while constructing the real extension command tree. + root := NewRootCommand() + initCmd, _, err := root.Find([]string{"init"}) + require.NoError(t, err) + require.NotNil(t, initCmd) + + require.NotNil(t, initCmd.InheritedFlags().Lookup("trace-log-file")) + require.NotNil(t, initCmd.InheritedFlags().Lookup("trace-log-url")) + require.NoError(t, initCmd.InheritedFlags().Set("trace-log-file", "trace.jsonl")) + require.NoError(t, initCmd.InheritedFlags().Set("trace-log-url", "http://localhost:4318")) + require.NoError(t, initCmd.Flags().Set("infra", "bicep")) + + var visited []string + initCmd.InheritedFlags().Visit(func(flag *pflag.Flag) { + visited = append(visited, flag.Name) + }) + assert.Contains(t, visited, "trace-log-file", "the test must exercise changed inherited flags") + assert.Contains(t, visited, "trace-log-url", "the test must exercise changed inherited flags") + + assert.NoError(t, validateStandaloneEjectArgs(initCmd, nil), + "SDK tracing and --infra are execution controls, not discarded init inputs") +} + func TestParseInfraProvider(t *testing.T) { t.Parallel() tests := []struct { @@ -816,6 +886,22 @@ func TestEnsureInfraDirAbsent(t *testing.T) { require.True(t, ok, "expected *azdext.LocalError, got %T", err) assert.Equal(t, exterrors.CodeInfraEjectExists, localErr.Code) }) + + t.Run("dangling symlink present", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires elevated privileges on some Windows hosts") + } + + dir := t.TempDir() + require.NoError(t, os.Symlink("missing-target", filepath.Join(dir, "infra"))) + + err := ensureInfraDirAbsent(dir) + 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.CodeInfraEjectExists, localErr.Code) + }) } // A project can point azd at IaC outside ./infra/ with `infra.path`. Eject @@ -825,35 +911,225 @@ func TestEnsureInfraDirAbsent(t *testing.T) { func TestEnsureDefaultInfraPath(t *testing.T) { t.Parallel() tests := []struct { - name string - yaml string - wantErr bool + name string + yaml string + wantCode string }{ {name: "no infra block", yaml: "name: my-project\n"}, {name: "infra block without path", yaml: "name: my-project\ninfra:\n provider: bicep\n"}, {name: "explicit default path", yaml: "name: my-project\ninfra:\n path: infra\n"}, {name: "explicit default path dot-prefixed", yaml: "name: my-project\ninfra:\n path: ./infra\n"}, + {name: "explicit default path windows separators", yaml: "name: my-project\ninfra:\n path: .\\infra\n"}, {name: "empty path", yaml: "name: my-project\ninfra:\n path: \"\"\n"}, - {name: "custom path", yaml: "name: my-project\ninfra:\n path: myinfra\n", wantErr: true}, - {name: "nested path", yaml: "name: my-project\ninfra:\n path: deploy/infra\n", wantErr: true}, - // Malformed YAML must keep the CodeInvalidAzureYaml classification the - // service scan and the provider stamp already give it. - {name: "malformed yaml defers classification", yaml: "name: [unterminated\n"}, + { + name: "custom path", + yaml: "name: my-project\ninfra:\n path: myinfra\n", + wantCode: exterrors.CodeInfraEjectCustomInfraPath, + }, + { + name: "nested path", + yaml: "name: my-project\ninfra:\n path: deploy/infra\n", + wantCode: exterrors.CodeInfraEjectCustomInfraPath, + }, + { + name: "space-padded path remains custom", + yaml: "name: my-project\ninfra:\n path: \" infra \"\n", + wantCode: exterrors.CodeInfraEjectCustomInfraPath, + }, + {name: "malformed yaml", yaml: "name: [unterminated\n", wantCode: exterrors.CodeInvalidAzureYaml}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := ensureDefaultInfraPath([]byte(tt.yaml)) - if !tt.wantErr { + if tt.wantCode == "" { assert.NoError(t, err) return } 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.CodeInfraEjectCustomInfraPath, localErr.Code) - assert.Contains(t, localErr.Suggestion, "without --infra", - "the non-destructive path has to be offered") + assert.Equal(t, tt.wantCode, localErr.Code) + if tt.wantCode == exterrors.CodeInfraEjectCustomInfraPath { + assert.Contains(t, localErr.Suggestion, "without --infra", + "the non-destructive path has to be offered") + } + }) + } +} + +func TestEnsureDefaultInfraModule(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + wantCode string + }{ + {name: "no infra block", yaml: "name: my-project\n"}, + {name: "infra block without module", yaml: "name: my-project\ninfra:\n provider: bicep\n"}, + {name: "explicit default module", yaml: "name: my-project\ninfra:\n module: main\n"}, + {name: "dot-prefixed default module", yaml: "name: my-project\ninfra:\n module: ./main\n"}, + { + name: "trailing separator is not the default module", + yaml: "name: my-project\ninfra:\n module: main/\n", + wantCode: exterrors.CodeInfraEjectCustomModule, + }, + { + name: "trailing dot segment is not the default module", + yaml: "name: my-project\ninfra:\n module: main/.\n", + wantCode: exterrors.CodeInfraEjectCustomModule, + }, + { + name: "custom module", + yaml: "name: my-project\ninfra:\n module: platform\n", + wantCode: exterrors.CodeInfraEjectCustomModule, + }, + { + name: "type-invalid module", + yaml: "name: my-project\ninfra:\n module: [main]\n", + wantCode: exterrors.CodeInvalidAzureYaml, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ensureDefaultInfraModule([]byte(tt.yaml)) + if tt.wantCode == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + assert.Equal(t, tt.wantCode, localErr.Code) + if tt.wantCode == exterrors.CodeInfraEjectCustomModule { + assert.Contains(t, localErr.Message, "infra.module") + assert.Contains(t, localErr.Suggestion, "without --infra") + } + }) + } +} + +func TestEnsureNoInfraLayers(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + wantCode string + }{ + {name: "no infra block", yaml: "name: my-project\n"}, + {name: "empty layers", yaml: "name: my-project\ninfra:\n layers: []\n"}, + { + name: "layer with explicit provider", + yaml: `name: my-project +infra: + provider: terraform + layers: + - name: app + provider: bicep + path: infra/app +`, + wantCode: exterrors.CodeInfraEjectLayersUnsupported, + }, + { + name: "layer inherits root provider", + yaml: `name: my-project +infra: + provider: terraform + layers: + - name: app + path: infra/app +`, + wantCode: exterrors.CodeInfraEjectLayersUnsupported, + }, + { + name: "type-invalid layers", + yaml: "name: my-project\ninfra:\n layers: unexpected\n", + wantCode: exterrors.CodeInvalidAzureYaml, + }, + {name: "malformed yaml", yaml: "name: [unterminated\n", wantCode: exterrors.CodeInvalidAzureYaml}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ensureNoInfraLayers([]byte(tt.yaml)) + if tt.wantCode == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + assert.Equal(t, tt.wantCode, localErr.Code) + if tt.wantCode == exterrors.CodeInfraEjectLayersUnsupported { + assert.Contains(t, localErr.Suggestion, "without --infra") + } + }) + } +} + +func TestEnsureCompatibleInfraProvider(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + requested string + wantCode string + }{ + {name: "unspecified provider accepts bicep", yaml: "name: my-project\n", requested: "bicep"}, + { + name: "bicep provider accepts bicep", + yaml: "name: my-project\ninfra:\n provider: bicep\n", + requested: "bicep", + }, + { + name: "foundry provider accepts bicep", + yaml: "name: my-project\ninfra:\n provider: microsoft.foundry\n", + requested: "bicep", + }, + { + name: "terraform provider rejects bicep", + yaml: "name: my-project\ninfra:\n provider: terraform\n", + requested: "bicep", + wantCode: exterrors.CodeInfraEjectProviderConflict, + }, + { + name: "terraform provider accepts terraform", + yaml: "name: my-project\ninfra:\n provider: terraform\n", + requested: "terraform", + }, + { + name: "type-invalid provider", + yaml: "name: my-project\ninfra:\n provider: [terraform]\n", + requested: "terraform", + wantCode: exterrors.CodeInvalidAzureYaml, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ensureCompatibleInfraProvider([]byte(tt.yaml), tt.requested) + if tt.wantCode == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + assert.Equal(t, tt.wantCode, localErr.Code) + if tt.wantCode == exterrors.CodeInfraEjectProviderConflict { + assert.Contains(t, localErr.Message, "ignore the generated files") + assert.Contains(t, localErr.Suggestion, "--infra=terraform") + } }) } } @@ -896,7 +1172,7 @@ services: require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "myinfra"), 0o750)) t.Chdir(projectRoot) - _, err := resolveInfraGate() + _, err := resolveInfraGate("bicep") require.Error(t, err) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok, "expected *azdext.LocalError, got %T", err) @@ -933,6 +1209,169 @@ services: "a refused eject must not stamp infra.provider or drop the declared infra.path") } +func TestResolveInfraGate_RefusesInfraLayers(t *testing.T) { + tests := []struct { + name string + host string + }{ + {name: "init fall-through", host: "containerapp"}, + {name: "standalone eject", host: "azure.ai.project"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), fmt.Sprintf(`name: my-project +infra: + provider: terraform + layers: + - name: existing + path: infra/existing +services: + service: + host: %s +`, tt.host)) + t.Chdir(projectRoot) + + _, err := resolveInfraGate("terraform") + 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.CodeInfraEjectLayersUnsupported, localErr.Code) + assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) + }) + } +} + +func TestResolveInfraGate_RefusesBicepWithTerraformProvider(t *testing.T) { + tests := []struct { + name string + host string + }{ + {name: "init fall-through", host: "containerapp"}, + {name: "standalone eject", host: "azure.ai.project"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), fmt.Sprintf(`name: my-project +infra: + provider: terraform +services: + service: + host: %s +`, tt.host)) + t.Chdir(projectRoot) + + _, err := resolveInfraGate("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.CodeInfraEjectProviderConflict, localErr.Code) + assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) + }) + } +} + +func TestResolveInfraGate_RefusesCustomInfraModule(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + projectRoot := t.TempDir() + mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), `name: my-project +infra: + module: platform +services: + service: + host: containerapp +`) + t.Chdir(projectRoot) + + _, err := resolveInfraGate("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.CodeInfraEjectCustomModule, localErr.Code) + assert.NoDirExists(t, filepath.Join(projectRoot, "infra")) +} + +func TestEjectInfra_RefusesInfraLayers(t *testing.T) { + t.Parallel() + dir := t.TempDir() + azureYAML := `name: my-project +infra: + provider: terraform + layers: + - name: foundry + path: infra/foundry +services: + my-foundry: + host: azure.ai.project +` + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), azureYAML) + + err := ejectInfra(dir, "terraform") + 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.CodeInfraEjectLayersUnsupported, localErr.Code) + assert.NoDirExists(t, filepath.Join(dir, "infra")) + + raw, readErr := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec // G304: test path from t.TempDir() + require.NoError(t, readErr) + assert.Equal(t, azureYAML, string(raw), "a refused eject must not rewrite the provider or layers") +} + +func TestEjectInfra_RefusesCustomInfraModule(t *testing.T) { + t.Parallel() + for _, provider := range []string{"bicep", "terraform"} { + t.Run(provider, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + azureYAML := `name: my-project +infra: + module: platform +services: + my-foundry: + host: azure.ai.project +` + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), azureYAML) + + err := ejectInfra(dir, provider) + 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.CodeInfraEjectCustomModule, localErr.Code) + assert.NoDirExists(t, filepath.Join(dir, "infra")) + + raw, readErr := os.ReadFile(filepath.Join(dir, "azure.yaml")) //nolint:gosec // G304: test path + require.NoError(t, readErr) + assert.Equal(t, azureYAML, string(raw), "a refused eject must not rewrite infra.module") + }) + } +} + +func TestEjectInfra_BicepRefusesTerraformProvider(t *testing.T) { + t.Parallel() + dir := t.TempDir() + azureYAML := `name: my-project +infra: + provider: terraform +services: + my-foundry: + host: azure.ai.project +` + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), azureYAML) + + err := ejectInfra(dir, "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.CodeInfraEjectProviderConflict, localErr.Code) + assert.NoDirExists(t, filepath.Join(dir, "infra")) +} + // TestResolveInfraGate_ExistingProjectWithoutFoundryServiceRunsInit is the // regression test for #9124: `azd ai agent init --infra` inside an azd project // that has no Foundry service must fall through to the normal init flow rather @@ -948,7 +1387,7 @@ services: `) t.Chdir(projectRoot) - gate, err := resolveInfraGate() + gate, err := resolveInfraGate("bicep") require.NoError(t, err, "an existing project without a Foundry service must not refuse") assert.False(t, gate.standaloneEject, "init runs first, then the trailing eject") assert.Equal(t, projectRoot, gate.projectRoot) @@ -960,7 +1399,7 @@ func TestResolveInfraGate_ExistingFoundryProjectEjectsStandalone(t *testing.T) { mustWriteFile(t, filepath.Join(projectRoot, "azure.yaml"), validFoundryAzureYAML) t.Chdir(projectRoot) - gate, err := resolveInfraGate() + gate, err := resolveInfraGate("bicep") require.NoError(t, err) assert.True(t, gate.standaloneEject) assert.Equal(t, projectRoot, gate.projectRoot) @@ -970,7 +1409,7 @@ func TestResolveInfraGate_NoProjectRunsInit(t *testing.T) { t.Setenv("AZD_EXEC_PROJECT_DIR", "") t.Chdir(t.TempDir()) - gate, err := resolveInfraGate() + gate, err := resolveInfraGate("bicep") require.NoError(t, err) assert.False(t, gate.standaloneEject) assert.Empty(t, gate.projectRoot, "no project root to eject from yet") @@ -991,7 +1430,7 @@ services: require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "infra"), 0o750)) t.Chdir(projectRoot) - _, err := resolveInfraGate() + _, err := resolveInfraGate("bicep") require.Error(t, err) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok, "expected *azdext.LocalError, got %T", err) @@ -1016,7 +1455,7 @@ services: `) t.Chdir(projectRoot) - _, err := resolveInfraGate() + _, err := resolveInfraGate("bicep") require.Error(t, err) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok, "expected *azdext.LocalError, got %T", err) @@ -1096,7 +1535,7 @@ services: `) t.Chdir(projectRoot) - gate, err := resolveInfraGate() + gate, err := resolveInfraGate("bicep") require.NoError(t, err) require.False(t, gate.standaloneEject, "no Foundry service yet, so init has to run first") require.Equal(t, projectRoot, gate.projectRoot) diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index ab626b971f8..3f14ee62873 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -232,6 +232,9 @@ const ( CodeInfraEjectNetworkUnsupported = "infra_eject_network_unsupported" CodeInfraEjectBrownfieldUnsupported = "infra_eject_brownfield_unsupported" CodeInfraEjectCustomInfraPath = "infra_eject_custom_infra_path" + CodeInfraEjectLayersUnsupported = "infra_eject_layers_unsupported" + CodeInfraEjectProviderConflict = "infra_eject_provider_conflict" + CodeInfraEjectCustomModule = "infra_eject_custom_module" ) // Operation names for the microsoft.foundry provisioning provider.