From 42a2013f94b1bf2c96dacdd61e7552631ae19086 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Fri, 10 Jul 2026 16:04:38 +0800 Subject: [PATCH 1/3] feat(agents): synthesize Foundry connections in Terraform eject too The Bicep eject path already creates connections at provision time; the Terraform eject path silently dropped the same `connections` param. Adds `connections.tf` (an `azapi_resource` for_each, mirroring `acr.tf`'s `acr_connection`) and forwards `connections` through to main.tfvars.json. --- .../internal/cmd/init_infra.go | 18 +++-- .../internal/cmd/init_infra_test.go | 65 +++++++++++++++++++ .../internal/synthesis/synthesizer_test.go | 1 + .../templates/terraform/connections.tf | 29 +++++++++ .../templates/terraform/outputs.tf.tmpl | 4 ++ .../templates/terraform/variables.tf | 13 ++++ 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/connections.tf 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..265e6ae6fee 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,18 @@ 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. ${VAR} references nested inside connections' + // credentials/metadata (e.g. an API key) are resolved the same way as the + // top-level placeholders above: azd's Terraform provider substitutes + // ${...} over the whole file's raw text before parsing it, so nesting + // depth does not matter. 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..3660f874055 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 @@ -621,6 +621,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 +717,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..52d9dcb7fd7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/connections.tf @@ -0,0 +1,29 @@ +# 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.credentials != null ? { credentials = each.value.credentials } : {}, + each.value.metadata != null ? { metadata = each.value.metadata } : {} + ) + } +} 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..6a896bb3885 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(map(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 From ff820d5db46c969040a9e3b995b85eec5b825719 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Tue, 14 Jul 2026 11:41:57 +0800 Subject: [PATCH 2/3] fix(terraform): preserve connection credentials --- .../extensions/azure.ai.agents/CHANGELOG.md | 3 + .../internal/cmd/init_infra_test.go | 22 +++++++- .../templates/terraform/connections.tf | 7 ++- .../templates/terraform/variables.tf | 2 +- .../terraform/terraform_provider.go | 55 ++++++++++++++++++- .../terraform/terraform_provider_test.go | 43 +++++++++++++++ 6 files changed, 126 insertions(+), 6 deletions(-) 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_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_test.go index 3660f874055..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) { 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 index 52d9dcb7fd7..4e881d335e1 100644 --- 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 @@ -22,8 +22,13 @@ resource "azapi_resource" "connection" { target = each.value.target authType = each.value.authType }, - each.value.credentials != null ? { credentials = each.value.credentials } : {}, 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/variables.tf b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf index 6a896bb3885..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 @@ -65,7 +65,7 @@ variable "connections" { category = string target = string authType = string - credentials = optional(map(any)) + credentials = optional(any) metadata = optional(map(string)) })) default = [] diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 3c86efc0055..757ff53440d 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -16,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -730,13 +731,14 @@ func (t *TerraformProvider) createInputParametersFile( if err != nil { return fmt.Errorf("reading parameter file template: %w", err) } - replaced, err := envsubst.Eval(string(parametersBytes), func(name string) string { + lookup := func(name string) string { if name == environment.PrincipalIdEnvVarName { return principalId } return t.env.Getenv(name) - }) + } + replaced, err := substituteInputParameters(templateFilePath, parametersBytes, lookup) if err != nil { return fmt.Errorf("substituting parameter file: %w", err) @@ -757,6 +759,55 @@ func (t *TerraformProvider) createInputParametersFile( return nil } +// substituteInputParameters expands strings in JSON parameter files without corrupting JSON values or Foundry expressions. +// Backend configuration files remain raw Terraform syntax and use envsubst directly. +func substituteInputParameters(templateFilePath string, parameters []byte, lookup func(string) string) (string, error) { + if filepath.Ext(templateFilePath) != ".json" { + return envsubst.Eval(string(parameters), lookup) + } + + var value any + if err := json.Unmarshal(parameters, &value); err != nil { + return "", fmt.Errorf("parsing JSON parameters: %w", err) + } + if err := expandJSONValue(&value, lookup); err != nil { + return "", err + } + + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", fmt.Errorf("marshaling JSON parameters: %w", err) + } + return string(append(data, '\n')), nil +} + +// expandJSONValue recursively expands JSON string leaves while preserving Foundry ${{...}} expressions. +func expandJSONValue(value *any, lookup func(string) string) error { + switch v := (*value).(type) { + case string: + expanded, err := foundry.ExpandEnv(v, lookup) + if err != nil { + return fmt.Errorf("expanding JSON parameter: %w", err) + } + *value = expanded + case []any: + for i := range v { + if err := expandJSONValue(&v[i], lookup); err != nil { + return err + } + } + case map[string]any: + for key := range v { + item := v[key] + if err := expandJSONValue(&item, lookup); err != nil { + return err + } + v[key] = item + } + } + return nil +} + // terraformShowOutput is a model type for the output of `terraform show` for a tfstate file. // see https://www.terraform.io/internals/json-format#state-representation for more information on the shape // of the JSON data diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index f62a474174b..d67f3778e1e 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -6,6 +6,7 @@ package terraform import ( "context" _ "embed" + "encoding/json" "fmt" "os" osexec "os/exec" @@ -27,10 +28,52 @@ import ( "github.com/azure/azure-dev/cli/azd/test/mocks/mockaccount" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +func TestSubstituteInputParameters_JSON(t *testing.T) { + t.Parallel() + + input := []byte(`{ + "connections": [{ + "credentials": { + "key": "${API_KEY}", + "nested": { "x-api-key": "${API_KEY}" }, + "foundry": "${{connections.other.credentials.key}}" + } + }] +}`) + replaced, err := substituteInputParameters("main.tfvars.json", input, func(name string) string { + if name == "API_KEY" { + return "quote\" slash\\ newline\n" + } + return "" + }) + require.NoError(t, err) + + var value map[string]any + require.NoError(t, json.Unmarshal([]byte(replaced), &value)) + credentials := value["connections"].([]any)[0].(map[string]any)["credentials"].(map[string]any) + assert.Equal(t, "quote\" slash\\ newline\n", credentials["key"]) + assert.Equal(t, "quote\" slash\\ newline\n", credentials["nested"].(map[string]any)["x-api-key"]) + assert.Equal(t, "${{connections.other.credentials.key}}", credentials["foundry"]) +} + +func TestSubstituteInputParameters_NonJSON(t *testing.T) { + t.Parallel() + + replaced, err := substituteInputParameters("backend.tfvars", []byte("key=${VALUE}"), func(name string) string { + if name == "VALUE" { + return "value" + } + return "" + }) + require.NoError(t, err) + assert.Equal(t, "key=value", replaced) +} + func TestTerraformPlan(t *testing.T) { skipIfTerraformNotInstalled(t) mockContext := mocks.NewMockContext(t.Context()) From 2341d33a72d896c721b9ff64eae0a8c3fdbc3deb Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Tue, 14 Jul 2026 13:32:55 +0800 Subject: [PATCH 3/3] revert(terraform): preserve connection credentials --- .../internal/cmd/init_infra.go | 7 +-- .../terraform/terraform_provider.go | 55 +------------------ .../terraform/terraform_provider_test.go | 43 --------------- 3 files changed, 4 insertions(+), 101 deletions(-) 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 265e6ae6fee..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 @@ -609,11 +609,8 @@ func writeTfvarsFile(infraDir string, params map[string]any) (ejectArtifact, err } // deployments and connections are the only synthesizer-derived values - // written to tfvars. ${VAR} references nested inside connections' - // credentials/metadata (e.g. an API key) are resolved the same way as the - // top-level placeholders above: azd's Terraform provider substitutes - // ${...} over the whole file's raw text before parsing it, so nesting - // depth does not matter. + // 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 } diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 757ff53440d..3c86efc0055 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -16,7 +16,6 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment" - "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -731,14 +730,13 @@ func (t *TerraformProvider) createInputParametersFile( if err != nil { return fmt.Errorf("reading parameter file template: %w", err) } - lookup := func(name string) string { + replaced, err := envsubst.Eval(string(parametersBytes), func(name string) string { if name == environment.PrincipalIdEnvVarName { return principalId } return t.env.Getenv(name) - } - replaced, err := substituteInputParameters(templateFilePath, parametersBytes, lookup) + }) if err != nil { return fmt.Errorf("substituting parameter file: %w", err) @@ -759,55 +757,6 @@ func (t *TerraformProvider) createInputParametersFile( return nil } -// substituteInputParameters expands strings in JSON parameter files without corrupting JSON values or Foundry expressions. -// Backend configuration files remain raw Terraform syntax and use envsubst directly. -func substituteInputParameters(templateFilePath string, parameters []byte, lookup func(string) string) (string, error) { - if filepath.Ext(templateFilePath) != ".json" { - return envsubst.Eval(string(parameters), lookup) - } - - var value any - if err := json.Unmarshal(parameters, &value); err != nil { - return "", fmt.Errorf("parsing JSON parameters: %w", err) - } - if err := expandJSONValue(&value, lookup); err != nil { - return "", err - } - - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return "", fmt.Errorf("marshaling JSON parameters: %w", err) - } - return string(append(data, '\n')), nil -} - -// expandJSONValue recursively expands JSON string leaves while preserving Foundry ${{...}} expressions. -func expandJSONValue(value *any, lookup func(string) string) error { - switch v := (*value).(type) { - case string: - expanded, err := foundry.ExpandEnv(v, lookup) - if err != nil { - return fmt.Errorf("expanding JSON parameter: %w", err) - } - *value = expanded - case []any: - for i := range v { - if err := expandJSONValue(&v[i], lookup); err != nil { - return err - } - } - case map[string]any: - for key := range v { - item := v[key] - if err := expandJSONValue(&item, lookup); err != nil { - return err - } - v[key] = item - } - } - return nil -} - // terraformShowOutput is a model type for the output of `terraform show` for a tfstate file. // see https://www.terraform.io/internals/json-format#state-representation for more information on the shape // of the JSON data diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index d67f3778e1e..f62a474174b 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -6,7 +6,6 @@ package terraform import ( "context" _ "embed" - "encoding/json" "fmt" "os" osexec "os/exec" @@ -28,52 +27,10 @@ import ( "github.com/azure/azure-dev/cli/azd/test/mocks/mockaccount" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func TestSubstituteInputParameters_JSON(t *testing.T) { - t.Parallel() - - input := []byte(`{ - "connections": [{ - "credentials": { - "key": "${API_KEY}", - "nested": { "x-api-key": "${API_KEY}" }, - "foundry": "${{connections.other.credentials.key}}" - } - }] -}`) - replaced, err := substituteInputParameters("main.tfvars.json", input, func(name string) string { - if name == "API_KEY" { - return "quote\" slash\\ newline\n" - } - return "" - }) - require.NoError(t, err) - - var value map[string]any - require.NoError(t, json.Unmarshal([]byte(replaced), &value)) - credentials := value["connections"].([]any)[0].(map[string]any)["credentials"].(map[string]any) - assert.Equal(t, "quote\" slash\\ newline\n", credentials["key"]) - assert.Equal(t, "quote\" slash\\ newline\n", credentials["nested"].(map[string]any)["x-api-key"]) - assert.Equal(t, "${{connections.other.credentials.key}}", credentials["foundry"]) -} - -func TestSubstituteInputParameters_NonJSON(t *testing.T) { - t.Parallel() - - replaced, err := substituteInputParameters("backend.tfvars", []byte("key=${VALUE}"), func(name string) string { - if name == "VALUE" { - return "value" - } - return "" - }) - require.NoError(t, err) - assert.Equal(t, "key=value", replaced) -} - func TestTerraformPlan(t *testing.T) { skipIfTerraformNotInstalled(t) mockContext := mocks.NewMockContext(t.Context())