Skip to content

Fix azd ai agent init --infra on an existing project without an agent service - #9407

Open
glharper wants to merge 2 commits into
mainfrom
glharper/fix-9124-infra-init-existing-project
Open

Fix azd ai agent init --infra on an existing project without an agent service#9407
glharper wants to merge 2 commits into
mainfrom
glharper/fix-9124-infra-init-existing-project

Conversation

@glharper

@glharper glharper commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes #9124

Problem

azd ai agent init --infra treated any existing azure.yaml as a standalone eject. Running it in a project azd already managed, but that had no Foundry service yet, failed outright:

ERROR: no foundry provisioning service found in azure.yaml (looking for host in [azure.ai.project azure.ai.agent microsoft.foundry]); nothing to eject

Suggestion: add a service with `host: azure.ai.project` to azure.yaml, or remove --infra to run init normally

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 --infra gate is extracted into resolveInfraGate() and now branches on whether azure.yaml already declares a Foundry service:

Project state Behavior
Declares a Foundry service Standalone eject (unchanged)
Exists, no Foundry service New: falls through to the normal init flow, then ejects
No project at all Normal init flow, then ejects (unchanged)

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.

validateStandaloneEjectArgs is now scoped to the standalone-eject branch only, so a positional path / -m / --src / --image stay valid on the fall-through, where they genuinely drive init. That matches how they were already accepted on the greenfield --infra path. 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 inside ejectInfra — failing before azure.yaml is 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 own infra/. The suggestion now leads with the non-destructive option and conditions the delete:

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

(Detecting eject provenance was considered and rejected: abbreviations.json, main.bicep and modules/ 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/ via infra.path. Statting <root>/infra cannot see that — the whole point of infra.path is that ./infra/ is absent — so such a project sailed through the gate. On --infra=terraform the trailing stampInfraProvider then set provider: terraform and deleted infra.path, aiming provisioning at the Foundry-only Terraform module and leaving the user's real IaC orphaned on disk. The hardcoded infra name predates this PR; the fall-through is what makes it reachable from arbitrary azd projects.

ensureDefaultInfraPath now refuses a non-default infra.path with infra_eject_custom_infra_path, before anything is written:

azure.yaml points this project's infrastructure at "myinfra" via infra.path; --infra writes a self-contained Foundry template to ./infra/ and cannot take over infrastructure the project already owns

Suggestion: run azd ai agent init without --infra to add the agent while "myinfra" stays the project's infrastructure, or remove infra.path from azure.yaml first if you want the generated ./infra/ to replace it

  • The refusal is on the declaration, not the directory: a declared-but-not-yet-authored infra.path would otherwise still be deleted by the provider stamp.
  • It fires in resolveInfraGate before the branch, so the standalone eject is covered too, and again inside ejectInfra for ejectInfraAfterInit and direct callers.
  • path: infra / path: ./infra are the default and are not refused; the ./infra/ check already covers them.
  • Malformed azure.yaml keeps its existing CodeInvalidAzureYaml classification: the service scan runs first, and the path read returns "" on a parse failure.

Notes

  • Extension-only. No cli/azd core changes.
  • One new error code: infra_eject_custom_infra_path.
  • ejectInfra's refusal order is azure.yaml missing -> no/multiple Foundry service -> non-default infra.path -> ./infra/ exists; only the CodeInfraEjectExists suggestion text differs from before.
  • --infra flag help text updated, which previously said "When azure.yaml already exists, runs as a standalone eject" and now contradicted the behavior.
  • No CHANGELOG.md entry, 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-default infra.path refuses on both branches, multiple Foundry services propagate.
  • TestEnsureDefaultInfraPath — default / dot-prefixed / nested / absent / malformed infra.path.
  • TestEjectInfra_Terraform_RefusesCustomInfraPath — asserts azure.yaml is byte-identical after the refusal, proving neither the provider: stamp nor the path: removal happened.
  • TestHasFoundryServiceForEject(false, nil) vs. error classification, incl. legacy hosts, missing and malformed azure.yaml.
  • TestEnsureInfraDirAbsent — absent / directory / regular file.
  • TestInitInfra_ExistingProjectWithoutFoundryServiceFallsThroughToInit — end-to-end through cobra with no pre-existing infra/, 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/ nor azure.yaml was touched.
  • TestInitInfra_FallThroughEjectsAfterInitAddsFoundryService — the payoff: gate (asserted as fall-through) -> the azure.yaml mutation init performs when it adds the agent service -> ejectInfraAfterInit, asserting infra/main.bicep and infra/main.parameters.json are 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 status as a subprocess, and a network template fetch — so that belongs in the functional suite.
  • TestInitInfra_ExistingProjectWithPreexistingInfraRefusesUpFront — command-level CodeInfraEjectExists, verified red against the old gate (it reproduced infra_eject_no_foundry_service exactly) and green after the fix.

Verified locally: go build ./..., gofmt -s -l . clean, golangci-lint run ./internal/... 0 issues, cspell clean, and the full go test ./... -count=1 suite for the extension passing.

…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
@glharper
glharper requested a review from JeffreyCA as a code owner August 3, 2026 17:52
Copilot AI balanced review requested due to automatic review settings August 3, 2026 17:52
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📋 Prioritization Note

Thanks for the contribution! The linked issue isn't in the current milestone yet.
Thank you for logging this issue; our team is reviewing it. If you need urgent prioritization, tag @RickWinter and @kristenwomack to let us know.

@azure-pipelines

Copy link
Copy Markdown
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go
@github-actions github-actions Bot added the ext-agents azure.ai.agents extension label Aug 3, 2026

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low

  • init.go:1145 / init_infra.go:36-45: the validateStandaloneEjectArgs refusal 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.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
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
Copilot AI review requested due to automatic review settings August 4, 2026 13:51
@glharper
glharper requested a review from jongio August 4, 2026 13:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: .\infra is rejected on Linux/macOS. Core azd explicitly normalizes backslash-only paths before filepath.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 ./infra symlink is treated as absent because os.Stat follows it and returns IsNotExist. On the new fall-through, init can then mutate azure.yaml; the trailing eject fails at MkdirAll, and its cleanup removes the user's symlink. Use os.Lstat so 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 becomes infra here, so Bicep eject is allowed to write ./infra while 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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-agents azure.ai.agents extension

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Agents Extension] agent init --infra fails on existing azd project without agent service

3 participants