Fix azd ai agent init --infra on an existing project without an agent service - #9407
Fix azd ai agent init --infra on an existing project without an agent service#9407glharper wants to merge 2 commits into
azd ai agent init --infra on an existing project without an agent service#9407Conversation
…nt 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
📋 Prioritization NoteThanks for the contribution! The linked issue isn't in the current milestone yet. |
|
Azure Pipelines: Successfully started running 1 pipeline(s). 21 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Updates azd ai agent init --infra to initialize existing projects without Foundry services before ejecting infrastructure.
Changes:
- Adds an infrastructure-flow decision gate.
- Prevents destructive handling of existing
infra/. - Adds decision-matrix and regression tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
internal/cmd/init.go |
Integrates the new gate and updates help text. |
internal/cmd/init_infra.go |
Implements gate and shared validation helpers. |
internal/cmd/init_infra_test.go |
Adds helper and regression coverage. |
jongio
left a comment
There was a problem hiding this comment.
One thing worth checking on the new fall-through path.
The gate treats "this project already owns infrastructure" as "./infra/ exists", but azd lets a project point elsewhere with infra.path in azure.yaml. Such a project passes the gate, and on --infra=terraform the trailing eject silently drops that path key. Details inline.
jongio
left a comment
There was a problem hiding this comment.
Low
init.go:1145/init_infra.go:36-45: thevalidateStandaloneEjectArgsrefusal text and its doc comment still describe the pre-PR scope ("on an existing project"), which now contradicts the fall-through.
My earlier note on infra.path and the --infra=terraform path is still open, and the test-coverage gap on the fall-through is already covered above.
Addresses PR review on #9407. - Refuse `--infra` when azure.yaml declares a non-default `infra.path`. ensureInfraDirAbsent only stats <root>/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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go:163
- [azd-code-reviewer] This compares the raw YAML path with host-OS semantics, so a valid Windows-style default such as
infra.path: .\infrais rejected on Linux/macOS. Core azd explicitly normalizes backslash-only paths beforefilepath.FromSlash(cli/azd/pkg/project/project.go:102-106); apply the same normalization here so the gate is consistent across platforms.
if declared == "" || filepath.Clean(declared) == defaultInfraDirName {
cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go:190
- [azd-code-reviewer] A dangling
./infrasymlink is treated as absent becauseos.Statfollows it and returnsIsNotExist. On the new fall-through, init can then mutateazure.yaml; the trailing eject fails atMkdirAll, and its cleanup removes the user's symlink. Useos.Lstatso any existing directory entry is refused, and add a dangling-symlink case to the helper tests.
if _, err := os.Stat(filepath.Join(projectRoot, defaultInfraDirName)); err != nil {
cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go:234
- [azd-code-reviewer] This adds a new externally observable error code, while the PR description explicitly states “no new error codes.” Keep the change contract accurate by either documenting this new classification in the PR description or reusing an existing code if consumers should not observe a new one.
CodeInfraEjectCustomInfraPath = "infra_eject_custom_infra_path"
cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go:146
- [azd-code-reviewer] Trimming a configured filesystem path can make a distinct path look like the default. For example,
infra.path: " infra "is preserved by YAML/core azd but becomesinfrahere, so Bicep eject is allowed to write./infrawhile provisioning still targets the space-padded directory. Compare the literal path and reject it instead of trimming it.
This issue also appears in the following locations of the same file:
- line 163
- line 190
return strings.TrimSpace(doc.Infra.Path)
Fixes #9124
Problem
azd ai agent init --infratreated any existingazure.yamlas a standalone eject. Running it in a project azd already managed, but that had no Foundry service yet, failed outright:As the issue puts it, this meant a user trying to get infra files for an existing project could not do it in a single step.
Fix
The
--infragate is extracted intoresolveInfraGate()and now branches on whetherazure.yamlalready declares a Foundry service:The fall-through needs no new eject call — every init exit path already ends in
ejectInfraAfterInit(infraProvider), which synthesizes./infra/once init has added the service.validateStandaloneEjectArgsis now scoped to the standalone-eject branch only, so a positional path /-m/--src/--imagestay valid on the fall-through, where they genuinely drive init. That matches how they were already accepted on the greenfield--infrapath. Its refusal message and doc comment are narrowed to match ("on a project that already declares a Foundry service"), since the old "on an existing project" wording now described the opposite of what the fall-through does with those inputs.Pre-existing
./infra/The fall-through opens a new "walk the user through the entire init flow, then fail on the last step" window, so the
./infra/refusal now fires up front in the gate rather than only insideejectInfra— failing beforeazure.yamlis mutated.That refusal is also reworded. Eject writes no marker, so nothing can distinguish its own prior output from IaC the user authored for the project's other services — and the old suggestion told users to delete the directory unconditionally. That is destructive advice for the exact
azd init -t <template>projects this issue is about, which ship their owninfra/. The suggestion now leads with the non-destructive option and conditions the delete:(Detecting eject provenance was considered and rejected:
abbreviations.json,main.bicepandmodules/are standard azd template paths, so probing for them would false-positive on precisely the projects at issue.)Projects that keep their IaC elsewhere (
infra.path)An azd project can point provisioning at a directory other than
./infra/viainfra.path. Statting<root>/infracannot see that — the whole point ofinfra.pathis that./infra/is absent — so such a project sailed through the gate. On--infra=terraformthe trailingstampInfraProviderthen setprovider: terraformand deletedinfra.path, aiming provisioning at the Foundry-only Terraform module and leaving the user's real IaC orphaned on disk. The hardcodedinfraname predates this PR; the fall-through is what makes it reachable from arbitrary azd projects.ensureDefaultInfraPathnow refuses a non-defaultinfra.pathwithinfra_eject_custom_infra_path, before anything is written:infra.pathwould otherwise still be deleted by the provider stamp.resolveInfraGatebefore the branch, so the standalone eject is covered too, and again insideejectInfraforejectInfraAfterInitand direct callers.path: infra/path: ./infraare the default and are not refused; the./infra/check already covers them.azure.yamlkeeps its existingCodeInvalidAzureYamlclassification: the service scan runs first, and the path read returns""on a parse failure.Notes
cli/azdcore changes.infra_eject_custom_infra_path.ejectInfra's refusal order isazure.yamlmissing -> no/multiple Foundry service -> non-defaultinfra.path->./infra/exists; only theCodeInfraEjectExistssuggestion text differs from before.--infraflag help text updated, which previously said "When azure.yaml already exists, runs as a standalone eject" and now contradicted the behavior.CHANGELOG.mdentry, per the repo's two-PR release process.Testing
New coverage in
init_infra_test.go:TestResolveInfraGate_*— the decision matrix: existing non-Foundry project falls through, Foundry project ejects standalone, no project falls through, pre-existing./infra/refuses up front, a non-defaultinfra.pathrefuses on both branches, multiple Foundry services propagate.TestEnsureDefaultInfraPath— default / dot-prefixed / nested / absent / malformedinfra.path.TestEjectInfra_Terraform_RefusesCustomInfraPath— assertsazure.yamlis byte-identical after the refusal, proving neither theprovider:stamp nor thepath:removal happened.TestHasFoundryServiceForEject—(false, nil)vs. error classification, incl. legacy hosts, missing and malformedazure.yaml.TestEnsureInfraDirAbsent— absent / directory / regular file.TestInitInfra_ExistingProjectWithoutFoundryServiceFallsThroughToInit— end-to-end through cobra with no pre-existinginfra/, so it exercises the fall-through itself: asserts no gate refusal fires, that the run reaches the azd host RPCs inside init, and that neither./infra/norazure.yamlwas touched.TestInitInfra_FallThroughEjectsAfterInitAddsFoundryService— the payoff: gate (asserted as fall-through) -> theazure.yamlmutation init performs when it adds the agent service ->ejectInfraAfterInit, assertinginfra/main.bicepandinfra/main.parameters.jsonare generated and the project's pre-existing service survives. Driving the real init to completion here isn't reachable — it needs a live azd host for prompts/model catalog,azd auth statusas a subprocess, and a network template fetch — so that belongs in the functional suite.TestInitInfra_ExistingProjectWithPreexistingInfraRefusesUpFront— command-levelCodeInfraEjectExists, verified red against the old gate (it reproducedinfra_eject_no_foundry_serviceexactly) and green after the fix.Verified locally:
go build ./...,gofmt -s -l .clean,golangci-lint run ./internal/...0 issues,cspellclean, and the fullgo test ./... -count=1suite for the extension passing.