diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 941dd97a5f9..481723b484f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -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. - [[#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://.services.ai.azure.com/api/projects//agents//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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 27003c6f9c3..5ebf4fb6597 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -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. @@ -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 + } data, err := json.MarshalIndent(doc, "", " ") if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index ab1aa5fb992..bcf9240c3ca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go @@ -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() { @@ -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"]) @@ -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) { @@ -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"), } @@ -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) { + // 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) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index d72300a5f80..2440f4b73c9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -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 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/connections.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/connections.tf new file mode 100644 index 00000000000..4e881d335e1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/connections.tf @@ -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 ? { + properties = { + credentials = each.value.credentials + } + } : null +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl index fa6bcc7635e..4e5c180fffc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl @@ -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 diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf index cab146a2bcf..3f9e0b55f21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf @@ -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) + 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