feat(agents): create Foundry connections at provision time#9046
Conversation
The microsoft.foundry synthesizer now parses host: azure.ai.connection
services from azure.yaml and creates them as project connections during
azd provision, for both greenfield and brownfield (endpoint:) projects.
- synthesizer: collect azure.ai.connection services into a connections
param, resolving ${VAR} on the provision path and keeping it verbatim
on eject (--infra), while preserving Foundry ${{...}} expressions
- add modules/connections.bicep (a for-loop over the connections param,
pinned to 2025-04-01-preview like the ACR connection) and wire it
through main.bicep + resources.bicep; --infra ejects it automatically
- brownfield: BrownfieldConnections + brownfield.bicep connections loop
so existing projects reach the same (all-auth-type) coverage
- emit AZURE_AI_PROJECT_CONNECTION_NAMES as a comma-joined string
(not an array) so it round-trips through .env without JSON
double-encoding
- regenerate main.arm.json and brownfield.arm.json
Foundry connections declared as host: azure.ai.connection services are now created at provision time by the microsoft.foundry provider, so the deploy-time service target no longer upserts them. This removes the redundant ARM write on every azd deploy and makes provision the single source of truth, mirroring the azure.ai.project target (also a no-op because the project is provisioned by the same provider). - Deploy returns an empty result with an informational message; the target still owns the azure.ai.connection host so azd can walk the entry in the deploy graph (uses: ordering for toolboxes/agents) - drop the now-dead upsert helpers (parse/env/credential/metadata) and replace their tests with no-op coverage Depends on the agents change (provision-time synthesis) landing first so connections are always created by provisioning.
ARM's management SDK mangles output-name casing (e.g. AZURE_AI_PROJECT_ID comes back as azurE_AI_PROJECT_ID). armOutputsToProto repairs known names via a case-insensitive match against canonicalOutputNames, but the new AZURE_AI_PROJECT_CONNECTION_NAMES output was missing from that list, so it landed in .env as azurE_AI_PROJECT_CONNECTION_NAMES instead of the canonical name. Found live while running the e2e test plan for provision-time connections. Add the missing entry and a matching case in TestArmOutputsToProto_RepairsMangledKeyCase.
Both files are dead weight in a greenfield eject: ejectInfra already refuses to eject a brownfield (endpoint:) project, main.bicep has no module reference to brownfield.bicep, and the provider's brownfield deploy/preview path always loads the embedded synthesis.BrownfieldARMTemplate() rather than anything under infra/ - even after later adding endpoint: while infra/main.bicep still exists on disk. Shipping them in every eject only invited confusion about whether they were required. writeEmbeddedTemplates now excludes brownfield.bicep and brownfield.arm.json the same way it already excluded main.arm.json. Verified live: a fresh --infra eject no longer writes either file.
…ns are pending deployBrownfield's progress-message switch only covered deployments+ACR/ACR-only/default, so a brownfield project declaring just a connection (no deployments, no ACR) printed "reconciling declared model deployments..." even though zero deployments existed. Found live while testing brownfield connection creation. Replace the switch with brownfieldReconcileMessage(hasDeployments, createACR, hasConnections), which names only what's actually pending. Callers only reach it when at least one is true (the earlier guard skips provisioning entirely otherwise), so the message never claims work that isn't happening. Add TestBrownfieldReconcileMessage covering all 7 reachable combinations, including the connections-only regression case. Verified live: provisioning now prints "reconciling connections..." for that case.
No logic changes -- reviewed the full branch diff against origin/main for
unnecessary code and comment verbosity.
- azure.ai.connections/service_target.go: the same fact ("connections are
created at provision by microsoft.foundry, so Deploy is a no-op") was
stated three times (const, struct, and method doc comments). Kept the full
explanation on Deploy only; trimmed the other two to brief pointers.
- synthesizer.go: shortened the Connection type, BrownfieldConnections, and
collectConnections doc comments to match the concision of sibling
functions (e.g. BrownfieldDeployments).
- foundry_provisioning_provider.go: trimmed brownfieldReconcileMessage's
doc comment.
- init_infra.go (+ matching test comment): trimmed the brownfield.bicep
exclusion rationale, which had grown to a 13-line bullet repeating detail
already covered by the commit history.
No unnecessary/dead code was found beyond comments -- the connectionType
Bicep duplication across main.bicep/resources.bicep/brownfield.bicep/
connections.bicep matches the pre-existing deploymentType pattern (Bicep has
no cross-file type import), and the Connection/connectionService type split
in synthesizer.go mirrors existing splits like agentBlock/serviceBlock.
Verified: go build, full test suite, gofmt, and golangci-lint all pass
unchanged on both azure.ai.agents and azure.ai.connections.
There was a problem hiding this comment.
Pull request overview
This PR moves Foundry project connection creation (services declared with host: azure.ai.connection) from deploy time to provision time via Bicep, for both greenfield and brownfield projects. Previously, connections were only created during azd deploy and only for the None/ApiKey/CustomKeys auth subset, so OAuth2/identity connections were silently skipped and azd provision alone never produced a usable connection. The new approach synthesizes a connections ARM parameter, supports every auth type, resolves ${VAR} on provision (preserving it on --infra eject), and makes the azure.ai.connection deploy step a no-op (mirroring azure.ai.project).
Changes:
- Synthesizer collects
host: azure.ai.connectionservices into aconnectionsparam (greenfield via newmodules/connections.bicep; brownfield inline), with${VAR}expansion and Foundry${{...}}pass-through; addsBrownfieldConnectionsand a newAZURE_AI_PROJECT_CONNECTION_NAMESoutput. azure.ai.connectionservice targetDeploybecomes a no-op; dead upsert helpers and their tests are removed.- Provider wires brownfield connections into
brownfieldParams, addsbrownfieldReconcileMessage(fixes a misleading "reconciling model deployments" line), repairs the new output's ARM key casing, and excludesbrownfield.bicep/brownfield.arm.jsonfrom greenfield--infraeject.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
azure.ai.connections/internal/cmd/service_target.go |
Deploy is now a no-op; removes upsert helpers (no remaining references). |
azure.ai.connections/internal/cmd/service_target_test.go |
Replaces upsert tests with no-op assertions for Deploy/Package/Publish/Endpoints. |
azure.ai.agents/.../templates/modules/connections.bicep |
New module creating one connection resource per entry (2025-04-01-preview). |
azure.ai.agents/.../templates/modules/resources.bicep |
Adds connection types/param, conditional module, and connection-names output. |
azure.ai.agents/.../templates/main.bicep |
Threads connections param and output through to the resources module. |
azure.ai.agents/.../templates/main.arm.json |
Recompiled ARM (guarded by schema_test.go). |
azure.ai.agents/.../templates/brownfield.bicep |
Adds inline project connections on the existing project + output. |
azure.ai.agents/.../templates/brownfield.arm.json |
Recompiled brownfield ARM (guarded by schema_test.go). |
azure.ai.agents/internal/synthesis/synthesizer.go |
Adds Connection, collectConnections, BrownfieldConnections, and ${VAR} expand helpers. |
azure.ai.agents/internal/synthesis/synthesizer_test.go |
Covers collect/sort, resolve vs. preserve, missing-var, brownfield. |
azure.ai.agents/internal/project/foundry_provisioning_provider.go |
Captures/wires brownfield connections; adds brownfieldReconcileMessage; adds output to canonicalOutputNames. |
azure.ai.agents/internal/project/foundry_provisioning_provider_test.go |
Adds key-case repair assertion for the new output. |
azure.ai.agents/.../foundry_provisioning_provider_brownfield_acr_test.go |
Tests brownfield params + reconcile-message matrix. |
azure.ai.agents/internal/cmd/init_infra.go |
Excludes brownfield.bicep/brownfield.arm.json from eject. |
azure.ai.agents/internal/cmd/init_infra_test.go |
Asserts brownfield files excluded and connection services ejected verbatim. |
Note (not in this diff): the azure.ai.connections CHANGELOG.md entry for #8818 still describes the removed deploy-time upsert behavior; a follow-up changelog update would keep it accurate.
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
📋 Prioritization NoteThanks for the contribution! The linked issue isn't in the current milestone yet. |
Summary
Foundry project connections declared via
host: azure.ai.connectionwere only ever created atazd deploy(and only forNone/ApiKey/CustomKeys), so OAuth2 and identity-based connections were silently skipped andazd provisionalone never produced a usable connection. This moves connection creation to provision time via Bicep, for both greenfield and brownfield projects, and makesazd deploya no-op for connections.Changes
synthesis/synthesizer.go+ newmodules/connections.bicep:Synthesize/BrownfieldConnectionscollecthost: azure.ai.connectionservices into aconnectionsparam, resolving${VAR}on provision and preserving it on--infraeject; supports every auth type, not just the deploy-time subset.azure.ai.connections/service_target.go:Deployis now a no-op -- connections are provisioned bymicrosoft.foundry, mirroringazure.ai.project's existing no-op. Removed the now-dead upsert helpers and their tests.foundry_provisioning_provider.go: repairedAZURE_AI_PROJECT_CONNECTION_NAMEScasing (canonicalOutputNames) and fixed a misleading brownfield progress message that claimed "reconciling model deployments" even with zero deployments declared (brownfieldReconcileMessage).init_infra.go: excludesbrownfield.bicep/brownfield.arm.jsonfrom--infraeject -- dead weight in a greenfield tree, never compiled or read by the brownfield path.