Skip to content
Open
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Other Changes

- [[#9112]](https://github.com/Azure/azure-dev/pull/9112) Terraform infrastructure eject now synthesizes
`azure.ai.connection` services into Foundry project connection resources, preserving their category, target,
authentication type, credentials, and metadata.
Comment thread
hund030 marked this conversation as resolved.
- [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://<account>.services.ai.azure.com/api/projects/<project>/agents/<agent>/endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path.

## 1.0.0-beta.5 (2026-07-09)
Expand Down
15 changes: 10 additions & 5 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,10 @@ func writeOutputsFile(infraDir string, includeAcr bool) (ejectArtifact, error) {

// writeTfvarsFile emits infra/main.tfvars.json. azd-core's Terraform provider
// reads this file and substitutes the ${...} placeholders from the azd
// environment at provision time. The synthesizer-known value `deployments` is
// written literally; deploy-time inputs (location, resource_group_name,
// foundry_project_name, principal_id, subscription_id, environment_name,
// resource_token_salt) are left as ${AZURE_*} placeholders.
// environment at provision time. The synthesizer-known values `deployments`
// and `connections` are written literally; deploy-time inputs (location,
// resource_group_name, foundry_project_name, principal_id, subscription_id,
// environment_name, resource_token_salt) are left as ${AZURE_*} placeholders.
//
// include_acr is NOT written: whether ACR is provisioned is decided at eject
// time by the presence of acr.tf, not by a Terraform variable.
Expand All @@ -608,10 +608,15 @@ func writeTfvarsFile(infraDir string, params map[string]any) (ejectArtifact, err
"resource_token_salt": "${AZURE_RESOURCE_TOKEN_SALT}",
}

// deployments is the only synthesizer-derived value written to tfvars.
// deployments and connections are the only synthesizer-derived values
// written to tfvars. The Terraform provider resolves ${VAR} references
// across the generated file at provision time.
if v, ok := params["deployments"]; ok {
doc["deployments"] = v
}
if v, ok := params["connections"]; ok {
doc["connections"] = v
Comment thread
hund030 marked this conversation as resolved.
}

data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,15 @@ services:
authType: ApiKey
credentials:
key: ${SEARCH_API_KEY}
mcp-conn:
host: azure.ai.connection
uses: [my-foundry]
category: RemoteTool
target: https://mcp.example.com
authType: CustomKeys
credentials:
keys:
x-api-key: ${MCP_KEY}
`)

withCapturedStdout(t, func() {
Expand All @@ -357,9 +366,9 @@ services:
require.Contains(t, doc.Parameters, "connections")
conns, ok := doc.Parameters["connections"].Value.([]any)
require.True(t, ok, "connections should be an array, got %T", doc.Parameters["connections"].Value)
require.Len(t, conns, 1)
require.Len(t, conns, 2)

conn, ok := conns[0].(map[string]any)
conn, ok := conns[1].(map[string]any)
require.True(t, ok, "connection entry should be an object, got %T", conns[0])
assert.Equal(t, "search-conn", conn["name"])
assert.Equal(t, "CognitiveSearch", conn["category"])
Expand All @@ -369,6 +378,15 @@ services:
creds, ok := conn["credentials"].(map[string]any)
require.True(t, ok, "credentials should be an object, got %T", conn["credentials"])
assert.Equal(t, "${SEARCH_API_KEY}", creds["key"])

// Nested CustomKeys credentials must remain an object so Terraform's
// optional(any) value can preserve mixed connection credential shapes.
mcpConn, ok := conns[0].(map[string]any)
require.True(t, ok, "connection entry should be an object, got %T", conns[0])
assert.Equal(t, "mcp-conn", mcpConn["name"])
mcpCreds := mcpConn["credentials"].(map[string]any)
keys := mcpCreds["keys"].(map[string]any)
assert.Equal(t, "${MCP_KEY}", keys["x-api-key"])
}

func TestEjectInfra_PreservesNetworkVarRefs(t *testing.T) {
Expand Down Expand Up @@ -621,6 +639,7 @@ func TestEjectInfra_Terraform_HappyPath_WritesExpectedFiles(t *testing.T) {
filepath.Join("infra", "variables.tf"),
filepath.Join("infra", "main.tf"),
filepath.Join("infra", "acr.tf"),
filepath.Join("infra", "connections.tf"),
filepath.Join("infra", "outputs.tf"),
filepath.Join("infra", "main.tfvars.json"),
}
Expand Down Expand Up @@ -716,6 +735,70 @@ func TestEjectInfra_Terraform_TfvarsShape(t *testing.T) {
deps, ok := doc["deployments"].([]any)
require.True(t, ok, "deployments should be an array, got %T", doc["deployments"])
require.Len(t, deps, 1)

// connections is always present too (empty here: the fixture declares
// none), so a project with no host: azure.ai.connection services still
// gets a well-typed empty list rather than a missing key.
conns, ok := doc["connections"].([]any)
require.True(t, ok, "connections should be an array, got %T", doc["connections"])
assert.Empty(t, conns)
}

func TestEjectInfra_Terraform_EjectsConnectionServices(t *testing.T) {
Comment thread
hund030 marked this conversation as resolved.
// Not parallel: captures os.Stdout.
// A host: azure.ai.connection service must be synthesized into the
// connections tfvars value, connections.tf must be part of the ejected
// tree, and any ${VAR} in credentials kept verbatim (environment-portable
// -- azd's Terraform provider substitutes ${...} at provision time).
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project
services:
my-foundry:
host: azure.ai.project
deployments: []
search-conn:
host: azure.ai.connection
uses: [my-foundry]
category: CognitiveSearch
target: https://my-search.search.windows.net
authType: ApiKey
credentials:
key: ${SEARCH_API_KEY}
`)

withCapturedStdout(t, func() {
require.NoError(t, ejectInfra(dir, "terraform"))
})

// connections.tf is part of the ejected tree.
_, err := os.Stat(filepath.Join(dir, "infra", "connections.tf"))
assert.NoError(t, err, "connections.tf must be ejected")

raw, err := os.ReadFile(filepath.Join(dir, "infra", "main.tfvars.json")) //nolint:gosec // G304: test file path from t.TempDir()
require.NoError(t, err)
var doc map[string]any
require.NoError(t, json.Unmarshal(raw, &doc))

conns, ok := doc["connections"].([]any)
require.True(t, ok, "connections should be an array, got %T", doc["connections"])
require.Len(t, conns, 1)

conn, ok := conns[0].(map[string]any)
require.True(t, ok, "connection entry should be an object, got %T", conns[0])
assert.Equal(t, "search-conn", conn["name"])
assert.Equal(t, "CognitiveSearch", conn["category"])
assert.Equal(t, "ApiKey", conn["authType"])

// ${VAR} in credentials must be preserved verbatim on the eject path.
creds, ok := conn["credentials"].(map[string]any)
require.True(t, ok, "credentials should be an object, got %T", conn["credentials"])
assert.Equal(t, "${SEARCH_API_KEY}", creds["key"])

// outputs.tf always carries the connection-names output, unconditional on
// includeAcr (unlike the ACR outputs).
outputs, err := os.ReadFile(filepath.Join(dir, "infra", "outputs.tf")) //nolint:gosec // G304: test path from t.TempDir()
require.NoError(t, err)
assert.Contains(t, string(outputs), "AZURE_AI_PROJECT_CONNECTION_NAMES")
}

func TestEjectInfra_Terraform_NoDockerOmitsAcr(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,7 @@ func TestTerraformTemplatesFS_Embedded(t *testing.T) {
"templates/terraform/variables.tf",
"templates/terraform/main.tf",
"templates/terraform/acr.tf",
"templates/terraform/connections.tf",
"templates/terraform/outputs.tf.tmpl",
}
for _, p := range wantFiles {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Foundry project connections declared as host: azure.ai.connection services
# in azure.yaml. One azapi_resource per entry.
#
# Provision-time equivalent of the deploy-time azure.ai.connection service
# target, but supports every auth type (the service target only upserts
# none/api-key/custom-keys). credentials/metadata pass through untouched.
#
# Pinned to 2025-04-01-preview: GA 2025-06-01 cannot resolve the
# projects/connections sub-resource (MissingApiVersionParameter), same as
# acr.tf's acr_connection.
resource "azapi_resource" "connection" {
for_each = { for c in var.connections : c.name => c }

type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview"
name = each.value.name
parent_id = azapi_resource.project.id

body = {
properties = merge(
{
category = each.value.category
target = each.value.target
authType = each.value.authType
},
each.value.metadata != null ? { metadata = each.value.metadata } : {}
)
}

sensitive_body = each.value.credentials != null ? {
Comment thread
hund030 marked this conversation as resolved.
properties = {
credentials = each.value.credentials
}
} : null
Comment thread
hund030 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ output "AZURE_OPENAI_ENDPOINT" {
output "FOUNDRY_PROJECT_ENDPOINT" {
value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}"
}

output "AZURE_AI_PROJECT_CONNECTION_NAMES" {
value = join(",", [for c in azapi_resource.connection : c.name])
}
{{ if .IncludeAcr }}
output "AZURE_CONTAINER_REGISTRY_ENDPOINT" {
value = azurerm_container_registry.this.login_server
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ variable "deployments" {
default = []
}

variable "connections" {
description = "Foundry project connections to create (host: azure.ai.connection services)."
type = list(object({
name = string
category = string
target = string
authType = string
credentials = optional(any)
Comment thread
hund030 marked this conversation as resolved.
metadata = optional(map(string))
}))
default = []
}

variable "principal_id" {
description = "Object id of the developer running azd. When empty, the developer role assignment is skipped."
type = string
Expand Down
Loading