Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions docs/specs/agent-init-infra-layers/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# Foundry Infrastructure Layers for `azd ai agent init --infra`

## Summary

`azd ai agent init --infra` cannot currently generate Foundry IaC in an azd
project that already has an `infra/` directory. Both the existing project and
the generated Foundry templates expect to own the same deployment entry point.

This design preserves the project's existing infrastructure and adds Foundry
infrastructure as a separate provisioning layer under `infra/foundry`.
Foundry-only projects keep the existing simple `infra/` layout.

> `--infra` adds editable Foundry infrastructure without merging into or
> overwriting infrastructure the user already owns.

Source issue: [Azure/azure-dev#9126](https://github.com/Azure/azure-dev/issues/9126).

## Problem

Developers commonly add a Foundry agent to an existing web application or API.
When that application already contains Bicep or Terraform, eject fails:

```text
ERROR: `./infra/` already exists
```

Deleting `infra/` loses application infrastructure. File-level merging is not
safe because both trees normally contain `main.bicep` or Terraform entry-point
files. Resource-level merging would require azd to understand and rewrite
arbitrary user-authored IaC.

## Goals

1. Add editable Foundry IaC to projects with existing infrastructure.
2. Preserve existing files and configuration.
3. Support Bicep and Terraform eject.
4. Avoid semantic IaC merging and silent overwrites.
5. Keep Foundry-only projects backward-compatible.

## Proposed Experience

The command surface is unchanged:

| Command | Result |
|---|---|
| `azd ai agent init --infra` | Eject Bicep |
| `azd ai agent init --infra=bicep` | Eject Bicep explicitly |
| `azd ai agent init --infra=terraform` | Eject Terraform |

### Behavior by project shape

| Project shape | Behavior |
|---|---|
| New or Foundry-only project | Generate the existing root layout under `infra/` |
| Existing single-layer Bicep or Terraform project | Preserve it as a layer and add `foundry` at `infra/foundry` |
| Existing custom/fileless provider | Preserve it as a layer and add `foundry` |

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.

fileless is what's breaking cspell-lint on this PR. It isn't in .vscode/cspell.global.yaml, there's no override covering docs/specs/agent-init-infra-layers/, and the word doesn't appear anywhere else in the repo.

The repo pattern for one-off terms is a file-scoped override in .vscode/cspell.misc.yaml:

    - filename: ./docs/specs/agent-init-infra-layers/**
      words:
          - fileless

Alternatively reword the row, if there's already an established term for a provider that ships no local templates.

| Existing layered project without `foundry` | Append a `foundry` layer; preserve existing layer bodies |
| Existing compatible `foundry` layer with empty target | Generate into its declared `path` and `module` |
| Existing `foundry` layer with a different provider | Refuse; do not convert providers implicitly |
| Existing `infra/foundry` directory without a layer | Add only non-conflicting files; refuse generated-file collisions |
| Existing files in the declared `foundry` target | Refuse without overwriting |
| Brownfield project with `endpoint:` | Refuse eject; existing resource remains externally owned |
| Terraform request with private `network:` | Refuse and recommend Bicep |

A folder and a layer are separate signals. An `infra/foundry` folder does not
authorize azd to overwrite its contents. An existing `foundry` layer is reused
only when its provider matches the requested format and generated files do not
already exist.

### Example migration

Before:

```yaml
infra:
provider: bicep
```

After `azd ai agent init --infra`:

```yaml
infra:
layers:
- name: infra
path: infra

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.

The migration example only covers a project using the defaults, so it doesn't say what happens to infra.path and infra.module.

Both schemas hard-forbid those keys once layers is non-empty. From schemas/v1.0/azure.yaml.json:

"allOf": [{
  "if":   { "required": ["layers"], "properties": { "layers": { "minItems": 1 } } },
  "then": { "properties": { "path": false, "module": false } }
}]

schemas/alpha/azure.yaml.json adds "deploymentStacks": false to the same then.

So for a project like this:

infra:
  provider: bicep
  path: iac
  module: deploy

the migration can't leave those at the root, it has to move them into the preserved layer. This example hardcodes path: infra, which is the value an implementer will copy, and the layer module defaults to main. Follow the example literally and that project's templates at iac/deploy.bicep stop being found, with a valid-looking azure.yaml and no error.

The already-layered path says "preserve existing layer bodies", but the root-to-layers path never states the equivalent. Worth saying explicitly that path, module, and deploymentStacks carry into the preserved layer, and adding a Safety Rule for it, since none of the eight listed cover field carryover.

provider: bicep
- name: foundry
path: infra/foundry
provider: microsoft.foundry
```

The existing `infra/main.bicep` remains unchanged. Foundry files are generated
under `infra/foundry/`.

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.

The motivating scenario is "add a Foundry agent to an existing web application or API," but the design stops at file layout and resource-group ownership. It never says how the existing infra layer or the app's services get the Foundry outputs (project endpoint, connection info) once Foundry sits in its own layer with its own resource group.

That's the part the user actually cares about after eject. If the wiring is expected to be manual, it belongs in Out of scope. If azd is supposed to plumb it, it changes both the safety rules and the dependsOn direction above.


The Foundry layer is independent by default. Eject does not add `dependsOn`;
projects can declare a dependency explicitly when their Foundry template
actually consumes another layer's outputs.
Comment on lines +95 to +97
Comment on lines +95 to +97

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.

"Independent by default" is doing more work here than it reads, because azd's layer dependency analyzer doesn't see this layer at all.

isBicepLayer (cli/azd/pkg/infra/provisioning/bicep/layer_deps.go:24-27) returns true only for provider: bicep or an unset provider. microsoft.foundry is an extension provider, so the Foundry layer is skipped in both phases of AnalyzeLayerDependencies:

  • Phase 1 (outputs): its outputs never land in outputProviders.
  • Phase 2 (inputs): its .bicepparam / .parameters.json are never scanned, and the hasUnknown "depend on all earlier layers" fallback never fires for it.

The paragraph covers the direction where the Foundry template consumes another layer. The reverse direction is the one the Problem section actually motivates: an existing app layer that wants the Foundry project endpoint. That layer is bicep so it does get scanned, but a ${AZURE_AI_PROJECT_ENDPOINT} reference resolves to "no in-graph producer, externally supplied", no edge is added, and both layers fan out in parallel. On first provision the app layer reads an empty value.

mergeLayerOutputsLocked only logs a warning when two layers write the same key, so nothing fails loudly either.

Worth stating outright that no dependency is inferred in either direction for a microsoft.foundry layer, and that a layer consuming Foundry outputs has to declare dependsOn: [foundry] itself.


## Ownership and Lifecycle

Generated Bicep continues to use `microsoft.foundry`, with the layer's `path`
and `module` telling the provider where to load the ejected templates.
Generated Terraform uses the core `terraform` provider because Terraform state
and lifecycle are owned by azd core.

The Foundry layer owns an isolated resource group:

```text
rg-${AZURE_ENV_NAME}-foundry
```
Comment on lines +108 to +110

It publishes `AZURE_FOUNDRY_RESOURCE_GROUP` rather than replacing the existing
application's `AZURE_RESOURCE_GROUP`. This keeps targeted `azd down foundry`
Comment on lines +112 to +113
from deleting sibling-layer resources.

## Safety Rules

The command validates the full plan before updating the project:

- Exactly one Foundry provisioning service must exist.
- Brownfield `endpoint:` projects cannot eject greenfield IaC.
- Terraform cannot eject private networking until it reaches Bicep parity.
- Layer names and paths must not conflict.
- Paths must stay inside the project and cannot escape through symlinks.
- Module names cannot contain traversal, separators, or file extensions.
- Existing generated destinations are never overwritten.
- Files are generated in a temporary directory, installed atomically, and
removed if the `azure.yaml` update fails.

## Key Decisions

### 1. Layers instead of file merging

We preserve existing IaC as one layer and generate Foundry IaC as another.
File-level merging was rejected because deployment entry-point conflicts are
semantic, not only textual.

### 2. Isolated resource-group ownership

The Foundry layer receives its own resource group instead of sharing
`AZURE_RESOURCE_GROUP`. This avoids Terraform import conflicts and destructive
cross-layer teardown.

### 3. Fail closed on rerun

If generated files already exist, eject refuses rather than regenerating or
using `--force`. Once ejected, IaC is user-owned and may contain intentional
customizations.

### 4. Preserve the simple project shape

Foundry-only projects continue using root `infra/`. Layers are introduced only
when composition requires them.

## Scope

### In scope

- Bicep and Terraform Foundry layers
- Migration from a root provider to `infra.layers`
- Appending to existing layered projects
- Path, module, provider, and file-conflict validation
- Isolated Foundry resource-group ownership

### Out of scope

- Semantic Bicep/Terraform merging
- Overwriting or regenerating user-owned IaC
- Terraform private networking
- Brownfield IaC generation
- Merging a unified sample `azure.yaml` into an existing project
([#8884](https://github.com/Azure/azure-dev/issues/8884))

## Rollout and Validation

Existing projects are unchanged until users explicitly run `--infra`. The
stable and alpha `azure.yaml` schemas add per-layer `provider` support.

Automated coverage includes:

- Root Bicep/Terraform migration
- Existing layered and custom-provider projects
- Existing `foundry` layer and folder conflicts
- Bicep/Terraform generation and complete non-interactive parameters
- Path traversal, symlink, module, brownfield, and networking failures
- Atomic install and rollback behavior
- Bicep compilation and agents/projects template parity

Before release, manually validate:

1. Existing Bicep app -> eject -> provision -> `azd down foundry`.
2. Existing Terraform app -> eject -> provision -> `azd down foundry`.
3. Existing mixed-layer project -> eject -> targeted preview/provision.
4. Foundry-only project -> root eject regression.
5. Rerun after editing generated files -> non-destructive refusal.

## Open Questions

1. **Should rerun support regeneration?** Proposed: no, until there is an
explicit backup/diff workflow.
2. **Should resource-group naming become an init flag?** Proposed: no; users
can edit generated parameters without expanding the init surface.
3. **Should all agent projects use layers?** Proposed: no; introduce layers
only when composing with existing infrastructure.

## References

- [Issue #9126](https://github.com/Azure/azure-dev/issues/9126)
- [Bicep-less/eject RFC #8065](https://github.com/Azure/azure-dev/issues/8065)
- [Layered provisioning PR #5492](https://github.com/Azure/azure-dev/pull/5492)
- [Unified manifest adoption boundary #8884](https://github.com/Azure/azure-dev/issues/8884)
Loading