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..436e7f4533c 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,28 @@ 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(infraProvider) + if gateErr != nil { + return gateErr } - 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 { + if gate.standaloneEject { + // 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(projectRoot, infraProvider) + return ejectInfra(gate.projectRoot, infraProvider) } } @@ -1575,7 +1580,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..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" ) @@ -33,22 +36,87 @@ 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. -func validateStandaloneEjectArgs(args []string, flags *initFlags) error { - if len(args) == 0 && flags.manifestPointer == "" && flags.src == "" && flags.image == "" { +// standalone-eject branch would silently drop. `--infra` on a project that +// already declares a Foundry service runs eject only; honoring a positional +// 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(cmd *cobra.Command, args []string) error { + conflicts := standaloneEjectConflictingInputs(cmd, args) + if len(conflicts) == 0 { 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", - "drop the extra argument and run `azd ai agent init --infra` from the project root, "+ + "`--infra` on a project that already declares a Foundry service runs eject only "+ + 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 @@ -68,6 +136,293 @@ 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 +} + +// 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. +// +// "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 + } + + 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 { + return false, nil + } + return false, err + } + + return true, nil +} + +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 declaredInfraConfig `yaml:"infra"` + } + if err := yaml.Unmarshal(rawYAML, &doc); err != nil { + return declaredInfraConfig{}, exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("parse azure.yaml: %s", err), + "verify azure.yaml is valid YAML", + ) + } + + 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 +// 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 { + config, err := readDeclaredInfraConfig(rawYAML) + if err != nil { + return err + } + + declared := config.Path + if declared == "" || normalizeInfraPath(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), + ) +} + +// 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. +// +// 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 { + // 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 + } + 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. 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 + } + if err != nil { + return infraGate{}, fmt.Errorf("resolve azd project directory: %w", err) + } + + 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 := 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 + } + + // 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 +439,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) } @@ -116,23 +467,17 @@ 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. 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) @@ -140,15 +485,25 @@ func ejectInfra(projectRoot, provider string) error { return err } - 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) + // 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 { + 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..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,14 +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" @@ -77,7 +81,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")) @@ -537,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", "") + } + for name, value := range tt.changed { + require.NoError(t, cmd.Flags().Set(name, value)) } - err := validateStandaloneEjectArgs(tt.args, flags) + + err := validateStandaloneEjectArgs(cmd, tt.args) if !tt.wantError { assert.NoError(t, err) return @@ -583,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 { @@ -713,6 +784,827 @@ 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) + }) + + 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 +// 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 + 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", + 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.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.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") + } + }) + } +} + +// 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("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.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") +} + +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 +// 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("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) +} + +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("bicep") + 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("bicep") + 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("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.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("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) +} + +// 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("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) + + // 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 +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() 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..3f14ee62873 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,10 @@ const ( CodeInfraEjectConflictingArguments = "infra_eject_conflicting_arguments" 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.