From 402d1c44a6e5e52ba9d5be7d0ba1168400d45c67 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 08:20:58 -0800 Subject: [PATCH 01/14] feat(foundry): provision default Application Insights for new Foundry projects Synthesizes workspace-backed Application Insights, Log Analytics and the AppInsights project connection for new projects. Synthesis lives in the Projects-owned azure.ai.projects/synthesis module shared with azure.ai.agents. Existing projects connect their own component through monitoring.applicationInsightsResourceId; creating one is not supported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.agents/README.md | 14 + cli/azd/extensions/azure.ai.agents/go.mod | 4 + .../azure.ai.agents/internal/cmd/env_refs.go | 2 +- .../azure.ai.agents/internal/cmd/init.go | 7 +- .../internal/cmd/init_adopt_deployment.go | 2 +- .../cmd/init_adopt_deployment_test.go | 2 +- .../internal/cmd/init_infra.go | 169 +- .../cmd/init_infra_monitoring_test.go | 147 ++ .../internal/cmd/init_infra_test.go | 36 +- .../internal/cmd/nextstep/state.go | 2 +- .../internal/featureflags/registry.go | 2 +- .../internal/project/prompt_model.go | 2 +- .../internal/project/service_target_prompt.go | 2 +- .../internal/synthesis/ownership_test.go | 172 -- .../internal/synthesis/schema_test.go | 257 -- .../internal/synthesis/synthesizer.go | 1295 --------- .../internal/synthesis/synthesizer_test.go | 1478 ----------- .../existing-project-eject.bicep.tmpl | 120 - .../templates/existing-project.arm.json | 773 ------ .../outputs.tf.tmpl | 47 - .../extensions/azure.ai.projects/AGENTS.md | 4 +- .../extensions/azure.ai.projects/README.md | 112 +- .../extensions/azure.ai.projects/ci-test.ps1 | 16 + cli/azd/extensions/azure.ai.projects/go.mod | 6 +- .../internal/cmd/project_add.go | 25 +- .../internal/cmd/project_deployment.go | 2 +- .../internal/cmd/project_deployment_add.go | 2 +- .../cmd/project_deployment_add_test.go | 2 +- .../cmd/project_deployment_lifecycle_test.go | 2 +- .../cmd/project_deployment_reconcile.go | 2 +- ...oject_deployment_reconcile_choices_test.go | 2 +- .../cmd/project_deployment_reconcile_test.go | 2 +- .../internal/cmd/project_existing_infra.go | 56 +- .../internal/cmd/project_ownership_test.go | 2 +- .../internal/cmd/project_service_config.go | 2 +- .../cmd/project_service_config_test.go | 2 +- .../cmd/project_service_reconciler.go | 2 +- .../internal/cmd/project_transaction_test.go | 16 +- .../internal/provisioning/aks_hosting_test.go | 11 +- .../provisioning/connection_migration.go | 63 +- .../provisioning/connection_migration_test.go | 6 +- .../foundry_provisioning_provider.go | 201 +- ...y_provisioning_provider_resolveenv_test.go | 85 +- .../foundry_provisioning_provider_test.go | 40 +- .../provisioning/monitoring_export_test.go | 186 ++ .../internal/provisioning/monitoring_graph.go | 138 + .../provisioning/monitoring_preflight.go | 275 ++ .../provisioning/monitoring_preflight_test.go | 606 +++++ .../provisioning/monitoring_target.go | 149 ++ .../provisioning/monitoring_target_test.go | 147 ++ .../provisioning/monitoring_template_test.go | 100 + .../provisioning/project_deployments.go | 2 +- .../provisioning/project_deployments_test.go | 2 +- .../resource_group_location_check.go | 2 +- .../internal/synthesis/acr_binding.go | 249 -- .../internal/synthesis/acr_binding_test.go | 247 -- .../internal/synthesis/condition.go | 41 - .../internal/synthesis/condition_test.go | 64 - .../internal/synthesis/deployment_bindings.go | 67 - .../synthesis/deployment_bindings_test.go | 75 - .../internal/synthesis/envrefs.go | 317 --- .../internal/synthesis/existing_acr_test.go | 252 -- .../internal/synthesis/hosting.go | 90 - .../internal/synthesis/parity_test.go | 92 - .../synthesis/prompt_synthesis_test.go | 255 -- .../synthesis/templates/abbreviations.json | 4 - .../templates/existing-project.bicep | 125 - .../synthesis/templates/main.arm.json | 2330 ----------------- .../internal/synthesis/templates/main.bicep | 181 -- .../modules/acr-pull-role-assignment.bicep | 27 - .../synthesis/templates/modules/acr.bicep | 127 - .../byo-identity-role-assignment.bicep | 19 - .../templates/modules/byo-prerequisites.bicep | 103 - .../modules/byo-role-assignment.bicep | 19 - .../modules/byo-storage-role-assignment.bicep | 19 - .../container-registry-eject.bicep.tmpl | 68 - .../modules/container-registry.bicep | 77 - .../templates/modules/foundry-project.bicep | 67 - .../synthesis/templates/modules/network.bicep | 95 - .../modules/private-endpoint-dns.bicep | 168 -- .../templates/modules/resources.bicep | 426 --- .../synthesis/templates/modules/subnet.bicep | 28 - .../container-registry-connect.tf | 32 - .../container-registry-create.tf | 73 - .../container-registry-reuse.tf | 50 - .../terraform-existing-project/main.tf | 15 - .../model-deployments.tf | 15 - .../terraform-existing-project/provider.tf | 25 - .../terraform-existing-project/variables.tf | 86 - .../templates/terraform/container-registry.tf | 63 - .../synthesis/templates/terraform/main.tf | 125 - .../templates/terraform/outputs.tf.tmpl | 46 - .../templates/terraform/variables.tf | 59 - .../internal/synthesis/templates_embed.go | 65 - .../schemas/azure.ai.project.json | 16 + .../synthesis/acr_binding.go | 0 .../synthesis/acr_binding_test.go | 0 .../synthesis/condition.go | 0 .../synthesis/condition_test.go | 0 .../synthesis/deployment_bindings.go | 0 .../synthesis/deployment_bindings_test.go | 0 .../synthesis/envrefs.go | 0 .../synthesis/existing_acr_test.go | 4 +- .../azure.ai.projects/synthesis/go.mod | 108 + .../azure.ai.projects/synthesis/go.sum | 327 +++ .../synthesis/hosting.go | 0 .../{internal => }/synthesis/hosting_test.go | 34 +- .../azure.ai.projects/synthesis/monitoring.go | 137 + .../synthesis/monitoring_discovery.go | 116 + .../synthesis/monitoring_discovery_test.go | 539 ++++ .../synthesis/monitoring_policy.go | 97 + .../synthesis/monitoring_reader.go | 161 ++ .../synthesis/monitoring_resolver.go | 100 + .../synthesis/monitoring_resolver_test.go | 22 + .../synthesis/monitoring_test.go | 142 + .../synthesis/ownership_test.go | 0 .../synthesis/prompt_synthesis_test.go | 0 .../{internal => }/synthesis/schema_test.go | 2 +- .../{internal => }/synthesis/synthesizer.go | 34 +- .../synthesis/synthesizer_test.go | 0 .../synthesis/templates/abbreviations.json | 0 .../existing-project-eject.bicep.tmpl | 41 +- .../templates/existing-project.arm.json | 310 ++- .../templates/existing-project.bicep | 37 +- .../synthesis/templates/main.arm.json | 309 ++- .../synthesis/templates/main.bicep | 33 + .../modules/acr-pull-role-assignment.bicep | 0 .../synthesis/templates/modules/acr.bicep | 0 .../byo-identity-role-assignment.bicep | 0 .../templates/modules/byo-prerequisites.bicep | 0 .../modules/byo-role-assignment.bicep | 0 .../modules/byo-storage-role-assignment.bicep | 0 .../container-registry-eject.bicep.tmpl | 0 .../modules/container-registry.bicep | 0 .../templates/modules/foundry-project.bicep | 0 .../modules/monitoring-connection.bicep | 42 + .../templates/modules/monitoring.bicep | 74 + .../synthesis/templates/modules/network.bicep | 0 .../modules/private-endpoint-dns.bicep | 0 .../templates/modules/resources.bicep | 0 .../synthesis/templates/modules/subnet.bicep | 0 .../adjunct-resource-group.tf | 5 + .../container-registry-connect.tf | 0 .../container-registry-create.tf | 6 - .../container-registry-reuse.tf | 0 .../terraform-existing-project/main.tf | 0 .../model-deployments.tf | 0 .../outputs.tf.tmpl | 2 +- .../terraform-existing-project/provider.tf | 0 .../terraform-existing-project/variables.tf | 0 .../templates/terraform/container-registry.tf | 0 .../synthesis/templates/terraform/main.tf | 0 .../templates/terraform/monitoring.tf.tmpl | 391 +++ .../templates/terraform/outputs.tf.tmpl | 0 .../terraform/provider-monitoring.tf.tmpl} | 4 +- .../synthesis/templates/terraform/provider.tf | 0 .../templates/terraform/variables.tf | 0 .../synthesis/templates_embed.go | 2 +- .../synthesis/terraform_monitoring.go | 188 ++ .../terraform_monitoring_plan_test.go | 315 +++ .../synthesis/terraform_monitoring_test.go | 74 + 161 files changed, 6124 insertions(+), 10637 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_monitoring_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/ownership_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_export_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_template_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/condition_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/envrefs.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/existing_acr_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/prompt_synthesis_test.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-prerequisites.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-role-assignment.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/model-deployments.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/acr_binding.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/acr_binding_test.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/condition.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/condition_test.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/deployment_bindings.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/deployment_bindings_test.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/envrefs.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/existing_acr_test.go (98%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/go.mod create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/go.sum rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/hosting.go (100%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/hosting_test.go (96%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_reader.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/ownership_test.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/prompt_synthesis_test.go (100%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/schema_test.go (99%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/synthesizer.go (97%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/synthesizer_test.go (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/abbreviations.json (100%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/templates/existing-project-eject.bicep.tmpl (71%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/templates/existing-project.arm.json (68%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/existing-project.bicep (72%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/main.arm.json (88%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/main.bicep (81%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/acr-pull-role-assignment.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/acr.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/byo-identity-role-assignment.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/byo-prerequisites.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/byo-role-assignment.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/byo-storage-role-assignment.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/container-registry-eject.bicep.tmpl (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/container-registry.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/foundry-project.bicep (100%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-connection.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/network.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/private-endpoint-dns.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/resources.bicep (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/modules/subnet.bicep (100%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/adjunct-resource-group.tf rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/container-registry-connect.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/container-registry-create.tf (92%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/container-registry-reuse.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/main.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/model-deployments.tf (100%) rename cli/azd/extensions/azure.ai.projects/{internal => }/synthesis/templates/terraform-existing-project/outputs.tf.tmpl (94%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/provider.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform-existing-project/variables.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform/container-registry.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform/main.tf (100%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform/outputs.tf.tmpl (100%) rename cli/azd/extensions/azure.ai.projects/{internal/synthesis/templates/terraform/provider.tf => synthesis/templates/terraform/provider-monitoring.tf.tmpl} (82%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform/provider.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates/terraform/variables.tf (100%) rename cli/azd/extensions/{azure.ai.agents/internal => azure.ai.projects}/synthesis/templates_embed.go (98%) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_test.go diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index fbae0e7d1f1..62b2835d2af 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -1,5 +1,19 @@ # Azure Developer CLI (azd) Agents Extension +## Project monitoring + +New Foundry projects created for agents automatically provision and connect +Application Insights through the normal Project infrastructure graph. +Existing projects are not opted in automatically. Optional BYO and opt-out +settings belong to the `host: azure.ai.project` service, not the agent +definition or a separate Connection service. Provisioning outputs are +configuration, not a trace-ingestion readiness guarantee; deploying an agent +does not repair monitoring. + +See [Project Application Insights configuration](../azure.ai.projects/README.md#application-insights) +for costs and data handling, existing-connection preservation, policy +requirements, and Bicep/Terraform ejection and cleanup semantics. + ## Preview feature switches All six preview capabilities are off by default. Each switch is controlled independently. diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 9ec92999670..bcb1dc48b3c 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -128,3 +128,7 @@ require ( golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect ) + +require azure.ai.projects/synthesis v0.0.0 + +replace azure.ai.projects/synthesis => ../azure.ai.projects/synthesis diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go index 72981a317e6..a2d633f6fa9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go @@ -4,7 +4,7 @@ package cmd import ( - "azureaiagent/internal/synthesis" + "azure.ai.projects/synthesis" ) // environmentReference and findEnvironmentReferences are this package's view of diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 1f325896dea..82be816474f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -942,12 +942,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return err } endValidation() - env, err := readInfraEjectEnvironment(ctx, azdClient) - if err != nil { - return err - } - defer inittelemetry.Start(ctx, inittelemetry.InfraGeneration)() - return ejectInfra(gate.projectRoot, infraProvider, env) + return ejectInfraAfterInit(ctx, infraProvider, azdClient) } } endValidation() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment.go index 155be079688..1c9a49787eb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment.go @@ -14,9 +14,9 @@ import ( "strconv" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "google.golang.org/protobuf/types/known/structpb" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment_test.go index abbf6cfb6d6..d25e71651fe 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_deployment_test.go @@ -13,9 +13,9 @@ import ( "strconv" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" 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 66d46d3e98c..1742ea51d8c 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 @@ -17,11 +17,11 @@ import ( "strings" "text/template" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/inittelemetry" projectPaths "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -57,6 +57,15 @@ type infraEjectPlan struct { updateDescription string } +type infraEjectPreparation struct { + rawYAML []byte + plan *infraEjectPlan + parameters map[string]any + existingProject bool + acrMode infraEjectAcrMode + environments []map[string]string +} + type infraTargetState struct { dir string exists bool @@ -485,7 +494,21 @@ func ejectInfraAfterInit(ctx context.Context, provider string, clients ...*azdex } } defer inittelemetry.Start(ctx, inittelemetry.InfraGeneration)() - return ejectInfra(projectRoot, provider, env) + if provider != project.TerraformProviderName { + return ejectInfra(projectRoot, provider, env) + } + prepared, err := prepareInfraEject(projectRoot, provider, env) + if err != nil { + return err + } + if prepared.existingProject { + if err := synthesis.ResolveTerraformMonitoringExport( + ctx, prepared.parameters, env["AZURE_AI_PROJECT_ID"], env, nil, + ); err != nil { + return err + } + } + return writeInfraEject(projectRoot, provider, prepared) } // ejectInfra synthesizes infrastructure templates from azure.yaml. A project @@ -506,15 +529,24 @@ func ejectInfraAfterInit(ctx context.Context, provider string, clients ...*azdex // // On success it prints the summary block and returns nil. func ejectInfra(projectRoot, provider string, environments ...map[string]string) error { - yamlPath := filepath.Join(projectRoot, "azure.yaml") - rawYAML, err := readProjectAzureYAML(projectRoot) + prepared, err := prepareInfraEject(projectRoot, provider, environments...) if err != nil { return err } + return writeInfraEject(projectRoot, provider, prepared) +} + +func prepareInfraEject( + projectRoot, provider string, environments ...map[string]string, +) (*infraEjectPreparation, error) { + rawYAML, err := readProjectAzureYAML(projectRoot) + if err != nil { + return nil, err + } svcName, err := findFoundryServiceForEject(rawYAML) if err != nil { - return err + return nil, err } var ejectEnv map[string]string if len(environments) > 0 { @@ -522,7 +554,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) } endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot, ejectEnv) if err != nil { - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInvalidAzureYaml, fmt.Sprintf("read endpoint for foundry project service %q: %s", svcName, err), "check the endpoint field under your azure.ai.project service", @@ -531,17 +563,17 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) existingProject := endpoint != "" if existingProject && len(environments) > 0 { if err := validateExistingProjectEjectEnvironment(endpoint, environments[0]); err != nil { - return err + return nil, err } } layerProvider := foundryLayerProvider(provider) plan, err := planInfraEject(projectRoot, rawYAML, provider, layerProvider) if err != nil { - return err + return nil, err } if err := validateEjectTarget(plan); err != nil { - return err + return nil, err } var environment map[string]string @@ -568,7 +600,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) if err != nil { // Reuse the provider's vocabulary so eject and provision report // consistent codes for the same azure.yaml problems. - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInvalidAzureYaml, fmt.Sprintf("synthesize foundry project service %q: %s", svcName, err), "check the endpoint, deployments, and network fields under your azure.ai.project service", @@ -576,7 +608,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) } deployments, present, err := project.DecodeProjectDeploymentsEnv(environment[projectDeploymentsEnvKey]) if err != nil { - return err + return nil, err } if present { res.Parameters["deployments"] = deployments @@ -586,14 +618,14 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) values := environment acrMode, err = resolveInfraEjectAcrMode(res.Parameters, values) if err != nil { - return err + return nil, err } if provider == project.TerraformProviderName && acrMode == infraEjectAcrCreate && (strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]) != "" || strings.TrimSpace(values["AZURE_CONTAINER_REGISTRY_ENDPOINT"]) != "" || strings.TrimSpace(values["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"]) != "" || strings.TrimSpace(values["AZD_FOUNDRY_RESOURCE_GROUP_ID"]) != "") { - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "Terraform eject cannot adopt the container registry previously created by microsoft.foundry", "run `azd down` before ejecting Terraform, or keep Bicep/microsoft.foundry for the existing resources", @@ -602,7 +634,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) } if provider == project.TerraformProviderName { if res.Hosting != nil { - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "BYO AKS requires the microsoft.foundry provisioning provider", "use embedded provisioning or Bicep eject to create the default AKS supporting resources and grants", @@ -614,7 +646,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) // public account — the exact silent public fallback the network: contract // forbids. Fail fast instead of ejecting an insecure template. if res.NetworkMode != synthesis.NetworkModeNone { - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInfraEjectNetworkUnsupported, "private networking (the service's network: block) is not yet supported with Terraform", "eject Bicep instead with `azd ai agent init --infra` (or `--infra=bicep`), "+ @@ -623,6 +655,15 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) } } + return &infraEjectPreparation{ + rawYAML: rawYAML, plan: plan, parameters: res.Parameters, existingProject: existingProject, + acrMode: acrMode, environments: environments, + }, nil +} + +func writeInfraEject(projectRoot, provider string, prepared *infraEjectPreparation) error { + plan := prepared.plan + params := prepared.parameters // Generate away from the destination, then install only after every file is // ready. This lets a layered eject merge into an existing infra/ tree without // exposing partial files or deleting user-owned content on failure. @@ -636,16 +677,16 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) defer os.RemoveAll(stageDir) var written []ejectArtifact - if existingProject && provider == project.TerraformProviderName { + if prepared.existingProject && provider == project.TerraformProviderName { written, err = ejectExistingProjectTerraform( - stageDir, plan.targetPath, plan.module, res.Parameters, acrMode, environments) + stageDir, plan.targetPath, plan.module, params, prepared.acrMode, prepared.environments) } else if provider == project.TerraformProviderName { - written, err = ejectTerraform(stageDir, plan.targetPath, plan.module, plan.layer, res.Parameters) - } else if existingProject { + written, err = ejectTerraform(stageDir, plan.targetPath, plan.module, plan.layer, params) + } else if prepared.existingProject { written, err = ejectExistingProjectBicep( - stageDir, plan.targetPath, plan.module, res.Parameters, acrMode, environments) + stageDir, plan.targetPath, plan.module, params, prepared.acrMode, prepared.environments) } else { - written, err = ejectBicep(stageDir, plan.targetPath, plan.module, plan.layer, res.Parameters) + written, err = ejectBicep(stageDir, plan.targetPath, plan.module, plan.layer, params) } if err != nil { return err @@ -656,7 +697,8 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) return err } if plan.updatedYAML != nil { - unchanged, err := azureYAMLUnchanged(yamlPath, rawYAML) + yamlPath := filepath.Join(projectRoot, "azure.yaml") + unchanged, err := azureYAMLUnchanged(yamlPath, prepared.rawYAML) if err != nil { rollback() return exterrors.Internal( @@ -747,12 +789,11 @@ func ejectExistingProjectBicep( params["projectResourceId"] = "${AZURE_AI_PROJECT_ID}" params["projectEndpoint"] = "${FOUNDRY_PROJECT_ENDPOINT}" delete(params, "includeAcr") - if acrMode == infraEjectAcrCreate { - params["resourceGroupName"] = "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}" - params["location"] = "${AZURE_LOCATION}" - params["resourceTokenSalt"] = "${AZD_RESOURCE_TOKEN_SALT}" - params["tags"] = map[string]string{"azd-env-name": "${AZURE_ENV_NAME}"} - } else if (acrMode == infraEjectAcrReuseConnect || acrMode == infraEjectAcrAlreadyConnected) && + params["resourceGroupName"] = "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}" + params["location"] = "${AZURE_LOCATION}" + params["resourceTokenSalt"] = "${AZD_RESOURCE_TOKEN_SALT}" + params["tags"] = map[string]string{"azd-env-name": "${AZURE_ENV_NAME}"} + if (acrMode == infraEjectAcrReuseConnect || acrMode == infraEjectAcrAlreadyConnected) && len(environments) > 0 { params["existingAcrEndpoint"] = existingAcrEndpoint params["existingAcrResourceId"] = environments[0]["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] @@ -781,11 +822,19 @@ func ejectTerraform( params map[string]any, ) ([]ejectArtifact, error) { includeAcr, _ := params["includeAcr"].(bool) + monitoring, err := synthesis.RenderTerraformMonitoring(params, false) + if err != nil { + return nil, err + } written, err := writeEmbeddedTerraformTemplates(infraDir, artifactRoot, includeAcr) if err != nil { return nil, err } + written, err = writeTerraformMonitoringFiles(infraDir, artifactRoot, written, monitoring) + if err != nil { + return nil, err + } markerArtifact, err := writeFoundryTerraformMarker(infraDir, artifactRoot) if err != nil { return nil, err @@ -814,6 +863,10 @@ func ejectExistingProjectTerraform( acrMode infraEjectAcrMode, environments []map[string]string, ) ([]ejectArtifact, error) { + monitoring, err := synthesis.RenderTerraformMonitoring(params, true) + if err != nil { + return nil, err + } existingAcrEndpoint := "" if len(environments) > 0 && (acrMode == infraEjectAcrReuseConnect || @@ -833,6 +886,10 @@ func ejectExistingProjectTerraform( if err != nil { return nil, err } + written, err = writeTerraformMonitoringFiles(infraDir, artifactRoot, written, monitoring) + if err != nil { + return nil, err + } markerArtifact, err := writeFoundryTerraformMarker(infraDir, artifactRoot) if err != nil { return nil, err @@ -846,6 +903,7 @@ func ejectExistingProjectTerraform( synthesis.ExistingProjectTerraformTemplatesFS(), false, string(acrMode), + acrMode == infraEjectAcrCreate, ) if err != nil { return nil, err @@ -859,6 +917,30 @@ func ejectExistingProjectTerraform( return append(written, tfvarsArtifact), nil } +func writeTerraformMonitoringFiles( + infraDir, artifactRoot string, + artifacts []ejectArtifact, + monitoring synthesis.TerraformMonitoring, +) ([]ejectArtifact, error) { + for _, name := range slices.Sorted(maps.Keys(monitoring.Files)) { + data := monitoring.Files[name] + //nolint:gosec // generated infrastructure is intended to be human-readable + if err := os.WriteFile(filepath.Join(infraDir, name), data, 0o644); err != nil { + return nil, infraInstallError("write monitoring Terraform", err) + } + artifact := ejectArtifact{relPath: filepath.ToSlash(filepath.Join(artifactRoot, name)), bytes: len(data)} + index := slices.IndexFunc(artifacts, func(existing ejectArtifact) bool { + return existing.relPath == artifact.relPath + }) + if index < 0 { + artifacts = append(artifacts, artifact) + } else { + artifacts[index] = artifact + } + } + return artifacts, nil +} + func writeFoundryTerraformMarker(infraDir, artifactRoot string) (ejectArtifact, error) { dst := filepath.Join(infraDir, foundryTerraformMarker) //nolint:gosec // G306: generated marker is intended to be readable by project tooling @@ -1176,6 +1258,8 @@ func writeExistingProjectBicepTemplates( target string }{ {"templates/modules/foundry-project.bicep", "modules/foundry-project.bicep"}, + {"templates/modules/monitoring.bicep", "modules/monitoring.bicep"}, + {"templates/modules/monitoring-connection.bicep", "modules/monitoring-connection.bicep"}, } if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) { registrySource, err := fs.ReadFile( @@ -2041,7 +2125,8 @@ func writeTerraformTemplateSet( name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".tf") || name == "container-registry-reuse.tf" || name == "container-registry-connect.tf" || - name == "container-registry-create.tf" { + name == "container-registry-create.tf" || + (name == "adjunct-resource-group.tf" && !includeAcr) { continue } data, err := fs.ReadFile(tfs, templatesRoot+"/"+name) @@ -2093,6 +2178,7 @@ func writeOutputsFile( synthesis.TerraformTemplatesFS(), layer, "", + false, ) } @@ -2104,6 +2190,7 @@ func writeTerraformOutputsFile( tfs fs.FS, layer bool, acrMode string, + includeAdjunct bool, ) (ejectArtifact, error) { raw, err := fs.ReadFile(tfs, tmplPath) if err != nil { @@ -2123,10 +2210,13 @@ func writeTerraformOutputsFile( var buf bytes.Buffer if err := tmpl.Execute(&buf, struct { - IncludeAcr bool - Layer bool - AcrMode string - }{IncludeAcr: includeAcr, Layer: layer, AcrMode: acrMode}); err != nil { + IncludeAcr bool + IncludeAdjunct bool + Layer bool + AcrMode string + }{ + IncludeAcr: includeAcr, IncludeAdjunct: includeAdjunct, Layer: layer, AcrMode: acrMode, + }); err != nil { return ejectArtifact{}, exterrors.Internal( exterrors.CodeInfraEjectWriteFailed, fmt.Sprintf("render outputs template: %s", err), @@ -2172,6 +2262,11 @@ func writeExistingProjectTfvarsFile( if v, ok := params["deployments"]; ok { doc["deployments"] = v } + monitoring, err := synthesis.RenderTerraformMonitoring(params, true) + if err != nil { + return ejectArtifact{}, err + } + maps.Copy(doc, monitoring.Variables) return writeJSONArtifact(infraDir, artifactRoot, module+".tfvars.json", doc) } @@ -2347,12 +2442,16 @@ func writeTfvarsFile( doc["foundry_project_name"] = "${AZURE_AI_PROJECT_NAME=}" } - // Model deployments are the only synthesizer-derived values - // written to tfvars. The Terraform provider resolves ${VAR} references + // The Terraform provider resolves ${VAR} references // across the generated file at provision time. if v, ok := params["deployments"]; ok { doc["deployments"] = v } + monitoring, err := synthesis.RenderTerraformMonitoring(params, false) + if err != nil { + return ejectArtifact{}, err + } + maps.Copy(doc, monitoring.Variables) filename := module + ".tfvars.json" return writeJSONArtifact(infraDir, artifactRoot, filename, doc) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_monitoring_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_monitoring_test.go new file mode 100644 index 00000000000..001d4675062 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_monitoring_test.go @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + ejectMonitoringEndpoint = "https://acct.services.ai.azure.com/api/projects/p1" + ejectMonitoringProjectID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/projects/p1" + ejectMonitoringComponentID = "/subscriptions/sub/resourceGroups/monitoring/providers/" + + "Microsoft.Insights/components/byo" +) + +func monitoringEjectFixture(t *testing.T, monitoring string) (string, map[string]string) { + t.Helper() + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "azure.yaml"), fmt.Sprintf(`name: monitoring-test +services: + project: + host: azure.ai.project + endpoint: %s + monitoring: {%s} +`, ejectMonitoringEndpoint, monitoring)) + return root, map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": ejectMonitoringEndpoint, + "AZURE_AI_PROJECT_ID": ejectMonitoringProjectID, + } +} + +func TestEjectInfra_TerraformMonitoringIsOffline(t *testing.T) { + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + for _, tt := range []struct { + name string + monitoring string + mode string + component string + }{ + { + name: "BYO", monitoring: "applicationInsightsResourceId: " + ejectMonitoringComponentID, + mode: "reuse-connect", component: ejectMonitoringComponentID, + }, + } { + t.Run(tt.name, func(t *testing.T) { + root, environment := monitoringEjectFixture(t, tt.monitoring) + withCapturedStdout(t, func() { + require.NoError(t, ejectInfra(root, "terraform", environment)) + }) + data, err := os.ReadFile(filepath.Join(root, "infra", "main.tfvars.json")) + require.NoError(t, err) + var values map[string]any + require.NoError(t, json.Unmarshal(data, &values)) + assert.Equal(t, tt.mode, values["monitoring_mode"]) + assert.Equal(t, tt.component, values["application_insights_resource_id"]) + assert.NoFileExists(t, filepath.Join(root, "infra", "adjunct-resource-group.tf")) + }) + } +} + +func TestEjectInfra_TerraformExistingProjectMonitoringCreateUnsupported(t *testing.T) { + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + root, environment := monitoringEjectFixture(t, "enabled: true") + err := ejectInfra(root, "terraform", environment) + require.ErrorContains(t, err, "not supported") + assert.NoDirExists(t, filepath.Join(root, "infra")) +} + +func TestWriteInfraEject_UsesResolvedMonitoringParameters(t *testing.T) { + root, environment := monitoringEjectFixture(t, "applicationInsightsResourceId: "+ejectMonitoringComponentID) + prepared, err := prepareInfraEject(root, "terraform", environment) + require.NoError(t, err) + require.Equal(t, "reuse-connect", prepared.parameters["monitoringMode"]) + assert.NoDirExists(t, filepath.Join(root, "infra")) + + prepared.parameters["monitoringMode"] = "already-connected" + withCapturedStdout(t, func() { + require.NoError(t, writeInfraEject(root, "terraform", prepared)) + }) + data, err := os.ReadFile(filepath.Join(root, "infra", "main.tfvars.json")) + require.NoError(t, err) + var values map[string]any + require.NoError(t, json.Unmarshal(data, &values)) + assert.Equal(t, "already-connected", values["monitoring_mode"]) + assert.Equal(t, ejectMonitoringComponentID, values["application_insights_resource_id"]) + assert.NoFileExists(t, filepath.Join(root, "infra", "adjunct-resource-group.tf")) +} + +func TestWriteInfraEject_RejectsManifestChangedSincePreparation(t *testing.T) { + root, environment := monitoringEjectFixture(t, "applicationInsightsResourceId: "+ejectMonitoringComponentID) + prepared, err := prepareInfraEject(root, "terraform", environment) + require.NoError(t, err) + prepared.parameters["monitoringMode"] = "already-connected" + changed := append(append([]byte(nil), prepared.rawYAML...), '\n') + require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), changed, 0o600)) + + err = writeInfraEject(root, "terraform", prepared) + require.ErrorContains(t, err, "azure.yaml changed") + assert.NoDirExists(t, filepath.Join(root, "infra")) + actual, err := os.ReadFile(filepath.Join(root, "azure.yaml")) + require.NoError(t, err) + assert.Equal(t, changed, actual) +} + +func TestInitInfra_TerraformExistingProjectMonitoringCreateRejected(t *testing.T) { + for _, standalone := range []bool{false, true} { + t.Run(fmt.Sprintf("standalone=%t", standalone), func(t *testing.T) { + t.Setenv("AZD_EXEC_PROJECT_DIR", "") + root, environment := monitoringEjectFixture(t, "enabled: true") + t.Chdir(root) + environment["AZD_FOUNDRY_MONITORING_MODE"] = "create" + environment["AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"] = ejectMonitoringComponentID + server := &testEnvironmentServiceServer{ + current: &azdext.Environment{Name: "dev"}, + values: map[string]map[string]string{"dev": environment}, + } + var err error + if standalone { + t.Setenv("AZD_SERVER", newTestAzdServer(t, server, &testWorkflowServiceServer{})) + command := newInitCommand(&azdext.ExtensionContext{}) + command.SetArgs([]string{"--infra=terraform"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + err = command.Execute() + } else { + client := newTestAzdClient(t, server, &testWorkflowServiceServer{}) + err = ejectInfraAfterInit(t.Context(), "terraform", client) + } + require.ErrorContains(t, err, "creating Application Insights for an existing project is not supported") + assert.NoDirExists(t, filepath.Join(root, "infra")) + unchanged, readErr := os.ReadFile(filepath.Join(root, "azure.yaml")) + require.NoError(t, readErr) + assert.Contains(t, string(unchanged), "monitoring: {enabled: true}") + }) + } +} 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 ecf8291afac..0632ff7d996 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 @@ -17,10 +17,10 @@ import ( "strings" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/inittelemetry" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/pflag" @@ -1439,6 +1439,40 @@ services: assert.Contains(t, string(tfvars), `"tenant_id": "${AZURE_TENANT_ID}"`) } +func TestWriteExistingProjectTerraformTemplatesSeparatesAdjunctResourceGroup(t *testing.T) { + t.Parallel() + + for _, mode := range []infraEjectAcrMode{ + infraEjectAcrNone, infraEjectAcrCreate, infraEjectAcrReuseConnect, infraEjectAcrAlreadyConnected, + } { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "infra") + artifacts, err := writeExistingProjectTerraformTemplates(dir, "infra", mode, false) + require.NoError(t, err) + adjunctPath := filepath.Join(dir, "adjunct-resource-group.tf") + if mode == infraEjectAcrCreate { + adjunct, err := os.ReadFile(adjunctPath) //nolint:gosec // test-owned path + require.NoError(t, err) + assert.Contains(t, string(adjunct), `resource "azurerm_resource_group" "adjunct"`) + registry, err := os.ReadFile(filepath.Join(dir, "container-registry.tf")) //nolint:gosec + require.NoError(t, err) + assert.NotContains(t, string(registry), `resource "azurerm_resource_group"`) + assert.Contains(t, string(registry), "azurerm_resource_group.adjunct.name") + assert.Contains(t, artifacts, ejectArtifact{ + relPath: "infra/adjunct-resource-group.tf", + bytes: len(adjunct), + }) + } else { + assert.NoFileExists(t, adjunctPath) + for _, artifact := range artifacts { + assert.NotEqual(t, "infra/adjunct-resource-group.tf", artifact.relPath) + } + } + }) + } +} + func TestEjectInfra_ExistingProjectTerraformWithoutDockerOmitsAcr(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 8e5cdeda0e9..7d91a5dd344 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -12,10 +12,10 @@ import ( "slices" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/envkey" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "go.yaml.in/yaml/v3" diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go index c2148bd68d9..4d539e405cc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/registry.go @@ -5,7 +5,7 @@ package featureflags -import "azureaiagent/internal/synthesis" +import "azure.ai.projects/synthesis" // Every agents preview feature is declared here and listed by All. var ( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_model.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_model.go index 34da9981fcc..b23c5028ea5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_model.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_model.go @@ -10,8 +10,8 @@ import ( "slices" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index ed673edb0f7..6438569762c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -17,11 +17,11 @@ import ( "strings" "time" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/envkey" - "azureaiagent/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/ownership_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/ownership_test.go deleted file mode 100644 index 335c371dbce..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/ownership_test.go +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSynthesisOwnsOnlyProjectInputs(t *testing.T) { - for _, existing := range []bool{false, true} { - for _, preserve := range []bool{false, true} { - t.Run(fmt.Sprintf("existing=%v/preserve=%v", existing, preserve), func(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "model.yaml"), []byte( - "name: model\nmodel: {name: gpt-4o, format: OpenAI, version: '2024-08-06'}\n"+ - "sku: {name: Standard, capacity: 10}\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), - []byte("kind: hosted\ncodeConfiguration: {runtime: python_3_13, entryPoint: app.py}\n"), 0o600)) - endpoint := "" - if existing { - endpoint = "https://existing.services.ai.azure.com/api/projects/project" - } - raw := fmt.Appendf(nil, `services: - project: - host: azure.ai.project - endpoint: %q - deployments: - - $ref: ./model.yaml - agent: - host: azure.ai.agent - config: - $ref: ./agent.yaml - unrelated-project: - host: azure.ai.project - deployments: invalid - connections: - $ref: ./missing-bundle.yaml - connection: - host: azure.ai.connection - uses: [project] - name: ' Private Registry ' - category: RemoteTool - target: ${UNRESOLVED_TARGET} - credentials: {keys: {key: secret-must-not-leak}} - env: {KEY: '${UNRESOLVED_KEY}'} - malformed-connection: - host: azure.ai.connection - condition: {invalid: condition} - credentials: [invalid] - $ref: ./missing-connection.yaml - toolbox: - host: azure.ai.toolbox - uses: [project, connection] - condition: [invalid] - config: - $ref: ./missing-toolbox.yaml -`, endpoint) - in := Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: root, PreserveVarRefs: preserve} - synthesize := Synthesize - if existing { - synthesize = SynthesizeExistingProject - } - result, err := synthesize(in) - require.NoError(t, err) - assert.NotContains(t, result.Parameters, "connections") - assert.NotContains(t, result.Parameters, "connectionCredentials") - assert.Equal(t, false, result.Parameters["includeAcr"]) - deployments, ok := result.Parameters["deployments"].([]Deployment) - require.True(t, ok) - require.Len(t, deployments, 1) - assert.Equal(t, "model", deployments[0].Name) - assert.Equal(t, 10, deployments[0].Sku.Capacity) - assert.NotContains(t, fmt.Sprint(result.Parameters), "secret-must-not-leak") - }) - } - } -} - -func TestSynthesisRejectsBundledDeclarations(t *testing.T) { - for _, field := range []string{"connections", "toolboxes"} { - for _, existing := range []bool{false, true} { - for _, location := range []string{"project", "agent", "agent-config", "inline-agent"} { - t.Run(fmt.Sprintf("%s/existing=%v/%s", field, existing, location), func(t *testing.T) { - endpoint := "" - if existing { - endpoint = "https://existing.services.ai.azure.com/api/projects/project" - } - raw := fmt.Sprintf("services:\n project:\n host: azure.ai.project\n endpoint: %q\n", endpoint) - switch location { - case "project": - raw += fmt.Sprintf(" %s: [{name: legacy, $ref: ./missing.yaml}]\n", field) - case "agent": - raw += fmt.Sprintf(" agent:\n host: azure.ai.agent\n %s: [{name: legacy, tools: []}]\n", field) - case "agent-config": - raw += fmt.Sprintf(" agent:\n host: azure.ai.agent\n config:\n %s: [{name: legacy, tools: []}]\n", field) - case "inline-agent": - raw += fmt.Sprintf(" agents:\n - kind: prompt\n %s: [{name: legacy, tools: []}]\n", field) - } - synthesize := Synthesize - if existing { - synthesize = SynthesizeExistingProject - } - result, err := synthesize(Input{RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: t.TempDir()}) - require.ErrorContains(t, err, "bundled declarations are no longer supported") - assert.Contains(t, err.Error(), "migrate") - assert.Contains(t, err.Error(), field) - assert.Nil(t, result) - }) - } - } - } -} - -func TestSynthesisPreservesAgentReferencesAndConditions(t *testing.T) { - t.Setenv("ENABLE_AGENT", "true") - for _, enabled := range []string{"true", "false"} { - t.Run(enabled, func(t *testing.T) { - result, err := Synthesize(Input{ - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n agent:\n" + - " host: azure.ai.agent\n condition: ${ENABLE_AGENT}\n kind: hosted\n" + - " toolboxes: [external-toolbox, {name: named-toolbox}]\n"), - ServiceName: "project", Env: map[string]string{"ENABLE_AGENT": enabled}, - }) - require.NoError(t, err) - assert.Equal(t, enabled == "true", result.Parameters["includeAcr"]) - }) - } -} - -func TestTemplatesDoNotOwnDeclaredConnections(t *testing.T) { - for name, files := range map[string]fs.FS{ - "bicep": TemplatesFS(), "terraform": TerraformTemplatesFS(), - "existing-terraform": ExistingProjectTerraformTemplatesFS(), - } { - t.Run(name, func(t *testing.T) { - err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { - require.NoError(t, err) - if entry.IsDir() { - return nil - } - assert.NotEqual(t, "connections.bicep", entry.Name()) - assert.NotEqual(t, "connections.tf", entry.Name()) - data, err := fs.ReadFile(files, path) - require.NoError(t, err) - for _, forbidden := range []string{ - "connectionCredentials", "connectionsType", "connectionType", "param connections ", - `variable "connections"`, `"connections"`, "var.connections", "projectConnections", - "AZURE_AI_PROJECT_CONNECTION_NAMES", "AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT", - } { - assert.NotContains(t, string(data), forbidden, path) - } - return nil - }) - require.NoError(t, err) - }) - } - for _, read := range []func() ([]byte, error){ARMTemplate, ExistingProjectARMTemplate} { - data, err := read() - require.NoError(t, err) - assert.Contains(t, string(data), `"category": "ContainerRegistry"`) - assert.Contains(t, string(data), "AZURE_AI_PROJECT_ACR_CONNECTION_NAME") - assert.Contains(t, string(data), "FOUNDRY_PROJECT_ENDPOINT") - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go deleted file mode 100644 index 2e715b72476..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" - "time" - - "github.com/azure/azure-dev/cli/azd/pkg/tools" - "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// schemaPath is the editor-tooling JSON schema for the Foundry project service body, -// resolved from this package directory. -const schemaPath = "../../../azure.ai.projects/schemas/azure.ai.project.json" - -// TestSchema_NetworkStructuralInvariants guards the network surface of the -// hand-maintained JSON schema against drift from the synthesizer's contract: -// peSubnet is mandatory, the old mode/byo/managed shape is gone, and every -// subnet requires an explicit vnet + name. -func TestSchema_NetworkStructuralInvariants(t *testing.T) { - raw, err := os.ReadFile(schemaPath) - if errors.Is(err, os.ErrNotExist) { - t.Skipf("project schema not found at %s; skipping cross-extension schema invariant test", schemaPath) - } - require.NoError(t, err) - - var doc struct { - Properties struct { - Network struct { - Required []string `json:"required"` - AllOf []json.RawMessage `json:"allOf"` - Properties map[string]json.RawMessage `json:"properties"` - } `json:"network"` - } `json:"properties"` - Definitions struct { - Subnet struct { - Required []string `json:"required"` - Properties map[string]json.RawMessage `json:"properties"` - } `json:"Subnet"` - } `json:"definitions"` - } - require.NoError(t, json.Unmarshal(raw, &doc), "schema must be valid JSON") - - net := doc.Properties.Network - assert.Contains(t, net.Required, "peSubnet", - "network must require peSubnet (no public data-plane fallback)") - assert.Contains(t, net.Properties, "agentSubnet", "network must expose agentSubnet") - assert.Contains(t, net.Properties, "isolationMode", "network must expose isolationMode") - assert.Contains(t, net.Properties, "peSubnet", "network must expose peSubnet") - - assertNetworkRejectsAgentSubnetWithIsolationMode(t, net.AllOf) - - // The retired mode-enum shape must not reappear. - assert.NotContains(t, net.Properties, "mode", "network.mode was removed") - assert.NotContains(t, net.Properties, "byo", "network.byo was removed") - assert.NotContains(t, net.Properties, "managed", "network.managed was removed") - - sub := doc.Definitions.Subnet - assert.ElementsMatch(t, []string{"vnet", "name"}, sub.Required, - "a subnet must require exactly vnet + name") - assert.Contains(t, sub.Properties, "prefix", "subnet must expose prefix (create vs reference)") -} - -func assertNetworkRejectsAgentSubnetWithIsolationMode(t *testing.T, allOf []json.RawMessage) { - t.Helper() - - for _, rule := range allOf { - var candidate struct { - Not struct { - Required []string `json:"required"` - } `json:"not"` - } - require.NoError(t, json.Unmarshal(rule, &candidate), "network allOf rule must be valid JSON") - if slices.Contains(candidate.Not.Required, "agentSubnet") && - slices.Contains(candidate.Not.Required, "isolationMode") { - return - } - } - - assert.Fail(t, "network schema must reject agentSubnet and isolationMode together") -} - -// TestARMTemplate_MatchesBicepBuild fails if templates/main.arm.json is stale -// relative to main.bicep. AGENTS guidance forbids hand-editing the ARM JSON; -// this catches a forgotten `bicep build`. Skipped when the bicep CLI is not on -// PATH (e.g. minimal CI images) so it never produces a phantom failure. -func TestARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := lookupBicep(t) - - templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "main.arm.json")) - require.NoError(t, err) - - out := filepath.Join(t.TempDir(), "main.arm.json") - //nolint:gosec // bicep comes from PATH or the Azure CLI - cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "main.bicep"), "--outfile", out) - var stderr bytes.Buffer - cmd.Stderr = &stderr - require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) - - //nolint:gosec // test-owned temporary output path - rebuilt, err := os.ReadFile(out) - require.NoError(t, err) - - committedNormalized := normalizeArmTemplate(t, committed) - rebuiltNormalized := normalizeArmTemplate(t, rebuilt) - - assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/main.arm.json is stale; regenerate with `bicep build main.bicep "+ - "--outfile main.arm.json` from the templates directory") -} - -// TestExistingProjectARMTemplate_MatchesBicepBuild is the existing-project.bicep counterpart -// of TestARMTemplate_MatchesBicepBuild: it catches a forgotten `bicep build` after -// editing the existing-project template. Skipped when bicep is absent. -func TestExistingProjectARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := lookupBicep(t) - - templatesDir := "templates" - committed, err := os.ReadFile(filepath.Join(templatesDir, "existing-project.arm.json")) - require.NoError(t, err) - - out := filepath.Join(t.TempDir(), "existing-project.arm.json") - //nolint:gosec // bicep comes from PATH or the Azure CLI - cmd := exec.CommandContext(t.Context(), bicep, "build", - filepath.Join(templatesDir, "existing-project.bicep"), "--outfile", out) - var stderr bytes.Buffer - cmd.Stderr = &stderr - require.NoErrorf(t, cmd.Run(), "bicep build failed: %s", stderr.String()) - - //nolint:gosec // test-owned temporary output path - rebuilt, err := os.ReadFile(out) - require.NoError(t, err) - - committedNormalized := normalizeArmTemplate(t, committed) - rebuiltNormalized := normalizeArmTemplate(t, rebuilt) - - assert.True(t, bytes.Equal(committedNormalized, rebuiltNormalized), - "templates/existing-project.arm.json is stale; regenerate with `bicep build "+ - "existing-project.bicep --outfile existing-project.arm.json` from the templates directory") -} - -// normalizeArmTemplate returns a stable JSON representation of an ARM template -// for drift comparison. Bicep generator metadata includes the local Bicep CLI -// version/hash and can differ between developer machines and CI images without -// changing the template semantics. -func normalizeArmTemplate(t *testing.T, raw []byte) []byte { - t.Helper() - - var doc any - require.NoError(t, json.Unmarshal(raw, &doc)) - stripBicepGeneratorMetadata(doc) - - normalized, err := json.Marshal(doc) - require.NoError(t, err) - return normalized -} - -// stripBicepGeneratorMetadata recursively removes Bicep's generator metadata -// from a decoded ARM template. Bicep emits this metadata for the top-level -// template and nested module templates. -func stripBicepGeneratorMetadata(value any) { - switch v := value.(type) { - case map[string]any: - delete(v, "_generator") - for _, child := range v { - stripBicepGeneratorMetadata(child) - } - case []any: - for _, child := range v { - stripBicepGeneratorMetadata(child) - } - } - -} - -func lookupBicep(t *testing.T) string { - t.Helper() - override := os.Getenv("AZD_BICEP_TOOL_PATH") - path, err := findBicep(t.Context(), override) - if err != nil { - if override != "" { - t.Fatal(err) - } - t.Skip(err) - } - return path -} - -func findBicep(ctx context.Context, override string) (string, error) { - var candidates []string - if override != "" { - candidates = append(candidates, override) - } else { - if path, err := exec.LookPath("bicep"); err == nil { - candidates = append(candidates, path) - } - if home, err := os.UserHomeDir(); err == nil { - for _, name := range []string{"bicep", "bicep.exe"} { - path := filepath.Join(home, ".azure", "bin", name) - if _, err := os.Stat(path); err == nil { - candidates = append(candidates, path) - } - } - } - } - var rejected []string - for _, path := range candidates { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - //nolint:gosec // Explicit override or locally discovered Bicep; never installs or upgrades tools. - output, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() - cancel() - if err != nil { - err = fmt.Errorf("read Bicep version: %w: %s", err, strings.TrimSpace(string(output))) - } else { - err = validateBicepVersion(string(output)) - } - if err == nil { - return path, nil - } - rejected = append(rejected, fmt.Sprintf("%s: %v", path, err)) - } - return "", fmt.Errorf("no supported Bicep CLI found (azd SDK minimum %s); "+ - "set AZD_BICEP_TOOL_PATH to a supported binary or add it to PATH; rejected: %s", - bicep.Version, strings.Join(rejected, "; ")) -} - -func validateBicepVersion(output string) error { - match := regexp.MustCompile(`^Bicep CLI version (\d+\.\d+\.\d+)(?:\s|$)`).FindStringSubmatch(strings.TrimSpace(output)) - if len(match) != 2 { - return fmt.Errorf("unrecognized Bicep version output %q", strings.TrimSpace(output)) - } - version, err := tools.ExtractVersion(match[1]) - if err != nil { - return fmt.Errorf("parse Bicep version: %w", err) - } - if version.LT(bicep.Version) { - return fmt.Errorf("Bicep version %s is below azd SDK minimum %s", version, bicep.Version) - } - return nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go deleted file mode 100644 index d0564da6225..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ /dev/null @@ -1,1295 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// Package synthesis turns the body of a Foundry service in azure.yaml -// into the inputs needed to compile an ARM template in memory: -// -// - the embedded main.bicep + modules tree, ready to be staged on disk -// for the bicep compiler -// - a Parameters map of the values the template's params consume -// -// Greenfield only: if the service has an endpoint: field, ErrEndpointBrownfield -// is returned so callers can short-circuit the provision path. -package synthesis - -import ( - "errors" - "fmt" - "maps" - "net" - "os" - "regexp" - "slices" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/foundry" - "go.yaml.in/yaml/v3" -) - -// Sentinel errors returned by Synthesize. -var ( - // ErrEndpointBrownfield indicates the service points at an existing - // Foundry project via endpoint:. The provider should skip ARM - // provisioning and connect to the endpoint directly. - ErrEndpointBrownfield = errors.New("synthesis: service has endpoint: (brownfield)") - - // ErrServiceNotFound indicates the requested service does not exist - // in azure.yaml or its host: value is not in AcceptedHosts. - ErrServiceNotFound = errors.New("synthesis: service not found or host not accepted") -) - -// Input is the synthesizer's view of azure.yaml. -type Input struct { - // RawAzureYAML is the full bytes of azure.yaml. - RawAzureYAML []byte - - // ServiceName is the key under services: to synthesize for - // (e.g. "my-project"). - ServiceName string - - // AcceptedHosts lists the values of `services..host` the - // caller treats as a Foundry service. If empty, the service's host - // value is not checked (only existence and endpoint: are). - AcceptedHosts []string - - // Env maps project-wide azd values used by registry selection, - // network fields, and conditions. Missing values may fall back to - // the process environment. - Env map[string]string - - // ResourceGroupName is the greenfield deployment group that - // azd down deletes. Callers supply the effective group, including - // any layer-specific default. - ResourceGroupName string - - // PreserveVarRefs keeps ${VAR} references verbatim instead of resolving - // them. Used by the eject path, where the synthesized main.parameters.json - // must stay environment-portable: the on-disk provision flow resolves - // ${VAR} from the azd environment at provision time. When false (the - // provision path), ${VAR} is resolved here and a missing variable fails. - PreserveVarRefs bool - - // ProjectRoot is the directory holding azure.yaml. When set, $ref file - // includes in the service entry (and its deployment items) are resolved - // against it before synthesis, so refs become the actual content rather - // than zero-valued params. Empty disables resolution. - ProjectRoot string - - // ACRBinding contains deployment facts, never setup intent. - ACRBinding *ACRBinding -} - -// Result bundles the bicep sources and the parameter values derived -// from the service body. Callers stage Templates on disk, compile -// main.bicep, and pass Parameters when invoking the resulting ARM -// deployment. -type Result struct { - // Hosting selects an existing AKS cluster. Supporting resources are planned by the provider. - Hosting *AgentHostingInput - - // Parameters maps bicep param names to plain Go values. Callers wrap - // these in ARM's {"value": ...} envelope when serializing. - Parameters map[string]any - - // NetworkMode is "none", "byo", or "managed" — derived from the - // network: block (or its absence). Exposed for telemetry. - NetworkMode string - - // ACRMode is the greenfield registry mode: create, - // reuse-connect, or none. - ACRMode string -} - -// Deployment mirrors the deploymentType in main.bicep. -type Deployment struct { - Name string `yaml:"name" json:"name"` - Model DeploymentModel `yaml:"model" json:"model"` - Sku DeploymentSku `yaml:"sku" json:"sku"` -} - -// DeploymentModel mirrors the model field of deploymentType. -type DeploymentModel struct { - Name string `yaml:"name" json:"name"` - Format string `yaml:"format" json:"format"` - Version string `yaml:"version" json:"version"` -} - -// DeploymentSku mirrors the sku field of deploymentType. -type DeploymentSku struct { - Name string `yaml:"name" json:"name"` - Capacity int `yaml:"capacity" json:"capacity"` -} - -// codeConfigBlock marks an agent as a code (ZIP) deploy. Its presence is the -// signal; the keys are camelCase because the unified azure.ai.agent service -// entry is serialized from the agent definition's JSON tags. -type codeConfigBlock struct { - Runtime string `yaml:"runtime,omitempty"` - EntryPoint string `yaml:"entryPoint,omitempty"` -} - -// agentBlock is the subset of an agent entry we inspect — both the legacy inline -// agents[] shape and the unified azure.ai.agent service shape. -type agentBlock struct { - Name string `yaml:"name,omitempty"` - Kind string `yaml:"kind,omitempty"` - Image string `yaml:"image,omitempty"` - CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` -} - -// serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. -type serviceBlock struct { - Host string `yaml:"host"` - Kind string `yaml:"kind,omitempty"` - Image string `yaml:"image,omitempty"` - CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` - Config *agentBlock `yaml:"config,omitempty"` - Agents []agentBlock `yaml:"agents,omitempty"` -} - -// projectService is the subset of a host: azure.ai.project service body the synthesizer reads. -// Unknown fields are intentionally ignored: they are reconciled in deploy-time service targets. -type projectService struct { - Host string `yaml:"host"` - Endpoint string `yaml:"endpoint,omitempty"` - Deployments []Deployment `yaml:"deployments,omitempty"` - Agents []agentBlock `yaml:"agents,omitempty"` - Network *networkBlock `yaml:"network,omitempty"` - AgentHosting *AgentHostingInput `yaml:"agentHosting,omitempty"` -} - -// networkBlock mirrors the network: sub-tree on the service body. -// -// The block models two orthogonal axes: -// -// - Egress (agent runtime network): agentSubnet present injects the agent into -// that customer subnet; agentSubnet absent uses the Microsoft-managed -// network. isolationMode tunes the managed network's outbound posture and is -// valid only when agentSubnet is absent. -// - Ingress (account data plane): peSubnet is required and always yields an -// account private endpoint, so a network-bound account is never public. -type networkBlock struct { - AgentSubnet *subnetSpec `yaml:"agentSubnet,omitempty"` - IsolationMode string `yaml:"isolationMode,omitempty"` - PESubnet *subnetSpec `yaml:"peSubnet,omitempty"` - DNS *dnsBlock `yaml:"dns,omitempty"` -} - -// subnetSpec is a self-contained subnet descriptor: vnet + name identify the -// subnet, and the optional prefix toggles create-vs-reference. -// -// vnet + name -> reference the existing subnet -// vnet + name + prefix -> create the subnet with that CIDR -type subnetSpec struct { - VNet string `yaml:"vnet,omitempty"` - Name string `yaml:"name,omitempty"` - Prefix string `yaml:"prefix,omitempty"` -} - -// dnsBlock mirrors network.dns (private DNS zone references). -type dnsBlock struct { - ResourceGroup string `yaml:"resourceGroup,omitempty"` - Subscription string `yaml:"subscription,omitempty"` -} - -// projectFile is the root of azure.yaml as we care about it: only services. -type projectFile struct { - Services map[string]yaml.Node `yaml:"services"` -} - -// Synthesize derives the parameter values needed by main.bicep from one -// Foundry project service in azure.yaml. -func Synthesize(in Input) (*Result, error) { - if len(in.RawAzureYAML) == 0 { - return nil, errors.New("synthesis: RawAzureYAML is empty") - } - if in.ServiceName == "" { - return nil, errors.New("synthesis: ServiceName is empty") - } - - var root projectFile - if err := yaml.Unmarshal(in.RawAzureYAML, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - svc, err := loadProjectService(root.Services, in.ServiceName, in.ProjectRoot) - if err != nil { - return nil, err - } - - if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { - return nil, ErrServiceNotFound - } - endpoint, err := expandEndpoint(svc.Endpoint, in.Env) - if err != nil { - return nil, err - } - if endpoint != "" { - if svc.AgentHosting != nil { - return nil, errors.New("agentHosting is only supported when azd creates a new Foundry account") - } - return nil, ErrEndpointBrownfield - } - - includeAcr, err := deriveIncludeAcr( - root.Services, - svc, - in.ProjectRoot, - in.Env, - ) - if err != nil { - return nil, err - } - - deployments := svc.Deployments - if deployments == nil { - deployments = []Deployment{} - } - - netParams, netMode, err := synthesizeNetwork(svc.Network, in.ServiceName, in.Env, !in.PreserveVarRefs) - if err != nil { - return nil, err - } - hosting, err := synthesizeAgentHosting(svc.AgentHosting, in.ServiceName, in.Env, !in.PreserveVarRefs) - if err != nil { - return nil, err - } - if includeAcr && netMode != NetworkModeNone { - return nil, errors.New( - "synthesis: private networking does not support an auto-created Azure Container Registry; specify an image instead", - ) - } - - params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - } - maps.Copy(params, netParams) - if hosting != nil { - params["agentHosting"] = agentHostingParameter{ - Enabled: true, HostingType: "ManagedCluster", Name: "default", - ClusterResourceID: hosting.ClusterResourceID, - } - } - - return &Result{ - Parameters: params, - NetworkMode: netMode, - Hosting: hosting, - }, nil -} - -// GreenfieldACRMode distinguishes selectors from deployed outputs. -func GreenfieldACRMode(in Input) (string, error) { - mode, _, err := ResolveGreenfieldACRSelection(in) - return mode, err -} - -// ResolveGreenfieldACRSelection returns the mode and effective ID. -// A binding supplies the ID when legacy aliases are absent. -// Unbound selectors still require ValidateACRSelector. -func ResolveGreenfieldACRSelection(in Input) (mode, registryID string, err error) { - env := in.Env - mode = env["AZD_FOUNDRY_ACR_MODE"] - id := env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] - binding := in.ACRBinding - if binding == nil { - binding, err = ParseACRBinding(env[ACRBindingEnvKey]) - if err != nil { - return "", "", err - } - } - if binding != nil { - if err := ValidateACRBinding(binding, mode, id); err != nil { - return "", "", err - } - return binding.Mode, binding.RegistryResourceID, nil - } - switch mode { - case "", "none", "create": - case "reuse-connect": - return mode, id, nil - default: - return "", "", fmt.Errorf("synthesis: AZD_FOUNDRY_ACR_MODE has unsupported greenfield value %q", mode) - } - if id == "" { - return "create", "", nil - } - group, err := GreenfieldACRResourceGroup(in) - if err != nil { - return "", "", err - } - // Legacy environments retain outputs instead of a binding. - // A same-group registry ID alone is still an external input. - projectID := env["AZURE_AI_PROJECT_ID"] - if containerRegistryIDPattern.MatchString(id) && projectResourceIDPattern.MatchString(projectID) && - !strings.EqualFold(env["USE_EXISTING_AI_PROJECT"], "true") { - registry, project := strings.Split(id, "/"), strings.Split(projectID, "/") - if group != "" && strings.EqualFold(registry[4], group) && - strings.EqualFold(registry[2], env["AZURE_SUBSCRIPTION_ID"]) && - strings.EqualFold(project[2], registry[2]) && strings.EqualFold(project[4], group) && - legacyACRLoginHostMatches(env["AZURE_CONTAINER_REGISTRY_ENDPOINT"], registry[8]) { - return "create", id, nil - } - } - return "reuse-connect", id, nil -} - -func legacyACRLoginHostMatches(endpoint, registryName string) bool { - host := strings.ToLower(strings.TrimSpace(endpoint)) - if scheme, address, ok := strings.Cut(host, "://"); ok { - if scheme != "https" && scheme != "http" { - return false - } - host = address - } - host = strings.TrimSuffix(strings.TrimSuffix(host, "/"), ".") - for _, suffix := range []string{"azurecr.io", "azurecr.us", "azurecr.cn"} { - if host == strings.ToLower(registryName)+"."+suffix { - return true - } - } - return false -} - -var containerRegistryIDPattern = regexp.MustCompile( - `(?i)^/subscriptions/[^/\s?#\\%]+/resourceGroups/[^/\s?#\\%]+/providers/` + - `Microsoft\.ContainerRegistry/registries/[a-z0-9]+$`, -) - -// SynthesizeExistingProject derives parameters for editable infrastructure that -// augments an existing Foundry project without taking ownership of it. -func SynthesizeExistingProject(in Input) (*Result, error) { - if len(in.RawAzureYAML) == 0 { - return nil, errors.New("synthesis: RawAzureYAML is empty") - } - if in.ServiceName == "" { - return nil, errors.New("synthesis: ServiceName is empty") - } - - var root projectFile - if err := yaml.Unmarshal(in.RawAzureYAML, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - svc, err := loadProjectService(root.Services, in.ServiceName, in.ProjectRoot) - if err != nil { - return nil, err - } - if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { - return nil, ErrServiceNotFound - } - if strings.TrimSpace(svc.Endpoint) == "" { - return nil, errors.New("synthesis: existing Foundry project endpoint is empty") - } - if svc.AgentHosting != nil { - return nil, errors.New("agentHosting is only supported when azd creates a new Foundry account") - } - - includeAcr, err := deriveIncludeAcr( - root.Services, - svc, - in.ProjectRoot, - in.Env, - ) - if err != nil { - return nil, err - } - deployments := svc.Deployments - if deployments == nil { - deployments = []Deployment{} - } - - return &Result{Parameters: map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - }, NetworkMode: NetworkModeNone}, nil -} - -// BrownfieldDeployments returns the model deployments declared on a brownfield -// (endpoint:) Foundry project service. Synthesize short-circuits with -// ErrEndpointBrownfield before reading deployments:, so the provider uses this -// to learn which model deployments to create on the existing account. Returns -// nil (not an error) when the service declares no deployments. -func BrownfieldDeployments( - raw []byte, - serviceName string, - projectRoot string, -) ([]Deployment, error) { - if len(raw) == 0 { - return nil, errors.New("synthesis: raw azure.yaml is empty") - } - if serviceName == "" { - return nil, errors.New("synthesis: serviceName is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return nil, fmt.Errorf("parse azure.yaml: %w", err) - } - - svc, err := loadProjectService(root.Services, serviceName, projectRoot) - if err != nil { - return nil, err - } - - return svc.Deployments, nil -} - -// ProjectEndpoint returns the endpoint configured on a Foundry project service, -// with ${VAR} references resolved from env. It resolves $ref includes before -// decoding the service body. -func ProjectEndpoint( - raw []byte, - serviceName string, - projectRoot string, - env map[string]string, -) (string, error) { - if len(raw) == 0 { - return "", errors.New("synthesis: raw azure.yaml is empty") - } - - var root projectFile - if err := yaml.Unmarshal(raw, &root); err != nil { - return "", fmt.Errorf("parse azure.yaml: %w", err) - } - - svc, err := loadProjectService(root.Services, serviceName, projectRoot) - if err != nil { - return "", err - } - return expandEndpoint(svc.Endpoint, env) -} - -func expandEndpoint(raw string, env map[string]string) (string, error) { - mapping := func(name string) string { - if value, found := env[name]; found { - return value - } - value, _ := os.LookupEnv(name) - return value - } - expanded, err := foundry.ExpandEnv(strings.TrimSpace(raw), mapping) - if err != nil { - return "", fmt.Errorf("expand endpoint: %w", err) - } - return strings.TrimSpace(expanded), nil -} - -// loadProjectService decodes a service after resolving any local $ref includes. -func loadProjectService( - services map[string]yaml.Node, - serviceName string, - projectRoot string, -) (projectService, error) { - node, ok := services[serviceName] - if !ok { - return projectService{}, ErrServiceNotFound - } - if err := rejectBundledDeclarations(node, serviceName, projectRoot != ""); err != nil { - return projectService{}, err - } - if projectRoot != "" { - var err error - node, err = resolveServiceRefs(node, projectRoot, serviceName) - if err != nil { - return projectService{}, err - } - } - if err := rejectBundledDeclarations(node, serviceName, false); err != nil { - return projectService{}, err - } - - var svc projectService - if err := validateAgentHostingInputs(node, serviceName); err != nil { - return svc, err - } - if err := node.Decode(&svc); err != nil { - return projectService{}, fmt.Errorf("decode service %q: %w", serviceName, err) - } - return svc, nil -} - -// rejectBundledDeclarations rejects legacy resource payloads, not agent references -// to independently owned resources. Before expanding refs, string connection names -// may defer the kind check to a referenced definition. Always recheck after resolution. -func rejectBundledDeclarations(node yaml.Node, path string, resolvingRefs bool) error { - var fields map[string]yaml.Node - if err := node.Decode(&fields); err != nil { - return nil - } - kind, hasKind := fields["kind"] - prompt := kind.Kind == yaml.ScalarNode && kind.Tag == "!!str" && - strings.EqualFold(strings.TrimSpace(kind.Value), "prompt") - ref := fields["$ref"] - deferredKind := resolvingRefs && !hasKind && ref.Kind == yaml.ScalarNode && - ref.Tag == "!!str" && strings.TrimSpace(ref.Value) != "" - for _, field := range []string{"connections", "toolboxes"} { - value, ok := fields[field] - if !ok { - continue - } - if field == "connections" && (prompt || deferredKind) && value.Kind == yaml.SequenceNode { - if !slices.ContainsFunc(value.Content, func(item *yaml.Node) bool { - return item.Kind != yaml.ScalarNode || item.Tag != "!!str" || strings.TrimSpace(item.Value) == "" - }) { - continue - } - } - if field == "toolboxes" && value.Kind == yaml.SequenceNode { - if !slices.ContainsFunc(value.Content, func(item *yaml.Node) bool { - if item.Kind == yaml.ScalarNode && item.Tag == "!!str" && strings.TrimSpace(item.Value) != "" { - return false - } - var reference map[string]yaml.Node - if err := item.Decode(&reference); err != nil || len(reference) != 1 { - return true - } - // A reference-only item is validated again after file expansion. - if ref := reference["$ref"]; ref.Kind == yaml.ScalarNode && ref.Tag == "!!str" { - return false - } - name := reference["name"] - return name.Kind != yaml.ScalarNode || name.Tag != "!!str" || strings.TrimSpace(name.Value) == "" - }) { - continue - } - } - return fmt.Errorf("services.%s.%s: bundled declarations are no longer supported; "+ - "migrate them to independent azure.ai.connection or azure.ai.toolbox services", path, field) - } - if config, ok := fields["config"]; ok { - if err := rejectBundledDeclarations(config, path+".config", resolvingRefs); err != nil { - return err - } - } - if agents, ok := fields["agents"]; ok && agents.Kind == yaml.SequenceNode { - for i, agent := range agents.Content { - if err := rejectBundledDeclarations(*agent, fmt.Sprintf("%s.agents[%d]", path, i), resolvingRefs); err != nil { - return err - } - } - } - return nil -} - -// resolveServiceRefs expands $ref file includes in one service entry. It decodes -// the node to the map shape foundry.ResolveFileRefs expects, resolves refs -// against projectRoot, and re-encodes to a yaml.Node so the rest of Synthesize -// decodes resolved content instead of raw {"$ref": ...} objects. -func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.Node, error) { - var raw map[string]any - if err := node.Decode(&raw); err != nil { - // Not a mapping (unexpected for a service entry); leave it untouched. - return node, nil - } - resolved, err := foundry.ResolveFileRefs(raw, projectRoot) - if err != nil { - return node, fmt.Errorf("resolve $ref includes for service %q: %w", serviceName, err) - } - var out yaml.Node - if err := out.Encode(resolved); err != nil { - return node, fmt.Errorf("re-encode service %q after $ref resolution: %w", serviceName, err) - } - return out, nil -} - -func serviceForHost( - node yaml.Node, - projectRoot string, - serviceName string, - expectedHost string, -) (yaml.Node, bool, error) { - var selector struct { - Host string `yaml:"host"` - Ref string `yaml:"$ref"` - } - if err := node.Decode(&selector); err != nil { - return node, false, nil - } - if selector.Host != "" && selector.Host != expectedHost { - return node, false, nil - } - if selector.Host == "" && selector.Ref == "" { - return node, false, nil - } - if expectedHost == "azure.ai.agent" && projectRoot != "" { - if err := validateAgentRootRefCoreFields( - node, - projectRoot, - serviceName, - ); err != nil { - return node, false, err - } - } - if projectRoot != "" { - resolved, err := resolveServiceRefs( - node, - projectRoot, - serviceName, - ) - if err != nil { - return node, false, err - } - node = resolved - } - if err := node.Decode(&selector); err != nil { - return node, false, fmt.Errorf( - "decode service %q selector: %w", - serviceName, - err, - ) - } - return node, selector.Host == expectedHost, nil -} - -func validateAgentRootRefCoreFields( - node yaml.Node, - projectRoot string, - serviceName string, -) error { - var raw map[string]any - if err := node.Decode(&raw); err != nil { - return nil - } - ref, _ := raw["$ref"].(string) - if ref == "" { - return nil - } - referenced, err := foundry.ResolveFileRefs( - map[string]any{"$ref": ref}, - projectRoot, - ) - if err != nil { - return fmt.Errorf( - "resolve $ref includes for service %q: %w", - serviceName, - err, - ) - } - host, _ := raw["host"].(string) - if host == "" { - host, _ = referenced["host"].(string) - } - if host != "azure.ai.agent" { - return nil - } - for _, field := range []string{ - "project", - "language", - "image", - "docker", - } { - if _, found := referenced[field]; found { - return fmt.Errorf( - "service %q root $ref must not provide core field %q; declare it in azure.yaml", - serviceName, - field, - ) - } - } - return nil -} - -// deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is -// needed when any agent is a hosted, build-from-source agent: azd builds its -// image and pushes it to the registry. Agents live on sibling azure.ai.agent -// services in the split shape, or under agents[] in the legacy inline shape; -// both are scanned. The decision mirrors the deploy-time contract: -// -// - codeConfiguration present -> code (ZIP) deploy, no ACR -// - image present -> pre-built image (BYO registry), no ACR -// - otherwise (hosted) -> container build from source, needs ACR -// -// Keying on image/codeConfiguration rather than the optional docker: block is -// deliberate: docker: is not in the agent schema and is dropped by omitempty -// when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr( - services map[string]yaml.Node, - svc projectService, - projectRoot string, - env map[string]string, -) (bool, error) { - includeAcr := slices.ContainsFunc(svc.Agents, agentNeedsAcr) - lookup := projectConditionLookup(env) - for _, serviceName := range slices.Sorted(maps.Keys(services)) { - node := services[serviceName] - var selector struct { - Host string `yaml:"host"` - Ref string `yaml:"$ref"` - } - if err := node.Decode(&selector); err != nil { - continue - } - // Other service targets own their payloads, refs and conditions. - if selector.Host != "azure.ai.agent" && (selector.Host != "" || selector.Ref == "") { - continue - } - enabled, err := serviceNodeEnabled(node, lookup) - if err != nil { - return false, fmt.Errorf("services.%s.condition: %w", serviceName, err) - } - if !enabled { - continue - } - if err := rejectBundledDeclarations(node, serviceName, projectRoot != ""); err != nil { - return false, err - } - node, matches, err := serviceForHost(node, projectRoot, serviceName, "azure.ai.agent") - if err != nil { - return false, err - } - if !matches { - continue - } - if err := rejectBundledDeclarations(node, serviceName, false); err != nil { - return false, err - } - var service serviceBlock - if err := node.Decode(&service); err != nil { - return false, fmt.Errorf("decode service %q: %w", serviceName, err) - } - agent := agentBlock{ - Kind: service.Kind, - Image: service.Image, - CodeConfiguration: service.CodeConfiguration, - } - if service.Config != nil { - if agent.Kind == "" { - agent.Kind = service.Config.Kind - } - if agent.Image == "" { - agent.Image = service.Config.Image - } - if agent.CodeConfiguration == nil { - agent.CodeConfiguration = service.Config.CodeConfiguration - } - } - includeAcr = includeAcr || agentNeedsAcr(agent) - } - return includeAcr, nil -} - -// RequiresACR reports source-build demand using synthesis rules. -func RequiresACR(in Input) (bool, error) { - _, needed, err := ACRRequirements(in) - return needed, err -} - -// ACRRequirements resolves enabled agents, including inline and $ref. -// A project-only manifest has no agents and permits setup intent. -func ACRRequirements(in Input) (bool, bool, error) { - root, projects, err := acrProjectServices(in) - if err != nil { - return false, false, err - } - var combined projectService - for _, svc := range projects { - combined.Agents = append(combined.Agents, svc.Agents...) - } - return deriveACRRequirements(root.Services, combined, in.ProjectRoot, in.Env) -} - -func acrProjectServices(in Input) (projectFile, []projectService, error) { - var root projectFile - if err := yaml.Unmarshal(in.RawAzureYAML, &root); err != nil { - return root, nil, fmt.Errorf("parse azure.yaml: %w", err) - } - hosts := []string{"azure.ai.project", "azure.ai.agents"} - for _, host := range in.AcceptedHosts { - if host != "azure.ai.agent" && !slices.Contains(hosts, host) { - hosts = append(hosts, host) - } - } - var projects []projectService - for _, name := range slices.Sorted(maps.Keys(root.Services)) { - node := root.Services[name] - var selector struct { - Host string `yaml:"host"` - Ref string `yaml:"$ref"` - } - if err := node.Decode(&selector); err != nil { - continue - } - if !slices.Contains(hosts, selector.Host) && (selector.Host != "" || selector.Ref == "") { - continue - } - enabled, err := serviceNodeEnabled(node, projectConditionLookup(in.Env)) - if err != nil { - return root, nil, fmt.Errorf("services.%s.condition: %w", name, err) - } - if !enabled { - continue - } - matches := false - for _, host := range hosts { - _, matches, err = serviceForHost(node, in.ProjectRoot, name, host) - if err != nil { - return root, nil, err - } - if matches { - break - } - } - if !matches { - continue - } - svc, err := loadProjectService(root.Services, name, in.ProjectRoot) - if err != nil { - return root, nil, err - } - projects = append(projects, svc) - } - return root, projects, nil -} - -func deriveACRRequirements( - services map[string]yaml.Node, - svc projectService, - projectRoot string, - env map[string]string, -) (bool, bool, error) { - hasAgents := len(svc.Agents) > 0 - includeAcr := slices.ContainsFunc(svc.Agents, agentNeedsAcr) - lookup := projectConditionLookup(env) - for _, serviceName := range slices.Sorted(maps.Keys(services)) { - node := services[serviceName] - var selector struct { - Host string `yaml:"host"` - Ref string `yaml:"$ref"` - } - if err := node.Decode(&selector); err != nil { - continue - } - // Other service targets own their payloads, - // refs and conditions. - if selector.Host != "azure.ai.agent" && (selector.Host != "" || selector.Ref == "") { - continue - } - enabled, err := serviceNodeEnabled(node, lookup) - if err != nil { - return false, false, fmt.Errorf("services.%s.condition: %w", serviceName, err) - } - if !enabled { - continue - } - if err := rejectBundledDeclarations(node, serviceName, projectRoot != ""); err != nil { - return false, false, err - } - node, matches, err := serviceForHost(node, projectRoot, serviceName, "azure.ai.agent") - if err != nil { - return false, false, err - } - if !matches { - continue - } - hasAgents = true - if err := rejectBundledDeclarations(node, serviceName, false); err != nil { - return false, false, err - } - var service serviceBlock - if err := node.Decode(&service); err != nil { - return false, false, fmt.Errorf("decode service %q: %w", serviceName, err) - } - if len(service.Agents) > 0 { - includeAcr = includeAcr || slices.ContainsFunc(service.Agents, agentNeedsAcr) - continue - } - agent := agentBlock{ - Kind: service.Kind, - Image: service.Image, - CodeConfiguration: service.CodeConfiguration, - } - if service.Config != nil { - if agent.Kind == "" { - agent.Kind = service.Config.Kind - } - if agent.Image == "" { - agent.Image = service.Config.Image - } - if agent.CodeConfiguration == nil { - agent.CodeConfiguration = service.Config.CodeConfiguration - } - } - includeAcr = includeAcr || agentNeedsAcr(agent) - } - return hasAgents, includeAcr, nil -} - -// agentNeedsAcr reports whether a single agent entry builds a container image -// from source (and therefore requires an ACR). Pre-built images and code/ZIP -// deploys do not; any other hosted agent does. -func agentNeedsAcr(a agentBlock) bool { - if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { - return false - } - // "hosted" is the only container kind; an empty kind defaults to hosted for - // back-compat. Other explicit kinds (prompt, prompt-voice, workflow) do not - // build a container image. - // NOTE: if a future non-container kind can omit kind:, replace this - // default-to-hosted with an explicit allowlist so it does not trigger ACR. - kind := strings.TrimSpace(a.Kind) - return kind == "" || strings.EqualFold(kind, "hosted") -} - -func serviceNodeEnabled( - node yaml.Node, - lookup func(string) string, -) (bool, error) { - value, present, err := serviceConditionValue(node) - if err != nil { - return false, err - } - if !present { - return true, nil - } - return evaluateCondition(value, lookup) -} - -func serviceConditionValue(node yaml.Node) (string, bool, error) { - var fields map[string]yaml.Node - if err := node.Decode(&fields); err != nil { - return "", false, nil - } - cond, ok := fields["condition"] - if !ok { - return "", false, nil - } - if cond.Kind != yaml.ScalarNode { - return "", true, fmt.Errorf("condition must be a scalar") - } - if cond.Tag == "!!null" { - return "", true, nil - } - return cond.Value, true, nil -} - -func projectConditionLookup(env map[string]string) func(string) string { - return func(name string) string { - if value, found := env[name]; found { - return value - } - value, _ := os.LookupEnv(name) - return value - } -} - -// Network mode values surfaced for telemetry and emitted as bicep params. -const ( - NetworkModeNone = "none" - NetworkModeByo = "byo" - NetworkModeManaged = "managed" -) - -// Default subnet names used when a subnet descriptor is omitted. -const ( - defaultAgentSubnetName = "agent-subnet" - defaultPESubnetName = "pe-subnet" -) - -// vnetIDPattern matches a Microsoft.Network/virtualNetworks ARM resource id. -var vnetIDPattern = regexp.MustCompile( - `(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+$`, -) - -// guidPattern matches a bare GUID. -var guidPattern = regexp.MustCompile( - `(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, -) - -// rgNamePattern matches a valid Azure resource group name. -var rgNamePattern = regexp.MustCompile(`^[-\w._()]{1,90}$`) - -// synthesizeNetwork validates the network: block and returns the bicep -// parameter set plus the telemetry mode. When net is nil the returned -// params disable network isolation and the output is byte-identical to the -// pre-network behavior. -// -// When resolve is true, ${VAR} references in byo.vnet.id / dns.subscription -// are expanded from env (provision path) and an unresolved variable fails. -// When resolve is false (eject path), ${VAR} references are kept verbatim so -// the synthesized parameters file stays environment-portable; the format -// checks that cannot run against an unexpanded placeholder are skipped. -func synthesizeNetwork( - net *networkBlock, - svcName string, - env map[string]string, - resolve bool, -) (map[string]any, string, error) { - // Public account: every network param defaults off. - params := map[string]any{ - "enableNetworkIsolation": false, - "useManagedEgress": false, - "vnetId": "", - "agentSubnetName": defaultAgentSubnetName, - "agentSubnetPrefix": "", - "createAgentSubnet": false, - "peSubnetName": defaultPESubnetName, - "peSubnetPrefix": "", - "createPESubnet": false, - "managedIsolationMode": "", - "dnsZonesResourceGroup": "", - "dnsZonesSubscription": "", - } - if net == nil { - return params, NetworkModeNone, nil - } - - fp := func(suffix string) string { - return fmt.Sprintf("services.%s.network%s", svcName, suffix) - } - - // Ingress: a network-bound account always gets an account private endpoint, - // so peSubnet is mandatory. There is no public data-plane fallback. - if net.PESubnet == nil { - return nil, "", fmt.Errorf("%s: private networking requires peSubnet", fp("")) - } - - // Egress: agentSubnet present injects the agent into the customer subnet; - // absent uses the Microsoft-managed network. - useManagedEgress := net.AgentSubnet == nil - - // isolationMode governs the Microsoft-managed network only. - isoMode := strings.TrimSpace(net.IsolationMode) - if isoMode != "" { - if !useManagedEgress { - return nil, "", fmt.Errorf( - "%s.isolationMode: only valid for managed egress (omit agentSubnet)", fp("")) - } - if isoMode != "AllowInternetOutbound" && isoMode != "AllowOnlyApprovedOutbound" { - return nil, "", fmt.Errorf( - "%s.isolationMode: %q is not one of AllowInternetOutbound, AllowOnlyApprovedOutbound", - fp(""), isoMode) - } - } - - // Ingress subnet (account private endpoint). - peVnet, peName, pePrefix, createPE, err := resolveSubnet(net.PESubnet, fp(".peSubnet"), env, resolve) - if err != nil { - return nil, "", err - } - vnetID := peVnet - - // Egress subnet (byo only). v1 keeps both subnets in one VNet so a single - // vnetId drives injection, the PE, and DNS linking. - if !useManagedEgress { - agentVnet, agentName, agentPrefix, createAgent, aerr := resolveSubnet( - net.AgentSubnet, fp(".agentSubnet"), env, resolve) - if aerr != nil { - return nil, "", aerr - } - if !sameVNet(agentVnet, peVnet) { - return nil, "", fmt.Errorf( - "%s: agentSubnet.vnet and peSubnet.vnet must reference the same virtual network", fp("")) - } - // The agent and PE subnets share one VNet, so their names must differ. - // Identical names would point the account private endpoint at the - // Microsoft.App/environments-delegated agent subnet (PEs cannot live in a - // delegated subnet), surfacing as a confusing deploy-time failure. - if strings.EqualFold(agentName, peName) { - return nil, "", fmt.Errorf( - "%s: agentSubnet.name and peSubnet.name must differ (both subnets share one VNet)", fp("")) - } - params["agentSubnetName"] = agentName - params["agentSubnetPrefix"] = agentPrefix - params["createAgentSubnet"] = createAgent - vnetID = agentVnet - } - - params["enableNetworkIsolation"] = true - params["useManagedEgress"] = useManagedEgress - params["vnetId"] = vnetID - params["peSubnetName"] = peName - params["peSubnetPrefix"] = pePrefix - params["createPESubnet"] = createPE - params["managedIsolationMode"] = isoMode - - if net.DNS != nil { - if rg := strings.TrimSpace(net.DNS.ResourceGroup); rg != "" { - if !rgNamePattern.MatchString(rg) { - return nil, "", fmt.Errorf("%s.dns.resourceGroup: %q is not a valid resource group name", fp(""), rg) - } - params["dnsZonesResourceGroup"] = rg - } - if sub := strings.TrimSpace(net.DNS.Subscription); sub != "" { - // Rejected before either path reads it: an unsupported '$' form is - // silently rewritten by the expander on the provision path, and - // written verbatim into the ejected template on the other. - if err := ValidateEnvReferences(sub); err != nil { - return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) - } - if resolve { - resolved, err := resolveVars(sub, env) - if err != nil { - return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) - } - sub = resolved - } - // Normalize to a bare GUID only when the value is final. On the eject - // path an unexpanded ${VAR} is normalized at provision time; once - // resolveVars has run there is nothing left to expand, so anything - // still shaped like a reference (an escaped $${VAR} resolves to a - // literal ${VAR}) is a subscription id that never will be. - if !resolve && containsVarRef(sub) { - params["dnsZonesSubscription"] = sub - } else { - guid, err := normalizeSubscription(sub) - if err != nil { - return nil, "", fmt.Errorf("%s.dns.subscription: %w", fp(""), err) - } - params["dnsZonesSubscription"] = guid - } - } - } - - mode := NetworkModeByo - if useManagedEgress { - mode = NetworkModeManaged - } - return params, mode, nil -} - -// resolveSubnet validates a subnet descriptor and returns the VNet id, subnet -// name, prefix, and whether azd should create the subnet. -// -// vnet + name -> reference existing subnet (create=false) -// vnet + name + prefix -> create subnet with that CIDR (create=true) -// -// vnet and name are required; ${VAR} references in vnet are expanded when -// resolve is true. The Microsoft.Network/virtualNetworks id shape is then -// checked, except on the eject path (resolve false), where an unexpanded -// reference is left for provision time to validate. -func resolveSubnet( - s *subnetSpec, fieldPath string, env map[string]string, resolve bool, -) (vnetID, name, prefix string, create bool, err error) { - if s == nil { - return "", "", "", false, fmt.Errorf("%s: required", fieldPath) - } - vnetID = strings.TrimSpace(s.VNet) - name = strings.TrimSpace(s.Name) - prefix = strings.TrimSpace(s.Prefix) - - if vnetID == "" { - return "", "", "", false, fmt.Errorf("%s.vnet: required", fieldPath) - } - if name == "" { - return "", "", "", false, fmt.Errorf("%s.name: required", fieldPath) - } - // Rejected on both paths: see the dns.subscription call for why this cannot - // wait for resolveVars. - if err := ValidateEnvReferences(vnetID); err != nil { - return "", "", "", false, fmt.Errorf("%s.vnet: %w", fieldPath, err) - } - if resolve { - resolved, rerr := resolveVars(vnetID, env) - if rerr != nil { - return "", "", "", false, fmt.Errorf("%s.vnet: %w", fieldPath, rerr) - } - vnetID = resolved - } - // Validate the ARM id shape unless the value can still change: on the eject - // path an unexpanded ${VAR} is validated at provision time. After - // resolveVars there is nothing left to expand, so a leftover ${VAR} (what an - // escaped $${VAR} resolves to) is checked now rather than deferred to a - // provision that can only fail. - if (resolve || !containsVarRef(vnetID)) && !vnetIDPattern.MatchString(vnetID) { - return "", "", "", false, fmt.Errorf( - "%s.vnet: %q is not a well-formed Microsoft.Network/virtualNetworks id", fieldPath, vnetID) - } - if prefix != "" { - if _, _, perr := net.ParseCIDR(prefix); perr != nil { - return "", "", "", false, fmt.Errorf("%s.prefix: %q is not a valid CIDR", fieldPath, prefix) - } - create = true - } - return vnetID, name, prefix, create, nil -} - -// sameVNet reports whether two VNet references point at the same VNet. Concrete -// ids compare case-insensitively (ARM ids are case-insensitive); unresolved -// ${VAR} references compare verbatim. -func sameVNet(a, b string) bool { - a = strings.TrimSpace(a) - b = strings.TrimSpace(b) - if containsVarRef(a) || containsVarRef(b) { - return a == b - } - return strings.EqualFold(a, b) -} - -// containsVarRef reports whether s still carries an azd ${VAR} reference the -// expander will resolve, including the ${VAR:-default} form. -// -// Escaped references and names reserved by a Foundry ${{...}} span do not count: -// [foundry.ExpandEnv] leaves those alone, so the value is already as concrete as -// it will ever be and the caller's own shape validation should run on it. -func containsVarRef(s string) bool { - return len(FindEnvReferences(s)) > 0 -} - -// resolveVars expands ${VAR} references in s using env first, then the -// process environment. An unresolved reference is an error naming the -// variable. -// -// Expansion routes through foundry.ExpandEnv, the shared expander every other -// Foundry field uses, so ${VAR:-default} and the $${VAR} escape behave here -// exactly as they do elsewhere. -// -// ExpandEnv resolves through a mapping callback that only receives the variable -// name, so the names that must resolve are collected up front from -// [FindEnvReferences]: a name is required only where it occurs at least once -// without a :- default, in a position the expander will actually act on. Reusing -// that scanner is what keeps an escaped or ${{...}} reserved occurrence from -// making a live, defaulted occurrence of the same name look unresolvable. -// -// Callers validate the value with [ValidateEnvReferences] first, so every -// occurrence the expander acts on is one the scanner saw. -func resolveVars(s string, env map[string]string) (string, error) { - required := map[string]struct{}{} - for _, reference := range FindEnvReferences(s) { - if !reference.HasDefault { - required[reference.Name] = struct{}{} - } - } - - var unresolved string - out, err := foundry.ExpandEnv(s, func(name string) string { - if v, ok := env[name]; ok { - return v - } - if v, ok := os.LookupEnv(name); ok { - return v - } - if _, ok := required[name]; ok && unresolved == "" { - unresolved = name - } - return "" - }) - if err != nil { - return "", err - } - if unresolved != "" { - return "", fmt.Errorf("unresolved environment variable ${%s}", unresolved) - } - return out, nil -} - -// ResolveEnvironmentValue expands environment references. -func ResolveEnvironmentValue(value string, env map[string]string) (string, error) { - return resolveVars(value, env) -} - -// normalizeSubscription accepts a bare GUID or a /subscriptions/[/...] -// path and returns the bare GUID. -func normalizeSubscription(s string) (string, error) { - s = strings.TrimSpace(s) - if guidPattern.MatchString(s) { - return s, nil - } - if strings.HasPrefix(strings.ToLower(s), "/subscriptions/") { - parts := strings.Split(strings.Trim(s, "/"), "/") - if len(parts) >= 2 && guidPattern.MatchString(parts[1]) { - return parts[1], nil - } - } - return "", errors.New("expected a subscription GUID or /subscriptions/ id") -} 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 deleted file mode 100644 index c65065adc59..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ /dev/null @@ -1,1478 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSynthesize(t *testing.T) { - tests := []struct { - name string - yaml string - serviceName string - - wantErr error - wantDeployLen int - wantIncludeAcr bool - // wantDeployName0, if non-empty, asserts the name of the first deployment. - wantDeployName0 string - }{ - { - name: "greenfield hosted agent with docker", - yaml: ` -name: my-foundry-agent -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: - format: OpenAI - name: gpt-4.1-mini - version: "2025-04-14" - sku: - capacity: 10 - name: GlobalStandard - agents: - - name: my-agent - kind: hosted - project: src/my-agent - docker: - path: Dockerfile - remoteBuild: true -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: true, - wantDeployName0: "gpt-4.1-mini", - }, - { - name: "split project with sibling docker agent => ACR on", - yaml: ` -name: my-foundry-agent -services: - my-agent: - host: azure.ai.agent - project: src/my-agent - uses: - - my-project - docker: - path: Dockerfile - remoteBuild: true - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: - format: OpenAI - name: gpt-4.1-mini - version: "2025-04-14" - sku: - capacity: 10 - name: GlobalStandard -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: true, - wantDeployName0: "gpt-4.1-mini", - }, - { - name: "split project with sibling docker agent and image => no ACR", - yaml: ` -services: - my-agent: - host: azure.ai.agent - project: src/my-agent - uses: - - my-project - image: myprivacr.azurecr.io/agents/my-agent:v1 - docker: - path: Dockerfile - remoteBuild: true - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "legacy inline docker agent with image => no ACR", - yaml: ` -services: - my-project: - host: azure.ai.project - agents: - - name: my-agent - kind: hosted - image: myprivacr.azurecr.io/agents/my-agent:v1 - docker: - path: Dockerfile -`, - serviceName: "my-project", - wantDeployLen: 0, - wantIncludeAcr: false, - }, - { - name: "greenfield hosted agent runtime-only (no docker) => ACR on", - yaml: ` -name: my-foundry-agent -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: - format: OpenAI - name: gpt-4.1-mini - version: "2025-04-14" - sku: - capacity: 10 - name: GlobalStandard - agents: - - name: my-agent - kind: hosted - project: src/my-agent - runtime: - stack: python - version: "3.12" -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: true, - }, - { - // Schema-conformant hand-authored shape (see schemas/examples/simple.azure.yaml): - // hosted agent built from source with no docker:/image:/codeConfiguration:. - name: "schema-conformant hosted agent, no docker => ACR on", - yaml: ` -services: - assistant: - host: azure.ai.agent - project: ./agents/assistant - kind: hosted - name: assistant - uses: - - ai-project - ai-project: - host: azure.ai.project - deployments: - - name: gpt-4o-mini - model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "ai-project", - wantDeployLen: 1, - wantIncludeAcr: true, - }, - { - name: "sibling hosted agent, kind omitted defaults hosted => ACR on", - yaml: ` -services: - assistant: - host: azure.ai.agent - project: ./agents/assistant - name: assistant - ai-project: - host: azure.ai.project - deployments: - - name: gpt-4o-mini - model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "ai-project", - wantDeployLen: 1, - wantIncludeAcr: true, - }, - { - name: "sibling hosted agent with codeConfiguration => no ACR", - yaml: ` -services: - assistant: - host: azure.ai.agent - project: ./agents/assistant - kind: hosted - name: assistant - codeConfiguration: - runtime: python_3_13 - entryPoint: app.py - ai-project: - host: azure.ai.project - deployments: - - name: gpt-4o-mini - model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "ai-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "sibling hosted agent with image, no docker => no ACR", - yaml: ` -services: - assistant: - host: azure.ai.agent - project: ./agents/assistant - kind: hosted - name: assistant - image: myprivacr.azurecr.io/agents/assistant:v1 - ai-project: - host: azure.ai.project - deployments: - - name: gpt-4o-mini - model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "ai-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "legacy configured agent with image => no ACR", - yaml: ` -services: - assistant: - host: azure.ai.agent - config: - kind: hosted - image: myprivacr.azurecr.io/agents/assistant:v1 - ai-project: - host: azure.ai.project -`, - serviceName: "ai-project", - wantIncludeAcr: false, - }, - { - name: "legacy configured agent with codeConfiguration => no ACR", - yaml: ` -services: - assistant: - host: azure.ai.agent - config: - kind: hosted - codeConfiguration: - runtime: python_3_13 - entryPoint: app.py - ai-project: - host: azure.ai.project -`, - serviceName: "ai-project", - wantIncludeAcr: false, - }, - { - name: "inline hosted agent with codeConfiguration => no ACR", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} - agents: - - name: my-agent - kind: hosted - project: src/my-agent - codeConfiguration: - runtime: dotnet_10 - entryPoint: MyAgent.dll -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "prompt-only agent (no project/runtime/docker)", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: - format: OpenAI - name: gpt-4.1-mini - version: "2025-04-14" - sku: - capacity: 10 - name: GlobalStandard - agents: - - name: triage-agent - kind: prompt - instructions: route the user -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "mixed: one runtime agent and one docker agent => ACR on", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1 - model: - format: OpenAI - name: gpt-4.1 - version: "2025-04-14" - sku: - capacity: 50 - name: GlobalStandard - agents: - - name: support-agent - kind: hosted - project: src/support-agent - runtime: {stack: python, version: "3.12"} - - name: research-agent - kind: hosted - project: src/research-agent - docker: {path: Dockerfile, remoteBuild: true} -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: true, - }, - { - name: "no deployments declared => empty array, not nil", - yaml: ` -services: - my-project: - host: azure.ai.project - agents: - - name: prompt-agent - kind: prompt - instructions: hi -`, - serviceName: "my-project", - wantDeployLen: 0, - wantIncludeAcr: false, - }, - { - name: "ignores inline skills and routines", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} - skills: - - name: s1 - instructions: hi - routines: - - name: r1 - agent: prompt-agent - trigger: {type: schedule, cron: "0 8 * * *"} - agents: - - name: prompt-agent - kind: prompt - instructions: hi -`, - serviceName: "my-project", - wantDeployLen: 1, - wantIncludeAcr: false, - }, - { - name: "ignores sibling azure.ai.connection services", - yaml: ` -services: - my-project: - host: azure.ai.project - search-conn: - host: azure.ai.connection - uses: [my-project] - category: CognitiveSearch - target: https://my-search.search.windows.net - authType: ApiKey - credentials: - key: static-key - bing-conn: - host: azure.ai.connection - uses: [my-project] - category: ApiKey - target: https://api.bing.microsoft.com - authType: ApiKey -`, - serviceName: "my-project", - wantDeployLen: 0, - wantIncludeAcr: false, - }, - { - name: "model-only project", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "my-project", - wantDeployLen: 1, - }, - { - name: "brownfield: endpoint set => ErrEndpointBrownfield", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, - }, - { - name: "brownfield: endpoint + network => network ignored, still ErrEndpointBrownfield", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 - network: - peSubnet: {vnet: /subscriptions/s/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/v, name: pe} -`, - serviceName: "my-project", - wantErr: ErrEndpointBrownfield, - }, - { - name: "blank endpoint is treated as greenfield", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: " " -`, - serviceName: "my-project", - }, - { - name: "service not found", - yaml: ` -services: - my-project: - host: azure.ai.project -`, - serviceName: "nope", - wantErr: ErrServiceNotFound, - }, - { - name: "wrong host treated as not found", - yaml: ` -services: - webapp: - host: containerapp - project: src/web -`, - serviceName: "webapp", - wantErr: ErrServiceNotFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - res, err := Synthesize(Input{ - RawAzureYAML: []byte(tt.yaml), - ServiceName: tt.serviceName, - AcceptedHosts: []string{"azure.ai.project"}, - }) - - if tt.wantErr != nil { - require.Error(t, err) - assert.True(t, errors.Is(err, tt.wantErr), "got %v, want %v", err, tt.wantErr) - return - } - - require.NoError(t, err) - require.NotNil(t, res) - - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - assert.Len(t, deployments, tt.wantDeployLen) - if tt.wantDeployName0 != "" { - require.NotEmpty(t, deployments) - assert.Equal(t, tt.wantDeployName0, deployments[0].Name) - } - - includeAcr, ok := res.Parameters["includeAcr"].(bool) - require.True(t, ok, "includeAcr param should be bool") - assert.Equal(t, tt.wantIncludeAcr, includeAcr) - - assert.NotContains(t, res.Parameters, "connections") - assert.NotContains(t, res.Parameters, "connectionCredentials") - }) - } -} - -func TestBrownfieldDeployments(t *testing.T) { - tests := []struct { - name string - yaml string - serviceName string - - wantErr error - wantLen int - wantName0 string - wantVersion string - }{ - { - name: "endpoint set with deployments returns them", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 - deployments: - - name: gpt-4.1-mini-new - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} -`, - serviceName: "my-project", - wantLen: 1, - wantName0: "gpt-4.1-mini-new", - wantVersion: "2025-04-14", - }, - { - name: "endpoint set, multiple deployments", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 - deployments: - - name: gpt-4.1 - model: {format: OpenAI, name: gpt-4.1, version: "2025-04-14"} - sku: {capacity: 50, name: GlobalStandard} - - name: text-embedding-3-large - model: {format: OpenAI, name: text-embedding-3-large, version: "1"} - sku: {capacity: 120, name: Standard} -`, - serviceName: "my-project", - wantLen: 2, - wantName0: "gpt-4.1", - }, - { - name: "endpoint set, no deployments => nil", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 -`, - serviceName: "my-project", - wantLen: 0, - }, - { - name: "service not found", - yaml: ` -services: - my-project: - host: azure.ai.project - endpoint: https://existing.services.ai.azure.com/api/projects/p1 -`, - serviceName: "nope", - wantErr: ErrServiceNotFound, - }, - { - name: "empty service name", - yaml: "services: {}", - serviceName: "", - wantErr: nil, // returns a non-typed error; asserted below - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := BrownfieldDeployments( - []byte(tt.yaml), - tt.serviceName, - "", - ) - - if tt.serviceName == "" { - require.Error(t, err) - return - } - if tt.wantErr != nil { - require.Error(t, err) - assert.True(t, errors.Is(err, tt.wantErr), "got %v, want %v", err, tt.wantErr) - return - } - - require.NoError(t, err) - assert.Len(t, got, tt.wantLen) - if tt.wantName0 != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantName0, got[0].Name) - } - if tt.wantVersion != "" { - require.NotEmpty(t, got) - assert.Equal(t, tt.wantVersion, got[0].Model.Version) - } - }) - } -} - -func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { - _, err := BrownfieldDeployments(nil, "my-project", "") - require.Error(t, err) -} - -// TestSynthesize_EndpointVarReference covers the portable endpoint shape -// `azd ai agent init` writes: endpoint: ${FOUNDRY_PROJECT_ENDPOINT}. The -// variable is expanded before the brownfield test, so the same azure.yaml -// provisions a new project when the variable is unset and reuses an existing -// one when it is set. Without expansion the unset case would take the -// brownfield branch and then try to call a literal "${...}" URL. -func TestSynthesize_EndpointVarReference(t *testing.T) { - // Not parallel: the greenfield case pins FOUNDRY_PROJECT_ENDPOINT to empty - // via t.Setenv so a developer who exports it locally still sees the unset - // behavior (expandEndpoint falls back to the process environment). - const yamlDoc = ` -services: - ai-project: - host: azure.ai.project - endpoint: ${FOUNDRY_PROJECT_ENDPOINT} -` - const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" - - t.Run("unset variable is greenfield", func(t *testing.T) { - t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") - - res, err := Synthesize(Input{ - RawAzureYAML: []byte(yamlDoc), - ServiceName: "ai-project", - Env: map[string]string{}, - }) - require.NoError(t, err) - require.NotNil(t, res) - }) - - t.Run("set variable is brownfield", func(t *testing.T) { - _, err := Synthesize(Input{ - RawAzureYAML: []byte(yamlDoc), - ServiceName: "ai-project", - Env: map[string]string{"FOUNDRY_PROJECT_ENDPOINT": endpoint}, - }) - require.ErrorIs(t, err, ErrEndpointBrownfield) - }) - - t.Run("eject keeps the greenfield/brownfield split", func(t *testing.T) { - // PreserveVarRefs only governs the values written out, never which - // branch is taken, so eject must agree with provision. - _, err := Synthesize(Input{ - RawAzureYAML: []byte(yamlDoc), - ServiceName: "ai-project", - Env: map[string]string{"FOUNDRY_PROJECT_ENDPOINT": endpoint}, - PreserveVarRefs: true, - }) - require.ErrorIs(t, err, ErrEndpointBrownfield) - }) - - t.Run("ProjectEndpoint resolves the reference", func(t *testing.T) { - got, err := ProjectEndpoint( - []byte(yamlDoc), "ai-project", "", - map[string]string{"FOUNDRY_PROJECT_ENDPOINT": endpoint}, - ) - require.NoError(t, err) - require.Equal(t, endpoint, got) - }) -} - -func TestBrownfieldDeployments_ResolvesFileRef( - t *testing.T, -) { - t.Parallel() - - root := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(root, "deployment.yaml"), - []byte( - "name: gpt-4o\n"+ - "model:\n"+ - " format: OpenAI\n"+ - " name: gpt-4o\n"+ - " version: \"2024-08-06\"\n"+ - "sku:\n"+ - " capacity: 10\n"+ - " name: GlobalStandard\n", - ), - 0o600, - )) - raw := []byte(`services: - my-project: - host: azure.ai.project - endpoint: https://example - deployments: - - $ref: ./deployment.yaml -`) - - deployments, err := BrownfieldDeployments( - raw, - "my-project", - root, - ) - - require.NoError(t, err) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) -} - -func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { - // Eject path: ${VAR} references must pass through verbatim (and skip the - // format checks that cannot run on an unexpanded placeholder), so the - // ejected main.parameters.json stays environment-portable. - yaml := ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} - dns: - resourceGroup: rg-dns - subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" -` - res, err := Synthesize(Input{ - RawAzureYAML: []byte(yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - PreserveVarRefs: true, - }) - require.NoError(t, err, "unset ${VAR} must not fail on the eject path") - require.NotNil(t, res) - assert.Equal(t, "${AZURE_VNET_ID}", res.Parameters["vnetId"]) - assert.Equal(t, "${AZURE_DNS_SUBSCRIPTION_ID}", res.Parameters["dnsZonesSubscription"]) - assert.Equal(t, "rg-dns", res.Parameters["dnsZonesResourceGroup"]) -} - -func TestSynthesize_NetworkPreserveVarRefs_StillValidatesConcrete(t *testing.T) { - // PreserveVarRefs only skips checks for unexpanded placeholders; a - // concrete-but-malformed value still fails on the eject path. - yaml := ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: not-an-arm-id, name: pe-subnet} -` - _, err := Synthesize(Input{ - RawAzureYAML: []byte(yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - PreserveVarRefs: true, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "not a well-formed") -} - -func TestSynthesize_ResolvesDeploymentRef(t *testing.T) { - // A deployment item authored as a $ref must be loaded so synthesis sees the - // real deployment, not a zero-valued {"$ref": ...} placeholder. - root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, "deployments"), 0750)) - require.NoError(t, os.WriteFile(filepath.Join(root, "deployments", "gpt-4o.yaml"), []byte( - "name: gpt-4o\nmodel:\n name: gpt-4o\n format: OpenAI\n version: \"2024-08-06\"\nsku:\n name: Standard\n capacity: 10\n"), - 0600)) - - yaml := ` -services: - my-project: - host: azure.ai.project - deployments: - - $ref: ./deployments/gpt-4o.yaml -` - res, err := Synthesize(Input{ - RawAzureYAML: []byte(yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - ProjectRoot: root, - }) - require.NoError(t, err) - deployments, ok := res.Parameters["deployments"].([]Deployment) - require.True(t, ok, "deployments param should be []Deployment, got %T", res.Parameters["deployments"]) - require.Len(t, deployments, 1) - assert.Equal(t, "gpt-4o", deployments[0].Name) - assert.Equal(t, "gpt-4o", deployments[0].Model.Name) - assert.Equal(t, 10, deployments[0].Sku.Capacity) -} - -func TestSynthesize_RequiresAgentImageInline(t *testing.T) { - t.Parallel() - - root := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(root, "agent.yaml"), - []byte( - "kind: hosted\n"+ - "name: referenced-agent\n", - ), - 0o600, - )) - raw := []byte(`services: - my-project: - host: azure.ai.project - referenced-agent: - host: azure.ai.agent - image: registry.example/agent:v1 - $ref: ./agent.yaml -`) - - result, err := Synthesize(Input{ - RawAzureYAML: raw, - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - ProjectRoot: root, - }) - - require.NoError(t, err) - includeAcr, ok := result.Parameters["includeAcr"].(bool) - require.True(t, ok) - assert.False(t, includeAcr) -} - -func TestSynthesize_RejectsCoreFieldsFromAgentRef(t *testing.T) { - t.Parallel() - - root := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(root, "agent.yaml"), - []byte( - "kind: hosted\n"+ - "name: referenced-agent\n"+ - "image: registry.example/agent:v1\n", - ), - 0o600, - )) - raw := []byte(`services: - my-project: - host: azure.ai.project - referenced-agent: - host: azure.ai.agent - $ref: ./agent.yaml -`) - - _, err := Synthesize(Input{ - RawAzureYAML: raw, - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - ProjectRoot: root, - }) - - require.ErrorContains(t, err, `core field "image"`) -} - -func TestSynthesize_ResolvesAgentHostFromRefForAcr(t *testing.T) { - t.Parallel() - - root := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(root, "agent.yaml"), - []byte( - "host: azure.ai.agent\n"+ - "kind: hosted\n"+ - "name: referenced-agent\n", - ), - 0o600, - )) - raw := []byte(`services: - my-project: - host: azure.ai.project - referenced-agent: - $ref: ./agent.yaml -`) - - result, err := Synthesize(Input{ - RawAzureYAML: raw, - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - ProjectRoot: root, - }) - - require.NoError(t, err) - includeAcr, ok := result.Parameters["includeAcr"].(bool) - require.True(t, ok) - assert.True(t, includeAcr) -} - -func TestSynthesize_SkipsRefsOnUnrelatedServices(t *testing.T) { - t.Parallel() - - raw := []byte(`services: - my-project: - host: azure.ai.project - unrelated: - host: containerapp - api: - $ref: ./missing-openapi.json -`) - - result, err := Synthesize(Input{ - RawAzureYAML: raw, - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - ProjectRoot: t.TempDir(), - }) - - require.NoError(t, err) - require.NotNil(t, result) - assert.Equal(t, false, result.Parameters["includeAcr"]) - assert.Empty(t, result.Parameters["connections"]) -} - -func TestSynthesize_InputValidation(t *testing.T) { - tests := []struct { - name string - in Input - want string - }{ - { - name: "empty yaml", - in: Input{ServiceName: "x"}, - want: "RawAzureYAML is empty", - }, - { - name: "empty service name", - in: Input{RawAzureYAML: []byte("services:\n x:\n host: azure.ai.project\n")}, - want: "ServiceName is empty", - }, - { - name: "malformed yaml", - in: Input{ - RawAzureYAML: []byte("services: [this is not a map"), - ServiceName: "x", - }, - want: "parse azure.yaml", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Synthesize(tt.in) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.want) - }) - } -} - -func TestTemplatesFS_Embedded(t *testing.T) { - fs := TemplatesFS() - - wantFiles := []string{ - "templates/main.bicep", - "templates/main.arm.json", - "templates/abbreviations.json", - "templates/modules/acr.bicep", - "templates/modules/acr-pull-role-assignment.bicep", - "templates/modules/network.bicep", - "templates/modules/subnet.bicep", - "templates/modules/private-endpoint-dns.bicep", - } - for _, p := range wantFiles { - t.Run(p, func(t *testing.T) { - data, err := fs.ReadFile(p) - require.NoError(t, err) - assert.NotEmpty(t, data, "%s should not be empty", p) - }) - } -} - -func TestTerraformTemplatesFS_Embedded(t *testing.T) { - fs := TerraformTemplatesFS() - - wantFiles := []string{ - "templates/terraform/provider.tf", - "templates/terraform/variables.tf", - "templates/terraform/main.tf", - "templates/terraform/container-registry.tf", - "templates/terraform/outputs.tf.tmpl", - } - for _, p := range wantFiles { - t.Run(p, func(t *testing.T) { - data, err := fs.ReadFile(p) - require.NoError(t, err) - assert.NotEmpty(t, data, "%s should not be empty", p) - }) - } - // outputs.tf is rendered from outputs.tf.tmpl at eject time, and - // main.tfvars.json is generated -- neither is embedded as a final file - // (otherwise they would go stale). - for _, p := range []string{ - "templates/terraform/outputs.tf", - "templates/terraform/main.tfvars.json", - } { - _, err := fs.ReadFile(p) - assert.Error(t, err, "%s must not be embedded; it is generated at eject time", p) - } -} - -// TestTerraformModule_DerivesNamesWhenEmpty guards the regression where unset -// AZURE_AI_PROJECT_NAME / AZURE_RESOURCE_GROUP substituted to "" in -// main.tfvars.json and failed at plan time (foundry_project_name validation / -// "name cannot be blank" on the resource group). The fix: main.tf derives both -// names from environment_name when the corresponding var is empty. This asserts -// the embedded templates still carry those fallbacks so they cannot regress. -func TestTerraformModule_DerivesNamesWhenEmpty(t *testing.T) { - fs := TerraformTemplatesFS() - - vars, err := fs.ReadFile("templates/terraform/variables.tf") - require.NoError(t, err) - // Empty must be accepted by the variable validation (not a hard 3-32 regex). - assert.Contains(t, string(vars), `var.foundry_project_name == ""`, - "variables.tf must allow an empty foundry_project_name (empty => derive from env)") - - main, err := fs.ReadFile("templates/terraform/main.tf") - require.NoError(t, err) - // main.tf must compute an effective project name with an env-name fallback. - assert.Contains(t, string(main), "derived_project_name", - "main.tf must derive a project name when foundry_project_name is empty") - assert.Contains(t, string(main), "local.foundry_project_name", - "the project resource must use the derived local, not the raw variable") - // main.tf must compute an effective resource group name with a fallback. - assert.Contains(t, string(main), "local.resource_group_name", - "the resource group must use the derived local, not the raw variable") - assert.Contains(t, string(main), `"rg-${local.derived_rg_suffix}"`, - "main.tf must derive an rg-{env} name when resource_group_name is empty") - assert.NotContains(t, string(vars), `variable "principal_id"`) - assert.NotContains(t, string(vars), `variable "principal_type"`) - assert.NotContains(t, string(main), `developer_cognitive_services_user`) -} - -func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { - data, err := ARMTemplate() - require.NoError(t, err) - require.NotEmpty(t, data) - - var arm map[string]any - require.NoError(t, json.Unmarshal(data, &arm), "ARM template must be valid JSON") - - // Sanity-check the ARM document is what we expect. - assert.Contains(t, arm, "$schema") - assert.Contains(t, arm, "resources") - assert.Contains(t, arm, "parameters") - - // The template is subscription-scoped so `azd provision --preview` can run - // what-if without creating the resource group first. - schema, _ := arm["$schema"].(string) - assert.Contains(t, schema, "subscriptionDeploymentTemplate", - "main.bicep must target subscription scope") - - // resourceGroupName is the parameter that drives the resource group the - // template creates; the provider supplies it at provision time. - params, ok := arm["parameters"].(map[string]any) - require.True(t, ok, "parameters must be an object") - assert.Contains(t, params, "resourceGroupName") - assert.NotContains(t, params, "principalId") - assert.NotContains(t, params, "principalType") - - assert.NotContains(t, params, "connections") - assert.NotContains(t, params, "connectionCredentials") - - // Network isolation parameters must exist so the synthesizer's network - // param set is accepted by ARM (extra params would fail the deployment). - for _, p := range []string{ - "enableNetworkIsolation", "useManagedEgress", "vnetId", - "agentSubnetName", "agentSubnetPrefix", "createAgentSubnet", - "peSubnetName", "peSubnetPrefix", "createPESubnet", - "managedIsolationMode", "dnsZonesResourceGroup", "dnsZonesSubscription", - } { - assert.Contains(t, params, p, "network param %q must be declared in the ARM template", p) - } - - // The old mode-enum param must be gone; egress is driven by useManagedEgress. - assert.NotContains(t, params, "networkMode", - "networkMode param was replaced by useManagedEgress") - - // Secure-by-default lock: the account data plane must be private whenever - // network isolation is on. The compiled template must gate public access on - // enableNetworkIsolation (not on egress mode), so a network-bound account is - // never left public. This is the regression guard for the data-plane fix. - text := string(data) - assert.NotContains(t, text, "developerCognitiveServicesUser") - assert.NotContains(t, text, "a97b65f3-24c7-4388-baec-2e87135dc908") - assert.Contains(t, text, "acrPullRoleId", "ACR pull assignment must remain") - wantDisable := `"disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]"` - wantPublic := `"publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]"` - assert.Contains(t, text, wantDisable, - "public data-plane access must be disabled for every network-isolated account") - assert.Contains(t, text, wantPublic, - "account publicNetworkAccess must follow disablePublicDataPlaneAccess") - - // Egress injection shape: byo injects into the customer subnet - // (useMicrosoftManagedNetwork=false), managed uses the Microsoft-managed - // network (useMicrosoftManagedNetwork=true). Both branches must survive - // compilation so the account gets the right networkInjections per mode. - assert.Contains(t, text, "'useMicrosoftManagedNetwork', false()", - "byo egress must inject the agent subnet (useMicrosoftManagedNetwork=false)") - assert.Contains(t, text, "'useMicrosoftManagedNetwork', true()", - "managed egress must use the Microsoft-managed network (useMicrosoftManagedNetwork=true)") - assert.Contains(t, text, `"networkInjections": "[variables('agentNetworkInjections')]"`, - "account must carry the computed networkInjections") - - // isolationMode must be wired to the V2 managed network child resource - // (regression guard: it was previously a no-op echoed only to output). - assert.Contains(t, text, `"type": "Microsoft.CognitiveServices/accounts/managedNetworks"`, - "managed isolationMode must provision a managedNetworks child resource") - assert.Contains(t, text, `"isolationMode": "[parameters('managedIsolationMode')]"`, - "managedNetworks isolationMode must come from the managedIsolationMode param") -} - -func TestSynthesize_Network(t *testing.T) { - t.Setenv("AZURE_VNET_ID", - "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ - "providers/Microsoft.Network/virtualNetworks/my-vnet") - - const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + - "providers/Microsoft.Network/virtualNetworks/my-vnet" - - tests := []struct { - name string - yaml string - wantMode string - check func(t *testing.T, p map[string]any) - }{ - { - name: "no network block => public account, isolation off", - yaml: ` -services: - my-project: - host: azure.ai.project - deployments: - - name: gpt-4.1-mini - model: {format: OpenAI, name: gpt-4.1-mini, version: "2025-04-14"} - sku: {capacity: 10, name: GlobalStandard} -`, - wantMode: NetworkModeNone, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, false, p["enableNetworkIsolation"]) - assert.Equal(t, false, p["useManagedEgress"]) - }, - }, - { - name: "byo egress (agentSubnet present) with explicit subnets => create both", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - agentSubnet: {vnet: ` + validVNet + `, name: agent-subnet, prefix: 192.168.0.0/24} - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} - dns: - resourceGroup: rg-private-dns - subscription: 22222222-2222-2222-2222-222222222222 -`, - wantMode: NetworkModeByo, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, true, p["enableNetworkIsolation"]) - assert.Equal(t, false, p["useManagedEgress"]) - assert.Equal(t, validVNet, p["vnetId"]) - assert.Equal(t, "agent-subnet", p["agentSubnetName"]) - assert.Equal(t, "192.168.0.0/24", p["agentSubnetPrefix"]) - assert.Equal(t, true, p["createAgentSubnet"]) - assert.Equal(t, true, p["createPESubnet"]) - assert.Equal(t, "rg-private-dns", p["dnsZonesResourceGroup"]) - assert.Equal(t, "22222222-2222-2222-2222-222222222222", p["dnsZonesSubscription"]) - }, - }, - { - name: "subnet without prefix => reference (create=false)", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - agentSubnet: {vnet: ` + validVNet + `, name: existing-agent} - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} -`, - wantMode: NetworkModeByo, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, "existing-agent", p["agentSubnetName"]) - assert.Equal(t, false, p["createAgentSubnet"]) - assert.Equal(t, "pe-subnet", p["peSubnetName"]) - assert.Equal(t, true, p["createPESubnet"]) - }, - }, - { - name: "subnet vnet from ${VAR}", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} -`, - wantMode: NetworkModeManaged, - check: func(t *testing.T, p map[string]any) { - assert.Contains(t, p["vnetId"], "/virtualNetworks/my-vnet") - }, - }, - { - name: "managed egress (agentSubnet absent) with isolation", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - isolationMode: AllowOnlyApprovedOutbound - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} -`, - wantMode: NetworkModeManaged, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, true, p["enableNetworkIsolation"]) - assert.Equal(t, true, p["useManagedEgress"]) - assert.Equal(t, false, p["createAgentSubnet"]) - assert.Equal(t, "AllowOnlyApprovedOutbound", p["managedIsolationMode"]) - }, - }, - { - name: "dns subscription normalized from /subscriptions/", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} - dns: - resourceGroup: rg-dns - subscription: /subscriptions/33333333-3333-3333-3333-333333333333 -`, - wantMode: NetworkModeManaged, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, "33333333-3333-3333-3333-333333333333", p["dnsZonesSubscription"]) - }, - }, - { - name: "managed egress, isolationMode unset => empty managedIsolationMode", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet, prefix: 192.168.1.0/24} -`, - wantMode: NetworkModeManaged, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, true, p["useManagedEgress"]) - assert.Equal(t, "", p["managedIsolationMode"]) - assert.Equal(t, true, p["createPESubnet"]) - }, - }, - { - name: "managed egress, AllowInternetOutbound with referenced peSubnet", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - isolationMode: AllowInternetOutbound - peSubnet: {vnet: ` + validVNet + `, name: existing-pe} -`, - wantMode: NetworkModeManaged, - check: func(t *testing.T, p map[string]any) { - assert.Equal(t, true, p["useManagedEgress"]) - assert.Equal(t, "AllowInternetOutbound", p["managedIsolationMode"]) - assert.Equal(t, "existing-pe", p["peSubnetName"]) - assert.Equal(t, false, p["createPESubnet"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - res, err := Synthesize(Input{ - RawAzureYAML: []byte(tt.yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - }) - require.NoError(t, err) - require.NotNil(t, res) - assert.Equal(t, tt.wantMode, res.NetworkMode) - if tt.check != nil { - tt.check(t, res.Parameters) - } - }) - } -} - -func TestSynthesize_RejectsAutoCreatedAcrWithPrivateNetworking(t *testing.T) { - const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + - "providers/Microsoft.Network/virtualNetworks/my-vnet" - const yaml = ` -services: - assistant: - host: azure.ai.agent - kind: hosted - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: ` + validVNet + `, name: pe-subnet} -` - - _, err := Synthesize(Input{ - RawAzureYAML: []byte(yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - }) - require.ErrorContains(t, err, "does not support an auto-created Azure Container Registry") -} - -func TestSynthesize_NetworkValidationErrors(t *testing.T) { - const validVNet = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + - "providers/Microsoft.Network/virtualNetworks/my-vnet" - const validVNet2 = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/" + - "providers/Microsoft.Network/virtualNetworks/other-vnet" - - tests := []struct { - name string - yaml string - wantSub string - }{ - { - name: "network present but peSubnet missing", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - isolationMode: AllowInternetOutbound -`, - wantSub: "private networking requires peSubnet", - }, - { - name: "isolationMode with agentSubnet present", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - isolationMode: AllowInternetOutbound - agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} - peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: 192.168.1.0/24} -`, - wantSub: "only valid for managed egress", - }, - { - name: "agentSubnet and peSubnet in different vnets", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - agentSubnet: {vnet: ` + validVNet + `, name: a, prefix: 192.168.0.0/24} - peSubnet: {vnet: ` + validVNet2 + `, name: pe, prefix: 192.168.1.0/24} -`, - wantSub: "same virtual network", - }, - { - name: "agentSubnet and peSubnet share a name in one vnet", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - agentSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.0.0/24} - peSubnet: {vnet: ` + validVNet + `, name: shared, prefix: 192.168.1.0/24} -`, - wantSub: "agentSubnet.name and peSubnet.name must differ", - }, - { - name: "subnet missing vnet", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {name: pe} -`, - wantSub: "peSubnet.vnet: required", - }, - { - name: "subnet missing name", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: ` + validVNet + `} -`, - wantSub: "peSubnet.name: required", - }, - { - name: "malformed vnet id", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: not-an-arm-id, name: pe} -`, - wantSub: "not a well-formed", - }, - { - name: "subnet invalid cidr", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: ` + validVNet + `, name: pe, prefix: not-a-cidr} -`, - wantSub: "not a valid CIDR", - }, - { - name: "unresolved var", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - peSubnet: {vnet: "${DEFINITELY_NOT_SET_VAR_XYZ}", name: pe} -`, - wantSub: "unresolved environment variable", - }, - { - name: "bad managed isolation mode", - yaml: ` -services: - my-project: - host: azure.ai.project - network: - isolationMode: Wide - peSubnet: {vnet: ` + validVNet + `, name: pe} -`, - wantSub: "isolationMode", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Synthesize(Input{ - RawAzureYAML: []byte(tt.yaml), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantSub) - // Errors carry the service-scoped field path. - assert.Contains(t, err.Error(), "services.my-project.network") - }) - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl deleted file mode 100644 index 2e77d82aed0..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project-eject.bicep.tmpl +++ /dev/null @@ -1,120 +0,0 @@ -// Editable infrastructure for an existing Foundry project. The account and -// project are referenced only. ACR behavior was selected when this file was -// generated, so the graph contains no runtime mode switch. - -targetScope = 'subscription' - -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -param projectResourceId string -param deployments deploymentType[] = [] -param projectEndpoint string -{{- if eq .AcrMode "create" }} -param resourceGroupName string -param location string -param resourceTokenSalt string = '' -param tags object = {} -{{- else if eq .AcrMode "reuse-connect" }} -param existingAcrResourceId string -param existingAcrEndpoint string -{{- else if eq .AcrMode "already-connected" }} -param existingAcrResourceId string -param existingAcrEndpoint string -param existingAcrConnectionName string -{{- end }} - -var projectIdParts = split(projectResourceId, '/') -var projectSubscriptionId = projectIdParts[2] -var projectResourceGroupName = projectIdParts[4] -var accountName = projectIdParts[8] -var projectName = projectIdParts[10] -{{- if eq .AcrMode "create" }} -var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' -var acrName = 'cr${toLower(uniqueString(tokenSeed))}' -{{- else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }} -var acrName = last(split(existingAcrResourceId, '/')) -{{- end }} - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} -{{- if eq .AcrMode "create" }} - -resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location - tags: tags -} - -module containerRegistry 'modules/container-registry.bicep' = { - name: 'container-registry' - scope: resourceGroup(resourceGroupName) - params: { - location: location - tags: tags - registryName: acrName - projectPrincipalId: foundryAccount::project.identity.principalId - } - dependsOn: [adjunctResourceGroup] -} -{{- else if and (eq .AcrMode "reuse-connect") (not .AcrPullAssigned) }} - -module containerRegistry 'modules/container-registry.bicep' = { - name: 'container-registry' - scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) - params: { - registryName: acrName - projectPrincipalId: foundryAccount::project.identity.principalId - } -} -{{- end }} - -module projectResources 'modules/foundry-project.bicep' = { - name: 'foundry-project-resources' - scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) - params: { - accountName: accountName - projectName: projectName - deployments: deployments -{{- if eq .AcrMode "create" }} - acrName: containerRegistry.outputs.registryName - acrEndpoint: containerRegistry.outputs.endpoint - acrResourceId: containerRegistry.outputs.resourceId - createAcrConnection: true -{{- else if eq .AcrMode "reuse-connect" }} - acrName: acrName - acrEndpoint: existingAcrEndpoint - acrResourceId: {{ if .AcrPullAssigned }}existingAcrResourceId{{ else }}containerRegistry.outputs.resourceId{{ end }} - createAcrConnection: true -{{- else if eq .AcrMode "already-connected" }} - existingAcrConnectionName: existingAcrConnectionName -{{- end }} - } -} - -output AZURE_AI_PROJECT_ID string = projectResourceId -output AZURE_AI_ACCOUNT_NAME string = accountName -output AZURE_AI_PROJECT_NAME string = projectName -output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' -output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint -output AZURE_FOUNDRY_RESOURCE_GROUP string = {{ if eq .AcrMode "create" }}resourceGroupName{{ else }}''{{ end }} -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.endpoint{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrEndpoint{{ else }}''{{ end }} -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.resourceId{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrResourceId{{ else }}''{{ end }} -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName -output AZD_FOUNDRY_ACR_MODE string = '{{ .AcrMode }}' diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json deleted file mode 100644 index 79e59e687bb..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json +++ /dev/null @@ -1,773 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "4511924015068411233" - } - }, - "definitions": { - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - } - } - }, - "parameters": { - "projectResourceId": { - "type": "string" - }, - "resourceGroupName": { - "type": "string" - }, - "location": { - "type": "string" - }, - "resourceTokenSalt": { - "type": "string", - "defaultValue": "" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "deployments": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "defaultValue": [] - }, - "acrMode": { - "type": "string", - "defaultValue": "none", - "allowedValues": [ - "none", - "create", - "reuse-connect", - "already-connected" - ] - }, - "existingAcrResourceId": { - "type": "string", - "defaultValue": "" - }, - "existingAcrEndpoint": { - "type": "string", - "defaultValue": "" - }, - "existingAcrConnectionName": { - "type": "string", - "defaultValue": "" - }, - "acrPullAssigned": { - "type": "bool", - "defaultValue": false - }, - "projectEndpoint": { - "type": "string" - } - }, - "variables": { - "projectIdParts": "[split(parameters('projectResourceId'), '/')]", - "projectSubscriptionId": "[variables('projectIdParts')[2]]", - "projectResourceGroupName": "[variables('projectIdParts')[4]]", - "accountName": "[variables('projectIdParts')[8]]", - "projectName": "[variables('projectIdParts')[10]]", - "tokenSeed": "[format('{0}{1}{2}', subscription().subscriptionId, parameters('resourceGroupName'), parameters('resourceTokenSalt'))]", - "acrName": "[format('cr{0}', toLower(uniqueString(variables('tokenSeed'))))]", - "createAcr": "[equals(parameters('acrMode'), 'create')]", - "reuseAcr": "[or(equals(parameters('acrMode'), 'reuse-connect'), equals(parameters('acrMode'), 'already-connected'))]", - "createAcrConnection": "[or(equals(parameters('acrMode'), 'create'), equals(parameters('acrMode'), 'reuse-connect'))]", - "effectiveAcrName": "[if(variables('createAcr'), variables('acrName'), if(variables('reuseAcr'), last(split(parameters('existingAcrResourceId'), '/')), ''))]" - }, - "resources": { - "foundryAccount::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "subscriptionId": "[variables('projectSubscriptionId')]", - "resourceGroup": "[variables('projectResourceGroupName')]", - "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]" - }, - "adjunctResourceGroup": { - "condition": "[variables('createAcr')]", - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2021-04-01", - "name": "[parameters('resourceGroupName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "subscriptionId": "[variables('projectSubscriptionId')]", - "resourceGroup": "[variables('projectResourceGroupName')]", - "name": "[variables('accountName')]" - }, - "newContainerRegistry": { - "condition": "[variables('createAcr')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "container-registry", - "resourceGroup": "[parameters('resourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "mode": { - "value": "create" - }, - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "registryName": { - "value": "[variables('acrName')]" - }, - "projectPrincipalId": { - "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "15235048848214962716" - } - }, - "parameters": { - "mode": { - "type": "string", - "allowedValues": [ - "create", - "reuse-connect" - ] - }, - "location": { - "type": "string" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "registryName": { - "type": "string" - }, - "projectPrincipalId": { - "type": "string" - }, - "pullAssigned": { - "type": "bool", - "defaultValue": false - }, - "accessOperationId": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "repositoryReaderRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b93aa761-3e63-49ed-ac28-beffa264f7ac')]" - }, - "resources": [ - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('registryName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" - ] - }, - { - "condition": "[and(equals(parameters('mode'), 'reuse-connect'), not(parameters('pullAssigned')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('accessOperationId')), format('existing-acr-access-{0}', uniqueString(parameters('projectPrincipalId'), parameters('registryName'))), format('azd-acr-access-{0}', parameters('accessOperationId')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('registryName')]" - }, - "principalId": { - "value": "[parameters('projectPrincipalId')]" - }, - "assignmentName": { - "value": "[parameters('accessOperationId')]" - }, - "ownershipDescription": "[if(empty(parameters('accessOperationId')), createObject('value', ''), createObject('value', format('azd-foundry:{0}', parameters('accessOperationId'))))]", - "roleDefinitionId": "[if(equals(parameters('mode'), 'reuse-connect'), if(equals(reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').roleAssignmentMode, 'AbacRepositoryPermissions'), createObject('value', variables('repositoryReaderRoleId')), createObject('value', variables('acrPullRoleId'))), createObject('value', variables('acrPullRoleId')))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "367829090767611126" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving pull access on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "Pull role definition resource ID (AcrPull or Container Registry Repository Reader)." - } - }, - "assignmentName": { - "type": "string", - "defaultValue": "" - }, - "ownershipDescription": { - "type": "string", - "defaultValue": "" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[if(empty(parameters('assignmentName')), guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId')), parameters('assignmentName'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]", - "description": "[parameters('ownershipDescription')]" - } - } - ] - } - } - } - ], - "outputs": { - "endpoint": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').loginServer)]" - }, - "resourceId": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" - } - } - } - }, - "dependsOn": [ - "adjunctResourceGroup", - "foundryAccount::project" - ] - }, - "existingContainerRegistry": { - "condition": "[and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "container-registry", - "subscriptionId": "[split(parameters('existingAcrResourceId'), '/')[2]]", - "resourceGroup": "[split(parameters('existingAcrResourceId'), '/')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "mode": { - "value": "reuse-connect" - }, - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "registryName": { - "value": "[variables('effectiveAcrName')]" - }, - "projectPrincipalId": { - "value": "[reference('foundryAccount::project', '2025-04-01-preview', 'full').identity.principalId]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "15235048848214962716" - } - }, - "parameters": { - "mode": { - "type": "string", - "allowedValues": [ - "create", - "reuse-connect" - ] - }, - "location": { - "type": "string" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "registryName": { - "type": "string" - }, - "projectPrincipalId": { - "type": "string" - }, - "pullAssigned": { - "type": "bool", - "defaultValue": false - }, - "accessOperationId": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "repositoryReaderRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b93aa761-3e63-49ed-ac28-beffa264f7ac')]" - }, - "resources": [ - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('registryName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" - ] - }, - { - "condition": "[and(equals(parameters('mode'), 'reuse-connect'), not(parameters('pullAssigned')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('accessOperationId')), format('existing-acr-access-{0}', uniqueString(parameters('projectPrincipalId'), parameters('registryName'))), format('azd-acr-access-{0}', parameters('accessOperationId')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('registryName')]" - }, - "principalId": { - "value": "[parameters('projectPrincipalId')]" - }, - "assignmentName": { - "value": "[parameters('accessOperationId')]" - }, - "ownershipDescription": "[if(empty(parameters('accessOperationId')), createObject('value', ''), createObject('value', format('azd-foundry:{0}', parameters('accessOperationId'))))]", - "roleDefinitionId": "[if(equals(parameters('mode'), 'reuse-connect'), if(equals(reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').roleAssignmentMode, 'AbacRepositoryPermissions'), createObject('value', variables('repositoryReaderRoleId')), createObject('value', variables('acrPullRoleId'))), createObject('value', variables('acrPullRoleId')))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "367829090767611126" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving pull access on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "Pull role definition resource ID (AcrPull or Container Registry Repository Reader)." - } - }, - "assignmentName": { - "type": "string", - "defaultValue": "" - }, - "ownershipDescription": { - "type": "string", - "defaultValue": "" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[if(empty(parameters('assignmentName')), guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId')), parameters('assignmentName'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]", - "description": "[parameters('ownershipDescription')]" - } - } - ] - } - } - } - ], - "outputs": { - "endpoint": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').loginServer)]" - }, - "resourceId": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" - } - } - } - }, - "dependsOn": [ - "foundryAccount::project" - ] - }, - "projectResources": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-project-resources", - "subscriptionId": "[variables('projectSubscriptionId')]", - "resourceGroup": "[variables('projectResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "accountName": { - "value": "[variables('accountName')]" - }, - "projectName": { - "value": "[variables('projectName')]" - }, - "deployments": { - "value": "[parameters('deployments')]" - }, - "acrName": { - "value": "[variables('effectiveAcrName')]" - }, - "acrEndpoint": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.endpoint.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.endpoint.value), createObject('value', parameters('existingAcrEndpoint'))))]", - "acrResourceId": "[if(variables('createAcr'), createObject('value', reference('newContainerRegistry').outputs.resourceId.value), if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), createObject('value', reference('existingContainerRegistry').outputs.resourceId.value), createObject('value', parameters('existingAcrResourceId'))))]", - "createAcrConnection": { - "value": "[variables('createAcrConnection')]" - }, - "existingAcrConnectionName": { - "value": "[parameters('existingAcrConnectionName')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "8439686740805277616" - } - }, - "definitions": { - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - } - } - }, - "parameters": { - "accountName": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "deployments": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "defaultValue": [] - }, - "acrName": { - "type": "string", - "defaultValue": "" - }, - "acrEndpoint": { - "type": "string", - "defaultValue": "" - }, - "acrResourceId": { - "type": "string", - "defaultValue": "" - }, - "createAcrConnection": { - "type": "bool", - "defaultValue": false - }, - "existingAcrConnectionName": { - "type": "string", - "defaultValue": "" - } - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "acrConnection": { - "condition": "[parameters('createAcrConnection')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('{0}-conn', parameters('acrName')))]", - "properties": { - "category": "ContainerRegistry", - "target": "[parameters('acrEndpoint')]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[parameters('acrResourceId')]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[parameters('acrResourceId')]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project" - ] - } - }, - "outputs": { - "acrConnectionName": { - "type": "string", - "value": "[if(parameters('createAcrConnection'), format('{0}-conn', parameters('acrName')), parameters('existingAcrConnectionName'))]" - } - } - } - }, - "dependsOn": [ - "existingContainerRegistry", - "newContainerRegistry" - ] - } - }, - "outputs": { - "AZURE_AI_PROJECT_ID": { - "type": "string", - "value": "[parameters('projectResourceId')]" - }, - "AZURE_AI_ACCOUNT_NAME": { - "type": "string", - "value": "[variables('accountName')]" - }, - "AZURE_AI_PROJECT_NAME": { - "type": "string", - "value": "[variables('projectName')]" - }, - "AZURE_OPENAI_ENDPOINT": { - "type": "string", - "value": "[format('https://{0}.openai.azure.com/', variables('accountName'))]" - }, - "FOUNDRY_PROJECT_ENDPOINT": { - "type": "string", - "value": "[parameters('projectEndpoint')]" - }, - "AZURE_FOUNDRY_RESOURCE_GROUP": { - "type": "string", - "value": "[if(variables('createAcr'), parameters('resourceGroupName'), '')]" - }, - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.endpoint.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.endpoint.value, parameters('existingAcrEndpoint'))))]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(equals(parameters('acrMode'), 'none'), '', if(variables('createAcr'), reference('newContainerRegistry').outputs.resourceId.value, if(and(equals(parameters('acrMode'), 'reuse-connect'), not(parameters('acrPullAssigned'))), reference('existingContainerRegistry').outputs.resourceId.value, parameters('existingAcrResourceId'))))]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[reference('projectResources').outputs.acrConnectionName.value]" - }, - "AZD_FOUNDRY_ACR_MODE": { - "type": "string", - "value": "[parameters('acrMode')]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl deleted file mode 100644 index 54d6ddc632a..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl +++ /dev/null @@ -1,47 +0,0 @@ -output "AZURE_AI_PROJECT_ID" { - value = local.normalized_project_id -} - -output "AZURE_AI_ACCOUNT_NAME" { - value = local.foundry_account_name -} - -output "AZURE_AI_PROJECT_NAME" { - value = local.foundry_project_name -} - -output "AZURE_OPENAI_ENDPOINT" { - value = "https://${local.foundry_account_name}.openai.azure.com/" -} - -output "FOUNDRY_PROJECT_ENDPOINT" { - value = var.project_endpoint - - precondition { - condition = ( - lower(local.project_endpoint_account) == lower(local.foundry_account_name) && - lower(local.project_endpoint_project) == lower(local.foundry_project_name) - ) - error_message = "project_endpoint must identify the same Foundry project as project_resource_id." - } -} - -output "AZURE_FOUNDRY_RESOURCE_GROUP" { - value = {{ if eq .AcrMode "create" }}var.resource_group_name{{ else }}""{{ end }} -} - -output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { - value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.login_server{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_endpoint{{ end }} -} - -output "AZURE_CONTAINER_REGISTRY_RESOURCE_ID" { - value = {{ if eq .AcrMode "create" }}azurerm_container_registry.this.id{{ else if eq .AcrMode "none" }}""{{ else }}var.existing_acr_resource_id{{ end }} -} - -output "AZURE_AI_PROJECT_ACR_CONNECTION_NAME" { - value = {{ if eq .AcrMode "already-connected" }}var.existing_acr_connection_name{{ else if eq .AcrMode "none" }}""{{ else }}azapi_resource.acr_connection.name{{ end }} -} - -output "AZD_FOUNDRY_ACR_MODE" { - value = "{{ .AcrMode }}" -} diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md index a0cc35cf6fa..afe0209de9c 100644 --- a/cli/azd/extensions/azure.ai.projects/AGENTS.md +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -22,10 +22,10 @@ Useful places to start: - `internal/cmd/`: Cobra commands, the endpoint resolver, and the config store - `internal/provisioning/`: the `microsoft.foundry` provider -- `internal/synthesis/`: project config synthesis and embedded IaC +- `synthesis/`: standalone shared Go module for project config synthesis and embedded IaC - `internal/exterrors/`: structured error factories and extension-specific codes -During the staged ownership migration, `internal/synthesis/` is also present in `azure.ai.agents` for `azd ai agent init --infra`. Keep the production files and templates byte-for-byte aligned until init moves. +Projects owns the standalone `azure.ai.projects/synthesis` module. Both extensions import it; do not copy synthesis or templates into Agents. Projects uses a child-module `replace`, while Agents uses a sibling-to-sibling `replace` pointing at this module. Run its tests separately with `go -C synthesis test ./...` because the parent module's `go test ./...` does not traverse nested modules. BYO AKS synthesis contract tests live in Projects; its parity test checks both production copies and embedded templates. Run the Projects synthesis suite when changing either copy. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index fb57057589e..02f8e66ed8d 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -133,6 +133,107 @@ declaration's binding to its managed name. For hand-edited agent bindings, update the JSON object with `azd env set AI_PROJECT_DEPLOYMENT_BINDINGS ''`, or set it to `'{}'` to clear explicit bindings. +### Application Insights + +New Foundry projects automatically receive a workspace-backed Application +Insights component and a project-scoped `AppInsights` connection during normal +provisioning. This includes projects created directly with `azd ai project add` +and projects created for agents. No additional command, flag, or connection +service is required. Existing projects are unchanged unless monitoring is +explicitly configured; upgrading an environment does not opt an existing project +into monitoring. + +To bring your own component, add the optional project setting: + +```yaml +infra: + provider: microsoft.foundry +services: + my-project: + host: azure.ai.project + monitoring: + applicationInsightsResourceId: /subscriptions//resourceGroups//providers/Microsoft.Insights/components/ +``` + +The component must already reference an accessible Log Analytics workspace. +It can belong to another resource group or subscription that the deploying +identity can read. Provisioning references it without changing its tags, +authentication, network settings, retention, or workspace, and creates only a +missing project connection. Credentials are resolved during deployment, not +stored in generated parameter files or azd environment outputs. A compatible +existing project or shared account connection is preserved, including +Managed Identity or Microsoft Entra authentication. + +Creating a new Application Insights component for an existing project is not +supported; an existing project must set `applicationInsightsResourceId` to +enable monitoring. +To skip new monitoring setup, use: + +```yaml +services: + my-project: + host: azure.ai.project + monitoring: + enabled: false +``` + +For embedded infrastructure, this opt-out does not delete or disable an existing +component, workspace, or connection. It also does not relinquish an existing +azd-owned supporting resource group's cleanup binding. + +New connections use ApiKey authentication, are scoped to one project, and are +named `azd-appinsights-` to stay within resource name limits. +No monitoring RBAC roles are added. +Discovery rejects conflicting destinations, protected names, ambiguous sharing, +unreadable resources, and authentication or ingestion policies that would need +to be weakened. ApiKey setup does not enable local authentication or configure +private-network ingestion. Such failures can fail normal provisioning; there +is no second best-effort deployment or agent deployment repair hook. + +Monitoring creates billable Azure resources and can store agent telemetry, +including application-supplied content. Review your application's telemetry, +data handling, and retention requirements before deployment. Newly created +workspaces start with 30-day retention; existing workspaces are not changed. +The normal `AZURE_APPLICATION_INSIGHTS_RESOURCE_ID`, +`AZURE_APPLICATION_INSIGHTS_APP_ID`, +`AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID`, +`AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME`, and +`AZD_FOUNDRY_MONITORING_MODE` outputs describe provisioning configuration, not +proof that an agent has emitted or ingested traces. + +#### Ejected infrastructure + +Monitoring is part of the main infrastructure graph, not an orphan template or +a separate deployment. Before ejection, `azure.yaml` controls monitoring. +After ejection, the Bicep files and their parameters, or Terraform files and +their variables, are authoritative. Changing stale YAML monitoring values does +not override those files. General configuration loading still requires valid +YAML and resolvable local references, even for values no longer used by IaC. + +Generated Bicep calls `modules/monitoring.bicep`; Terraform declares monitoring +in `monitoring.tf`. Monitoring modes are `none`, `create`, `reuse-connect`, and +`already-connected`. The last mode references compatible external monitoring +without managing its connection. Existing-project creation shares the normal +supporting resource group used by ACR rather than creating a separate monitoring +resource group. Native/Bicep preview uses normal ARM What-If. + +Monitoring-enabled Terraform exports require Terraform 1.11 or later and AzAPI +2.7 or later (existing-project exports retain their higher provider baseline). +They use an ephemeral credential read and a write-only connection field. +Previously native-created resources are not implicitly imported into Terraform; +reference the component explicitly for reuse or perform a deliberate state +handoff. Terraform resources have normal removal and destroy semantics: +changing managed monitoring to `none` or deleting its declarations can schedule +resource deletion, unlike the embedded-provider YAML opt-out. Review the plan +before applying changes. A reuse-only existing-project export cannot switch to +creation until its owned supporting resource-group graph is declared. + +Export-time discovery selects a stable graph, and Terraform rechecks effective +connections and policy during planning/apply. Managed resource counts do not +depend on whether a connection happens to exist. Neither ARM nor Terraform +provides a transaction across discovery and concurrent connection changes: +avoid creating different monitoring connections concurrently with provisioning. + ### Deployment preview `azd deploy --preview` performs no work for an `azure.ai.project` service, just @@ -302,7 +403,8 @@ Split `host: azure.ai.connection` services are reconciled during deploy by the `azure.ai.connections` extension, never by Project provisioning. This applies to embedded templates and newly ejected Bicep/Terraform alike. Project synthesis does not read Connection or Toolbox payloads, environments, or credentials. -The system ACR connection used by the Project's registry remains Project-owned. +The system ACR connection and generated system AppInsights connection remain +Project-owned. ### Breaking migration @@ -315,8 +417,8 @@ or `connectionCredentials` are allowed and retain normal parameter substitution; parameter names alone do not identify the removed Foundry contract. Linked templates are not fetched or inspected by this validation. -Only the generated system ACR connection's name, target, project identity and -registry resource ID wiring are exempt. Other `ContainerRegistry` connections, +Only generated system ACR and AppInsights connections with their expected name, +target, project identity, and resource ID wiring are exempt. Other `ContainerRegistry` connections, including those using `ManagedIdentity`, belong in `azure.ai.connection` services. Preserve that generated wiring when customizing ejected Bicep, or regenerate it. @@ -340,8 +442,8 @@ azd ai project add --project-id "" --infra=terraform The default format is Bicep. The generated infrastructure references the existing account and project without taking ownership of them. It manages only -declared model deployments, the system ACR connection, and any required container -registry resources. Endpoint-only setup cannot eject infrastructure; rerun +declared model deployments, system ACR and explicitly enabled monitoring +connections, and any required registry or monitoring resources. Endpoint-only setup cannot eject infrastructure; rerun `project add` with the full project resource ID. When an agent needs a registry, ejection preserves the registry state selected diff --git a/cli/azd/extensions/azure.ai.projects/ci-test.ps1 b/cli/azd/extensions/azure.ai.projects/ci-test.ps1 index 2e66dea3138..7fcaa230ea0 100644 --- a/cli/azd/extensions/azure.ai.projects/ci-test.ps1 +++ b/cli/azd/extensions/azure.ai.projects/ci-test.ps1 @@ -22,6 +22,22 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +# synthesis is a nested Go module, so ./... above does not include it. +Write-Host "Running synthesis module tests..." +Push-Location (Join-Path $PSScriptRoot "synthesis") +try { + go test ./... -count=1 + $synthesisExit = $LASTEXITCODE +} finally { + Pop-Location +} + +if ($synthesisExit -ne 0) { + Write-Host "" + Write-Host "Synthesis tests failed with exit code: $synthesisExit" -ForegroundColor Red + exit $synthesisExit +} + Write-Host "" Write-Host "All tests passed!" -ForegroundColor Green exit 0 diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index 454e1b3f6b8..b90f04a6daf 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -13,7 +13,7 @@ require ( github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.19.0 github.com/google/uuid v1.6.0 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 @@ -112,3 +112,7 @@ require ( golang.org/x/time v0.16.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +require azure.ai.projects/synthesis v0.0.0 + +replace azure.ai.projects/synthesis => ./synthesis diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_add.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_add.go index e2ff37e8920..c1e5f239d09 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_add.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_add.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "path/filepath" "regexp" @@ -22,7 +23,7 @@ import ( "azure.ai.projects/internal/azure" "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" @@ -2010,9 +2011,17 @@ func writeTerraformEjectedInfraAt( if err != nil { return err } + monitoring, err := synthesis.RenderTerraformMonitoring(parameters, false) + if err != nil { + return err + } + maps.Copy(variables, monitoring.Variables) if err := copyEmbeddedTerraform(infraDir, includeAcr); err != nil { return fmt.Errorf("copy Terraform templates: %w", err) } + if err := writeTerraformMonitoring(infraDir, monitoring); err != nil { + return err + } if err := renderTerraformOutputs(infraDir, includeAcr, layer); err != nil { return fmt.Errorf("render Terraform outputs: %w", err) } @@ -2065,7 +2074,9 @@ func terraformEjectionVariables( } func copyEmbeddedTerraform(destination string, includeAcr bool) error { - skip := map[string]struct{}{"outputs.tf.tmpl": {}} + skip := map[string]struct{}{ + "outputs.tf.tmpl": {}, "monitoring.tf.tmpl": {}, "provider-monitoring.tf.tmpl": {}, + } if !includeAcr { skip["container-registry.tf"] = struct{}{} } @@ -2073,6 +2084,16 @@ func copyEmbeddedTerraform(destination string, includeAcr bool) error { skip) } +func writeTerraformMonitoring(destination string, monitoring synthesis.TerraformMonitoring) error { + for name, data := range monitoring.Files { + // #nosec G306 -- generated infrastructure is intended to be human-readable. + if err := os.WriteFile(filepath.Join(destination, name), data, 0644); err != nil { + return fmt.Errorf("write monitoring infrastructure %s: %w", name, err) + } + } + return nil +} + func renderTerraformOutputs(destination string, includeAcr, layer bool) error { const templatePath = "templates/terraform/outputs.tf.tmpl" source, err := fs.ReadFile(synthesis.TerraformTemplatesFS(), templatePath) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go index 278193ec40c..4008cbee558 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment.go @@ -11,7 +11,7 @@ import ( "strings" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/foundry" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go index a197c2142c3..59ead97fa31 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go @@ -14,7 +14,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add_test.go index 30d22beef1c..44ac43bd8a1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add_test.go @@ -15,7 +15,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_lifecycle_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_lifecycle_test.go index e1fddf5fc4e..1b1fc2bb078 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_lifecycle_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_lifecycle_test.go @@ -12,7 +12,7 @@ import ( "testing" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile.go index 177e148e773..812a9c7d78a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile.go @@ -13,7 +13,7 @@ import ( "azure.ai.projects/internal/azure" "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_choices_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_choices_test.go index 8887397fc4f..57d51c463ae 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_choices_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_choices_test.go @@ -8,7 +8,7 @@ import ( "testing" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_test.go index a8da23b4255..496807a2068 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_reconcile_test.go @@ -6,7 +6,7 @@ package cmd import ( "testing" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go index 064e77f5839..f9abc75853d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go @@ -16,9 +16,10 @@ import ( "strings" "text/template" + "azure.ai.projects/internal/azure" "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "go.yaml.in/yaml/v3" @@ -395,6 +396,14 @@ func ejectExistingProjectInfra( ) } + if provider == provisioning.TerraformProviderName { + if err := synthesis.ResolveTerraformMonitoringExport( + ctx, result.Parameters, identity.ResourceId, environment, azure.NewArmClientOptions(), + ); err != nil { + return err + } + } + stageDir, err := os.MkdirTemp(projectRoot, ".azd-foundry-eject-*") if err != nil { return exterrors.Internal( @@ -962,21 +971,27 @@ func writeExistingProjectBicep( } } } + for _, name := range []string{"monitoring.bicep", "monitoring-connection.bicep"} { + data, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/"+name) + if err != nil { + return fmt.Errorf("read monitoring Bicep module: %w", err) + } + // #nosec G306 + if err := os.WriteFile(filepath.Join(moduleDir, name), data, 0644); err != nil { + return fmt.Errorf("write monitoring Bicep module: %w", err) + } + } outputParams := map[string]any{} maps.Copy(outputParams, params) outputParams["projectResourceId"] = "${AZURE_AI_PROJECT_ID}" outputParams["projectEndpoint"] = "${FOUNDRY_PROJECT_ENDPOINT}" delete(outputParams, "includeAcr") + outputParams["resourceGroupName"] = "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}" + outputParams["location"] = "${AZURE_LOCATION}" + outputParams["resourceTokenSalt"] = "${AZD_RESOURCE_TOKEN_SALT}" + outputParams["tags"] = map[string]string{"azd-env-name": "${AZURE_ENV_NAME}"} switch mode { - case projectEjectAcrCreate: - outputParams["resourceGroupName"] = - "${AZURE_FOUNDRY_RESOURCE_GROUP=rg-${AZURE_ENV_NAME}-foundry}" - outputParams["location"] = "${AZURE_LOCATION}" - outputParams["resourceTokenSalt"] = "${AZD_RESOURCE_TOKEN_SALT}" - outputParams["tags"] = map[string]string{ - "azd-env-name": "${AZURE_ENV_NAME}", - } case projectEjectAcrReuseConnect, projectEjectAcrAlreadyConnected: outputParams["existingAcrEndpoint"] = existingAcrEndpoint @@ -1028,6 +1043,10 @@ func writeExistingProjectTerraform( mode projectEjectAcrMode, values map[string]string, ) error { + monitoring, err := synthesis.RenderTerraformMonitoring(params, true) + if err != nil { + return err + } existingAcrEndpoint := "" if mode == projectEjectAcrReuseConnect || mode == projectEjectAcrAlreadyConnected { @@ -1052,7 +1071,8 @@ func writeExistingProjectTerraform( if entry.IsDir() || !strings.HasSuffix(name, ".tf") || name == "container-registry-create.tf" || name == "container-registry-reuse.tf" || - name == "container-registry-connect.tf" { + name == "container-registry-connect.tf" || + (name == "adjunct-resource-group.tf" && mode != projectEjectAcrCreate) { continue } data, err := fs.ReadFile( @@ -1099,6 +1119,9 @@ func writeExistingProjectTerraform( return fmt.Errorf("write container-registry.tf: %w", err) } } + if err := writeTerraformMonitoring(infraDir, monitoring); err != nil { + return err + } outputs, err := fs.ReadFile( filesystem, @@ -1113,12 +1136,14 @@ func writeExistingProjectTerraform( } var rendered bytes.Buffer if err := outputTemplate.Execute(&rendered, struct { - IncludeAcr bool - Layer bool - AcrMode string + IncludeAcr bool + IncludeAdjunct bool + Layer bool + AcrMode string }{ - IncludeAcr: mode != projectEjectAcrNone, - AcrMode: string(mode), + IncludeAcr: mode != projectEjectAcrNone, + IncludeAdjunct: mode == projectEjectAcrCreate || monitoring.CreatesResources, + AcrMode: string(mode), }); err != nil { return fmt.Errorf("render Terraform outputs: %w", err) } @@ -1147,6 +1172,7 @@ func writeExistingProjectTerraform( if deployments, ok := params["deployments"]; ok { tfvars["deployments"] = deployments } + maps.Copy(tfvars, monitoring.Variables) if err := writeJSONFile( filepath.Join(infraDir, module+".tfvars.json"), tfvars, ); err != nil { diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go index c0ab0cf63bc..0e1004470d3 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_ownership_test.go @@ -17,7 +17,7 @@ import ( "testing" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go index 3e4a2cf807f..2f537c70596 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config.go @@ -15,7 +15,7 @@ import ( "strings" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/foundry" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go index d890e9d9f8f..bad7c7c8bca 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_config_test.go @@ -16,7 +16,7 @@ import ( "testing" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go index c20b0680ed7..90f63f71c3a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_service_reconciler.go @@ -14,7 +14,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/provisioning" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/foundry" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go index bfe2c2a40dd..0901021a409 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go @@ -14,7 +14,7 @@ import ( "testing" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" @@ -684,6 +684,20 @@ func TestWriteExistingProjectTerraformAcrModes(t *testing.T) { } else { assert.NoFileExists(t, registryPath) } + adjunctPath := filepath.Join(dir, "adjunct-resource-group.tf") + if test.mode == projectEjectAcrCreate { + // #nosec G304 + adjunct, err := os.ReadFile(adjunctPath) + require.NoError(t, err) + assert.Contains(t, string(adjunct), `resource "azurerm_resource_group" "adjunct"`) + // #nosec G304 + registry, err := os.ReadFile(registryPath) + require.NoError(t, err) + assert.NotContains(t, string(registry), `resource "azurerm_resource_group"`) + assert.Contains(t, string(registry), "azurerm_resource_group.adjunct.name") + } else { + assert.NoFileExists(t, adjunctPath) + } // #nosec G304 outputs, err := os.ReadFile(filepath.Join(dir, "outputs.tf")) require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting_test.go index eeeabbecb0a..674db1764a1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -34,6 +34,7 @@ const offlineBYOAzureYAML = ` services: project: host: azure.ai.project + monitoring: {enabled: false} agentHosting: {clusterResourceId: ` + offlineClusterID + `} ` @@ -225,6 +226,7 @@ func TestBYOAllowsCustomizedOnDiskTemplateAndParameters(t *testing.T) { mode: templateModeBicep, armTemplate: p.armTemplate, parameters: map[string]any{ "includeAcr": map[string]any{"value": true}, + "monitoringMode": map[string]any{"value": "none"}, "resourceTokenSalt": map[string]any{"value": "custom"}, "tags": map[string]any{"value": map[string]string{"team": "test"}}, }, @@ -259,7 +261,10 @@ func TestBYONonBooleanIncludeAcrReachesARM(t *testing.T) { p, fake := offlineBYOProvider(t) p.onDiskSource = &templateSource{ mode: templateModeBicep, armTemplate: p.armTemplate, - parameters: map[string]any{"includeAcr": map[string]any{"value": test.value}}, + parameters: map[string]any{ + "includeAcr": map[string]any{"value": test.value}, + "monitoringMode": map[string]any{"value": "none"}, + }, } method, suffix := http.MethodPut, "" var err error @@ -287,7 +292,7 @@ func TestBYONonBooleanIncludeAcrReachesARM(t *testing.T) { func TestNativeFoundryDeployKeepsStandardOutputsWithoutAKSReads(t *testing.T) { p, fake := offlineBYOProvider(t) result, err := synthesis.Synthesize(synthesis.Input{ServiceName: "project", - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n")}) + RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n")}) require.NoError(t, err) p.synthResult = result deployed, err := p.Deploy(t.Context(), func(string) {}) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration.go index 07abd9ff8a7..e4612cf4e53 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration.go @@ -19,7 +19,7 @@ func legacyConnectionTemplateError(sourcePath string) error { fmt.Sprintf("on-disk Foundry template %q uses the removed generic Connection provisioning contract", sourcePath), "remove the generic Foundry Connection modules/resources and their associated "+ "connections/connectionCredentials inputs from your on-disk Bicep and parameter files; "+ - "keep unrelated parameters and keep the system ACR connection. "+ + "keep unrelated parameters and keep the system ACR connection and protected system monitoring connection. "+ "Declare connections as services with host: azure.ai.connection, then run `azd deploy` "+ "after provisioning the Project. No templates or Azure resources have been changed", ) @@ -32,10 +32,10 @@ func legacyConnectionTemplateError(sourcePath string) error { // Both Bicep entry paths run this before any template is sent to Azure. Linked // templates are not fetched; their parameter names do not identify their resources. func validateProjectTemplate(template map[string]any, sourcePath string) error { - return validateProjectResources(template["resources"], "", sourcePath) + return validateProjectResources(template["resources"], "", sourcePath, template) } -func validateProjectResources(resources any, parentType, sourcePath string) error { +func validateProjectResources(resources any, parentType, sourcePath string, template map[string]any) error { validate := func(value any) error { resource, ok := value.(map[string]any) if !ok { @@ -51,7 +51,7 @@ func validateProjectResources(resources any, parentType, sourcePath string) erro case "microsoft.cognitiveservices/accounts/connections": return legacyConnectionTemplateError(sourcePath) case "microsoft.cognitiveservices/accounts/projects/connections": - if !isSystemAcrConnection(resource) { + if !isSystemAcrConnection(resource) && !isSystemMonitoringConnection(resource, template) { return legacyConnectionTemplateError(sourcePath) } case "microsoft.resources/deployments": @@ -61,7 +61,7 @@ func validateProjectResources(resources any, parentType, sourcePath string) erro } } } - return validateProjectResources(resource["resources"], resourceType, sourcePath) + return validateProjectResources(resource["resources"], resourceType, sourcePath, template) } // ARM languageVersion 2.0 uses symbolic-name objects rather than arrays. @@ -82,6 +82,59 @@ func validateProjectResources(resources any, parentType, sourcePath string) erro return nil } +// Only the protected project connection and its ARM-side credential lookup are +// constrained. Workspace/component properties and enclosing modules remain editable. +func isSystemMonitoringConnection(resource, template map[string]any) bool { + for _, field := range []string{"copy", "scope", "subscriptionId", "resourceGroup", "condition"} { + if _, exists := resource[field]; exists { + return false + } + } + if resource["name"] != "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), "+ + "format('azd-appinsights-{0}', take(replace(guid(toLower(parameters('projectName'))), '-', ''), 13)))]" { + return false + } + options, _ := resource["@options"].(map[string]any) + if !reflect.DeepEqual(options["onlyIfNotExists"], []any{}) { + return false + } + variables, _ := template["variables"].(map[string]any) + if variables["componentParts"] != "[split(parameters('applicationInsightsResourceId'), '/')]" { + return false + } + resources, _ := template["resources"].(map[string]any) + component, _ := resources["component"].(map[string]any) + if !reflect.DeepEqual(component, map[string]any{ + "existing": true, + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "subscriptionId": "[variables('componentParts')[2]]", + "resourceGroup": "[variables('componentParts')[4]]", + "name": "[variables('componentParts')[8]]", + }) { + return false + } + target := "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', " + + "variables('componentParts')[2], variables('componentParts')[4]), " + + "'Microsoft.Insights/components', variables('componentParts')[8])]" + return reflect.DeepEqual(resource["properties"], map[string]any{ + "category": "AppInsights", + "authType": "ApiKey", + "target": target, + "credentials": map[string]any{ + "key": "[reference('component').ConnectionString]", + }, + "metadata": map[string]any{ + "ApiType": "Azure", + "ResourceId": target, + "azd-created-by": "azure.ai.projects", + "azd-environment": "[parameters('environmentName')]", + "azd-monitoring-project": "[resourceId('Microsoft.CognitiveServices/accounts/projects', " + + "parameters('accountName'), parameters('projectName'))]", + }, + }) +} + // isSystemAcrConnection recognizes the registry connection emitted by acr.bicep // or foundry-project.bicep. Category/auth and even matching resource IDs alone // also describe user-declared connections, so preserve only the generated name, diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration_test.go index 1852d467df9..ac2336373ea 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/connection_migration_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" @@ -302,7 +302,9 @@ func findSystemAcrConnections(template map[string]any) []map[string]any { visit = func(value any) { switch value := value.(type) { case map[string]any: - if value["type"] == "Microsoft.CognitiveServices/accounts/projects/connections" { + properties, _ := value["properties"].(map[string]any) + if value["type"] == "Microsoft.CognitiveServices/accounts/projects/connections" && + properties["category"] == "ContainerRegistry" { connections = append(connections, value) } for _, child := range value { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index a32d0c72acb..bc015691e5e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -19,7 +19,7 @@ import ( "time" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -84,6 +84,7 @@ type FoundryProvisioningProvider struct { foundryRGOwnerID string foundryName string credential azcore.TokenCredential + armClientOptions *arm.ClientOptions tenantID string // resolved lazily by ensureCredential; surfaced as AZURE_TENANT_ID armTemplate map[string]any // embedded ARM JSON; nil when on-disk Bicep is configured onDiskSource *templateSource // non-nil after on-disk Bicep has been compiled and validated @@ -105,7 +106,6 @@ type FoundryProvisioningProvider struct { // Lazily constructed on first compile. nil until needed. bicepCliInstance bicepCompiler - armClientOptions *arm.ClientOptions } func readProjectFile(projectRoot string) ([]byte, string, error) { @@ -347,11 +347,17 @@ func (p *FoundryProvisioningProvider) Initialize( existingResult.Parameters["deployments"] = deployments } if !existingProjectHasMutations(existingResult) { - p.brownfieldEndpoint = endpoint - p.existingProjectReuseOnly = true - p.synthResult = existingResult - p.foundryName = projectNameFromEndpoint(endpoint) - return nil + owner, err := p.envValue(ctx, envKeyFoundryRGOwner) + if err != nil { + return fmt.Errorf("read existing Foundry resource-group ownership: %w", err) + } + if owner == "" { + p.brownfieldEndpoint = endpoint + p.existingProjectReuseOnly = true + p.synthResult = existingResult + p.foundryName = projectNameFromEndpoint(endpoint) + return nil + } } } return p.initializeInfrastructure(ctx, rawYAML, projectRoot, svcName, endpoint, onDisk) @@ -372,11 +378,12 @@ func (p *FoundryProvisioningProvider) initializeInfrastructure( } input := synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: p.networkEnvMap(ctx), - ProjectRoot: projectRoot, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: p.networkEnvMap(ctx), + ProjectRoot: projectRoot, + InfrastructureOwnsMonitoring: onDisk, } var res *synthesis.Result if endpoint != "" { @@ -468,7 +475,8 @@ func (p *FoundryProvisioningProvider) refreshReuseOnlyDeployments(ctx context.Co func existingProjectHasMutations(result *synthesis.Result) bool { includeAcr, _ := result.Parameters["includeAcr"].(bool) deployments, _ := result.Parameters["deployments"].([]synthesis.Deployment) - return includeAcr || len(deployments) > 0 + monitoringMode, _ := result.Parameters["monitoringMode"].(string) + return includeAcr || len(deployments) > 0 || (monitoringMode != "" && monitoringMode != "none") } func (p *FoundryProvisioningProvider) resolveExistingProjectResourceGroup(ctx context.Context) error { @@ -649,13 +657,13 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str log.Printf("[debug] foundry provider: no environment client; network ${VAR} uses process env only") return out } - curr, err := envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil || curr.GetEnvironment() == nil { + envName, err := p.selectedEnvironmentName(ctx) + if err != nil { log.Printf("[debug] foundry provider: no current azd environment (%v); "+ "network ${VAR} uses process env only", err) return out } - resp, err := envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{Name: curr.GetEnvironment().GetName()}) + resp, err := envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName}) if err != nil { log.Printf("[debug] foundry provider: GetValues failed (%s); network ${VAR} uses process env only", err) return out @@ -817,9 +825,11 @@ func (p *FoundryProvisioningProvider) withTenantOutput( func (p *FoundryProvisioningProvider) normalizeOutputs( outputs map[string]*azdext.ProvisioningOutputParameter, ) map[string]*azdext.ProvisioningOutputParameter { - trackSupportingResourceGroup := p.isLayer || - (p.brownfieldEndpoint != "" && p.existingAcrMode == "create") - if trackSupportingResourceGroup { + monitoringMode := "" + if output := outputs["AZD_FOUNDRY_MONITORING_MODE"]; output != nil { + monitoringMode = output.Value + } + if p.tracksSupportingResourceGroup(monitoringMode) { if outputs == nil { outputs = map[string]*azdext.ProvisioningOutputParameter{} } @@ -829,10 +839,22 @@ func (p *FoundryProvisioningProvider) normalizeOutputs( outputs[envKeyFoundryRGOwner] = &azdext.ProvisioningOutputParameter{ Type: "string", Value: p.foundryRGOwnerID, } + // Incremental deployments retain the group after setup is disabled. + // Keep its existing ownership binding available for a later azd down. + if resourceGroupIDMatches(p.foundryRGOwnerID, p.subID, p.rgName) { + outputs[envKeyFoundryRG] = &azdext.ProvisioningOutputParameter{ + Type: "string", Value: p.rgName, + } + } } return outputs } +func (p *FoundryProvisioningProvider) tracksSupportingResourceGroup(monitoringMode string) bool { + return p.isLayer || (p.brownfieldEndpoint != "" && + (p.existingAcrMode == "create" || monitoringMode == "create" || p.foundryRGOwnerID != "")) +} + // projectNameFromEndpoint extracts the project name from a Foundry project // endpoint of the form https://.services.ai.azure.com/api/projects/. // Returns "" when the path does not carry a project segment. @@ -873,17 +895,10 @@ func sameExistingProjectEndpoint(a, b string) bool { // resolveEnv pulls the env values the provider needs from azd-core. It does // no Azure work; credentials are deferred until needed. func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { - envClient := p.azdClient.Environment() - - currEnv, err := envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil { - return exterrors.Dependency( - exterrors.CodeEnvironmentNotFound, - fmt.Sprintf("get current azd environment: %s", err), - "run 'azd env new' to create an environment", - ) + if err := p.resolveEnvName(ctx); err != nil { + return err } - p.envName = currEnv.Environment.Name + envClient := p.azdClient.Environment() get := func(key string) (string, error) { if value := strings.TrimSpace(p.virtualEnv[key]); value != "" { @@ -902,6 +917,7 @@ func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { // A read error (env not found / corrupted / transport) is distinct from a // key that is simply unset: GetValue returns ("", nil) for an absent key. // Surface read failures; only prompt when the value is present-but-empty. + var err error if p.subID, err = get(envKeySubscriptionID); err != nil { return exterrors.Dependency( exterrors.CodeEnvironmentValuesFailed, @@ -983,22 +999,34 @@ func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { } func (p *FoundryProvisioningProvider) resolveEnvName(ctx context.Context) error { - currEnv, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil || currEnv.GetEnvironment().GetName() == "" { - message := "current azd environment is empty" - if err != nil { - message = err.Error() - } + name, err := p.selectedEnvironmentName(ctx) + if err != nil { return exterrors.Dependency( exterrors.CodeEnvironmentNotFound, - fmt.Sprintf("get current azd environment: %s", message), + fmt.Sprintf("get current azd environment: %s", err), "run 'azd env new' to create an environment", ) } - p.envName = currEnv.GetEnvironment().GetName() + p.envName = name return nil } +func (p *FoundryProvisioningProvider) selectedEnvironmentName(ctx context.Context) (string, error) { + // The host forwards -e through AZD_ENVIRONMENT; GetCurrent returns the persisted default. + if name := strings.TrimSpace(os.Getenv("AZD_ENVIRONMENT")); name != "" { + return name, nil + } + current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "", err + } + name := current.GetEnvironment().GetName() + if name == "" { + return "", fmt.Errorf("current azd environment is empty") + } + return name, nil +} + // promptSubscription asks the user to select an Azure subscription when // AZURE_SUBSCRIPTION_ID is not set, then persists the choice to the azd // environment and updates p.subID. This mirrors core `azd up`, which prompts @@ -1233,9 +1261,29 @@ func (p *FoundryProvisioningProvider) Deploy( if err != nil { return nil, err } + if err := p.prepareMonitoring(ctx, src, client, progress); err != nil { + return nil, err + } + monitoringMode, err := monitoringParameter(src, "monitoringMode") + if err != nil { + return nil, err + } resourceGroupExisted := false - trackSupportingResourceGroup := p.isLayer || - (p.brownfieldEndpoint != "" && p.existingProjectAcrMode(ctx) == "create") + trackSupportingResourceGroup := p.tracksSupportingResourceGroup(monitoringMode) + if trackSupportingResourceGroup && monitoringMode == "create" { + group, err := monitoringParameter(src, "resourceGroupName") + if err != nil { + return nil, err + } + if group == "" { + return nil, fmt.Errorf("monitoring creation requires the actual supporting resourceGroupName") + } + if p.foundryRGOwnerID != "" && !resourceGroupIDMatches(p.foundryRGOwnerID, p.subID, group) { + return nil, fmt.Errorf("the authored monitoring graph selects a different owned supporting resource group; " + + "retain the existing group binding or explicitly clean it up before changing groups") + } + p.rgName = group + } if trackSupportingResourceGroup && !resourceGroupIDMatches(p.foundryRGOwnerID, p.subID, p.rgName) { resourceGroupExisted, err = p.resourceGroupExists(ctx) if err != nil { @@ -1248,17 +1296,12 @@ func (p *FoundryProvisioningProvider) Deploy( poller, err := client.BeginCreateOrUpdateAtSubscriptionScope(ctx, name, dep, nil) if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) + return nil, p.deploymentFailure(ctx, err, trackSupportingResourceGroup && !resourceGroupExisted) } resp, err := pollWithProgress(ctx, poller, progress, "Foundry deployment in progress") if err != nil { - if trackSupportingResourceGroup && !resourceGroupExisted { - if ownershipErr := p.persistCreatedResourceGroupOwnership(ctx); ownershipErr != nil { - log.Printf("[debug] recover Foundry resource-group ownership after deployment failure: %v", ownershipErr) - } - } - return nil, exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) + return nil, p.deploymentFailure(ctx, err, trackSupportingResourceGroup && !resourceGroupExisted) } progress("Foundry deployment complete") @@ -1280,6 +1323,20 @@ func (p *FoundryProvisioningProvider) Deploy( }, nil } +func (p *FoundryProvisioningProvider) deploymentFailure(ctx context.Context, err error, recoverOwnership bool) error { + deploymentErr := exterrors.ServiceFromAzure(err, exterrors.OpArmDeploymentCreate) + if recoverOwnership { + if ownershipErr := p.persistCreatedResourceGroupOwnership(ctx); ownershipErr != nil { + return errors.Join(exterrors.Internal( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("Foundry deployment failed (%s) and supporting resource-group ownership "+ + "could not be recorded: %s", synthesis.MonitoringReadError("deploy Foundry infrastructure", err), ownershipErr), + ), err) + } + } + return deploymentErr +} + func (p *FoundryProvisioningProvider) existingProjectReuseOutputs() map[string]*azdext.ProvisioningOutputParameter { return p.withTenantOutput(map[string]*azdext.ProvisioningOutputParameter{ "AZURE_AI_PROJECT_NAME": {Type: "string", Value: p.foundryName}, @@ -1363,6 +1420,11 @@ func (p *FoundryProvisioningProvider) resolveTemplate( if p.onDiskSource != nil { log.Printf("[debug] foundry provider: using on-disk template at %s", p.onDiskSource.sourcePath) hostParameters := parametersDeclaredByTemplate(p.armParameters(), p.onDiskSource.armTemplate) + for _, name := range []string{ + "monitoringMode", "applicationInsightsResourceId", "monitoringCreateConnection", "monitoringConnectionName", + } { + delete(hostParameters, name) + } merged := mergeParameters(p.onDiskSource.parameters, hostParameters) return &templateSource{ mode: p.onDiskSource.mode, @@ -1515,6 +1577,10 @@ func (p *FoundryProvisioningProvider) Preview( return nil, err } + if err := p.prepareMonitoring(ctx, src, client, progress); err != nil { + return nil, err + } + whatIf := armresources.DeploymentWhatIf{ Location: new(p.location), Properties: &armresources.DeploymentWhatIfProperties{ @@ -1587,11 +1653,11 @@ func (p *FoundryProvisioningProvider) Destroy( return &azdext.ProvisioningDestroyResult{}, nil } mode := p.existingProjectAcrMode(ctx) - if mode == "none" || mode == "already-connected" { + if (mode == "none" || mode == "already-connected") && p.foundryRGOwnerID == "" { progress("Existing Foundry project resources are not owned by azd; leaving them in place") return &azdext.ProvisioningDestroyResult{}, nil } - if mode != "create" { + if mode != "create" && p.foundryRGOwnerID == "" { return nil, exterrors.Validation( exterrors.CodeInvalidServiceConfig, "azd down cannot safely remove resources created inside an existing Foundry project", @@ -1643,11 +1709,13 @@ func (p *FoundryProvisioningProvider) Destroy( return nil, err } if p.brownfieldEndpoint != "" { - if err := p.deleteExistingProjectAcrConnection(ctx, progress); err != nil { - return nil, err + if p.existingAcrMode == "create" { + if err := p.deleteExistingProjectAcrConnection(ctx, progress); err != nil { + return nil, err + } } } - factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) + factory, err := armresources.NewClientFactory(p.subID, p.credential, p.armClientOptions) if err != nil { return nil, exterrors.Internal( exterrors.CodeAzdClientFailed, @@ -1742,7 +1810,7 @@ func (p *FoundryProvisioningProvider) deleteExistingProjectAcrConnection( "re-run `azd ai agent init` against the configured existing project", ) } - client, err := armcognitiveservices.NewProjectConnectionsClient(projectID.SubscriptionID, p.credential, nil) + client, err := armcognitiveservices.NewProjectConnectionsClient(projectID.SubscriptionID, p.credential, p.armClientOptions) if err != nil { return exterrors.Internal( exterrors.CodeAzdClientFailed, @@ -1829,7 +1897,7 @@ func (p *FoundryProvisioningProvider) destroyResult() *azdext.ProvisioningDestro func (p *FoundryProvisioningProvider) confirmDestroy(ctx context.Context) (bool, error) { target := fmt.Sprintf("resource group %q and all resources inside it", p.rgName) if p.brownfieldEndpoint != "" { - target += " plus its Container Registry connection inside the existing Foundry project" + target += " plus its azd-created Container Registry and monitoring connections inside the existing Foundry project" } forceRequired := exterrors.Validation( exterrors.CodeDestroyRequiresForce, @@ -1901,7 +1969,7 @@ func (p *FoundryProvisioningProvider) listAccountsInRG( ctx context.Context, progress grpcbroker.ProgressFunc, ) ([]*armcognitiveservices.Account, error) { - accountsClient, err := armcognitiveservices.NewAccountsClient(p.subID, p.credential, nil) + accountsClient, err := armcognitiveservices.NewAccountsClient(p.subID, p.credential, p.armClientOptions) if err != nil { return nil, exterrors.Internal( exterrors.CodeAzdClientFailed, @@ -2023,7 +2091,7 @@ func (p *FoundryProvisioningProvider) purgeCognitiveAccounts( // Deploy, so azd-core can clear them after a successful Destroy. func invalidatedEnvKeysResult() *azdext.ProvisioningDestroyResult { return &azdext.ProvisioningDestroyResult{ - InvalidatedEnvKeys: []string{ + InvalidatedEnvKeys: append([]string{ "AZURE_AI_PROJECT_ID", "AZURE_AI_ACCOUNT_NAME", "AZURE_AI_PROJECT_NAME", @@ -2036,7 +2104,7 @@ func invalidatedEnvKeysResult() *azdext.ProvisioningDestroyResult { "AZD_FOUNDRY_ACR_MODE", "AZD_FOUNDRY_ACR_PULL_ASSIGNED", envKeyFoundryRGOwner, - }, + }, monitoringOutputKeys()...), } } @@ -2138,6 +2206,11 @@ var canonicalOutputNames = []string{ "AZURE_FOUNDRY_NETWORK_MODE", "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", "AZD_FOUNDRY_ACR_MODE", + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", + "AZURE_APPLICATION_INSIGHTS_APP_ID", + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", + "AZD_FOUNDRY_MONITORING_MODE", } var greenfieldOutputNames = []string{ @@ -2153,6 +2226,11 @@ var greenfieldOutputNames = []string{ "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", "AZURE_FOUNDRY_NETWORK_MODE", "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE", + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", + "AZURE_APPLICATION_INSIGHTS_APP_ID", + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", + "AZD_FOUNDRY_MONITORING_MODE", } var existingProjectOutputNames = []string{ @@ -2166,6 +2244,11 @@ var existingProjectOutputNames = []string{ "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", "AZD_FOUNDRY_ACR_MODE", + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", + "AZURE_APPLICATION_INSIGHTS_APP_ID", + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", + "AZD_FOUNDRY_MONITORING_MODE", } // --- helpers --- @@ -2425,7 +2508,7 @@ func (p *FoundryProvisioningProvider) lookupResourceGroupState( if p.resourceGroupState != nil { return p.resourceGroupState(ctx) } - factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) + factory, err := armresources.NewClientFactory(p.subID, p.credential, p.armClientOptions) if err != nil { return nil, false, exterrors.Internal( exterrors.CodeAzdClientFailed, @@ -2470,7 +2553,7 @@ func (p *FoundryProvisioningProvider) verifyLayerResourceGroupAzureOwnership(ctx if err := p.ensureCredential(ctx); err != nil { return err } - factory, err := armresources.NewClientFactory(p.subID, p.credential, nil) + factory, err := armresources.NewClientFactory(p.subID, p.credential, p.armClientOptions) if err != nil { return exterrors.Internal( exterrors.CodeAzdClientFailed, @@ -2605,7 +2688,7 @@ func armOutputsToProto(outputs any) map[string]*azdext.ProvisioningOutputParamet } typeStr, _ := entry["type"].(string) out[canonicalizeOutputName(k)] = &azdext.ProvisioningOutputParameter{ - Type: typeStr, + Type: strings.ToLower(typeStr), Value: encodeParamValue(entry["value"]), } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go index 1a2d447bc20..841c1665fe1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_resolveenv_test.go @@ -27,11 +27,12 @@ import ( // empty, which is what triggers the prompt path), and records SetValue writes. type resolveEnvStubEnvServer struct { azdext.UnimplementedEnvironmentServiceServer - envName string - get map[string]string - getErr map[string]error - set map[string]string - process map[string]string + envName string + get map[string]string + getErr map[string]error + set map[string]string + process map[string]string + environments map[string]*resolveEnvStubEnvServer } func (s *resolveEnvStubEnvServer) GetCurrent( @@ -41,8 +42,15 @@ func (s *resolveEnvStubEnvServer) GetCurrent( } func (s *resolveEnvStubEnvServer) GetValue( - _ context.Context, req *azdext.GetEnvRequest, + ctx context.Context, req *azdext.GetEnvRequest, ) (*azdext.KeyValueResponse, error) { + if s.environments != nil { + env, found := s.environments[req.GetEnvName()] + if !found { + return nil, status.Error(codes.NotFound, "environment not found") + } + return env.GetValue(ctx, req) + } if err := s.getErr[req.Key]; err != nil { return nil, err } @@ -54,8 +62,15 @@ func (s *resolveEnvStubEnvServer) GetValue( } func (s *resolveEnvStubEnvServer) GetValues( - _ context.Context, _ *azdext.GetEnvironmentRequest, + ctx context.Context, req *azdext.GetEnvironmentRequest, ) (*azdext.KeyValueListResponse, error) { + if s.environments != nil { + env, found := s.environments[req.GetName()] + if !found { + return nil, status.Error(codes.NotFound, "environment not found") + } + return env.GetValues(ctx, req) + } values := make([]*azdext.KeyValue, 0, len(s.get)) for key, value := range s.get { values = append(values, &azdext.KeyValue{Key: key, Value: value}) @@ -64,8 +79,15 @@ func (s *resolveEnvStubEnvServer) GetValues( } func (s *resolveEnvStubEnvServer) SetValue( - _ context.Context, req *azdext.SetEnvRequest, + ctx context.Context, req *azdext.SetEnvRequest, ) (*azdext.EmptyResponse, error) { + if s.environments != nil { + env, found := s.environments[req.GetEnvName()] + if !found { + return nil, status.Error(codes.NotFound, "environment not found") + } + return env.SetValue(ctx, req) + } if s.set == nil { s.set = map[string]string{} } @@ -135,6 +157,53 @@ func newResolveEnvTestClient( return client } +func TestResolveEnvSelectedEnvironment(t *testing.T) { + for _, selected := range []string{"", "prod", " prod ", "missing"} { + t.Run(fmt.Sprintf("selection=%q", selected), func(t *testing.T) { + t.Setenv("AZD_ENVIRONMENT", selected) + defaultEnv := &resolveEnvStubEnvServer{get: map[string]string{ + envKeySubscriptionID: "default-sub", envKeyLocation: "westus3", + envKeyResourceGroup: "default-group", "DESTINATION": "default-component", + }} + selectedEnv := &resolveEnvStubEnvServer{get: map[string]string{ + envKeySubscriptionID: "selected-sub", envKeyLocation: "eastus2", + envKeyResourceGroup: "selected-group", "DESTINATION": "selected-component", + }} + env := &resolveEnvStubEnvServer{ + envName: "test", environments: map[string]*resolveEnvStubEnvServer{ + "test": defaultEnv, "prod": selectedEnv, + }, + } + p := &FoundryProvisioningProvider{ + azdClient: newResolveEnvTestClient(t, env, &resolveEnvStubPromptServer{}), + } + values := p.networkEnvMap(t.Context()) + err := p.resolveEnv(t.Context()) + if selected == "missing" { + require.ErrorContains(t, err, "environment not found") + assert.Empty(t, values) + assert.Empty(t, defaultEnv.set) + assert.Empty(t, selectedEnv.set) + return + } + require.NoError(t, err) + wantEnv, wantName := selectedEnv, "prod" + if selected == "" { + wantEnv, wantName = defaultEnv, "test" + } + assert.Equal(t, wantName, p.envName) + assert.Equal(t, wantEnv.get[envKeySubscriptionID], p.subID) + assert.Equal(t, wantEnv.get[envKeyResourceGroup], p.rgName) + assert.Equal(t, wantEnv.get["DESTINATION"], values["DESTINATION"]) + require.NoError(t, p.setEnv(t.Context(), "OWNERSHIP", "selected-owner")) + assert.Equal(t, "selected-owner", wantEnv.set["OWNERSHIP"]) + if selected != "" { + assert.Empty(t, defaultEnv.set) + } + }) + } +} + func TestResolveEnv_PromptsAndPersistsSubscriptionAndLocation(t *testing.T) { // Neither AZURE_SUBSCRIPTION_ID nor AZURE_LOCATION is set: resolveEnv must // prompt for both (matching core `azd up`) and persist the choices, instead diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 2ecb7fe3fca..65de697e006 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "slices" @@ -14,7 +15,7 @@ import ( "testing" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" @@ -457,6 +458,7 @@ func TestArmOutputsToProto(t *testing.T) { assert.Len(t, got, tt.wantLen) if tt.wantKey != "" { assert.Equal(t, tt.wantVal, got[tt.wantKey].Value) + assert.Equal(t, "string", got[tt.wantKey].Type) } }) } @@ -1179,6 +1181,42 @@ func TestResolveTemplate_FallsBackToEmbeddedWhenNoOnDisk(t *testing.T) { require.Contains(t, got.parameters, "includeAcr") } +func TestResolveTemplateMonitoringAuthority(t *testing.T) { + t.Parallel() + for _, mode := range []templateMode{templateModeBicep, templateModeBicepParam} { + for _, authored := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/authored=%t", mode, authored), func(t *testing.T) { + t.Parallel() + parameters := map[string]any{} + if authored { + parameters["monitoringMode"] = map[string]any{"value": "reuse-connect"} + parameters["applicationInsightsResourceId"] = map[string]any{"value": "authored-component"} + } + p := &FoundryProvisioningProvider{ + projectPath: t.TempDir(), + synthResult: &synthesis.Result{Parameters: map[string]any{ + "monitoringMode": "create", "applicationInsightsResourceId": "stale-component", + "monitoringCreateConnection": true, "monitoringConnectionName": "stale-name", + }}, + onDiskSource: &templateSource{ + mode: mode, parameters: parameters, + armTemplate: map[string]any{"parameters": map[string]any{ + "monitoringMode": map[string]any{"type": "string", "defaultValue": "none"}, + "applicationInsightsResourceId": map[string]any{"type": "string", "defaultValue": ""}, + "monitoringCreateConnection": map[string]any{"type": "bool", "defaultValue": false}, + "monitoringConnectionName": map[string]any{"type": "string", "defaultValue": ""}, + }}, + }, + } + result, err := p.resolveTemplate(t.Context(), func(string) {}) + require.NoError(t, err) + assert.Equal(t, parameters, result.parameters, + "neither synthesis nor host defaults may override authored monitoring parameters or template defaults") + }) + } + } +} + func TestResolveTemplate_PrefersOnDiskWhenPresent(t *testing.T) { t.Parallel() // Setup: a project root with both an "embedded" template stashed diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_export_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_export_test.go new file mode 100644 index 00000000000..1f4f7bed056 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_export_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "context" + "net/http" + "testing" + + "azure.ai.projects/synthesis" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTerraformMonitoringExportDiscovery(t *testing.T) { + t.Parallel() + for _, tt := range []*struct { + name string + mode string + component string + fixture monitoringCloudFixture + environment map[string]string + wantMode string + wantErr string + }{ + {name: "fresh creation", mode: "create", wantMode: "create"}, + { + name: "missing connection cross-subscription reuse", mode: "reuse-connect", + component: monitoringTestComponent, wantMode: "reuse-connect", + }, + { + name: "preserve selected environment reference", mode: "reuse-connect", component: "${MONITORING_TEST_ID}", + environment: map[string]string{"MONITORING_TEST_ID": monitoringTestComponent}, wantMode: "reuse-connect", + }, + { + name: "compatible managed identity with disabled local auth", mode: "create", wantMode: "already-connected", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("external", monitoringTestComponent, "ManagedIdentity", false)}, + componentPolicy: map[string]any{"DisableLocalAuth": true}, + }, + }, + { + name: "compatible shared Entra monitoring", mode: "reuse-connect", component: "${MONITORING_TEST_ID}", + environment: map[string]string{"MONITORING_TEST_ID": monitoringTestComponent}, wantMode: "already-connected", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection("inherited", monitoringTestComponent, "AAD", true)}, + }, + }, + { + name: "sibling monitoring is not inherited", mode: "create", wantMode: "create", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection("azd-appinsights-sibling", monitoringTestComponent, "ApiKey", false)}, + }, + }, + { + name: "conflicting explicit destination", mode: "reuse-connect", component: monitoringTestComponent + "-different", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("external", monitoringTestComponent, "ApiKey", false)}, + }, + wantErr: "conflicts", + }, + { + name: "reserved account name", mode: "create", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection(monitoringTestConnectionName, monitoringTestComponent, "ApiKey", false)}, + }, + wantErr: "protected", + }, + {name: "project missing", mode: "create", fixture: monitoringCloudFixture{projectStatus: 404}, wantErr: "does not exist"}, + { + name: "project forbidden", mode: "create", + fixture: monitoringCloudFixture{projectStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + { + name: "failed discovery", mode: "create", + fixture: monitoringCloudFixture{listStatus: http.StatusServiceUnavailable}, wantErr: "HTTP 503", + }, + { + name: "incomplete project list", mode: "create", + fixture: monitoringCloudFixture{missingProjectList: true}, wantErr: "incomplete list", + }, + { + name: "incomplete account list", mode: "create", + fixture: monitoringCloudFixture{missingAccountList: true}, wantErr: "incomplete list", + }, + { + name: "slot lookup forbidden", mode: "create", + fixture: monitoringCloudFixture{slotStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + { + name: "disabled component local auth", mode: "reuse-connect", component: monitoringTestComponent, + fixture: monitoringCloudFixture{componentPolicy: map[string]any{"DisableLocalAuth": true}}, + wantErr: "disabled local authentication", + }, + { + name: "restricted component ingestion", mode: "reuse-connect", component: monitoringTestComponent, + fixture: monitoringCloudFixture{componentPolicy: map[string]any{"publicNetworkAccessForIngestion": "Disabled"}}, + wantErr: "restricted public ingestion", + }, + { + name: "disabled workspace local auth", mode: "reuse-connect", component: monitoringTestComponent, + fixture: monitoringCloudFixture{workspacePolicy: map[string]any{ + "features": map[string]any{"disableLocalAuth": true}, + }}, + wantErr: "disabled workspace local authentication", + }, + { + name: "malformed workspace auth policy", mode: "reuse-connect", component: monitoringTestComponent, + fixture: monitoringCloudFixture{workspacePolicy: map[string]any{"features": "unavailable"}}, + wantErr: "authentication policy is unavailable", + }, + { + name: "workspace read forbidden", mode: "reuse-connect", component: monitoringTestComponent, + fixture: monitoringCloudFixture{workspaceStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + { + name: "declared connection missing", mode: "already-connected", component: monitoringTestComponent, + wantErr: "was not found", + }, + { + name: "stale selected project", mode: "create", + environment: map[string]string{"AZURE_AI_PROJECT_ID": monitoringTestProject + "-different"}, + wantErr: "different Foundry project", + }, + { + name: "native resources are not implicitly imported", mode: "create", + environment: map[string]string{ + "AZD_FOUNDRY_MONITORING_MODE": "create", "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": monitoringTestComponent, + }, + wantErr: "cannot adopt", + }, + { + name: "explicit empty reference is not create", mode: "reuse-connect", component: "${MONITORING_TEST_ID}", + environment: map[string]string{"MONITORING_TEST_ID": ""}, wantErr: "nonempty", + }, + {name: "disabled monitoring needs no reads", mode: "none", wantMode: "none"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + provider := newMonitoringCloudFixture(t, &tt.fixture) + parameters := map[string]any{ + "monitoringMode": tt.mode, "applicationInsightsResourceId": tt.component, + } + resolver := synthesis.MonitoringResolver{ + Credential: provider.credential, ClientOptions: provider.armClientOptions, + } + err := resolver.ResolveTerraformExport( + t.Context(), parameters, monitoringTestProject, tt.environment) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.NotContains(t, err.Error(), "synthetic-secret-must-not-leak") + assert.Equal(t, tt.mode, parameters["monitoringMode"], "failed export must not select a different graph") + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantMode, parameters["monitoringMode"]) + if tt.component != "" { + assert.Equal(t, tt.component, parameters["applicationInsightsResourceId"], "preserve authored references") + } else if tt.wantMode == "already-connected" { + assert.Equal(t, monitoringTestComponent, parameters["applicationInsightsResourceId"]) + } + } + assert.Empty(t, tt.fixture.writes) + assert.Empty(t, tt.fixture.deletions) + for _, request := range tt.fixture.requests { + assert.Contains(t, request, "GET ") + } + if tt.mode == "none" { + assert.Empty(t, tt.fixture.requests) + } + }) + } +} + +func TestTerraformMonitoringExportCancellation(t *testing.T) { + t.Parallel() + provider := newMonitoringCloudFixture(t, &monitoringCloudFixture{}) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + resolver := synthesis.MonitoringResolver{Credential: provider.credential, ClientOptions: provider.armClientOptions} + err := resolver.ResolveTerraformExport(ctx, map[string]any{ + "monitoringMode": "create", "applicationInsightsResourceId": "", + }, monitoringTestProject, nil) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go new file mode 100644 index 00000000000..65e38a80862 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "context" + "fmt" + "strings" + + "azure.ai.projects/synthesis" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +func monitoringPlannedResource( + result *armresources.WhatIfOperationResult, resourceType, subscriptionID, resourceGroup string, +) (string, error) { + if result == nil || result.Properties == nil { + return "", fmt.Errorf("ARM What-If did not resolve monitoring resources; no monitoring writes were authorized") + } + selected := "" + for _, change := range result.Properties.Changes { + if change == nil || change.ResourceID == nil || change.ChangeType == nil || + *change.ChangeType == armresources.ChangeTypeIgnore { + continue + } + id, err := arm.ParseResourceID(*change.ResourceID) + if err != nil || !strings.EqualFold(id.ResourceType.String(), resourceType) { + continue + } + if !strings.EqualFold(id.SubscriptionID, subscriptionID) || !strings.EqualFold(id.ResourceGroupName, resourceGroup) { + return "", fmt.Errorf("the monitoring creation graph must stay in the selected owned resource group") + } + switch *change.ChangeType { + case armresources.ChangeTypeCreate, armresources.ChangeTypeModify, + armresources.ChangeTypeNoChange, armresources.ChangeTypeDeploy: + default: + return "", fmt.Errorf("ARM What-If could not safely resolve the monitoring creation graph") + } + if selected != "" && !strings.EqualFold(selected, id.String()) { + return "", fmt.Errorf("ARM What-If resolved multiple monitoring resources of type %s", resourceType) + } + if after, ok := change.After.(map[string]any); ok { + if properties, ok := after["properties"].(map[string]any); ok { + if err := synthesis.MonitoringKeyPolicy(properties); err != nil { + return "", err + } + } + } + selected = id.String() + } + if selected == "" { + return "", fmt.Errorf("ARM What-If did not resolve the declared %s; no monitoring writes were authorized", resourceType) + } + return selected, nil +} + +func validateMonitoringConnectionPlan(result *armresources.WhatIfOperationResult, projectID string) error { + if result == nil || result.Properties == nil { + return nil + } + project, err := synthesis.MonitoringResourceID(projectID, "Microsoft.CognitiveServices/accounts/projects") + if err != nil { + return err + } + expected := projectID + "/connections/" + synthesis.MonitoringConnectionName(project.Name) + for _, change := range result.Properties.Changes { + if change == nil || change.ResourceID == nil || change.ChangeType == nil || + *change.ChangeType == armresources.ChangeTypeIgnore { + continue + } + id, err := arm.ParseResourceID(*change.ResourceID) + if err != nil || !strings.EqualFold(id.ResourceType.String(), "Microsoft.CognitiveServices/accounts/projects/connections") || + !strings.HasPrefix(strings.ToLower(id.Name), "azd-appinsights-") { + continue + } + if !strings.EqualFold(id.String(), expected) { + return fmt.Errorf("the monitoring graph targets a different project connection; no monitoring writes were authorized") + } + } + return nil +} + +// Check the ARM-resolved names before allowing ordinary resource PUTs. A stable +// generated name alone never authorizes adopting a pre-existing component. +func (p *FoundryProvisioningProvider) validateMonitoringCreateGraph( + ctx context.Context, source *templateSource, binding *monitoringTargetBinding, connection *synthesis.MonitoringConnection, +) error { + group, err := monitoringParameter(source, "resourceGroupName") + if err != nil { + return err + } + resolver := synthesis.MonitoringResolver{Credential: p.credential, ClientOptions: p.armClientOptions} + outputs := armOutputsToProto(binding.outputs) + for _, resource := range []struct { + resourceType string + output string + version string + }{ + {"Microsoft.Insights/components", "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", "2020-02-02"}, + {"Microsoft.OperationalInsights/workspaces", "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "2023-09-01"}, + } { + id, err := monitoringPlannedResource(binding.plan, resource.resourceType, p.subID, group) + if err != nil { + return err + } + if connection != nil && resource.resourceType == "Microsoft.Insights/components" && + !strings.EqualFold(connection.Target, id) { + return fmt.Errorf("the authored creation graph would replace the effective monitoring destination") + } + parsed, err := synthesis.MonitoringResourceID(id, resource.resourceType) + if err != nil { + return err + } + existing, found, err := resolver.Resource(ctx, parsed, resource.version) + if err != nil { + return err + } + if !found { + continue + } + expected := outputs[resource.output] + if !binding.owned || (!binding.retry && (expected == nil || !strings.EqualFold(expected.Value, id))) { + return fmt.Errorf("a monitoring resource already occupies the planned name and is not owned by this deployment; " + + "use applicationInsightsResourceId to reuse it without adopting or changing it") + } + properties, err := synthesis.MonitoringProperties(existing) + if err != nil { + return err + } + if err := synthesis.MonitoringKeyPolicy(properties); err != nil { + return err + } + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go new file mode 100644 index 00000000000..466cb8828e5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + "azure.ai.projects/synthesis" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" +) + +func monitoringOutputKeys() []string { + return []string{ + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", + "AZURE_APPLICATION_INSIGHTS_APP_ID", + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", + "AZD_FOUNDRY_MONITORING_MODE", + } +} + +func templateParameter(source *templateSource, name string) (any, bool) { + if envelope, ok := source.parameters[name].(map[string]any); ok { + value, present := envelope["value"] + return value, present + } + parameters, _ := source.armTemplate["parameters"].(map[string]any) + parameter, _ := parameters[name].(map[string]any) + value, present := parameter["defaultValue"] + return value, present +} + +func monitoringParameter(source *templateSource, name string) (string, error) { + value, present := templateParameter(source, name) + if !present { + return "", nil + } + text, ok := value.(string) + if !ok || strings.HasPrefix(text, "[") { + return "", fmt.Errorf("monitoring parameter %s must resolve to a literal string before provisioning", name) + } + return strings.TrimSpace(text), nil +} + +// Both Preview and Deploy resolve the same graph. This is read-only and never +// runs during State or agent deployment. +func (p *FoundryProvisioningProvider) prepareMonitoring( + ctx context.Context, + source *templateSource, + client *armresources.DeploymentsClient, + progress grpcbroker.ProgressFunc, +) error { + mode, err := monitoringParameter(source, "monitoringMode") + if err != nil { + return err + } + if mode == "" || mode == "none" { + return nil + } + if mode != "create" && mode != "reuse-connect" && mode != "already-connected" { + return fmt.Errorf("monitoringMode must be none, create, reuse-connect, or already-connected") + } + componentID, err := monitoringParameter(source, "applicationInsightsResourceId") + if err != nil { + return err + } + if mode != "create" && componentID == "" { + return fmt.Errorf("monitoring mode %s requires applicationInsightsResourceId", mode) + } + projectID, previous, err := p.resolveMonitoringTarget(ctx, source, client, progress) + if err != nil { + return err + } + project, err := synthesis.MonitoringResourceID(projectID, "Microsoft.CognitiveServices/accounts/projects") + if err != nil || project.Parent == nil { + return fmt.Errorf("monitoring target must identify a Foundry project") + } + resolver := synthesis.MonitoringResolver{Credential: p.credential, ClientOptions: p.armClientOptions} + projectResource, projectExists, err := resolver.Resource(ctx, project, "2025-04-01-preview") + if err != nil { + return err + } + implicit := source.mode == templateModeEmbedded && p.synthResult != nil && !p.synthResult.MonitoringExplicit + if implicit && projectExists && (previous == nil || previous.legacy) { + // An upgrade, a changed identity input, or a stale cached ID is not + // authorization to enable monitoring on a pre-existing project. + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + return nil + } + account, accountExists, err := resolver.Resource(ctx, project.Parent, "2025-04-01-preview") + if err != nil { + return err + } + if projectExists && !accountExists { + return fmt.Errorf("monitoring target account could not be verified") + } + connection, err := resolver.DiscoverConnection(ctx, project, projectExists, accountExists) + if err != nil { + return err + } + owned := previous != nil && previous.owned + if connection != nil { + if componentID != "" && !strings.EqualFold(componentID, connection.Target) { + return fmt.Errorf("the selected component conflicts with the effective AppInsights connection") + } + if owned && mode == "create" { + if err := p.validateMonitoringCreateGraph(ctx, source, previous, connection); err != nil { + return err + } + } else if mode == "create" && source.mode != templateModeEmbedded { + return fmt.Errorf("a compatible AppInsights connection already exists; preserve it with " + + "monitoringMode already-connected and its applicationInsightsResourceId in the authored infrastructure") + } else { + mode = "already-connected" + componentID = connection.Target + } + if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create"); err != nil { + return err + } + source.parameters["monitoringMode"] = map[string]any{"value": mode} + source.parameters["applicationInsightsResourceId"] = map[string]any{"value": componentID} + source.parameters["monitoringCreateConnection"] = map[string]any{"value": false} + source.parameters["monitoringConnectionName"] = map[string]any{"value": connection.Name} + return nil + } + if mode == "already-connected" { + return fmt.Errorf("the declared existing AppInsights connection was not found; no connection was created") + } + if enabled, present := templateParameter(source, "enableNetworkIsolation"); present && enabled != false { + return fmt.Errorf("automatic monitoring does not configure private-network ingestion; " + + "preserve the existing connection or disable monitoring setup") + } + for _, resource := range []*armresources.GenericResource{account, projectResource} { + if resource == nil { + continue + } + properties, err := synthesis.MonitoringProperties(resource) + if err != nil { + return err + } + if err := synthesis.MonitoringNetworkPolicy(properties); err != nil { + return err + } + } + if mode == "reuse-connect" { + if err := resolver.ValidateComponent(ctx, componentID, true); err != nil { + return err + } + } + if mode == "create" { + if err := p.validateMonitoringCreateGraph(ctx, source, previous, nil); err != nil { + return err + } + } + return nil +} + +func (p *FoundryProvisioningProvider) resolveMonitoringTarget( + ctx context.Context, + source *templateSource, + client *armresources.DeploymentsClient, + progress grpcbroker.ProgressFunc, +) (string, *monitoringTargetBinding, error) { + bindingParameters := map[string]any{} + for _, name := range []string{ + "location", "resourceGroupName", "resourceTokenSalt", "foundryProjectName", "projectResourceId", + } { + if value, present := templateParameter(source, name); present { + bindingParameters[name] = map[string]any{"value": value} + } + } + var previous *monitoringTargetBinding + response, err := client.GetAtSubscriptionScope(ctx, p.deploymentName(), nil) + if err != nil && !isNotFound(err) { + return "", nil, synthesis.MonitoringReadError("read previous Foundry deployment", err) + } + if err == nil { + previous = previousMonitoringTarget(bindingParameters, response.Properties, p.subID) + } + existingID := "" + if _, present := templateParameter(source, "projectResourceId"); present { + existingID, err = monitoringParameter(source, "projectResourceId") + if err != nil || existingID == "" { + return "", nil, fmt.Errorf("monitoring requires the actual existing projectResourceId") + } + } + mode, err := monitoringParameter(source, "monitoringMode") + if err != nil { + return "", nil, err + } + if existingID != "" && mode != "create" { + return existingID, previous, nil + } + poller, err := client.BeginWhatIfAtSubscriptionScope(ctx, p.deploymentName(), armresources.DeploymentWhatIf{ + Location: new(p.location), + Properties: &armresources.DeploymentWhatIfProperties{ + Template: source.armTemplate, Parameters: source.parameters, + Mode: new(armresources.DeploymentModeIncremental), + }, + }, nil) + if err != nil { + return "", nil, synthesis.MonitoringReadError("resolve monitoring target with ARM What-If", err) + } + result, err := pollWithProgress(ctx, poller, progress, "Resolving Foundry monitoring target") + if err != nil { + return "", nil, synthesis.MonitoringReadError("resolve monitoring target with ARM What-If", err) + } + if err := whatIfFailure(result.WhatIfOperationResult); err != nil { + return "", nil, err + } + id := existingID + if id == "" { + group, err := monitoringParameter(source, "resourceGroupName") + if err != nil { + return "", nil, err + } + name, err := monitoringParameter(source, "foundryProjectName") + if err != nil { + return "", nil, err + } + id, err = monitoringProjectFromWhatIf(result.WhatIfOperationResult, p.subID, group, name) + if err != nil { + return "", nil, err + } + } + if err := validateMonitoringConnectionPlan(&result.WhatIfOperationResult, id); err != nil { + return "", nil, err + } + if previous != nil && !strings.EqualFold(previous.projectID, id) { + previous = nil + } + if previous == nil { + previous = &monitoringTargetBinding{projectID: id, legacy: true} + } + previous.plan = &result.WhatIfOperationResult + if !previous.owned && mode == "create" && monitoringFailedCreateAttempt(bindingParameters, response.Properties) { + exported, err := client.ExportTemplateAtSubscriptionScope(ctx, p.deploymentName(), nil) + if err != nil { + return "", nil, synthesis.MonitoringReadError("verify the previous failed monitoring graph", err) + } + before, beforeErr := json.Marshal(exported.Template) + after, afterErr := json.Marshal(source.armTemplate) + if beforeErr != nil || afterErr != nil || !bytes.Equal(before, after) { + return "", nil, fmt.Errorf("the failed deployment used a different monitoring graph; " + + "restore its infrastructure or explicitly configure existing-component reuse before retrying") + } + previous.owned, previous.legacy, previous.retry = true, false, true + } + return id, previous, nil +} + +// A failed deployment's outputs are not an identity binding. Its exact graph +// and identity inputs can still authorize retry after What-If resolves targets. +func monitoringFailedCreateAttempt( + parameters map[string]any, previous *armresources.DeploymentPropertiesExtended, +) bool { + if previous == nil || previous.ProvisioningState == nil || + (*previous.ProvisioningState != armresources.ProvisioningStateFailed && + *previous.ProvisioningState != armresources.ProvisioningStateCanceled) || + !monitoringIdentityMatches(parameters, previous.Parameters) { + return false + } + old, _ := previous.Parameters.(map[string]any) + if mode, _ := armStringParameter(old, "monitoringMode"); mode != "create" { + return false + } + return true +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go new file mode 100644 index 00000000000..8ec14d60b40 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -0,0 +1,606 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "azure.ai.projects/synthesis" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + monitoringTestProject = "/subscriptions/sub/resourceGroups/project-rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/project" + monitoringTestComponent = "/subscriptions/other-sub/resourceGroups/monitoring-rg/providers/" + + "Microsoft.Insights/components/component" + monitoringTestWorkspace = "/subscriptions/other-sub/resourceGroups/monitoring-rg/providers/" + + "Microsoft.OperationalInsights/workspaces/workspace" + monitoringTestConnectionName = "azd-appinsights-b35f1607b6f65" +) + +type monitoringTestCredential struct{} + +func (monitoringTestCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "synthetic-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type monitoringCloudFixture struct { + projectConnections []any + accountConnections []any + missingProjectList bool + missingAccountList bool + componentPolicy map[string]any + workspacePolicy map[string]any + projectStatus int + accountStatus int + listStatus int + slotStatus int + workspaceStatus int + groupStatus int + plannedStatus int + previous map[string]any + mu sync.Mutex + writes []map[string]any + plans []map[string]any + requests []string + plannedChanges []any + slotConnection any + legacySlotConnection any + groupResources []any + ownedComponent map[string]any + resourceListStatus int + connectionDeleteStatus int + deletions []string + deploymentStatus int + deploymentOutputs map[string]any + deploymentResources []any +} + +func newMonitoringCloudFixture(t *testing.T, fixture *monitoringCloudFixture) *FoundryProvisioningProvider { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + fixture.mu.Lock() + defer fixture.mu.Unlock() + fixture.requests = append(fixture.requests, request.Method+" "+request.URL.Path) + writer.Header().Set("Content-Type", "application/json") + reply := func(status int, value any) { + writer.WriteHeader(status) + require.NoError(t, json.NewEncoder(writer).Encode(value)) + } + failure := func(status int) { + reply(status, map[string]any{"error": map[string]any{ + "code": "SyntheticReadFailure", "message": "synthetic-secret-must-not-leak", + }}) + } + switch { + case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg"): + if request.Method == http.MethodDelete { + fixture.deletions = append(fixture.deletions, request.URL.Path) + if fixture.groupStatus != 0 { + failure(fixture.groupStatus) + return + } + reply(http.StatusOK, map[string]any{}) + return + } + require.Equal(t, http.MethodGet, request.Method) + if fixture.groupStatus != 0 { + reply(fixture.groupStatus, map[string]any{"error": map[string]any{ + "code": "SyntheticReadFailure", "message": "resource group lookup failed", + }}) + return + } + reply(http.StatusOK, map[string]any{ + "id": request.URL.Path, "tags": map[string]any{"azd-env-name": "test"}, + }) + case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg/resources"): + if fixture.resourceListStatus != 0 { + failure(fixture.resourceListStatus) + return + } + resources := []any{} + filter := request.URL.Query().Get("$filter") + for _, resource := range fixture.groupResources { + value, ok := resource.(map[string]any) + require.True(t, ok) + resourceType, ok := value["type"].(string) + require.True(t, ok) + if strings.Contains(filter, resourceType) { + resources = append(resources, resource) + } + } + reply(http.StatusOK, map[string]any{"value": resources}) + case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg/providers/"+ + "Microsoft.CognitiveServices/accounts"): + if fixture.groupStatus != 0 { + failure(fixture.groupStatus) + return + } + reply(http.StatusOK, map[string]any{"value": []any{}}) + case fixture.ownedComponent != nil && request.URL.Path == fixture.ownedComponent["id"]: + reply(http.StatusOK, fixture.ownedComponent) + case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg/providers/"+ + "Microsoft.Insights/components/owned"): + failure(http.StatusNotFound) + case strings.Contains(request.URL.Path, "/providers/Microsoft.Resources/deployments/"): + if request.Method == http.MethodGet { + if fixture.previous != nil { + reply(http.StatusOK, map[string]any{"properties": fixture.previous}) + return + } + failure(http.StatusNotFound) + return + } + if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/exportTemplate") { + reply(http.StatusOK, map[string]any{"template": fixture.previous["template"]}) + return + } + var body map[string]any + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/whatIf") { + fixture.plans = append(fixture.plans, body) + reply(http.StatusOK, map[string]any{ + "status": "Succeeded", "properties": map[string]any{"changes": fixture.plannedChanges}, + }) + return + } + require.Equal(t, http.MethodPut, request.Method) + fixture.writes = append(fixture.writes, body) + if fixture.deploymentStatus != 0 { + failure(fixture.deploymentStatus) + return + } + reply(http.StatusOK, map[string]any{ + "id": request.URL.Path, + "properties": map[string]any{ + "provisioningState": "Succeeded", + "outputs": fixture.deploymentOutputs, "outputResources": fixture.deploymentResources, + }, + }) + case request.URL.Path == monitoringTestProject: + if fixture.projectStatus != 0 { + failure(fixture.projectStatus) + return + } + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{}}) + case request.URL.Path == monitoringTestProject+"/connections": + if fixture.listStatus != 0 { + failure(fixture.listStatus) + return + } + if fixture.missingProjectList { + reply(http.StatusOK, map[string]any{}) + return + } + connections := fixture.projectConnections + if connections == nil { + connections = []any{} + } + reply(http.StatusOK, map[string]any{"value": connections}) + case strings.HasSuffix(request.URL.Path, "/projects/project/connections/azd-appinsights-project"): + if fixture.legacySlotConnection != nil { + reply(http.StatusOK, fixture.legacySlotConnection) + return + } + failure(http.StatusNotFound) + case strings.HasSuffix(request.URL.Path, "/projects/project/connections/"+monitoringTestConnectionName): + if request.Method == http.MethodDelete { + fixture.deletions = append(fixture.deletions, request.URL.Path) + if fixture.connectionDeleteStatus != 0 { + failure(fixture.connectionDeleteStatus) + return + } + writer.WriteHeader(http.StatusNoContent) + return + } + if fixture.slotConnection != nil { + reply(http.StatusOK, fixture.slotConnection) + return + } + status := http.StatusNotFound + if fixture.slotStatus != 0 { + status = fixture.slotStatus + } + failure(status) + case strings.HasSuffix(request.URL.Path, "/accounts/account/connections"): + if fixture.missingAccountList { + reply(http.StatusOK, map[string]any{}) + return + } + connections := fixture.accountConnections + if connections == nil { + connections = []any{} + } + reply(http.StatusOK, map[string]any{"value": connections}) + case strings.HasSuffix(request.URL.Path, "/accounts/account"): + if fixture.accountStatus != 0 { + failure(fixture.accountStatus) + return + } + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "disableLocalAuth": true, "publicNetworkAccess": "Enabled", + }}) + case strings.HasSuffix(request.URL.Path, "/components/new"), strings.HasSuffix(request.URL.Path, "/workspaces/new"): + status := http.StatusNotFound + if fixture.plannedStatus != 0 { + status = fixture.plannedStatus + } + if status == http.StatusOK { + reply(status, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "WorkspaceResourceId": strings.Replace( + request.URL.Path, "/Microsoft.Insights/components/new", "/Microsoft.OperationalInsights/workspaces/new", 1), + "ConnectionString": "synthetic-secret-must-not-leak", + }}) + return + } + failure(status) + case request.URL.Path == monitoringTestComponent: + properties := map[string]any{ + "WorkspaceResourceId": monitoringTestWorkspace, "AppId": "synthetic-app-id", + "ConnectionString": "synthetic-secret-must-not-leak", + } + maps.Copy(properties, fixture.componentPolicy) + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": properties}) + case request.URL.Path == monitoringTestWorkspace: + if fixture.workspaceStatus != 0 { + failure(fixture.workspaceStatus) + return + } + properties := map[string]any{} + maps.Copy(properties, fixture.workspacePolicy) + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": properties}) + default: + t.Errorf("unexpected monitoring request %s %s", request.Method, request.URL.Path) + failure(http.StatusBadRequest) + } + })) + t.Cleanup(server.Close) + return &FoundryProvisioningProvider{ + projectPath: t.TempDir(), subID: "sub", envName: "test", location: "westus3", + rgName: "adjunct-rg", foundryName: "project", credential: monitoringTestCredential{}, + armClientOptions: &arm.ClientOptions{ClientOptions: policy.ClientOptions{ + Transport: server.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + Cloud: cloud.Configuration{Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: server.URL, Audience: "https://management.azure.com/"}, + }}, + }}, + } +} + +func monitoringTestConnection(name, target, auth string, shared bool) any { + return map[string]any{ + "name": name, "properties": map[string]any{ + "category": "AppInsights", "authType": auth, "target": target, "isSharedToAll": shared, + }, + } +} + +func monitoringTestSource(mode string) *templateSource { + return &templateSource{ + mode: templateModeEmbedded, + parameters: map[string]any{ + "projectResourceId": map[string]any{"value": monitoringTestProject}, + "monitoringMode": map[string]any{"value": mode}, + "applicationInsightsResourceId": map[string]any{"value": monitoringTestComponent}, + }, + armTemplate: map[string]any{"$schema": "synthetic", "resources": []any{}}, + } +} + +func TestMonitoringPreflightCloudPreservation(t *testing.T) { + t.Parallel() + for _, tt := range []*struct { + name string + fixture monitoringCloudFixture + wantErr string + wantMode string + wantName string + }{ + {name: "missing connection uses cross-subscription BYO", wantMode: "reuse-connect"}, + { + name: "MI connection survives disabled local auth", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("user-monitoring", monitoringTestComponent, "ManagedIdentity", false)}, + componentPolicy: map[string]any{"DisableLocalAuth": true}, + }, + wantMode: "already-connected", wantName: "user-monitoring", + }, + { + name: "shared inherited connection is preserved", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection("shared-monitoring", monitoringTestComponent, "AAD", true)}, + }, + wantMode: "already-connected", wantName: "shared-monitoring", + }, + { + name: "unshared sibling does not block missing connection", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection("azd-appinsights-sibling", monitoringTestComponent, "ApiKey", false)}, + }, + wantMode: "reuse-connect", + }, + { + name: "shared same-name destination must not be hidden as a mirror", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("monitoring", monitoringTestComponent, "ApiKey", false)}, + accountConnections: []any{monitoringTestConnection("monitoring", monitoringTestComponent+"-other", "ApiKey", true)}, + }, + wantErr: "conflicting destinations", + }, + { + name: "actual account mirror is not a second connection", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("monitoring", monitoringTestComponent, "ApiKey", false)}, + accountConnections: []any{monitoringTestConnection("monitoring", monitoringTestComponent, "ApiKey", false)}, + }, + wantMode: "already-connected", wantName: "monitoring", + }, + { + name: "reserved account name is protected", + fixture: monitoringCloudFixture{ + accountConnections: []any{monitoringTestConnection(monitoringTestConnectionName, monitoringTestComponent, "ApiKey", false)}, + }, + wantErr: "protected", + }, + { + name: "different selected component conflicts", + fixture: monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection("user-monitoring", monitoringTestComponent+"-other", "ApiKey", false)}, + }, + wantErr: "conflicts", + }, + { + name: "project read denied is not absence", + fixture: monitoringCloudFixture{projectStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + { + name: "failed list is not absence", + fixture: monitoringCloudFixture{listStatus: http.StatusServiceUnavailable}, wantErr: "HTTP 503", + }, + { + name: "failed reserved-name lookup is not absence", + fixture: monitoringCloudFixture{slotStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + { + name: "disabled local auth blocks a new ApiKey", + fixture: monitoringCloudFixture{componentPolicy: map[string]any{"DisableLocalAuth": true}}, + wantErr: "disabled local authentication", + }, + { + name: "restricted ingestion is not weakened", + fixture: monitoringCloudFixture{componentPolicy: map[string]any{"publicNetworkAccessForIngestion": "Disabled"}}, + wantErr: "restricted public ingestion", + }, + { + name: "workspace access is required", + fixture: monitoringCloudFixture{workspaceStatus: http.StatusForbidden}, wantErr: "HTTP 403", + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := newMonitoringCloudFixture(t, &tt.fixture) + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + source := monitoringTestSource("reuse-connect") + err = p.prepareMonitoring(t.Context(), source, client, func(string) {}) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.NotContains(t, err.Error(), "synthetic-secret-must-not-leak") + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantMode, source.parameters["monitoringMode"].(map[string]any)["value"]) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, source.parameters["monitoringConnectionName"].(map[string]any)["value"]) + assert.Equal(t, false, source.parameters["monitoringCreateConnection"].(map[string]any)["value"]) + } + } + tt.fixture.mu.Lock() + defer tt.fixture.mu.Unlock() + assert.Empty(t, tt.fixture.writes, "preflight performs no deployment or connection writes") + }) + } +} + +func TestMonitoringPreflightRunsBeforeDeployment(t *testing.T) { + t.Parallel() + for _, preview := range []bool{false, true} { + for _, disabled := range []bool{false, true} { + t.Run(fmt.Sprintf("preview=%t/disabled-auth=%t", preview, disabled), func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{componentPolicy: map[string]any{"DisableLocalAuth": disabled}} + p := newMonitoringCloudFixture(t, &fixture) + p.onDiskSource = monitoringTestSource("reuse-connect") + p.onDiskSource.mode = templateModeBicep + var err error + if preview { + _, err = p.Preview(t.Context(), func(string) {}) + } else { + _, err = p.Deploy(t.Context(), func(string) {}) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + if disabled { + require.ErrorContains(t, err, "disabled local authentication") + assert.Empty(t, fixture.writes, "unsafe setup must fail before the root deployment or final What-If") + return + } + require.NoError(t, err) + submissions := fixture.writes + if preview { + assert.Empty(t, fixture.writes, "preview never submits a deployment") + submissions = fixture.plans + } + require.Len(t, submissions, 1, "one ordinary root deployment or final What-If, not a second monitoring deployment") + properties := submissions[0]["properties"].(map[string]any) + parameters := properties["parameters"].(map[string]any) + assert.Equal(t, "reuse-connect", parameters["monitoringMode"].(map[string]any)["value"]) + }) + } + } +} + +func TestMonitoringPreflightImplicitExistingProjectIsNotConsent(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: false} + source := monitoringTestSource("create") + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "none", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) + assert.Len(t, fixture.requests, 3, "only deployment binding, What-If and project identity are read; no setup or discovery") +} + +func TestMonitoringPreflightOwnedExistingProjectRetainsCreateGraph(t *testing.T) { + t.Parallel() + source := monitoringTestSource("create") + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "monitoring-rg", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + fixture := monitoringCloudFixture{ + projectConnections: []any{monitoringTestConnection(monitoringTestConnectionName, monitoringTestComponent, "ApiKey", false)}, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestComponent, "changeType": "NoChange"}, + map[string]any{"resourceId": monitoringTestWorkspace, "changeType": "NoChange"}, + }, + previous: map[string]any{ + "provisioningState": "Succeeded", "parameters": source.parameters, + "outputs": map[string]any{ + "AZURE_AI_PROJECT_ID": map[string]any{"type": "String", "value": monitoringTestProject}, + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": map[string]any{"type": "String", "value": monitoringTestComponent}, + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID": map[string]any{"type": "String", "value": monitoringTestWorkspace}, + }, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.subID = "other-sub" + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "create", source.parameters["monitoringMode"].(map[string]any)["value"], + "observing the connection must not drop owned monitoring resources or the shared adjunct group") + assert.Equal(t, false, source.parameters["monitoringCreateConnection"].(map[string]any)["value"]) +} + +func TestMonitoringPreflightDoesNotAdoptPlannedResource(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestComponent, "changeType": "Modify"}, + map[string]any{"resourceId": monitoringTestWorkspace, "changeType": "Modify"}, + }} + p := newMonitoringCloudFixture(t, &fixture) + p.subID = "other-sub" + source := monitoringTestSource("create") + source.parameters["resourceGroupName"] = map[string]any{"value": "monitoring-rg"} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.ErrorContains(t, p.prepareMonitoring(t.Context(), source, client, func(string) {}), "not owned") + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) +} + +func TestMonitoringPreflightFreshNativeTargetComesFromARM(t *testing.T) { + t.Parallel() + component := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.Insights/components/new" + workspace := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.OperationalInsights/workspaces/new" + fixture := monitoringCloudFixture{ + projectStatus: http.StatusNotFound, accountStatus: http.StatusNotFound, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestProject, "changeType": "Create"}, + map[string]any{"resourceId": component, "changeType": "Create"}, + map[string]any{"resourceId": workspace, "changeType": "Create"}, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{} + p.virtualEnv = map[string]string{"AZURE_AI_PROJECT_ID": monitoringTestProject + "-stale"} + source := monitoringTestSource("create") + delete(source.parameters, "projectResourceId") + source.parameters["applicationInsightsResourceId"] = map[string]any{"value": ""} + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "project-rg", "foundryProjectName": "project", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "create", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) + assert.Len(t, fixture.plans, 1) + assert.Contains(t, fixture.requests, "GET "+monitoringTestProject) + for _, request := range fixture.requests { + assert.NotContains(t, request, "-stale") + } +} + +func TestMonitoringPreflightFailedNativeAttemptCanRetryWithoutTrustingOutputs(t *testing.T) { + t.Parallel() + source := monitoringTestSource("create") + delete(source.parameters, "projectResourceId") + source.parameters["applicationInsightsResourceId"] = map[string]any{"value": ""} + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "project-rg", "foundryProjectName": "project", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + fixture := monitoringCloudFixture{ + plannedStatus: http.StatusOK, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestProject, "changeType": "NoChange"}, + map[string]any{ + "resourceId": "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.Insights/components/new", + "changeType": "Modify", + }, + map[string]any{ + "resourceId": "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.OperationalInsights/workspaces/new", + "changeType": "NoChange", + }, + }, + previous: map[string]any{ + "provisioningState": "Failed", "parameters": source.parameters, "template": source.armTemplate, + "outputs": map[string]any{ + "AZURE_AI_PROJECT_ID": map[string]any{"type": "String", "value": monitoringTestProject + "-untrusted"}, + }, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "create", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) + for _, request := range fixture.requests { + assert.NotContains(t, request, "-untrusted") + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target.go new file mode 100644 index 00000000000..e379f32b500 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target.go @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "fmt" + "strings" + + "azure.ai.projects/synthesis" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +type monitoringTargetBinding struct { + projectID string + owned bool + legacy bool + outputs any + plan *armresources.WhatIfOperationResult + retry bool +} + +// A deployment record is a binding only while all inputs that can select a +// different native project still agree. Cached environment IDs are not bindings. +func previousMonitoringTarget( + parameters map[string]any, + previous *armresources.DeploymentPropertiesExtended, + subscriptionID string, +) *monitoringTargetBinding { + if previous == nil || previous.ProvisioningState == nil || + *previous.ProvisioningState != armresources.ProvisioningStateSucceeded { + return nil + } + if !monitoringIdentityMatches(parameters, previous.Parameters) { + return nil + } + oldParameters := previous.Parameters.(map[string]any) + existingID, existing := armStringParameter(parameters, "projectResourceId") + outputs := armOutputsToProto(previous.Outputs) + project := outputs["AZURE_AI_PROJECT_ID"] + if project == nil { + return nil + } + group, _ := armStringParameter(parameters, "resourceGroupName") + name, _ := armStringParameter(parameters, "foundryProjectName") + if existing { + if !strings.EqualFold(existingID, project.Value) { + return nil + } + if _, err := synthesis.MonitoringResourceID(existingID, "Microsoft.CognitiveServices/accounts/projects"); err != nil { + return nil + } + } else if !monitoringProjectMatches(project.Value, subscriptionID, group, name) { + return nil + } + mode, present := armStringParameter(oldParameters, "monitoringMode") + if !present { + if output := outputs["AZD_FOUNDRY_MONITORING_MODE"]; output != nil { + mode, present = output.Value, true + } + } + return &monitoringTargetBinding{ + projectID: project.Value, + owned: mode == "create", + legacy: !present || mode == "none", + outputs: previous.Outputs, + } +} + +func monitoringIdentityMatches(parameters map[string]any, previous any) bool { + oldParameters, ok := previous.(map[string]any) + if !ok { + return false + } + identityParameters := []string{"location", "resourceGroupName", "resourceTokenSalt"} + if _, existing := armStringParameter(parameters, "projectResourceId"); existing { + identityParameters = append(identityParameters, "projectResourceId") + } else { + identityParameters = append(identityParameters, "foundryProjectName") + } + for _, name := range identityParameters { + current, currentOK := armStringParameter(parameters, name) + old, oldOK := armStringParameter(oldParameters, name) + if !currentOK || !oldOK || current != old { + return false + } + } + return true +} + +// Use ARM's resolved resource IDs, not a second implementation of uniqueString. +// What-If also returns unrelated resources as Ignore; they are never candidates. +func monitoringProjectFromWhatIf( + result armresources.WhatIfOperationResult, + subscriptionID, resourceGroup, projectName string, +) (string, error) { + if err := whatIfFailure(result); err != nil { + return "", err + } + projectID := "" + if result.Properties != nil { + for _, change := range result.Properties.Changes { + if change == nil || change.ResourceID == nil || change.ChangeType == nil || + *change.ChangeType == armresources.ChangeTypeIgnore { + continue + } + if !monitoringProjectMatches(*change.ResourceID, subscriptionID, resourceGroup, projectName) { + continue + } + if *change.ChangeType == armresources.ChangeTypeUnsupported { + return "", fmt.Errorf("ARM What-If could not resolve the selected Foundry project; no monitoring writes were authorized") + } + switch *change.ChangeType { + case armresources.ChangeTypeCreate, armresources.ChangeTypeModify, + armresources.ChangeTypeNoChange, armresources.ChangeTypeDeploy: + default: + return "", fmt.Errorf("ARM What-If returned an unsafe project change; no monitoring writes were authorized") + } + if projectID != "" && !strings.EqualFold(projectID, *change.ResourceID) { + return "", fmt.Errorf("ARM What-If resolved multiple Foundry project targets; no monitoring writes were authorized") + } + projectID = *change.ResourceID + } + } + if projectID == "" { + return "", fmt.Errorf("ARM What-If did not resolve the selected Foundry project; no monitoring writes were authorized") + } + return projectID, nil +} + +func monitoringProjectMatches(id, subscriptionID, resourceGroup, projectName string) bool { + resource, err := arm.ParseResourceID(id) + return err == nil && resource.Parent != nil && + strings.EqualFold(resource.ResourceType.String(), "Microsoft.CognitiveServices/accounts/projects") && + strings.EqualFold(resource.SubscriptionID, subscriptionID) && + strings.EqualFold(resource.ResourceGroupName, resourceGroup) && + strings.EqualFold(resource.Name, projectName) +} + +func armStringParameter(parameters map[string]any, name string) (string, bool) { + envelope, ok := parameters[name].(map[string]any) + if !ok { + return "", false + } + value, ok := envelope["value"].(string) + return value, ok +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target_test.go new file mode 100644 index 00000000000..c0475a2c03a --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_target_test.go @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "maps" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreviousMonitoringTarget(t *testing.T) { + t.Parallel() + const projectID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/project" + parameters := map[string]any{ + "location": map[string]any{"value": "westus3"}, + "resourceGroupName": map[string]any{"value": "rg"}, + "foundryProjectName": map[string]any{"value": "project"}, + "resourceTokenSalt": map[string]any{"value": "env"}, + } + tests := []struct { + name string + change string + priorMode string + priorID string + failed bool + wantBinding bool + wantOwned bool + wantLegacy bool + }{ + {name: "owned graph remains create", priorMode: "create", wantBinding: true, wantOwned: true}, + {name: "BYO does not gain component ownership", priorMode: "reuse-connect", wantBinding: true}, + {name: "pre-feature deployment is not fresh consent", wantBinding: true, wantLegacy: true}, + {name: "prior opt out is not fresh consent", priorMode: "none", wantBinding: true, wantLegacy: true}, + {name: "changed group", change: "resourceGroupName"}, + {name: "changed project name", change: "foundryProjectName"}, + {name: "changed salt", change: "resourceTokenSalt"}, + {name: "changed location", change: "location"}, + {name: "stale output", priorID: projectID + "-other"}, + {name: "unsuccessful deployment", failed: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := maps.Clone(parameters) + if tt.change != "" { + old[tt.change] = map[string]any{"value": "other"} + } + if tt.priorMode != "" { + old["monitoringMode"] = map[string]any{"value": tt.priorMode} + } + id := projectID + if tt.priorID != "" { + id = tt.priorID + } + state := armresources.ProvisioningStateSucceeded + if tt.failed { + state = armresources.ProvisioningStateFailed + } + prior := &armresources.DeploymentPropertiesExtended{ + ProvisioningState: &state, + Parameters: old, + Outputs: map[string]any{ + "AZURE_AI_PROJECT_ID": map[string]any{"type": "String", "value": id}, + }, + } + binding := previousMonitoringTarget(parameters, prior, "sub") + if !tt.wantBinding { + assert.Nil(t, binding) + return + } + require.NotNil(t, binding) + assert.Equal(t, projectID, binding.projectID) + assert.Equal(t, tt.wantOwned, binding.owned) + assert.Equal(t, tt.wantLegacy, binding.legacy) + }) + } + assert.Nil(t, previousMonitoringTarget(parameters, nil, "sub")) +} + +func TestMonitoringProjectFromWhatIf(t *testing.T) { + t.Parallel() + const prefix = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/" + project := prefix + "account/projects/project" + tests := []struct { + name string + changes []*armresources.WhatIfChange + wantErr string + }{ + { + name: "actual target and ignored existing sibling", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(prefix + "sibling/projects/project"), ChangeType: new(armresources.ChangeTypeIgnore)}, + {ResourceID: new(project), ChangeType: new(armresources.ChangeTypeCreate)}, + }, + }, + { + name: "reprovision no change", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(project), ChangeType: new(armresources.ChangeTypeNoChange)}, + }, + }, + {name: "nested expansion missing", wantErr: "did not resolve"}, + { + name: "wrong project cannot authorize writes", wantErr: "did not resolve", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(project + "-other"), ChangeType: new(armresources.ChangeTypeCreate)}, + }, + }, + { + name: "ambiguous accounts", wantErr: "multiple", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(project), ChangeType: new(armresources.ChangeTypeCreate)}, + {ResourceID: new(prefix + "different/projects/project"), ChangeType: new(armresources.ChangeTypeCreate)}, + }, + }, + { + name: "unsupported expansion", wantErr: "could not resolve", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(project), ChangeType: new(armresources.ChangeTypeUnsupported)}, + }, + }, + { + name: "deletion is not a setup target", wantErr: "unsafe project change", + changes: []*armresources.WhatIfChange{ + {ResourceID: new(project), ChangeType: new(armresources.ChangeTypeDelete)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := monitoringProjectFromWhatIf(armresources.WhatIfOperationResult{ + Properties: &armresources.WhatIfOperationProperties{Changes: tt.changes}, + }, "sub", "rg", "project") + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, actual) + return + } + require.NoError(t, err) + assert.Equal(t, project, actual) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_template_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_template_test.go new file mode 100644 index 00000000000..a605bc662d4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_template_test.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package provisioning + +import ( + "encoding/json" + "testing" + + "azure.ai.projects/synthesis" + + "github.com/stretchr/testify/require" +) + +func TestMonitoringTemplateAllowsEditableResources(t *testing.T) { + t.Parallel() + template := compiledMonitoringTemplate(t) + require.NoError(t, validateProjectTemplate(template, "main.bicep")) + workspace := findMonitoringTemplateObject(template, func(object map[string]any) bool { + return object["type"] == "Microsoft.OperationalInsights/workspaces" + }) + require.NotNil(t, workspace) + workspace["properties"].(map[string]any)["retentionInDays"] = float64(90) + require.NoError(t, validateProjectTemplate(template, "custom-main.bicep")) +} + +func TestMonitoringTemplateRejectsUnsafeConnectionEdits(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(map[string]any, map[string]any) + }{ + {name: "unguarded", mutate: func(connection, _ map[string]any) { delete(connection, "@options") }}, + {name: "scope override", mutate: func(connection, _ map[string]any) { connection["scope"] = "/subscriptions/other" }}, + {name: "loop", mutate: func(connection, _ map[string]any) { connection["copy"] = map[string]any{} }}, + {name: "condition override", mutate: func(connection, _ map[string]any) { connection["condition"] = true }}, + {name: "different name", mutate: func(connection, _ map[string]any) { connection["name"] = "user-connection" }}, + {name: "different credential", mutate: func(connection, _ map[string]any) { + connection["properties"].(map[string]any)["credentials"] = map[string]any{"key": "[parameters('userKey')]"} + }}, + {name: "shared connection", mutate: func(connection, _ map[string]any) { + connection["properties"].(map[string]any)["isSharedToAll"] = true + }}, + {name: "different target", mutate: func(connection, _ map[string]any) { + connection["properties"].(map[string]any)["target"] = "[parameters('otherComponent')]" + }}, + {name: "different component reference", mutate: func(_, template map[string]any) { + connectionTemplate := findMonitoringTemplateObject(template, func(object map[string]any) bool { + resources, _ := object["resources"].(map[string]any) + return resources["connection"] != nil && resources["component"] != nil + }) + require.NotNil(t, connectionTemplate) + component := connectionTemplate["resources"].(map[string]any)["component"].(map[string]any) + component["name"] = "other-component" + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + template := compiledMonitoringTemplate(t) + connection := findMonitoringTemplateObject(template, func(object map[string]any) bool { + properties, _ := object["properties"].(map[string]any) + return object["type"] == "Microsoft.CognitiveServices/accounts/projects/connections" && + properties["category"] == "AppInsights" + }) + require.NotNil(t, connection) + tt.mutate(connection, template) + require.Error(t, validateProjectTemplate(template, "modified-main.bicep")) + }) + } +} + +func compiledMonitoringTemplate(t *testing.T) map[string]any { + t.Helper() + data, err := synthesis.ARMTemplate() + require.NoError(t, err) + var template map[string]any + require.NoError(t, json.Unmarshal(data, &template)) + return template +} + +func findMonitoringTemplateObject(value any, matches func(map[string]any) bool) map[string]any { + switch value := value.(type) { + case map[string]any: + if matches(value) { + return value + } + for _, child := range value { + if match := findMonitoringTemplateObject(child, matches); match != nil { + return match + } + } + case []any: + for _, child := range value { + if match := findMonitoringTemplateObject(child, matches); match != nil { + return match + } + } + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments.go index 211af3b53e3..5f752f1d715 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments.go @@ -10,7 +10,7 @@ import ( "io" "strings" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments_test.go index 331a3bd12ac..8a56851d16f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/project_deployments_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index 0612851fa61..aaffcefbf8f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -10,7 +10,7 @@ import ( "strings" "azure.ai.projects/internal/azure" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding.go deleted file mode 100644 index 5b5d230c34c..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "regexp" - "strings" - - "go.yaml.in/yaml/v3" -) - -// ACRBindingConfigKey stores deployed facts in environment config. -const ACRBindingConfigKey = "extensions.ai-projects.acrBinding" - -// ACRBindingEnvKey carries a binding in a synthesis snapshot. -const ACRBindingEnvKey = "AZD_FOUNDRY_ACR_BINDING" - -var projectResourceIDPattern = regexp.MustCompile( - `(?i)^/subscriptions/[^/\s?#\\%]+/resourceGroups/[^/\s?#\\%]+/providers/` + - `Microsoft\.CognitiveServices/accounts/[^/\s?#\\%]+/projects/[^/\s?#\\%]+$`, -) - -var acrOwnershipTokenPattern = regexp.MustCompile( - `(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, -) - -// ACRAssignment identifies an environment-owned access grant. -type ACRAssignment struct { - ID string `json:"id"` - PrincipalID string `json:"principalId"` - RoleDefinitionID string `json:"roleDefinitionId"` - OwnershipToken string `json:"ownershipToken"` - DeploymentIDs []string `json:"deploymentIds,omitempty"` -} - -// ACRBinding records deployed registry facts, not setup intent. -type ACRBinding struct { - Version int `json:"version"` - Mode string `json:"mode"` - RegistryResourceID string `json:"registryResourceId"` - ProjectResourceID string `json:"projectResourceId"` - PrincipalID string `json:"principalId"` - DeploymentSalt string `json:"deploymentSalt,omitempty"` - OwnedAssignments []ACRAssignment `json:"ownedAssignments,omitempty"` -} - -// ParseACRBinding rejects malformed persisted deployment facts. -func ParseACRBinding(value any) (*ACRBinding, error) { - if value == nil { - return nil, nil - } - var data []byte - switch v := value.(type) { - case []byte: - data = v - case json.RawMessage: - data = v - case string: - data = []byte(v) - default: - var err error - data, err = json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode ACR binding: %w", err) - } - } - if len(bytes.TrimSpace(data)) == 0 { - return nil, nil - } - var binding ACRBinding - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&binding); err != nil { - return nil, fmt.Errorf("invalid deployed ACR binding: %w", err) - } - if err := decoder.Decode(new(any)); err != io.EOF { - return nil, errors.New("invalid deployed ACR binding: expected one object") - } - if binding.Version != 1 || (binding.Mode != "create" && binding.Mode != "reuse-connect") || - !containerRegistryIDPattern.MatchString(binding.RegistryResourceID) || - !projectResourceIDPattern.MatchString(binding.ProjectResourceID) || binding.PrincipalID == "" { - return nil, errors.New("invalid deployed ACR binding: incomplete or unsupported deployment facts") - } - for _, assignment := range binding.OwnedAssignments { - expectedPrefix := binding.RegistryResourceID + "/providers/Microsoft.Authorization/roleAssignments/" - parts := strings.Split(binding.RegistryResourceID, "/") - rolePrefix := "/subscriptions/" + parts[2] + "/providers/Microsoft.Authorization/roleDefinitions/" - knownRole := strings.EqualFold(assignment.RoleDefinitionID, rolePrefix+"7f951dda-4ed3-4680-a7ca-43fe172d538d") || - strings.EqualFold(assignment.RoleDefinitionID, rolePrefix+"b93aa761-3e63-49ed-ac28-beffa264f7ac") - if !strings.EqualFold(assignment.ID, expectedPrefix+assignment.OwnershipToken) || - !acrOwnershipTokenPattern.MatchString(assignment.OwnershipToken) || - !strings.EqualFold(assignment.PrincipalID, binding.PrincipalID) || - !knownRole { - return nil, errors.New("invalid deployed ACR binding: incomplete owned assignment") - } - deploymentScope := strings.Join(parts[:5], "/") + "/providers/Microsoft.Resources/deployments/" - expected := []string{ - deploymentScope + "azd-acr-access-" + assignment.OwnershipToken, - deploymentScope + "azd-acr-" + assignment.OwnershipToken, - } - if len(assignment.DeploymentIDs) != len(expected) { - return nil, errors.New("invalid deployed ACR binding: incomplete deployment ownership") - } - for i, id := range expected { - if !strings.EqualFold(id, assignment.DeploymentIDs[i]) { - return nil, errors.New("invalid deployed ACR binding: mismatched deployment ownership") - } - } - } - return &binding, nil -} - -// ValidateACRBinding rejects migration of a deployed registry. -func ValidateACRBinding(binding *ACRBinding, mode, registryID string) error { - if binding == nil { - return nil - } - if _, err := ParseACRBinding(binding); err != nil { - return err - } - if mode == "" { - mode = binding.Mode - } - if mode != binding.Mode || - (registryID != "" && !strings.EqualFold(binding.RegistryResourceID, registryID)) { - return fmt.Errorf("registry migration is not supported for an already provisioned Foundry project; "+ - "retain the deployed %s registry %q or use a new azd environment", binding.Mode, binding.RegistryResourceID) - } - return nil -} - -// ValidateACRSelector validates a registry before writes. -func ValidateACRSelector(registryID, subscriptionID, resourceGroup string) error { - if !containerRegistryIDPattern.MatchString(registryID) { - return errors.New("AZURE_CONTAINER_REGISTRY_RESOURCE_ID must be a full ARM ID " + - "of a Microsoft.ContainerRegistry/registries resource") - } - parts := strings.Split(registryID, "/") - if subscriptionID != "" && !strings.EqualFold(parts[2], subscriptionID) { - return errors.New("AZURE_CONTAINER_REGISTRY_RESOURCE_ID must use the same subscription " + - "as the Foundry project (AZURE_SUBSCRIPTION_ID); cross-subscription registry reuse is not supported") - } - if subscriptionID != "" && resourceGroup != "" && - strings.EqualFold(parts[2], subscriptionID) && strings.EqualFold(parts[4], resourceGroup) { - return errors.New("AZURE_CONTAINER_REGISTRY_RESOURCE_ID must be outside the Foundry " + - "resource group because azd down deletes that group") - } - return nil -} - -// ValidateGreenfieldACRSelection validates a setup selector. -func ValidateGreenfieldACRSelection(env map[string]string, selectedID string) error { - group := env["AZURE_FOUNDRY_RESOURCE_GROUP"] - if group == "" { - group = env["AZURE_RESOURCE_GROUP"] - } - if err := ValidateACRSelector(selectedID, env["AZURE_SUBSCRIPTION_ID"], group); err != nil { - return err - } - binding, err := ParseACRBinding(env[ACRBindingEnvKey]) - if err != nil { - return err - } - return ValidateACRBinding(binding, "reuse-connect", selectedID) -} - -// GreenfieldACRResourceGroup resolves the actual teardown group. -func GreenfieldACRResourceGroup(in Input) (string, error) { - if in.ResourceGroupName != "" { - return in.ResourceGroupName, nil - } - var doc struct { - Infra struct { - Provider string `yaml:"provider"` - Layers []struct { - Name string `yaml:"name"` - Provider string `yaml:"provider"` - } `yaml:"layers"` - } `yaml:"infra"` - } - if err := yaml.Unmarshal(in.RawAzureYAML, &doc); err != nil { - return "", fmt.Errorf("parse infrastructure scope in azure.yaml: %w", err) - } - layered := false - for _, layer := range doc.Infra.Layers { - provider := strings.TrimSpace(layer.Provider) - if provider == "" { - provider = strings.TrimSpace(doc.Infra.Provider) - } - if provider != "microsoft.foundry" { - continue - } - if strings.TrimSpace(layer.Name) == "" || layered { - return "", errors.New("registry selection requires one named Foundry infrastructure layer") - } - layered = true - } - key, suffix := "AZURE_RESOURCE_GROUP", "" - if layered { - key, suffix = "AZURE_FOUNDRY_RESOURCE_GROUP", "-foundry" - } - if group := in.Env[key]; group != "" { - return group, nil - } - if name := in.Env["AZURE_ENV_NAME"]; name != "" { - return "rg-" + name + suffix, nil - } - return "", nil -} - -// ValidateGreenfieldACRSelectionForInput guards setup and eject. -func ValidateGreenfieldACRSelectionForInput(in Input, selectedID string) error { - _, projects, err := acrProjectServices(in) - if err != nil { - return err - } - for _, project := range projects { - if strings.TrimSpace(project.Endpoint) != "" { - return fmt.Errorf("%w: --acr-resource-id is only supported for a new Foundry project; "+ - "use the existing project's registry connection instead of a source-build selector", ErrEndpointBrownfield) - } - } - group, err := GreenfieldACRResourceGroup(in) - if err != nil { - return err - } - subscription := in.Env["AZURE_SUBSCRIPTION_ID"] - if err := ValidateACRSelector(selectedID, subscription, group); err != nil { - return err - } - if subscription == "" && group != "" && strings.EqualFold(strings.Split(selectedID, "/")[4], group) { - return errors.New("cannot establish that the registry is outside the Foundry resource group; " + - "configure AZURE_SUBSCRIPTION_ID before selecting a registry with the same resource-group name") - } - binding := in.ACRBinding - if binding == nil { - binding, err = ParseACRBinding(in.Env[ACRBindingEnvKey]) - if err != nil { - return err - } - } - return ValidateACRBinding(binding, "reuse-connect", selectedID) -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding_test.go deleted file mode 100644 index b95e59f809f..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding_test.go +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "encoding/json" - "maps" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestACRBindingSelection(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - for _, mode := range []string{"create", "reuse-connect"} { - binding := &ACRBinding{ - Version: 1, Mode: mode, RegistryResourceID: id, - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - } - parsed, err := ParseACRBinding(binding) - require.NoError(t, err) - assert.Equal(t, binding, parsed) - require.NoError(t, ValidateACRBinding(parsed, "", "")) - require.NoError(t, ValidateACRBinding(parsed, mode, "")) - require.NoError(t, ValidateACRBinding(parsed, mode, strings.ToUpper(id))) - require.ErrorContains(t, ValidateACRBinding(parsed, mode, id+"other"), "migration is not supported") - opposite := "create" - if mode == opposite { - opposite = "reuse-connect" - } - require.ErrorContains(t, ValidateACRBinding(parsed, opposite, id), "migration is not supported") - } - require.NoError(t, ValidateACRBinding(nil, "reuse-connect", id)) - for _, raw := range []string{"null", "{}", `{"version":2}`, `{"extra":true}`, "{} {}"} { - _, err := ParseACRBinding(raw) - require.Error(t, err, raw) - } -} - -func TestResolveGreenfieldACRSelectionUsesBinding(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - for _, mode := range []string{"create", "reuse-connect"} { - binding := &ACRBinding{ - Version: 1, Mode: mode, RegistryResourceID: id, - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - } - raw, err := json.Marshal(binding) - require.NoError(t, err) - for _, source := range []string{"input", "environment"} { - for _, alias := range []struct { - name, mode, id string - }{ - {name: "absent"}, - {name: "mode only", mode: mode}, - {name: "id only", id: id}, - {name: "matching", mode: mode, id: strings.ToUpper(id)}, - } { - t.Run(mode+"/"+source+"/"+alias.name, func(t *testing.T) { - in := Input{ - ResourceGroupName: "foundry", - Env: map[string]string{ - "AZURE_SUBSCRIPTION_ID": "sub", - "AZD_FOUNDRY_ACR_MODE": alias.mode, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": alias.id, - }, - } - if source == "input" { - in.ACRBinding = binding - } else { - in.Env[ACRBindingEnvKey] = string(raw) - } - before := maps.Clone(in.Env) - selectedMode, selectedID, err := ResolveGreenfieldACRSelection(in) - require.NoError(t, err) - assert.Equal(t, mode, selectedMode) - assert.Equal(t, id, selectedID) - require.NoError(t, ValidateACRSelector(selectedID, "sub", "foundry")) - err = ValidateGreenfieldACRSelectionForInput(in, selectedID) - if mode == "reuse-connect" { - require.NoError(t, err) - } else { - require.ErrorContains(t, err, "migration is not supported") - } - require.ErrorContains(t, ValidateGreenfieldACRSelectionForInput(in, ""), "full ARM ID") - require.ErrorContains(t, ValidateGreenfieldACRSelection(in.Env, ""), "full ARM ID") - assert.Equal(t, before, in.Env) - after, err := json.Marshal(binding) - require.NoError(t, err) - assert.Equal(t, raw, after) - }) - } - } - } -} - -func TestResolveGreenfieldACRSelectionRejectsConflictsAndInvalidBindings(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - binding := &ACRBinding{ - Version: 1, Mode: "reuse-connect", RegistryResourceID: id, - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - } - for _, tt := range []struct { - name, mode, id string - }{ - {name: "create mode", mode: "create"}, - {name: "none mode", mode: "none"}, - {name: "unknown mode", mode: "unknown"}, - {name: "different registry", id: id + "other"}, - {name: "malformed registry", id: "invalid"}, - } { - t.Run(tt.name, func(t *testing.T) { - mode, selectedID, err := ResolveGreenfieldACRSelection(Input{ - ACRBinding: binding, - Env: map[string]string{ - "AZD_FOUNDRY_ACR_MODE": tt.mode, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": tt.id, - }, - }) - require.ErrorContains(t, err, "migration is not supported") - assert.Empty(t, mode) - assert.Empty(t, selectedID) - }) - } - t.Run("invalid input binding", func(t *testing.T) { - invalid := *binding - invalid.Version = 2 - _, _, err := ResolveGreenfieldACRSelection(Input{ACRBinding: &invalid}) - require.ErrorContains(t, err, "invalid deployed ACR binding") - require.ErrorContains(t, ValidateACRBinding(&invalid, "", ""), "invalid deployed ACR binding") - }) - for _, raw := range []string{"null", "{}", `{"version":2}`, `{"extra":true}`, "{} {}"} { - t.Run("invalid environment binding/"+raw, func(t *testing.T) { - _, _, err := ResolveGreenfieldACRSelection(Input{Env: map[string]string{ACRBindingEnvKey: raw}}) - require.ErrorContains(t, err, "invalid deployed ACR binding") - }) - } -} - -func TestACRRequirementsBeforeProjectSetup(t *testing.T) { - for _, tt := range []struct { - name, service string - has, needs bool - }{ - {"empty", "services: {}", false, false}, - {"project only", "services:\n project:\n host: azure.ai.project", false, false}, - {"source", "services:\n agent:\n host: azure.ai.agent\n kind: hosted", true, true}, - {"image", "services:\n agent:\n host: azure.ai.agent\n image: registry/image:v1", true, false}, - {"code", "services:\n agent:\n host: azure.ai.agent\n codeConfiguration: {}", true, false}, - {"prompt", "services:\n agent:\n host: azure.ai.agent\n kind: prompt", true, false}, - {"disabled", "services:\n agent:\n host: azure.ai.agent\n condition: false", false, false}, - {"legacy image", "services:\n agent:\n host: azure.ai.agent\n agents:\n" + - " - name: agent\n image: registry/image:v1", true, false}, - } { - t.Run(tt.name, func(t *testing.T) { - has, needs, err := ACRRequirements(Input{RawAzureYAML: []byte(tt.service)}) - require.NoError(t, err) - assert.Equal(t, tt.has, has) - assert.Equal(t, tt.needs, needs) - }) - } -} - -func TestGreenfieldACRResourceGroup(t *testing.T) { - for _, tt := range []struct { - name, raw, override, want string - env map[string]string - }{ - {name: "root default", want: "rg-test"}, - {name: "root explicit", env: map[string]string{"AZURE_RESOURCE_GROUP": "root"}, want: "root"}, - {name: "layer ignores root", raw: "infra:\n layers:\n - name: foundry\n provider: microsoft.foundry", - env: map[string]string{"AZURE_RESOURCE_GROUP": "root"}, want: "rg-test-foundry"}, - {name: "layer explicit", raw: "infra:\n layers:\n - name: foundry\n provider: microsoft.foundry", - env: map[string]string{"AZURE_FOUNDRY_RESOURCE_GROUP": "custom"}, want: "custom"}, - {name: "inherited layer", raw: "infra:\n provider: microsoft.foundry\n layers:\n - name: foundry", - want: "rg-test-foundry"}, - {name: "other layer", raw: "infra:\n layers:\n - name: app\n provider: bicep", want: "rg-test"}, - {name: "override", override: "override", want: "override"}, - } { - t.Run(tt.name, func(t *testing.T) { - env := map[string]string{"AZURE_ENV_NAME": "test"} - maps.Copy(env, tt.env) - group, err := GreenfieldACRResourceGroup(Input{ - RawAzureYAML: []byte(tt.raw), ResourceGroupName: tt.override, Env: env, - }) - require.NoError(t, err) - assert.Equal(t, tt.want, group) - }) - } -} - -func TestGreenfieldACRSelectionForInput(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/rg-test-foundry/" + - "providers/Microsoft.ContainerRegistry/registries/registry" - in := Input{ - RawAzureYAML: []byte("infra:\n layers:\n - name: foundry\n provider: microsoft.foundry"), - Env: map[string]string{"AZURE_ENV_NAME": "test", "AZURE_RESOURCE_GROUP": "other"}, - } - require.ErrorContains(t, ValidateGreenfieldACRSelectionForInput(in, id), "configure AZURE_SUBSCRIPTION_ID") - in.Env["AZURE_SUBSCRIPTION_ID"] = "SUB" - require.ErrorContains(t, ValidateGreenfieldACRSelectionForInput(in, id), "azd down deletes") - in.Env["AZURE_SUBSCRIPTION_ID"] = "other" - require.ErrorContains(t, ValidateGreenfieldACRSelectionForInput(in, id), "same subscription") - in.Env["AZURE_SUBSCRIPTION_ID"] = "SUB" - in.ResourceGroupName = "another-group" - require.NoError(t, ValidateGreenfieldACRSelectionForInput(in, id)) - for _, suffix := range []string{"?sig=secret", "#secret", "%3Fsecret", "@secret", "/child", "\\secret"} { - err := ValidateGreenfieldACRSelectionForInput(in, id+suffix) - require.ErrorContains(t, err, "full ARM ID") - assert.NotContains(t, err.Error(), "secret") - } -} - -func TestGreenfieldACRSelectionRejectsAuthoredExistingProject(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - for _, host := range []string{"azure.ai.project", "azure.ai.agents"} { - t.Run(host, func(t *testing.T) { - in := Input{RawAzureYAML: []byte("services:\n project:\n host: " + host + - "\n endpoint: ${FOUNDRY_PROJECT_ENDPOINT}\n")} - err := ValidateGreenfieldACRSelectionForInput(in, id) - require.ErrorContains(t, err, "only supported for a new Foundry project") - require.ErrorIs(t, err, ErrEndpointBrownfield) - in.RawAzureYAML = []byte(string(in.RawAzureYAML) + " condition: false\n") - require.NoError(t, ValidateGreenfieldACRSelectionForInput(in, id)) - }) - } - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "project.yaml"), []byte( - "host: azure.ai.project\nendpoint: https://account.services.ai.azure.com/api/projects/project\n"), 0o600)) - in := Input{ - RawAzureYAML: []byte("services:\n project:\n $ref: project.yaml\n"), - ProjectRoot: root, - } - err := ValidateGreenfieldACRSelectionForInput(in, id) - require.ErrorContains(t, err, "only supported for a new Foundry project") - require.ErrorIs(t, err, ErrEndpointBrownfield) -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go deleted file mode 100644 index c4ac64d6455..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "fmt" - - "github.com/azure/azure-dev/cli/azd/pkg/osutil" -) - -// evaluateCondition matches project.ServiceConfig.IsEnabled. -// Foundry expressions are not valid in condition values. -func evaluateCondition( - value string, - getenv func(string) string, -) (bool, error) { - if value == "" { - return true, nil - } - if getenv == nil { - getenv = func(string) string { return "" } - } - expanded, err := osutil.NewExpandableString(value).Envsubst(getenv) - if err != nil { - return false, fmt.Errorf( - "malformed condition template: %w", - err, - ) - } - return isTruthyCondition(expanded), nil -} - -func isTruthyCondition(value string) bool { - switch value { - case "1", "true", "TRUE", "True", "yes", "YES", "Yes": - return true - default: - return false - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition_test.go deleted file mode 100644 index 4ff85f023b7..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/condition_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEvaluateCondition(t *testing.T) { - tests := []struct { - name string - value string - env map[string]string - want bool - wantErr string - }{ - { - name: "empty condition is enabled", - want: true, - }, - { - name: "truthy environment value enables service", - value: "${ENABLE_CONNECTION}", - env: map[string]string{"ENABLE_CONNECTION": "true"}, - want: true, - }, - { - name: "falsy environment value disables service", - value: "${ENABLE_CONNECTION}", - env: map[string]string{"ENABLE_CONNECTION": "false"}, - want: false, - }, - { - name: "Foundry expression is malformed", - value: "${{connections.foo}}", - wantErr: "malformed condition template", - }, - { - name: "unclosed environment expression is malformed", - value: "${UNCLOSED", - wantErr: "malformed condition template", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := evaluateCondition(tt.value, func(name string) string { - return tt.env[name] - }) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - return - } - - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings.go deleted file mode 100644 index 54b3e6e73e4..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "encoding/json" - "fmt" - "strings" -) - -// DeploymentBindingsEnvKey stores explicit per-declaration selections. -const DeploymentBindingsEnvKey = "AI_PROJECT_DEPLOYMENT_BINDINGS" - -// DecodeDeploymentBindings reads raw or environment-escaped JSON. -// An empty target records an explicitly skipped declaration. -func DecodeDeploymentBindings(value string) (map[string]string, error) { - bindings := map[string]string{} - value = strings.TrimSpace(value) - if value == "" { - return bindings, nil - } - if !json.Valid([]byte(value)) { - var unescaped string - if err := json.Unmarshal([]byte(`"`+value+`"`), &unescaped); err != nil { - return nil, fmt.Errorf("decoding %s: %w", DeploymentBindingsEnvKey, err) - } - value = unescaped - } - if err := json.Unmarshal([]byte(value), &bindings); err != nil { - return nil, fmt.Errorf("decoding %s: %w", DeploymentBindingsEnvKey, err) - } - if bindings == nil { - return nil, fmt.Errorf("%s must be a JSON object", DeploymentBindingsEnvKey) - } - for name := range bindings { - if strings.TrimSpace(name) == "" { - return nil, fmt.Errorf("%s contains an empty declaration name", DeploymentBindingsEnvKey) - } - } - return bindings, nil -} - -// SetDeploymentBinding updates case-equivalent declaration keys without changing -// their spelling, or adds the declaration when no matching key exists. -func SetDeploymentBinding(bindings map[string]string, declaration, target string) { - matched := false - for name := range bindings { - if strings.EqualFold(name, declaration) { - bindings[name] = target - matched = true - } - } - if !matched { - bindings[declaration] = target - } -} - -// EncodeDeploymentBindings writes environment-escaped JSON selections. -func EncodeDeploymentBindings(bindings map[string]string) (string, error) { - data, err := json.Marshal(bindings) - if err != nil { - return "", fmt.Errorf("encoding %s: %w", DeploymentBindingsEnvKey, err) - } - escaped := strings.ReplaceAll(string(data), "\\", "\\\\") - return strings.ReplaceAll(escaped, "\"", "\\\""), nil -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings_test.go deleted file mode 100644 index 86f61e0ba9d..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/deployment_bindings_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "maps" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestSetDeploymentBinding(t *testing.T) { - for _, test := range []struct { - name string - bindings map[string]string - target string - want map[string]string - }{ - { - name: "new declaration", - bindings: map[string]string{"other": "untouched"}, - target: "Existing", - want: map[string]string{"other": "untouched", "template-chat": "Existing"}, - }, - { - name: "preserve existing spelling", - bindings: map[string]string{"Template-Chat": "old", "other": "untouched"}, - target: "Existing", - want: map[string]string{"Template-Chat": "Existing", "other": "untouched"}, - }, - { - name: "update every existing alias", - bindings: map[string]string{"Template-Chat": "old", "TEMPLATE-CHAT": "", "other": "untouched"}, - target: "Existing", - want: map[string]string{"Template-Chat": "Existing", "TEMPLATE-CHAT": "Existing", "other": "untouched"}, - }, - { - name: "skip existing declaration", - bindings: map[string]string{"Template-Chat": "old", "other": "untouched"}, - want: map[string]string{"Template-Chat": "", "other": "untouched"}, - }, - { - name: "skip new declaration", - bindings: map[string]string{}, - want: map[string]string{"template-chat": ""}, - }, - } { - t.Run(test.name, func(t *testing.T) { - bindings := maps.Clone(test.bindings) - SetDeploymentBinding(bindings, "template-chat", test.target) - require.Equal(t, test.want, bindings) - }) - } -} - -func TestDeploymentBindings(t *testing.T) { - want := map[string]string{"chat": "existing-chat", "embeddings": "existing-embeddings", "skipped": ""} - encoded, err := EncodeDeploymentBindings(want) - require.NoError(t, err) - for _, value := range []string{ - encoded, `{"chat":"existing-chat","embeddings":"existing-embeddings","skipped":""}`, - } { - got, err := DecodeDeploymentBindings(value) - require.NoError(t, err) - require.Equal(t, want, got) - } - got, err := DecodeDeploymentBindings("") - require.NoError(t, err) - require.Empty(t, got) - for _, value := range []string{"{", "[]", "null", `{"chat":5}`, `{"":"existing"}`} { - _, err := DecodeDeploymentBindings(value) - require.ErrorContains(t, err, DeploymentBindingsEnvKey) - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/envrefs.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/envrefs.go deleted file mode 100644 index 5afddf74645..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/envrefs.go +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "fmt" - "regexp" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/foundry" -) - -// EnvReference is one azd ${VAR} occurrence in a string. Start and End bound the -// whole reference, including any :- default, so a caller can resume scanning at -// End. -type EnvReference struct { - Name string - Start int - End int - HasDefault bool -} - -// envReferencePrefix parses only the reference prefix. Balanced defaults remain -// the scanner's responsibility. -var envReferencePrefix = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)(\}|:-)`) - -// FindEnvReferences returns the azd ${VAR} references in value that -// [foundry.ExpandEnv] actually resolves, in order of appearance. -// -// This is the single scanner for azd references in a Foundry value. Every -// consumer layers its own policy on the result rather than reimplementing -// discovery: init prompting skips references with a default because the -// expander supplies the fallback, the generated service env block records them -// so the owning extension can re-apply the default, and resolveVars treats the -// ones without a default as the names that must resolve. Re-deriving the escape -// and ${{...}} rules per consumer is what lets them drift away from the -// expander. -// -// References the expander would not resolve are dropped: escaped ones, and any -// reserved by a Foundry ${{...}} span. -// -// A reference inside a :- default is not reported: nested azd references are -// unsupported by design, so ${OUTER:-${NESTED}} yields OUTER only. -// [foundry.ExpandEnv] still resolves NESTED at deploy, but nothing discovers it, -// so init never prompts for it and it gets no entry in the generated service env -// block. It then resolves only where the consumer keeps an azd environment -// fallback, and to empty where a declared env: drops it. Keep defaults literal. -func FindEnvReferences(value string) []EnvReference { - candidates := envReferenceCandidates(value) - if len(candidates) == 0 { - return nil - } - - protected := protectedEnvReferences(value, candidates) - references := make([]EnvReference, 0, len(candidates)) - for i, candidate := range candidates { - if protected[i] { - continue - } - references = append(references, candidate) - } - if len(references) == 0 { - return nil - } - return references -} - -// ValidateEnvReferences reports an error when value carries a '$' form that -// [foundry.ExpandEnv] would act on but [FindEnvReferences] does not report. -// -// drone/envsubst, which backs the expander, implements the full shell parameter -// grammar: ${VAR:=default}, ${VAR:+alt}, ${VAR:?message}, ${VAR#prefix} and -// ${VAR:0:3} all expand. None of them are shapes the scanner above reports, so -// without this check they slip past the unresolved-variable guard and quietly -// rewrite a value that the caller then validates as if the user had written it. -// Typing ':=' instead of ':-' is a one character slip that would otherwise -// succeed while skipping the very guard it looks like it is using. -// -// A reference nested in a :- default is refused too — and that is a shape which -// resolves correctly today whenever the nested name is set: -// ${VNET_ID:-${FALLBACK_VNET_ID}} works on a project that has FALLBACK_VNET_ID -// in its environment. It is withdrawn rather than fixed because `required` is -// computed statically, and whether the nested name has to resolve depends on -// whether the outer one does — which is not known when the value is scanned. -// Reporting the nested name would raise a false unresolved-variable error every -// time the outer name IS set; not reporting it leaves ${A:-${B}} with neither -// set expanding to empty, so the caller blames the empty value instead of naming -// B. Neither half is right, so the shape goes. -// -// Where this runs, it makes [FindEnvReferences] complete: every occurrence the -// expander acts on is one the scanner saw. That is a property of the *call*, -// not of the scanner — only callers that invoke this get it. Today that is the -// three project network fields (network.agentSubnet.vnet, network.peSubnet.vnet, -// network.dns.subscription). Discovery-only consumers, such as init prompting -// and the generated service env block, still scan values that were never -// validated; extending the check to them is tracked by -// https://github.com/Azure/azure-dev/issues/9428. -func ValidateEnvReferences(value string) error { - // Non-zero while scanning the inside of a :- default. Nesting is refused on - // sight, so one boundary is enough — there is never a second level. - defaultEnd := 0 - for index := 0; index < len(value); { - if defaultEnd > 0 && index >= defaultEnd { - defaultEnd = 0 - } - if value[index] != '$' { - index++ - continue - } - // Outside a default, a '$' pair collapses to a literal '$', - // neutralizing only the '${' it precedes. Inside a :- default, - // envsubst re-parses the default text and the second '$' can still open - // a live ${VAR}, so leave it for the nested-reference check below. - // A Foundry span is masked before envsubst sees the pair in either case. - if defaultEnd == 0 && - strings.HasPrefix(value[index:], "$$") && - !strings.HasPrefix(value[index+1:], "${{") { - index += 2 - continue - } - // A Foundry span is reserved verbatim for the service to resolve. Legal - // as a default value, so this stays allowed inside one. - if strings.HasPrefix(value[index:], "${{") { - end := strings.Index(value[index+3:], "}}") - if end < 0 { - return fmt.Errorf("%q is missing the closing }} of a Foundry expression", - unsupportedEnvFragment(value, index)) - } - index += end + 5 - continue - } - // A bare '$' is not a reference: envsubst expands only the braced form, - // so "$VAR" and "costs $5" survive expansion untouched. - if !strings.HasPrefix(value[index:], "${") { - index++ - continue - } - reference, found := envReferenceAt(value, index) - if defaultEnd > 0 && found { - return fmt.Errorf( - "%q nests an environment variable reference inside a :- default, which azd "+ - "cannot check: whether the nested name is required depends on whether the "+ - "outer one resolves, and that is not known when the value is scanned. Use a "+ - "single ${VAR} and set it in the azd environment, or give the default a "+ - "literal value", - value[reference.Start:reference.End]) - } - if !found { - return fmt.Errorf( - "%q is not a supported environment variable reference; use ${VAR} or "+ - "${VAR:-default}, $${VAR} to keep it literal, or ${{...}} for a Foundry expression", - unsupportedEnvFragment(value, index)) - } - if reference.HasDefault { - // Step into the default rather than over it. FindEnvReferences stops - // at the default because nested references are not *discovered*; - // envsubst still *expands* whatever is in there, so both an - // unsupported form and a nested reference have to be caught here. - defaultEnd = reference.End - index = reference.Start + len("${") + len(reference.Name) + len(":-") - continue - } - index = reference.End - } - return nil -} - -// unsupportedEnvFragment returns the reference-looking fragment starting at -// index so an error can quote the offending text rather than the whole value. It -// stops at the first '}' because an unsupported form is by definition one the -// span scanner cannot bound. -func unsupportedEnvFragment(value string, index int) string { - rest := value[index:] - if end := strings.IndexByte(rest, '}'); end >= 0 { - return rest[:end+1] - } - return rest -} - -// envReferenceCandidates scans value left to right for ${NAME} and -// ${NAME:-default} occurrences. drone/envsubst, which backs -// [foundry.ExpandEnv], collapses a '$' pair into a literal '$' and keeps -// reading, so an escape only neutralizes the '${' it precedes: the text after -// it, including a default, still holds live references. Membership of a ${{...}} -// span is left to [FindEnvReferences]. Scanning resumes at the end of a match, -// so a default span is never scanned again; that is what keeps nested references -// out. -func envReferenceCandidates(value string) []EnvReference { - var references []EnvReference - for index := 0; index < len(value); { - if value[index] != '$' { - index++ - continue - } - if strings.HasPrefix(value[index:], "$$") { - index += 2 - continue - } - - reference, found := envReferenceAt(value, index) - if !found { - index++ - continue - } - - references = append(references, reference) - index = reference.End - } - return references -} - -// envReferenceAt parses the reference opening at start. The anchored prefix -// keeps a bare '$' from being read as one. Balanced defaults still need the -// stateful end scanner below. -func envReferenceAt(value string, start int) (EnvReference, bool) { - if start < 0 || start >= len(value) { - return EnvReference{}, false - } - - match := envReferencePrefix.FindStringSubmatch(value[start:]) - if match == nil { - return EnvReference{}, false - } - - name := match[1] - prefixEnd := start + len(match[0]) - if match[2] == "}" { - return EnvReference{ - Name: name, - Start: start, - End: prefixEnd, - }, true - } - - end, found := envReferenceEnd(value, prefixEnd) - if !found { - return EnvReference{}, false - } - return EnvReference{ - Name: name, - Start: start, - End: end, - HasDefault: true, - }, true -} - -// envReferenceEnd finds the '}' closing a :- default. It counts nested ${...} -// and steps over Foundry ${{...}} spans, which are legal default values, so the -// reported span covers the whole reference. -func envReferenceEnd(value string, index int) (int, bool) { - depth := 1 - for index < len(value) { - if strings.HasPrefix(value[index:], "${{") { - end := strings.Index(value[index+3:], "}}") - if end < 0 { - return 0, false - } - index += end + 5 - continue - } - if strings.HasPrefix(value[index:], "${") { - depth++ - index += 2 - continue - } - if value[index] == '}' { - depth-- - index++ - if depth == 0 { - return index, true - } - continue - } - index++ - } - return 0, false -} - -// protectedEnvReferences reports which candidates sit inside a server-side -// ${{...}} span. Each candidate is replaced with a unique probe before running -// [foundry.ExpandEnv]; probes left verbatim are reserved by the shared expander. -// This keeps discovery linked to the owning implementation without ambiguous -// name-based occurrence counting. -func protectedEnvReferences(value string, references []EnvReference) []bool { - protected := make([]bool, len(references)) - if len(references) == 0 { - return protected - } - - probePrefix := "AZD_ENV_REFERENCE_PROBE_" - for strings.Contains(value, probePrefix) { - probePrefix += "_" - } - - probeRefs := make([]string, len(references)) - var probed strings.Builder - last := 0 - for i, reference := range references { - probed.WriteString(value[last:reference.Start]) - probeRefs[i] = fmt.Sprintf("${%s%d}", probePrefix, i) - probed.WriteString(probeRefs[i]) - last = reference.End - } - probed.WriteString(value[last:]) - - expanded, err := foundry.ExpandEnv(probed.String(), func(name string) string { - return "expanded_" + name - }) - if err != nil { - return protected - } - for i, probeRef := range probeRefs { - protected[i] = strings.Contains(expanded, probeRef) - } - return protected -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/existing_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/existing_acr_test.go deleted file mode 100644 index e2685a9c01a..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/existing_acr_test.go +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGreenfieldACRMode(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/foundry/providers/Microsoft.ContainerRegistry/registries/registry" - const projectID = "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project" - for _, tt := range []struct { - name, mode, id, group, project, endpoint, want string - binding *ACRBinding - }{ - {name: "explicit reuse", mode: "reuse-connect", id: id, group: "foundry", want: "reuse-connect"}, - {name: "malformed reuse is validated by synthesis", mode: "reuse-connect", id: "invalid", - group: "foundry", want: "reuse-connect"}, - {name: "fresh in-group input", id: id, group: "foundry", want: "reuse-connect"}, - {name: "fresh in-group create intent", mode: "create", id: id, group: "foundry", want: "reuse-connect"}, - {name: "malformed create intent", mode: "create", id: "created-output", - group: "foundry", want: "reuse-connect"}, - {name: "external input after none", mode: "none", id: id, group: "external", want: "reuse-connect"}, - {name: "no input", group: "foundry", want: "create"}, - {name: "external input", id: id, group: "external", want: "reuse-connect"}, - {name: "legacy deployment outputs", id: strings.ToUpper(id), group: "foundry", - project: projectID, endpoint: "registry.azurecr.io", want: "create"}, - {name: "project ID alone is not ownership", id: id, group: "foundry", - project: projectID, want: "reuse-connect"}, - {name: "endpoint alone is not ownership", id: id, group: "foundry", - endpoint: "registry.azurecr.io", want: "reuse-connect"}, - {name: "mismatched project outputs", id: id, group: "foundry", - project: strings.Replace(projectID, "/foundry/", "/other/", 1), - endpoint: "registry.azurecr.io", want: "reuse-connect"}, - {name: "mismatched registry output", id: id, group: "foundry", - project: projectID, endpoint: "other.azurecr.io", want: "reuse-connect"}, - {name: "binding without legacy output variables", group: "foundry", - binding: &ACRBinding{Version: 1, Mode: "create", RegistryResourceID: id, - ProjectResourceID: projectID, PrincipalID: "principal"}, want: "create"}, - } { - t.Run(tt.name, func(t *testing.T) { - mode, err := GreenfieldACRMode(Input{ - Env: map[string]string{ - "AZD_FOUNDRY_ACR_MODE": tt.mode, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": tt.id, - "AZURE_SUBSCRIPTION_ID": "sub", - "AZURE_AI_PROJECT_ID": tt.project, - "AZURE_CONTAINER_REGISTRY_ENDPOINT": tt.endpoint, - }, - ResourceGroupName: tt.group, - ACRBinding: tt.binding, - }) - require.NoError(t, err) - assert.Equal(t, tt.want, mode) - }) - } -} - -func TestGreenfieldACRModeLegacyLoginHost(t *testing.T) { - const id = "/subscriptions/sub/resourceGroups/foundry/providers/Microsoft.ContainerRegistry/registries/registry" - const projectID = "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project" - for _, tt := range []struct { - name, endpoint string - legacy bool - }{ - {name: "public", endpoint: "registry.azurecr.io", legacy: true}, - {name: "government", endpoint: "registry.azurecr.us", legacy: true}, - {name: "china", endpoint: "registry.azurecr.cn", legacy: true}, - {name: "case and whitespace", endpoint: " REGISTRY.AzureCR.IO ", legacy: true}, - {name: "https", endpoint: "https://registry.azurecr.io/", legacy: true}, - {name: "http", endpoint: "http://registry.azurecr.io/", legacy: true}, - {name: "scheme case", endpoint: "HTTPS://REGISTRY.AZURECR.IO/", legacy: true}, - {name: "absolute DNS", endpoint: "registry.azurecr.io.", legacy: true}, - {name: "foreign domain", endpoint: "registry.example.com"}, - {name: "appended suffix", endpoint: "registry.azurecr.io.example.com"}, - {name: "extra label", endpoint: "registry.extra.azurecr.io"}, - {name: "private link", endpoint: "registry.privatelink.azurecr.io"}, - {name: "different registry", endpoint: "other.azurecr.io"}, - {name: "name prefix", endpoint: "registry1.azurecr.io"}, - {name: "unqualified", endpoint: "registry"}, - //nolint:gosec // G101: synthetic rejection fixture - {name: "credentials", endpoint: "https://acr-user:acr-password@registry.azurecr.io"}, - {name: "userinfo spoof", endpoint: "https://registry.azurecr.io@untrusted.example"}, - {name: "query", endpoint: "registry.azurecr.io?sig=acr-secret"}, - {name: "fragment", endpoint: "registry.azurecr.io#acr-secret"}, - {name: "port", endpoint: "registry.azurecr.io:443"}, - {name: "path", endpoint: "https://registry.azurecr.io/repository"}, - {name: "double slash", endpoint: "https://registry.azurecr.io//"}, - {name: "encoded host", endpoint: "registry.azurecr.%69%6f"}, - {name: "unsupported scheme", endpoint: "ftp://registry.azurecr.io"}, - {name: "double scheme", endpoint: "https://http://registry.azurecr.io"}, - {name: "backslash", endpoint: `registry.azurecr.io\`}, - } { - t.Run(tt.name, func(t *testing.T) { - mode, err := GreenfieldACRMode(Input{ - ResourceGroupName: "foundry", - Env: map[string]string{ - "AZURE_SUBSCRIPTION_ID": "sub", - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": id, - "AZURE_AI_PROJECT_ID": projectID, - "AZURE_CONTAINER_REGISTRY_ENDPOINT": tt.endpoint, - }, - }) - require.NoError(t, err) - want := "reuse-connect" - if tt.legacy { - want = "create" - } - assert.Equal(t, want, mode) - }) - } -} - -func TestARMExistingRegistryShape(t *testing.T) { - raw, err := ARMTemplate() - require.NoError(t, err) - type moduleProperties struct { - Template json.RawMessage `json:"template"` - Parameters map[string]any `json:"parameters"` - } - type template struct { - Parameters map[string]struct { - DefaultValue any `json:"defaultValue"` - } `json:"parameters"` - Resources map[string]struct { - Name string `json:"name"` - Type string `json:"type"` - Condition string `json:"condition"` - Properties json.RawMessage `json:"properties"` - } `json:"resources"` - } - var main template - require.NoError(t, json.Unmarshal(raw, &main)) - require.Contains(t, main.Parameters, "existingAcrResourceId") - assert.Equal(t, "", main.Parameters["existingAcrResourceId"].DefaultValue) - require.Len(t, main.Resources, 2) - var resourcesModule moduleProperties - require.NoError(t, json.Unmarshal(main.Resources["resources"].Properties, &resourcesModule)) - var resources template - require.NoError(t, json.Unmarshal(resourcesModule.Template, &resources)) - found := map[string]bool{} - for name, resource := range resources.Resources { - switch name { - case "acr": - assert.Equal(t, "[and(parameters('includeAcr'), empty(parameters('existingAcrResourceId')))]", resource.Condition) - found["acr"] = true - case "existingAcr", "existingAcrConnection": - expectedCondition := "[and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId'))))]" - if name == "existingAcr" { - expectedCondition = "[and(and(parameters('includeAcr'), " + - "not(empty(parameters('existingAcrResourceId')))), not(parameters('acrPullAssigned')))]" - } - assert.Equal(t, expectedCondition, resource.Condition) - prefix := "existing-acr" - if name == "existingAcrConnection" { - prefix += "-connection" - } - expectedName := "[format('" + prefix + "-{0}', " + - "uniqueString(variables('activeFoundryAccountId'), variables('activeFoundryProjectId')))]" - if name == "existingAcr" { - expectedName = "[if(empty(parameters('acrAccessOperationId')), " + - strings.TrimSuffix(strings.TrimPrefix(expectedName, "["), "]") + - ", format('azd-acr-{0}', parameters('acrAccessOperationId')))]" - } - assert.Equal(t, expectedName, resource.Name) - if name == "existingAcrConnection" { - var properties moduleProperties - require.NoError(t, json.Unmarshal(resource.Properties, &properties)) - condition := "and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId'))))" - assert.Equal(t, "[if("+condition+", createObject('value', "+ - "reference('externalRegistry').loginServer), createObject('value', ''))]", - properties.Parameters["acrEndpoint"]) - assert.Equal(t, "[if("+condition+", createObject('value', "+ - "extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', "+ - "split(parameters('existingAcrResourceId'), '/')[2], "+ - "split(parameters('existingAcrResourceId'), '/')[4]), "+ - "'Microsoft.ContainerRegistry/registries', "+ - "last(split(parameters('existingAcrResourceId'), '/')))), createObject('value', ''))]", - properties.Parameters["acrResourceId"]) - } - found[name] = true - } - } - assert.Len(t, found, 3) -} - -func TestARMExistingRegistrySelectsPullRoleByPermissionMode(t *testing.T) { - raw, err := ARMTemplate() - require.NoError(t, err) - body := string(raw) - assert.Contains(t, body, "7f951dda-4ed3-4680-a7ca-43fe172d538d") - assert.Contains(t, body, "b93aa761-3e63-49ed-ac28-beffa264f7ac") - assert.Contains(t, body, "AbacRepositoryPermissions") - assert.Contains(t, body, "roleAssignmentMode") - assert.Contains(t, body, "2025-11-01") -} - -func TestARMExistingRegistryRoleLookupIsLazy(t *testing.T) { - for _, tt := range []struct { - name string - read func() ([]byte, error) - roles int - }{ - {"greenfield", ARMTemplate, 1}, - {"existing project", ExistingProjectARMTemplate, 2}, - } { - t.Run(tt.name, func(t *testing.T) { - raw, err := tt.read() - require.NoError(t, err) - var template any - require.NoError(t, json.Unmarshal(raw, &template)) - roles := 0 - var inspect func(any) - inspect = func(value any) { - switch node := value.(type) { - case map[string]any: - if role, exists := node["roleDefinitionId"]; exists { - if parameter, ok := role.(map[string]any); ok { - role = parameter["value"] - } - if expression, ok := role.(string); ok && strings.Contains(expression, "roleAssignmentMode") { - roles++ - assert.Equal(t, "[if(equals(parameters('mode'), 'reuse-connect'), "+ - "if(equals(reference(resourceId('Microsoft.ContainerRegistry/registries', "+ - "parameters('registryName')), '2025-11-01').roleAssignmentMode, "+ - "'AbacRepositoryPermissions'), createObject('value', variables('repositoryReaderRoleId')), "+ - "createObject('value', variables('acrPullRoleId'))), "+ - "createObject('value', variables('acrPullRoleId')))]", expression) - } - } - for _, child := range node { - inspect(child) - } - case []any: - for _, child := range node { - inspect(child) - } - } - } - inspect(template) - assert.Equal(t, tt.roles, roles) - }) - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting.go deleted file mode 100644 index aa534d8cced..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// cspell:ignore BYOAKS - -package synthesis - -import ( - "fmt" - "strings" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "go.yaml.in/yaml/v3" -) - -// BYOAKSFeatureID identifies the existing-AKS hosting preview. -const BYOAKSFeatureID = "agents.byo_aks" - -// BYOAKSEnvVar is the process-environment switch for Agents project initialization and adoption. -const BYOAKSEnvVar = "AZD_ALPHA_ENABLE_AGENTS_BYO_AKS" - -// AgentHostingInput selects the existing cluster used to host Foundry agents. -type AgentHostingInput struct { - ClusterResourceID string `yaml:"clusterResourceId"` -} - -type agentHostingParameter struct { - Enabled bool `json:"enabled"` - HostingType string `json:"hostingType"` - Name string `json:"name"` - ClusterResourceID string `json:"clusterResourceId"` -} - -func validateAgentHostingInputs(node yaml.Node, service string) error { - var fields map[string]yaml.Node - if err := node.Decode(&fields); err != nil { - return err - } - hosting, exists := fields["agentHosting"] - if !exists { - return nil - } - if hosting.Kind != yaml.MappingNode { - return fmt.Errorf("services.%s.agentHosting: expected an object containing clusterResourceId", service) - } - for i := 0; i < len(hosting.Content); i += 2 { - name := hosting.Content[i].Value - if name != "clusterResourceId" { - return fmt.Errorf("services.%s.agentHosting.%s: unsupported property; "+ - "azd creates the storage account and both managed identities", service, name) - } - value := hosting.Content[i+1] - if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { - return fmt.Errorf("services.%s.agentHosting.clusterResourceId: expected a nonempty resource ID", service) - } - } - return nil -} - -func synthesizeAgentHosting( - input *AgentHostingInput, service string, env map[string]string, resolve bool, -) (*AgentHostingInput, error) { - if input == nil { - return nil, nil - } - field := fmt.Sprintf("services.%s.agentHosting.clusterResourceId", service) - value := strings.TrimSpace(input.ClusterResourceID) - if err := ValidateEnvReferences(value); err != nil { - return nil, fmt.Errorf("%s: %w", field, err) - } - if resolve { - resolved, err := resolveVars(value, env) - if err != nil { - return nil, fmt.Errorf("%s: %w", field, err) - } - value = strings.TrimSpace(resolved) - } - if value == "" { - return nil, fmt.Errorf("%s: required", field) - } - if resolve || !containsVarRef(value) { - id, err := arm.ParseResourceID(value) - if err != nil || id.SubscriptionID == "" || id.ResourceGroupName == "" || id.Name == "" || - !strings.EqualFold(id.ResourceType.String(), "Microsoft.ContainerService/managedClusters") || - len(strings.Split(value, "/")) != 9 || strings.ContainsAny(value, "?#%\\@:\x00 \t\r\n") { - return nil, fmt.Errorf("%s: expected a complete Microsoft.ContainerService/managedClusters resource ID", field) - } - } - return &AgentHostingInput{ClusterResourceID: value}, nil -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go deleted file mode 100644 index 94c9ec3b987..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/parity_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "bytes" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const agentsSynthesisPath = "../../../azure.ai.agents/internal/synthesis" - -func TestAgentsSynthesisCopyMatches(t *testing.T) { - t.Parallel() - - canonical := readSynthesisFiles(t, ".") - agents := readSynthesisFiles(t, agentsSynthesisPath) - - assert.Equal(t, canonical, agents, - "agents synthesis must match the projects-owned copy") -} - -func TestExtensionsUseSameAzdSdk(t *testing.T) { - t.Parallel() - - projectsVersion := readAzdSdkVersion(t, "../../go.mod") - agentsVersion := readAzdSdkVersion( - t, - filepath.Join(agentsSynthesisPath, "..", "..", "go.mod"), - ) - - assert.Equal(t, projectsVersion, agentsVersion) -} - -func readSynthesisFiles(t *testing.T, root string) map[string][]byte { - t.Helper() - - files := map[string][]byte{} - err := filepath.WalkDir( - root, - func(path string, entry fs.DirEntry, walkErr error) error { - require.NoError(t, walkErr) - if entry.IsDir() { - return nil - } - - rel, err := filepath.Rel(root, path) - require.NoError(t, err) - if !isParityFile(rel) { - return nil - } - - //nolint:gosec // repository-controlled parity path - data, err := os.ReadFile(path) - require.NoError(t, err) - files[filepath.ToSlash(rel)] = data - return nil - }, - ) - require.NoError(t, err) - return files -} - -func isParityFile(path string) bool { - path = filepath.ToSlash(path) - if strings.HasPrefix(path, "templates/") { - return true - } - return strings.HasSuffix(path, ".go") && - !strings.HasSuffix(path, "_test.go") -} - -func readAzdSdkVersion(t *testing.T, path string) string { - t.Helper() - - //nolint:gosec // repository-controlled module path - data, err := os.ReadFile(path) - require.NoError(t, err) - match := regexp.MustCompile( - `(?m)^\s*github\.com/azure/azure-dev/cli/azd\s+(\S+)`, - ).FindSubmatch(data) - require.Len(t, match, 2) - return string(bytes.TrimSpace(match[1])) -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/prompt_synthesis_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/prompt_synthesis_test.go deleted file mode 100644 index 5dc2054fa74..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/prompt_synthesis_test.go +++ /dev/null @@ -1,255 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSynthesisPromptConnectionReferences(t *testing.T) { - for _, test := range []struct { - name, connections string - wantError bool - }{ - {name: "names", connections: "[search, external-connection]"}, - {name: "empty list", connections: "[]"}, - {name: "name object", connections: "[{name: search}]", wantError: true}, - {name: "definition", connections: "[{name: search, target: 'https://search.example'}]", wantError: true}, - {name: "mixed", connections: "[search, {name: other}]", wantError: true}, - {name: "object ref", connections: "[{$ref: ./connection.yaml}]", wantError: true}, - {name: "blank", connections: "[' ']", wantError: true}, - {name: "number", connections: "[42]", wantError: true}, - {name: "boolean", connections: "[true]", wantError: true}, - {name: "scalar", connections: "search", wantError: true}, - {name: "null", connections: "null", wantError: true}, - } { - for _, shape := range []string{"inline", "config", "inline-agent", "file", "kind-ref"} { - for _, existing := range []bool{false, true} { - for _, preserve := range []bool{false, true} { - name := fmt.Sprintf("%s/%s/existing=%v/preserve=%v", test.name, shape, existing, preserve) - t.Run(name, func(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "connection.yaml"), - []byte("name: search\ntarget: https://search.example\n"), 0o600)) - body := fmt.Sprintf("kind: prompt, name: assistant, model: model, "+ - "instructions: help, connections: %s", - test.connections) - raw := "services:\n project:\n host: azure.ai.project\n" + - " deployments: [{name: model, model: {name: gpt-4o}, sku: {name: Standard, capacity: 10}}]\n" - if existing { - raw += " endpoint: https://existing.services.ai.azure.com/api/projects/project\n" - } - switch shape { - case "inline": - raw += fmt.Sprintf(" agent: {host: azure.ai.agent, %s}\n", body) - case "config": - raw += fmt.Sprintf(" agent: {host: azure.ai.agent, config: {%s}}\n", body) - case "inline-agent": - raw += fmt.Sprintf(" agents: [{%s}]\n", body) - case "file": - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), - fmt.Appendf(nil, "{%s}\n", body), 0o600)) - raw += " agent: {host: azure.ai.agent, $ref: ./agent.yaml}\n" - case "kind-ref": - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), - []byte("kind: prompt\nname: assistant\nmodel: model\ninstructions: help\n"), 0o600)) - raw += fmt.Sprintf(" agent: {host: azure.ai.agent, $ref: ./agent.yaml, connections: %s}\n", - test.connections) - } - // These payloads, refs, conditions and credentials belong to other extensions. - raw += " search:\n host: azure.ai.connection\n condition: [invalid]\n" + - " $ref: ./missing-connection.yaml\n credentials: {key: secret-must-not-leak}\n" + - " toolbox: {host: azure.ai.toolbox, $ref: ./missing-toolbox.yaml}\n" - synthesize := Synthesize - if existing { - synthesize = SynthesizeExistingProject - } - result, err := synthesize(Input{ - RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: root, PreserveVarRefs: preserve, - }) - if test.wantError { - require.ErrorContains(t, err, "bundled declarations are no longer supported") - assert.Contains(t, err.Error(), "connections") - assert.Nil(t, result) - return - } - require.NoError(t, err) - assert.Equal(t, false, result.Parameters["includeAcr"]) - assert.NotContains(t, result.Parameters, "connections") - assert.NotContains(t, result.Parameters, "connectionCredentials") - assert.NotContains(t, fmt.Sprint(result.Parameters), "secret-must-not-leak") - deployments, ok := result.Parameters["deployments"].([]Deployment) - require.True(t, ok) - require.Len(t, deployments, 1) - assert.Equal(t, "model", deployments[0].Name) - assert.Equal(t, 10, deployments[0].Sku.Capacity) - }) - } - } - } - } -} - -func TestSynthesisConnectionReferenceKindFromFile(t *testing.T) { - for _, test := range []struct { - name, definition, overlay string - wantError bool - }{ - {name: "prompt", definition: "kind: prompt\n"}, - {name: "hosted", definition: "kind: hosted\n", wantError: true}, - {name: "default hosted", definition: "name: agent\n", wantError: true}, - {name: "voice", definition: "kind: prompt-voice\n", wantError: true}, - {name: "prompt overlay", definition: "kind: hosted\n", overlay: "kind: prompt,"}, - {name: "hosted overlay", definition: "kind: prompt\n", overlay: "kind: hosted,", wantError: true}, - } { - for _, shape := range []string{"inline", "config", "inline-agent"} { - t.Run(test.name+"/"+shape, func(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), []byte(test.definition), 0o600)) - body := test.overlay + " $ref: ./agent.yaml, connections: [search]" - raw := "services:\n project:\n host: azure.ai.project\n" - switch shape { - case "inline": - raw += fmt.Sprintf(" agent: {host: azure.ai.agent, %s}\n", body) - case "config": - raw += fmt.Sprintf(" agent: {host: azure.ai.agent, config: {%s}}\n", body) - case "inline-agent": - raw += fmt.Sprintf(" agents: [{%s}]\n", body) - } - result, err := Synthesize(Input{RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: root}) - if test.wantError { - require.ErrorContains(t, err, "bundled declarations are no longer supported") - assert.Nil(t, result) - return - } - require.NoError(t, err) - assert.Equal(t, false, result.Parameters["includeAcr"]) - }) - } - } -} - -func TestSynthesisPromptConnectionsRespectConditions(t *testing.T) { - t.Setenv("ENABLE_AGENT", "true") - for _, test := range []struct { - name, kind, connections, enabled string - wantError bool - }{ - {name: "enabled prompt", kind: "prompt", connections: "[search]", enabled: "true"}, - {name: "disabled prompt", kind: "prompt", connections: "[search]", enabled: "false"}, - {name: "disabled hosted legacy", kind: "hosted", connections: "[{name: search}]", enabled: "false"}, - {name: "enabled hosted legacy", kind: "hosted", connections: "[search]", enabled: "true", wantError: true}, - {name: "enabled prompt definition", kind: "prompt", connections: "[{name: search}]", - enabled: "true", wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - raw := fmt.Sprintf(`services: - project: {host: azure.ai.project} - a-build: {host: azure.ai.agent, kind: hosted} - agent: - host: azure.ai.agent - condition: ${ENABLE_AGENT} - kind: %s - connections: %s - disabled: - host: azure.ai.agent - condition: false - $ref: ./missing-agent.yaml -`, test.kind, test.connections) - result, err := Synthesize(Input{ - RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: t.TempDir(), - Env: map[string]string{"ENABLE_AGENT": test.enabled}, - }) - if test.wantError { - require.ErrorContains(t, err, "bundled declarations are no longer supported") - assert.Nil(t, result) - return - } - require.NoError(t, err) - assert.Equal(t, true, result.Parameters["includeAcr"], "the hosted sibling still needs ACR") - }) - } -} - -func TestSynthesisPromptToolboxesRemainReferenceOnly(t *testing.T) { - for _, test := range []struct { - name, toolboxes string - wantError bool - }{ - {name: "references", toolboxes: "[tools, {name: other}]"}, - {name: "definition", toolboxes: "[{name: tools, tools: []}]", wantError: true}, - {name: "referenced definition", toolboxes: "[{$ref: ./toolbox.yaml}]", wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "toolbox.yaml"), - []byte("name: tools\ntools: []\n"), 0o600)) - raw := fmt.Appendf(nil, "services:\n project: {host: azure.ai.project}\n"+ - " agent: {host: azure.ai.agent, kind: prompt, connections: [search], toolboxes: %s}\n", test.toolboxes) - result, err := Synthesize(Input{RawAzureYAML: raw, ServiceName: "project", ProjectRoot: root}) - if test.wantError { - require.ErrorContains(t, err, "bundled declarations are no longer supported") - assert.Contains(t, err.Error(), "toolboxes") - assert.Nil(t, result) - return - } - require.NoError(t, err) - assert.Equal(t, false, result.Parameters["includeAcr"]) - }) - } -} - -func TestProjectEndpointEnvironmentExpansion(t *testing.T) { - const endpoint = "https://account.services.ai.azure.com/api/projects/project" - for _, test := range []struct { - name, value, process, want string - env map[string]string - wantError bool - }{ - {name: "unset", value: "${SYNTHESIS_PROJECT_ENDPOINT}"}, - {name: "process fallback", value: "${SYNTHESIS_PROJECT_ENDPOINT}", process: endpoint, want: endpoint}, - {name: "environment wins", value: "${SYNTHESIS_PROJECT_ENDPOINT}", process: "https://other.example", - env: map[string]string{"SYNTHESIS_PROJECT_ENDPOINT": endpoint}, want: endpoint}, - {name: "explicit empty wins", value: "${SYNTHESIS_PROJECT_ENDPOINT}", process: endpoint, - env: map[string]string{"SYNTHESIS_PROJECT_ENDPOINT": ""}}, - {name: "default", value: "${SYNTHESIS_PROJECT_ENDPOINT:-" + endpoint + "}", want: endpoint}, - {name: "trim", value: " ${SYNTHESIS_PROJECT_ENDPOINT} ", process: " " + endpoint + " ", want: endpoint}, - {name: "malformed", value: "${SYNTHESIS_PROJECT_ENDPOINT", wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - t.Setenv("SYNTHESIS_PROJECT_ENDPOINT", test.process) - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "project.yaml"), - fmt.Appendf(nil, "host: azure.ai.project\nendpoint: %q\n", test.value), 0o600)) - raw := []byte("services:\n project: {$ref: ./project.yaml}\n") - got, err := ProjectEndpoint(raw, "project", root, test.env) - if test.wantError { - require.ErrorContains(t, err, "expand endpoint") - } else { - require.NoError(t, err) - assert.Equal(t, test.want, got) - } - for _, preserve := range []bool{false, true} { - result, err := Synthesize(Input{ - RawAzureYAML: raw, ServiceName: "project", ProjectRoot: root, Env: test.env, PreserveVarRefs: preserve, - }) - switch { - case test.wantError: - require.ErrorContains(t, err, "expand endpoint") - case test.want != "": - require.ErrorIs(t, err, ErrEndpointBrownfield) - default: - require.NoError(t, err) - require.NotNil(t, result) - } - } - }) - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json deleted file mode 100644 index 8e6ed84df9f..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/abbreviations.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "cognitiveServicesAccounts": "cog-", - "containerRegistryRegistries": "cr" -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep deleted file mode 100644 index 595d7dabfc1..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.bicep +++ /dev/null @@ -1,125 +0,0 @@ -// Editable infrastructure for an existing Foundry project. The account and -// project are referenced only; scoped modules manage project children and an -// optional adjunct resource group without taking ownership of the project. - -targetScope = 'subscription' - -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -param projectResourceId string -param resourceGroupName string -param location string -param resourceTokenSalt string = '' -param tags object = {} -param deployments deploymentType[] = [] -@allowed([ - 'none' - 'create' - 'reuse-connect' - 'already-connected' -]) -param acrMode string = 'none' -param existingAcrResourceId string = '' -param existingAcrEndpoint string = '' -param existingAcrConnectionName string = '' -param acrPullAssigned bool = false -param projectEndpoint string - -var projectIdParts = split(projectResourceId, '/') -var projectSubscriptionId = projectIdParts[2] -var projectResourceGroupName = projectIdParts[4] -var accountName = projectIdParts[8] -var projectName = projectIdParts[10] -var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' -var acrName = 'cr${toLower(uniqueString(tokenSeed))}' -var createAcr = acrMode == 'create' -var reuseAcr = acrMode == 'reuse-connect' || acrMode == 'already-connected' -var createAcrConnection = acrMode == 'create' || acrMode == 'reuse-connect' -var effectiveAcrName = createAcr ? acrName : (reuseAcr ? last(split(existingAcrResourceId, '/')) : '') -var effectiveAcrEndpoint = createAcr - ? newContainerRegistry!.outputs.endpoint - : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.endpoint : existingAcrEndpoint) -var effectiveAcrResourceId = createAcr - ? newContainerRegistry!.outputs.resourceId - : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.resourceId : existingAcrResourceId) - -resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAcr) { - name: resourceGroupName - location: location - tags: tags -} - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -module newContainerRegistry 'modules/container-registry.bicep' = if (createAcr) { - name: 'container-registry' - scope: resourceGroup(resourceGroupName) - params: { - mode: 'create' - location: location - tags: tags - registryName: acrName - projectPrincipalId: foundryAccount::project.identity.principalId - } - dependsOn: [adjunctResourceGroup] -} - -module existingContainerRegistry 'modules/container-registry.bicep' = if (acrMode == 'reuse-connect' && !acrPullAssigned) { - name: 'container-registry' - scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) - params: { - mode: 'reuse-connect' - location: location - tags: tags - registryName: effectiveAcrName - projectPrincipalId: foundryAccount::project.identity.principalId - } -} - -module projectResources 'modules/foundry-project.bicep' = { - name: 'foundry-project-resources' - scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) - params: { - accountName: accountName - projectName: projectName - deployments: deployments - acrName: effectiveAcrName - acrEndpoint: effectiveAcrEndpoint - acrResourceId: effectiveAcrResourceId - createAcrConnection: createAcrConnection - existingAcrConnectionName: existingAcrConnectionName - } -} - -output AZURE_AI_PROJECT_ID string = projectResourceId -output AZURE_AI_ACCOUNT_NAME string = accountName -output AZURE_AI_PROJECT_NAME string = projectName -output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' -output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint -output AZURE_FOUNDRY_RESOURCE_GROUP string = createAcr ? resourceGroupName : '' -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode == 'none' - ? '' - : effectiveAcrEndpoint -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode == 'none' - ? '' - : effectiveAcrResourceId -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName -output AZD_FOUNDRY_ACR_MODE string = acrMode diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json deleted file mode 100644 index d555b1c02b8..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json +++ /dev/null @@ -1,2330 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "2998290216332527788" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "agentHostingType": { - "type": "object", - "properties": { - "enabled": { - "type": "bool" - }, - "hostingType": { - "type": "string", - "allowedValues": [ - "", - "ManagedCluster" - ] - }, - "name": { - "type": "string" - }, - "clusterResourceId": { - "type": "string" - } - } - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Azure region for all resources." - } - }, - "resourceGroupName": { - "type": "string", - "minLength": 1, - "maxLength": 90, - "metadata": { - "description": "Name of the resource group to create and deploy resources into." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to all resources." - } - }, - "resourceTokenSalt": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional salt to vary resource names across re-provisions." - } - }, - "foundryProjectName": { - "type": "string", - "minLength": 3, - "maxLength": 32, - "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to provision on the Foundry account." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Include an Azure Container Registry. Set true when any agent uses docker:." - } - }, - "agentHosting": { - "$ref": "#/definitions/agentHostingType", - "defaultValue": { - "enabled": false, - "hostingType": "", - "name": "", - "clusterResourceId": "" - }, - "metadata": { - "description": "Existing AKS cluster hosting. Supporting resources are created automatically." - } - }, - "kubeletObjectId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "AKS kubelet principal ID, resolved by microsoft.foundry for registry access." - } - }, - "federatedIdentityRoleDefinitionId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Built-in federation-management role, resolved by microsoft.foundry." - } - }, - "existingAcrResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Full ARM ID of an existing container registry to reuse instead of creating one." - } - }, - "acrPullAssigned": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Project already has effective registry pull access." - } - }, - "acrAccessOperationId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Unique access operation ID persisted by azd before deployment." - } - }, - "enableNetworkIsolation": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Master switch: when true the account is VNet-bound (private)." - } - }, - "useManagedEgress": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." - } - }, - "vnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "ARM id of the existing customer VNet (byo mode)." - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "Agent (delegated) subnet name." - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." - } - }, - "createAgentSubnet": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true, create the agent subnet; when false, reference it." - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "Private-endpoint subnet name." - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." - } - }, - "createPESubnet": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true, create the PE subnet; when false, reference it." - } - }, - "managedIsolationMode": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Managed-network isolation mode (managed mode)." - } - }, - "dnsZonesResourceGroup": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Resource group holding existing private DNS zones. Empty creates new zones." - } - }, - "dnsZonesSubscription": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." - } - } - }, - "resources": { - "resourceGroup": { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2021-04-01", - "name": "[parameters('resourceGroupName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - "resources": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-resources", - "resourceGroup": "[parameters('resourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "resourceTokenSalt": { - "value": "[parameters('resourceTokenSalt')]" - }, - "foundryProjectName": { - "value": "[parameters('foundryProjectName')]" - }, - "deployments": { - "value": "[parameters('deployments')]" - }, - "includeAcr": { - "value": "[parameters('includeAcr')]" - }, - "agentHosting": { - "value": "[parameters('agentHosting')]" - }, - "kubeletObjectId": { - "value": "[parameters('kubeletObjectId')]" - }, - "federatedIdentityRoleDefinitionId": { - "value": "[parameters('federatedIdentityRoleDefinitionId')]" - }, - "existingAcrResourceId": { - "value": "[parameters('existingAcrResourceId')]" - }, - "acrPullAssigned": { - "value": "[parameters('acrPullAssigned')]" - }, - "acrAccessOperationId": { - "value": "[parameters('acrAccessOperationId')]" - }, - "enableNetworkIsolation": { - "value": "[parameters('enableNetworkIsolation')]" - }, - "useManagedEgress": { - "value": "[parameters('useManagedEgress')]" - }, - "vnetId": { - "value": "[parameters('vnetId')]" - }, - "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "agentSubnetPrefix": { - "value": "[parameters('agentSubnetPrefix')]" - }, - "createAgentSubnet": { - "value": "[parameters('createAgentSubnet')]" - }, - "peSubnetName": { - "value": "[parameters('peSubnetName')]" - }, - "peSubnetPrefix": { - "value": "[parameters('peSubnetPrefix')]" - }, - "createPESubnet": { - "value": "[parameters('createPESubnet')]" - }, - "managedIsolationMode": { - "value": "[parameters('managedIsolationMode')]" - }, - "dnsZonesResourceGroup": { - "value": "[parameters('dnsZonesResourceGroup')]" - }, - "dnsZonesSubscription": { - "value": "[parameters('dnsZonesSubscription')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "11291957688034724305" - } - }, - "definitions": { - "deploymentsType": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "metadata": { - "description": "Shape of one model deployment entry in azure.yaml." - } - }, - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - }, - "metadata": { - "description": "Shape of a single model deployment." - } - }, - "agentHostingType": { - "type": "object", - "properties": { - "enabled": { - "type": "bool" - }, - "hostingType": { - "type": "string", - "allowedValues": [ - "", - "ManagedCluster" - ] - }, - "name": { - "type": "string" - }, - "clusterResourceId": { - "type": "string" - } - } - } - }, - "parameters": { - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for all resources." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to all resources." - } - }, - "resourceTokenSalt": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional salt to vary resource names across re-provisions." - } - }, - "foundryProjectName": { - "type": "string", - "minLength": 3, - "maxLength": 32, - "metadata": { - "description": "Foundry project name. 3-32 alphanumeric/hyphen chars." - } - }, - "deployments": { - "$ref": "#/definitions/deploymentsType", - "defaultValue": [], - "metadata": { - "description": "Model deployments to provision on the Foundry account." - } - }, - "includeAcr": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Include an Azure Container Registry. Set true when any agent uses docker:." - } - }, - "agentHosting": { - "$ref": "#/definitions/agentHostingType", - "defaultValue": { - "enabled": false, - "hostingType": "", - "name": "", - "clusterResourceId": "" - } - }, - "kubeletObjectId": { - "type": "string", - "defaultValue": "" - }, - "federatedIdentityRoleDefinitionId": { - "type": "string", - "defaultValue": "" - }, - "existingAcrResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Full ARM ID of an existing container registry to reuse instead of creating one." - } - }, - "acrPullAssigned": { - "type": "bool", - "defaultValue": false - }, - "acrAccessOperationId": { - "type": "string", - "defaultValue": "" - }, - "enableNetworkIsolation": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Master switch: when true the account is VNet-bound (private)." - } - }, - "useManagedEgress": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet." - } - }, - "vnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "ARM id of the existing customer VNet (byo mode)." - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "Agent (delegated) subnet name." - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Agent subnet CIDR. Empty derives a /24 from the VNet space." - } - }, - "createAgentSubnet": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true, create the agent subnet; when false, reference it." - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "Private-endpoint subnet name." - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space." - } - }, - "createPESubnet": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true, create the PE subnet; when false, reference it." - } - }, - "managedIsolationMode": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound." - } - }, - "dnsZonesResourceGroup": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." - } - }, - "dnsZonesSubscription": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." - } - } - }, - "variables": { - "$fxv#0": { - "cognitiveServicesAccounts": "cog-", - "containerRegistryRegistries": "cr" - }, - "resourceToken": "[if(empty(parameters('resourceTokenSalt')), uniqueString(subscription().id, resourceGroup().id, parameters('location')), uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('resourceTokenSalt')))]", - "abbrs": "[variables('$fxv#0')]", - "foundryAccountName": "[format('{0}{1}', variables('abbrs').cognitiveServicesAccounts, variables('resourceToken'))]", - "byoFoundryAccountName": "[format('aks-{0}', variables('foundryAccountName'))]", - "activeFoundryAccountName": "[if(parameters('agentHosting').enabled, variables('byoFoundryAccountName'), variables('foundryAccountName'))]", - "activeFoundryAccountId": "[resourceId('Microsoft.CognitiveServices/accounts', variables('activeFoundryAccountName'))]", - "activeFoundryProjectId": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('activeFoundryAccountName'), parameters('foundryProjectName'))]", - "storageAccountName": "[format('st{0}', variables('resourceToken'))]", - "hostingIdentityName": "[format('{0}-hosting-mi', variables('foundryAccountName'))]", - "workloadIdentityName": "[format('{0}-workload-mi', variables('foundryAccountName'))]", - "useByoNetwork": "[and(parameters('enableNetworkIsolation'), not(parameters('useManagedEgress')))]", - "useManagedNetwork": "[and(parameters('enableNetworkIsolation'), parameters('useManagedEgress'))]", - "disablePublicDataPlaneAccess": "[parameters('enableNetworkIsolation')]", - "agentSubnetArmId": "[format('{0}/subnets/{1}', parameters('vnetId'), parameters('agentSubnetName'))]", - "agentNetworkInjections": "[if(variables('useByoNetwork'), createArray(createObject('scenario', 'agent', 'subnetArmId', variables('agentSubnetArmId'), 'useMicrosoftManagedNetwork', false())), if(variables('useManagedNetwork'), createArray(createObject('scenario', 'agent', 'useMicrosoftManagedNetwork', true())), null()))]", - "foundryAccountProperties": { - "allowProjectManagement": true, - "customSubDomainName": "[variables('activeFoundryAccountName')]", - "publicNetworkAccess": "[if(variables('disablePublicDataPlaneAccess'), 'Disabled', 'Enabled')]", - "disableLocalAuth": true, - "networkAcls": { - "defaultAction": "[if(variables('disablePublicDataPlaneAccess'), 'Deny', 'Allow')]", - "bypass": "[if(variables('disablePublicDataPlaneAccess'), 'AzureServices', null())]", - "virtualNetworkRules": [], - "ipRules": [] - }, - "networkInjections": "[variables('agentNetworkInjections')]" - } - }, - "resources": { - "foundryAccount::modelDeployments": { - "copy": { - "name": "foundryAccount::modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "condition": "[not(parameters('agentHosting').enabled)]", - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]", - "dependsOn": [ - "foundryAccount" - ] - }, - "foundryAccount::project": { - "condition": "[not(parameters('agentHosting').enabled)]", - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', variables('foundryAccountName'), parameters('foundryProjectName'))]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "description": "[format('{0} Project', parameters('foundryProjectName'))]", - "displayName": "[parameters('foundryProjectName')]" - }, - "dependsOn": [ - "foundryAccount", - "foundryAccount::modelDeployments" - ] - }, - "byoFoundryAccount::modelDeployments": { - "copy": { - "name": "byoFoundryAccount::modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "condition": "[parameters('agentHosting').enabled]", - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2026-07-15-preview", - "name": "[format('{0}/{1}', variables('byoFoundryAccountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]", - "dependsOn": [ - "byoFoundryAccount" - ] - }, - "byoFoundryAccount::project": { - "condition": "[parameters('agentHosting').enabled]", - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2026-07-15-preview", - "name": "[format('{0}/{1}', variables('byoFoundryAccountName'), parameters('foundryProjectName'))]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "description": "[format('{0} Project', parameters('foundryProjectName'))]", - "displayName": "[parameters('foundryProjectName')]" - }, - "dependsOn": [ - "byoFoundryAccount", - "byoFoundryAccount::modelDeployments" - ] - }, - "foundryAccount": { - "condition": "[not(parameters('agentHosting').enabled)]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[variables('foundryAccountName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "S0" - }, - "kind": "AIServices", - "identity": { - "type": "SystemAssigned" - }, - "properties": "[variables('foundryAccountProperties')]", - "dependsOn": [ - "network" - ] - }, - "byoFoundryAccount": { - "condition": "[parameters('agentHosting').enabled]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2026-07-15-preview", - "name": "[variables('byoFoundryAccountName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "S0" - }, - "kind": "AIServices", - "identity": { - "type": "SystemAssigned, UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('hostingIdentityName')))]": {} - } - }, - "properties": "[shallowMerge(createArray(variables('foundryAccountProperties'), createObject('agentHostingConfigurations', createArray(createObject('hostingType', 'ManagedCluster', 'name', 'default', 'clusterResourceId', parameters('agentHosting').clusterResourceId, 'hostingManagementIdentityResourceId', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('hostingIdentityName')), 'storageAccountResourceId', resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), 'workloadIdentityResourceId', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('workloadIdentityName')))))))]", - "dependsOn": [ - "network", - "prerequisites" - ] - }, - "foundryManagedNetwork": { - "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", - "type": "Microsoft.CognitiveServices/accounts/managedNetworks", - "apiVersion": "2025-10-01-preview", - "name": "[format('{0}/default', variables('activeFoundryAccountName'))]", - "properties": { - "managedNetwork": { - "isolationMode": "[parameters('managedIsolationMode')]" - } - }, - "dependsOn": [ - "byoFoundryAccount", - "foundryAccount" - ] - }, - "externalRegistry": { - "condition": "[and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId'))))]", - "existing": true, - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2025-11-01", - "subscriptionId": "[split(parameters('existingAcrResourceId'), '/')[2]]", - "resourceGroup": "[split(parameters('existingAcrResourceId'), '/')[4]]", - "name": "[last(split(parameters('existingAcrResourceId'), '/'))]" - }, - "prerequisites": { - "condition": "[parameters('agentHosting').enabled]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "byo-prerequisites", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "clusterResourceId": { - "value": "[parameters('agentHosting').clusterResourceId]" - }, - "storageAccountName": { - "value": "[variables('storageAccountName')]" - }, - "hostingIdentityName": { - "value": "[variables('hostingIdentityName')]" - }, - "workloadIdentityName": { - "value": "[variables('workloadIdentityName')]" - }, - "federatedIdentityRoleDefinitionId": { - "value": "[parameters('federatedIdentityRoleDefinitionId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "5400303943902594065" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "clusterResourceId": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "hostingIdentityName": { - "type": "string" - }, - "workloadIdentityName": { - "type": "string" - }, - "federatedIdentityRoleDefinitionId": { - "type": "string" - } - }, - "variables": { - "clusterParts": "[split(parameters('clusterResourceId'), '/')]", - "storageZrsUnsupportedRegions": [ - "australiacentral", - "australiacentral2", - "australiasoutheast", - "brazilsoutheast", - "canadaeast", - "francesouth", - "germanynorth", - "koreasouth", - "northcentralus", - "norwaywest", - "southafricawest", - "southindia", - "swedensouth", - "switzerlandwest", - "taiwannorth", - "taiwannorthwest", - "uaecentral", - "ukwest", - "westcentralus", - "westindia", - "westus" - ], - "readerRoleId": "acdd72a7-3385-48ef-bd42-f606fba81ae7", - "kubernetesExtensionContributorRoleId": "85cb6faf-e071-4c9b-8136-154b5a04f717", - "blobContributorRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]" - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[parameters('hostingIdentityName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[parameters('workloadIdentityName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-05-01", - "name": "[parameters('storageAccountName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "kind": "StorageV2", - "sku": { - "name": "[if(contains(variables('storageZrsUnsupportedRegions'), toLower(parameters('location'))), 'Standard_LRS', 'Standard_ZRS')]" - }, - "properties": { - "accessTier": "Hot", - "isHnsEnabled": false, - "publicNetworkAccess": "Enabled", - "networkAcls": { - "defaultAction": "Allow" - }, - "minimumTlsVersion": "TLS1_2", - "supportsHttpsTrafficOnly": true, - "allowBlobPublicAccess": false, - "allowSharedKeyAccess": false - } - }, - { - "copy": { - "name": "clusterGrants", - "count": "[length(createArray(variables('readerRoleId'), variables('kubernetesExtensionContributorRoleId')))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('byo-cluster-{0}-{1}', uniqueString(resourceGroup().id, parameters('hostingIdentityName')), createArray(variables('readerRoleId'), variables('kubernetesExtensionContributorRoleId'))[copyIndex()])]", - "subscriptionId": "[variables('clusterParts')[2]]", - "resourceGroup": "[variables('clusterParts')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "resourceName": { - "value": "[last(variables('clusterParts'))]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName')), '2023-01-31').principalId]" - }, - "roleDefinitionId": { - "value": "[subscriptionResourceId(variables('clusterParts')[2], 'Microsoft.Authorization/roleDefinitions', createArray(variables('readerRoleId'), variables('kubernetesExtensionContributorRoleId'))[copyIndex()])]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "9565368374017416886" - } - }, - "parameters": { - "resourceName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "roleDefinitionId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerService/managedClusters', parameters('resourceName'))]", - "name": "[guid(resourceId('Microsoft.ContainerService/managedClusters', parameters('resourceName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "byo-federation-access", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "resourceName": { - "value": "[parameters('workloadIdentityName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName')), '2023-01-31').principalId]" - }, - "roleDefinitionId": { - "value": "[parameters('federatedIdentityRoleDefinitionId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "1047566723091155563" - } - }, - "parameters": { - "resourceName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "roleDefinitionId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('resourceName'))]", - "name": "[guid(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('resourceName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName'))]", - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('workloadIdentityName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "byo-storage-access", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "resourceName": { - "value": "[parameters('storageAccountName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('workloadIdentityName')), '2023-01-31').principalId]" - }, - "roleDefinitionId": { - "value": "[variables('blobContributorRoleId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "8476384347606291870" - } - }, - "parameters": { - "resourceName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "roleDefinitionId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('resourceName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('resourceName')), parameters('principalId'), parameters('roleDefinitionId'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('workloadIdentityName'))]" - ] - } - ] - } - } - }, - "network": { - "condition": "[parameters('enableNetworkIsolation')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-network", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetId": { - "value": "[parameters('vnetId')]" - }, - "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "agentSubnetPrefix": { - "value": "[parameters('agentSubnetPrefix')]" - }, - "createAgentSubnet": { - "value": "[parameters('createAgentSubnet')]" - }, - "peSubnetName": { - "value": "[parameters('peSubnetName')]" - }, - "peSubnetPrefix": { - "value": "[parameters('peSubnetPrefix')]" - }, - "createPESubnet": { - "value": "[parameters('createPESubnet')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "1675403609570962535" - } - }, - "parameters": { - "vnetId": { - "type": "string", - "metadata": { - "description": "ARM resource id of the existing customer VNet." - } - }, - "agentSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the agent (delegated) subnet." - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "CIDR for the agent subnet. Empty derives a /24 from the VNet space." - } - }, - "createAgentSubnet": { - "type": "bool", - "metadata": { - "description": "When true, create the agent subnet; when false, reference it." - } - }, - "peSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the private-endpoint subnet." - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space." - } - }, - "createPESubnet": { - "type": "bool", - "metadata": { - "description": "When true, create the PE subnet; when false, reference it." - } - } - }, - "variables": { - "vnetParts": "[split(parameters('vnetId'), '/')]", - "vnetSubscriptionId": "[variables('vnetParts')[2]]", - "vnetResourceGroupName": "[variables('vnetParts')[4]]", - "vnetName": "[last(variables('vnetParts'))]" - }, - "resources": [ - { - "condition": "[parameters('createAgentSubnet')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", - "subscriptionId": "[variables('vnetSubscriptionId')]", - "resourceGroup": "[variables('vnetResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetName": { - "value": "[variables('vnetName')]" - }, - "subnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", - "delegations": { - "value": [ - { - "name": "Microsoft.App/environments", - "properties": { - "serviceName": "Microsoft.App/environments" - } - } - ] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "13172875044587293551" - } - }, - "parameters": { - "vnetName": { - "type": "string", - "metadata": { - "description": "Name of the virtual network the subnet belongs to." - } - }, - "subnetName": { - "type": "string", - "metadata": { - "description": "Name of the subnet to create." - } - }, - "addressPrefix": { - "type": "string", - "metadata": { - "description": "CIDR for the subnet." - } - }, - "delegations": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." - } - } - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/subnets", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", - "properties": { - "addressPrefix": "[parameters('addressPrefix')]", - "delegations": "[parameters('delegations')]" - } - } - ], - "outputs": { - "subnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" - }, - "subnetName": { - "type": "string", - "value": "[parameters('subnetName')]" - } - } - } - } - }, - { - "condition": "[parameters('createPESubnet')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", - "subscriptionId": "[variables('vnetSubscriptionId')]", - "resourceGroup": "[variables('vnetResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetName": { - "value": "[variables('vnetName')]" - }, - "subnetName": { - "value": "[parameters('peSubnetName')]" - }, - "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", - "delegations": { - "value": [] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "13172875044587293551" - } - }, - "parameters": { - "vnetName": { - "type": "string", - "metadata": { - "description": "Name of the virtual network the subnet belongs to." - } - }, - "subnetName": { - "type": "string", - "metadata": { - "description": "Name of the subnet to create." - } - }, - "addressPrefix": { - "type": "string", - "metadata": { - "description": "CIDR for the subnet." - } - }, - "delegations": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Subnet delegations (e.g. Microsoft.App/environments for the agent subnet)." - } - } - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/subnets", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", - "properties": { - "addressPrefix": "[parameters('addressPrefix')]", - "delegations": "[parameters('delegations')]" - } - } - ], - "outputs": { - "subnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" - }, - "subnetName": { - "type": "string", - "value": "[parameters('subnetName')]" - } - } - } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName'))))]" - ] - } - ], - "outputs": { - "vnetId": { - "type": "string", - "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName'))]" - }, - "vnetName": { - "type": "string", - "value": "[variables('vnetName')]" - }, - "vnetLocation": { - "type": "string", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), '2024-05-01', 'full').location]" - }, - "vnetSubscriptionId": { - "type": "string", - "value": "[variables('vnetSubscriptionId')]" - }, - "vnetResourceGroupName": { - "type": "string", - "value": "[variables('vnetResourceGroupName')]" - }, - "agentSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('agentSubnetName'))]" - }, - "peSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('vnetSubscriptionId'), variables('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', variables('vnetName')), parameters('peSubnetName'))]" - }, - "peSubnetName": { - "type": "string", - "value": "[parameters('peSubnetName')]" - } - } - } - } - }, - "acr": { - "condition": "[and(parameters('includeAcr'), empty(parameters('existingAcrResourceId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "acr", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "name": { - "value": "[format('{0}{1}', variables('abbrs').containerRegistryRegistries, variables('resourceToken'))]" - }, - "foundryAccountName": { - "value": "[variables('activeFoundryAccountName')]" - }, - "foundryProjectName": { - "value": "[parameters('foundryProjectName')]" - }, - "foundryProjectPrincipalId": "[if(parameters('agentHosting').enabled, createObject('value', reference('byoFoundryAccount::project', '2026-07-15-preview', 'full').identity.principalId), createObject('value', reference('foundryAccount::project', '2025-06-01', 'full').identity.principalId))]", - "enableNetworkIsolation": { - "value": "[parameters('enableNetworkIsolation')]" - }, - "byoAks": { - "value": "[parameters('agentHosting').enabled]" - }, - "kubeletObjectId": { - "value": "[parameters('kubeletObjectId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "4757893513251572043" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Azure region." - } - }, - "tags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Tags applied to all resources." - } - }, - "name": { - "type": "string", - "minLength": 5, - "maxLength": 50, - "metadata": { - "description": "Registry name. 5-50 alphanumeric chars." - } - }, - "foundryAccountName": { - "type": "string", - "metadata": { - "description": "Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection." - } - }, - "foundryProjectName": { - "type": "string", - "metadata": { - "description": "Name of the existing Foundry project receiving the ACR connection." - } - }, - "foundryProjectPrincipalId": { - "type": "string", - "metadata": { - "description": "Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity." - } - }, - "enableNetworkIsolation": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "When true, the registry disables public network access to stay inside the isolation boundary." - } - }, - "byoAks": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Enable the standard registry for an existing AKS cluster with managed-identity image pulls." - } - }, - "kubeletObjectId": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]" - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('foundryAccountName'), parameters('foundryProjectName'), format('{0}-conn', parameters('name')))]", - "properties": { - "category": "ContainerRegistry", - "target": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[parameters('foundryProjectPrincipalId')]", - "resourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - } - }, - "dependsOn": [ - "[extensionResourceId(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId')))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - ] - }, - { - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "[if(and(parameters('enableNetworkIsolation'), not(parameters('byoAks'))), 'Disabled', 'Enabled')]", - "zoneRedundancy": "Disabled" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('foundryProjectPrincipalId'), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[parameters('foundryProjectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - ] - }, - { - "condition": "[parameters('byoAks')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('kubeletObjectId'), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[parameters('kubeletObjectId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - ] - } - ], - "outputs": { - "loginServer": { - "type": "string", - "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]" - }, - "resourceId": { - "type": "string", - "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" - }, - "connectionName": { - "type": "string", - "value": "[format('{0}-conn', parameters('name'))]" - } - } - } - }, - "dependsOn": [ - "byoFoundryAccount::project", - "foundryAccount::project" - ] - }, - "existingAcr": { - "condition": "[and(and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId')))), not(parameters('acrPullAssigned')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('acrAccessOperationId')), format('existing-acr-{0}', uniqueString(variables('activeFoundryAccountId'), variables('activeFoundryProjectId'))), format('azd-acr-{0}', parameters('acrAccessOperationId')))]", - "subscriptionId": "[split(parameters('existingAcrResourceId'), '/')[2]]", - "resourceGroup": "[split(parameters('existingAcrResourceId'), '/')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "mode": { - "value": "reuse-connect" - }, - "location": { - "value": "[parameters('location')]" - }, - "registryName": { - "value": "[last(split(parameters('existingAcrResourceId'), '/'))]" - }, - "projectPrincipalId": "[if(parameters('agentHosting').enabled, createObject('value', reference('byoFoundryAccount::project', '2026-07-15-preview', 'full').identity.principalId), createObject('value', reference('foundryAccount::project', '2025-06-01', 'full').identity.principalId))]", - "pullAssigned": { - "value": "[parameters('acrPullAssigned')]" - }, - "accessOperationId": { - "value": "[parameters('acrAccessOperationId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "15235048848214962716" - } - }, - "parameters": { - "mode": { - "type": "string", - "allowedValues": [ - "create", - "reuse-connect" - ] - }, - "location": { - "type": "string" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "registryName": { - "type": "string" - }, - "projectPrincipalId": { - "type": "string" - }, - "pullAssigned": { - "type": "bool", - "defaultValue": false - }, - "accessOperationId": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "acrPullRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "repositoryReaderRoleId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b93aa761-3e63-49ed-ac28-beffa264f7ac')]" - }, - "resources": [ - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('registryName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "Premium" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled", - "zoneRedundancy": "Disabled" - } - }, - { - "condition": "[equals(parameters('mode'), 'create')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('projectPrincipalId'), variables('acrPullRoleId'))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('acrPullRoleId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]" - ] - }, - { - "condition": "[and(equals(parameters('mode'), 'reuse-connect'), not(parameters('pullAssigned')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('accessOperationId')), format('existing-acr-access-{0}', uniqueString(parameters('projectPrincipalId'), parameters('registryName'))), format('azd-acr-access-{0}', parameters('accessOperationId')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "registryName": { - "value": "[parameters('registryName')]" - }, - "principalId": { - "value": "[parameters('projectPrincipalId')]" - }, - "assignmentName": { - "value": "[parameters('accessOperationId')]" - }, - "ownershipDescription": "[if(empty(parameters('accessOperationId')), createObject('value', ''), createObject('value', format('azd-foundry:{0}', parameters('accessOperationId'))))]", - "roleDefinitionId": "[if(equals(parameters('mode'), 'reuse-connect'), if(equals(reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').roleAssignmentMode, 'AbacRepositoryPermissions'), createObject('value', variables('repositoryReaderRoleId')), createObject('value', variables('acrPullRoleId'))), createObject('value', variables('acrPullRoleId')))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "367829090767611126" - } - }, - "parameters": { - "registryName": { - "type": "string", - "metadata": { - "description": "Name of the Azure Container Registry." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Principal receiving pull access on the registry." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "Pull role definition resource ID (AcrPull or Container Registry Repository Reader)." - } - }, - "assignmentName": { - "type": "string", - "defaultValue": "" - }, - "ownershipDescription": { - "type": "string", - "defaultValue": "" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName'))]", - "name": "[if(empty(parameters('assignmentName')), guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), parameters('principalId'), parameters('roleDefinitionId')), parameters('assignmentName'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[parameters('roleDefinitionId')]", - "description": "[parameters('ownershipDescription')]" - } - } - ] - } - } - } - ], - "outputs": { - "endpoint": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2023-07-01').loginServer, reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), '2025-11-01').loginServer)]" - }, - "resourceId": { - "type": "string", - "value": "[if(equals(parameters('mode'), 'create'), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')), resourceId('Microsoft.ContainerRegistry/registries', parameters('registryName')))]" - } - } - } - }, - "dependsOn": [ - "byoFoundryAccount::project", - "foundryAccount::project" - ] - }, - "existingAcrConnection": { - "condition": "[and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId'))))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-acr-connection-{0}', uniqueString(variables('activeFoundryAccountId'), variables('activeFoundryProjectId')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "accountName": { - "value": "[variables('activeFoundryAccountName')]" - }, - "projectName": { - "value": "[parameters('foundryProjectName')]" - }, - "acrName": { - "value": "[last(split(parameters('existingAcrResourceId'), '/'))]" - }, - "acrEndpoint": "[if(and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId')))), createObject('value', reference('externalRegistry').loginServer), createObject('value', ''))]", - "acrResourceId": "[if(and(parameters('includeAcr'), not(empty(parameters('existingAcrResourceId')))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('existingAcrResourceId'), '/')[2], split(parameters('existingAcrResourceId'), '/')[4]), 'Microsoft.ContainerRegistry/registries', last(split(parameters('existingAcrResourceId'), '/')))), createObject('value', ''))]", - "createAcrConnection": { - "value": true - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "8439686740805277616" - } - }, - "definitions": { - "deploymentType": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "format": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "sku": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "capacity": { - "type": "int" - } - } - } - } - } - }, - "parameters": { - "accountName": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "deployments": { - "type": "array", - "items": { - "$ref": "#/definitions/deploymentType" - }, - "defaultValue": [] - }, - "acrName": { - "type": "string", - "defaultValue": "" - }, - "acrEndpoint": { - "type": "string", - "defaultValue": "" - }, - "acrResourceId": { - "type": "string", - "defaultValue": "" - }, - "createAcrConnection": { - "type": "bool", - "defaultValue": false - }, - "existingAcrConnectionName": { - "type": "string", - "defaultValue": "" - } - }, - "resources": { - "foundryAccountPreview::project": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" - }, - "foundryAccount": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('accountName')]" - }, - "modelDeployments": { - "copy": { - "name": "modelDeployments", - "count": "[length(parameters('deployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('deployments')[copyIndex()].name)]", - "properties": { - "model": "[parameters('deployments')[copyIndex()].model]" - }, - "sku": "[parameters('deployments')[copyIndex()].sku]" - }, - "foundryAccountPreview": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]" - }, - "acrConnection": { - "condition": "[parameters('createAcrConnection')]", - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('{0}-conn', parameters('acrName')))]", - "properties": { - "category": "ContainerRegistry", - "target": "[parameters('acrEndpoint')]", - "authType": "ManagedIdentity", - "credentials": { - "clientId": "[reference('foundryAccountPreview::project', '2025-04-01-preview', 'full').identity.principalId]", - "resourceId": "[parameters('acrResourceId')]" - }, - "isSharedToAll": true, - "metadata": { - "ResourceId": "[parameters('acrResourceId')]" - } - }, - "dependsOn": [ - "foundryAccountPreview::project" - ] - } - }, - "outputs": { - "acrConnectionName": { - "type": "string", - "value": "[if(parameters('createAcrConnection'), format('{0}-conn', parameters('acrName')), parameters('existingAcrConnectionName'))]" - } - } - } - }, - "dependsOn": [ - "byoFoundryAccount::project", - "existingAcr", - "externalRegistry", - "foundryAccount::project" - ] - }, - "privateEndpointDns": { - "condition": "[parameters('enableNetworkIsolation')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "foundry-private-endpoint-dns", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiAccountName": { - "value": "[variables('activeFoundryAccountName')]" - }, - "location": { - "value": "[reference('network').outputs.vnetLocation.value]" - }, - "vnetId": { - "value": "[reference('network').outputs.vnetId.value]" - }, - "peSubnetId": { - "value": "[reference('network').outputs.peSubnetId.value]" - }, - "suffix": { - "value": "[variables('resourceToken')]" - }, - "dnsZonesResourceGroup": { - "value": "[parameters('dnsZonesResourceGroup')]" - }, - "dnsZonesSubscription": { - "value": "[parameters('dnsZonesSubscription')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12830623367111962862" - } - }, - "parameters": { - "aiAccountName": { - "type": "string", - "metadata": { - "description": "Name of the Foundry (AIServices) account to bind the private endpoint to." - } - }, - "location": { - "type": "string", - "metadata": { - "description": "Azure region of the customer VNet." - } - }, - "vnetId": { - "type": "string", - "metadata": { - "description": "ARM resource id of the customer VNet." - } - }, - "peSubnetId": { - "type": "string", - "metadata": { - "description": "ARM resource id of the private-endpoint subnet." - } - }, - "suffix": { - "type": "string", - "metadata": { - "description": "Suffix for unique resource/link names." - } - }, - "dnsZonesResourceGroup": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." - } - }, - "dnsZonesSubscription": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." - } - } - }, - "variables": { - "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", - "openAiDnsZoneName": "privatelink.openai.azure.com", - "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", - "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", - "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", - "aiServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", - "openAiZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", - "cognitiveServicesZoneId": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]" - }, - "resources": [ - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('peSubnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", - "properties": { - "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", - "groupIds": [ - "account" - ] - } - } - ] - } - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('aiServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('openAiDnsZoneName')]", - "location": "global" - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('cognitiveServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[parameters('vnetId')]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" - ] - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[parameters('vnetId')]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" - ] - }, - { - "condition": "[not(variables('useExistingZones'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[parameters('vnetId')]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('aiServicesZoneId')]" - } - }, - { - "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('openAiZoneId')]" - } - }, - { - "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('cognitiveServicesZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" - ] - } - ] - } - }, - "dependsOn": [ - "byoFoundryAccount", - "foundryAccount", - "network" - ] - } - }, - "outputs": { - "AZURE_AI_PROJECT_ID": { - "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('activeFoundryAccountName'), parameters('foundryProjectName'))]" - }, - "AZURE_AI_ACCOUNT_NAME": { - "type": "string", - "value": "[variables('activeFoundryAccountName')]" - }, - "AZURE_AI_PROJECT_NAME": { - "type": "string", - "value": "[parameters('foundryProjectName')]" - }, - "AZURE_OPENAI_ENDPOINT": { - "type": "string", - "value": "[format('https://{0}.openai.azure.com/', variables('activeFoundryAccountName'))]" - }, - "FOUNDRY_PROJECT_ENDPOINT": { - "type": "string", - "value": "[format('https://{0}.services.ai.azure.com/api/projects/{1}', variables('activeFoundryAccountName'), parameters('foundryProjectName'))]" - }, - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[if(not(parameters('includeAcr')), '', if(empty(parameters('existingAcrResourceId')), reference('acr').outputs.loginServer.value, reference('externalRegistry').loginServer))]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[if(not(parameters('includeAcr')), '', if(empty(parameters('existingAcrResourceId')), reference('acr').outputs.resourceId.value, extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('existingAcrResourceId'), '/')[2], split(parameters('existingAcrResourceId'), '/')[4]), 'Microsoft.ContainerRegistry/registries', last(split(parameters('existingAcrResourceId'), '/')))))]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[if(not(parameters('includeAcr')), '', if(empty(parameters('existingAcrResourceId')), reference('acr').outputs.connectionName.value, reference('existingAcrConnection').outputs.acrConnectionName.value))]" - }, - "AZURE_FOUNDRY_NETWORK_MODE": { - "type": "string", - "value": "[if(not(parameters('enableNetworkIsolation')), 'none', if(parameters('useManagedEgress'), 'managed', 'byo'))]" - }, - "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { - "type": "string", - "value": "[if(variables('useManagedNetwork'), parameters('managedIsolationMode'), '')]" - } - } - } - }, - "dependsOn": [ - "resourceGroup" - ] - } - }, - "outputs": { - "AZURE_RESOURCE_GROUP": { - "type": "string", - "value": "[parameters('resourceGroupName')]" - }, - "AZURE_FOUNDRY_RESOURCE_GROUP": { - "type": "string", - "value": "[parameters('resourceGroupName')]" - }, - "AZURE_AI_PROJECT_ID": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ID.value]" - }, - "AZURE_AI_ACCOUNT_NAME": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_AI_ACCOUNT_NAME.value]" - }, - "AZURE_AI_PROJECT_NAME": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_AI_PROJECT_NAME.value]" - }, - "AZURE_OPENAI_ENDPOINT": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_OPENAI_ENDPOINT.value]" - }, - "FOUNDRY_PROJECT_ENDPOINT": { - "type": "string", - "value": "[reference('resources').outputs.FOUNDRY_PROJECT_ENDPOINT.value]" - }, - "AZURE_CONTAINER_REGISTRY_ENDPOINT": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT.value]" - }, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID.value]" - }, - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME.value]" - }, - "AZURE_FOUNDRY_NETWORK_MODE": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_FOUNDRY_NETWORK_MODE.value]" - }, - "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { - "type": "string", - "value": "[reference('resources').outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE.value]" - } - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep deleted file mode 100644 index 8594b66fd5c..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep +++ /dev/null @@ -1,181 +0,0 @@ -// Provisioning template for a Foundry project service. -// -// Inputs are derived from the host: azure.ai.project service body in -// azure.yaml by internal/synthesis. This entry point creates a new Foundry -// account and project; existing projects use the separate editable entry point. -// -// Subscription-scoped so the resource group is part of the deployment. This -// keeps `azd provision --preview` side-effect free: the resource group shows -// up as a previewed Create instead of being created up front to satisfy a -// resource-group-scoped what-if. - -targetScope = 'subscription' - -// User-defined types - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -type agentHostingType = { - enabled: bool - hostingType: 'ManagedCluster' | '' - name: string - clusterResourceId: string -} - -// Parameters - -@description('Azure region for all resources.') -param location string - -@description('Name of the resource group to create and deploy resources into.') -@minLength(1) -@maxLength(90) -param resourceGroupName string - -@description('Tags applied to all resources.') -param tags object = {} - -@description('Optional salt to vary resource names across re-provisions.') -param resourceTokenSalt string = '' - -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) -param foundryProjectName string - -@description('Model deployments to provision on the Foundry account.') -param deployments deploymentsType = [] - -@description('Include an Azure Container Registry. Set true when any agent uses docker:.') -param includeAcr bool = false - -@description('Existing AKS cluster hosting. Supporting resources are created automatically.') -param agentHosting agentHostingType = { - enabled: false - hostingType: '' - name: '' - clusterResourceId: '' -} - -@description('AKS kubelet principal ID, resolved by microsoft.foundry for registry access.') -param kubeletObjectId string = '' - -@description('Built-in federation-management role, resolved by microsoft.foundry.') -param federatedIdentityRoleDefinitionId string = '' - -@description('Full ARM ID of an existing container registry to reuse instead of creating one.') -param existingAcrResourceId string = '' - -@description('Project already has effective registry pull access.') -param acrPullAssigned bool = false - -@description('Unique access operation ID persisted by azd before deployment.') -param acrAccessOperationId string = '' - -// Network isolation parameters (see modules/resources.bicep for semantics). -// All default off so an absent network: block yields a public account. - -@description('Master switch: when true the account is VNet-bound (private).') -param enableNetworkIsolation bool = false - -@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') -param useManagedEgress bool = false - -@description('ARM id of the existing customer VNet (byo mode).') -param vnetId string = '' - -@description('Agent (delegated) subnet name.') -param agentSubnetName string = 'agent-subnet' - -@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') -param agentSubnetPrefix string = '' - -@description('When true, create the agent subnet; when false, reference it.') -param createAgentSubnet bool = false - -@description('Private-endpoint subnet name.') -param peSubnetName string = 'pe-subnet' - -@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') -param peSubnetPrefix string = '' - -@description('When true, create the PE subnet; when false, reference it.') -param createPESubnet bool = false - -@description('Managed-network isolation mode (managed mode).') -param managedIsolationMode string = '' - -@description('Resource group holding existing private DNS zones. Empty creates new zones.') -param dnsZonesResourceGroup string = '' - -@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') -param dnsZonesSubscription string = '' - -// Resources - -resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location - tags: tags -} - -module resources 'modules/resources.bicep' = { - name: 'foundry-resources' - scope: resourceGroup - params: { - location: location - tags: tags - resourceTokenSalt: resourceTokenSalt - foundryProjectName: foundryProjectName - deployments: deployments - includeAcr: includeAcr - agentHosting: agentHosting - kubeletObjectId: kubeletObjectId - federatedIdentityRoleDefinitionId: federatedIdentityRoleDefinitionId - existingAcrResourceId: existingAcrResourceId - acrPullAssigned: acrPullAssigned - acrAccessOperationId: acrAccessOperationId - enableNetworkIsolation: enableNetworkIsolation - useManagedEgress: useManagedEgress - vnetId: vnetId - agentSubnetName: agentSubnetName - agentSubnetPrefix: agentSubnetPrefix - createAgentSubnet: createAgentSubnet - peSubnetName: peSubnetName - peSubnetPrefix: peSubnetPrefix - createPESubnet: createPESubnet - managedIsolationMode: managedIsolationMode - dnsZonesResourceGroup: dnsZonesResourceGroup - dnsZonesSubscription: dnsZonesSubscription - } -} - -// Outputs - -output AZURE_RESOURCE_GROUP string = resourceGroupName -output AZURE_FOUNDRY_RESOURCE_GROUP string = resourceGroupName -output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID -output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME -output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME -output AZURE_OPENAI_ENDPOINT string = resources.outputs.AZURE_OPENAI_ENDPOINT -output FOUNDRY_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME -output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE -output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep deleted file mode 100644 index e935ce47a56..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep +++ /dev/null @@ -1,27 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Name of the Azure Container Registry.') -param registryName string - -@description('Principal receiving pull access on the registry.') -param principalId string - -@description('Pull role definition resource ID (AcrPull or Container Registry Repository Reader).') -param roleDefinitionId string -param assignmentName string = '' -param ownershipDescription string = '' - -resource registry 'Microsoft.ContainerRegistry/registries@2025-11-01' existing = { - name: registryName -} - -resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: empty(assignmentName) ? guid(registry.id, principalId, roleDefinitionId) : assignmentName - scope: registry - properties: { - principalId: principalId - principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId - description: ownershipDescription - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep deleted file mode 100644 index 5784a6980ab..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/acr.bicep +++ /dev/null @@ -1,127 +0,0 @@ -// Azure Container Registry for hosted agents that use docker:. -// Wires the registry as a connection on the Foundry project so the -// project's managed identity can pull images. -// -// Premium SKU is intentional: Foundry recommends Premium so the registry -// can support content trust and geo-replication if the user enables them -// post-provision. - -// Parameters - -@description('Azure region.') -param location string - -@description('Tags applied to all resources.') -param tags object = {} - -@description('Registry name. 5-50 alphanumeric chars.') -@minLength(5) -@maxLength(50) -param name string - -@description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') -param foundryAccountName string - -@description('Name of the existing Foundry project receiving the ACR connection.') -param foundryProjectName string - -@description('Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity.') -param foundryProjectPrincipalId string - -@description('When true, the registry disables public network access to stay inside the isolation boundary.') -param enableNetworkIsolation bool = false - -@description('Enable the standard registry for an existing AKS cluster with managed-identity image pulls.') -param byoAks bool = false -param kubeletObjectId string = '' - -// Variables - -// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// Resources - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { - name: name - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - // BYO AKS uses public authenticated pulls; ordinary network isolation - // retains its disabled public endpoint. - publicNetworkAccess: enableNetworkIsolation && !byoAks ? 'Disabled' : 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -// Grant the Foundry project's managed identity AcrPull on this registry so the -// hosted agent can pull images using the project identity. -resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) - scope: registry - properties: { - principalId: foundryProjectPrincipalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId - } -} - -resource kubeletAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (byoAks) { - name: guid(registry.id, kubeletObjectId, acrPullRoleId) - scope: registry - properties: { - principalId: kubeletObjectId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId - } -} - -// Existing parent references so the connection can be nested under the -// project. Pinned to 2025-04-01-preview: GA 2025-06-01 fails to resolve the -// projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: foundryAccountName - - resource project 'projects' existing = { - name: foundryProjectName - - // Project-scoped connection so Foundry can resolve the registry by name. - resource acrConnection 'connections' = { - name: '${name}-conn' - properties: { - category: 'ContainerRegistry' - target: registry.properties.loginServer - authType: 'ManagedIdentity' - // RegistryIdentity auth requires both the identity client id (the - // project principal) and the registry resource id. - credentials: { - clientId: foundryProjectPrincipalId - resourceId: registry.id - } - isSharedToAll: true - metadata: { - ResourceId: registry.id - } - } - dependsOn: [ - foundryAcrPull - ] - } - } -} - -// Outputs - -output loginServer string = registry.properties.loginServer -output resourceId string = registry.id -output connectionName string = foundryAccount::project::acrConnection.name diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep deleted file mode 100644 index 80ac8c01da4..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep +++ /dev/null @@ -1,19 +0,0 @@ -targetScope = 'resourceGroup' - -param resourceName string -param principalId string -param roleDefinitionId string - -resource target 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { - name: resourceName -} - -resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(target.id, principalId, roleDefinitionId) - scope: target - properties: { - principalId: principalId - principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-prerequisites.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-prerequisites.bicep deleted file mode 100644 index 7452bc9c6d3..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-prerequisites.bicep +++ /dev/null @@ -1,103 +0,0 @@ -targetScope = 'resourceGroup' - -param location string -param tags object -param clusterResourceId string -param storageAccountName string -param hostingIdentityName string -param workloadIdentityName string -param federatedIdentityRoleDefinitionId string - -var clusterParts = split(clusterResourceId, '/') -var storageZrsUnsupportedRegions = [ - 'australiacentral' - 'australiacentral2' - 'australiasoutheast' - 'brazilsoutheast' - 'canadaeast' - 'francesouth' - 'germanynorth' - 'koreasouth' - 'northcentralus' - 'norwaywest' - 'southafricawest' - 'southindia' - 'swedensouth' - 'switzerlandwest' - 'taiwannorth' - 'taiwannorthwest' - 'uaecentral' - 'ukwest' - 'westcentralus' - 'westindia' - 'westus' -] -var readerRoleId = 'acdd72a7-3385-48ef-bd42-f606fba81ae7' -var kubernetesExtensionContributorRoleId = '85cb6faf-e071-4c9b-8136-154b5a04f717' -var blobContributorRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' -) - -resource hosting 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: hostingIdentityName - location: location - tags: tags -} - -resource workload 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: workloadIdentityName - location: location - tags: tags -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: storageAccountName - location: location - tags: tags - kind: 'StorageV2' - sku: { name: contains(storageZrsUnsupportedRegions, toLower(location)) ? 'Standard_LRS' : 'Standard_ZRS' } - properties: { - accessTier: 'Hot' - isHnsEnabled: false - publicNetworkAccess: 'Enabled' - networkAcls: { defaultAction: 'Allow' } - minimumTlsVersion: 'TLS1_2' - supportsHttpsTrafficOnly: true - allowBlobPublicAccess: false - allowSharedKeyAccess: false - } -} - -module clusterGrants 'byo-role-assignment.bicep' = [ - for role in [ - readerRoleId - kubernetesExtensionContributorRoleId - ]: { - name: 'byo-cluster-${uniqueString(resourceGroup().id, hostingIdentityName)}-${role}' - scope: resourceGroup(clusterParts[2], clusterParts[4]) - params: { - resourceName: last(clusterParts) - principalId: hosting.properties.principalId - roleDefinitionId: subscriptionResourceId(clusterParts[2], 'Microsoft.Authorization/roleDefinitions', role) - } - } -] - -module federationGrant 'byo-identity-role-assignment.bicep' = { - name: 'byo-federation-access' - params: { - resourceName: workload.name - principalId: hosting.properties.principalId - roleDefinitionId: federatedIdentityRoleDefinitionId - } -} - -module storageGrant 'byo-storage-role-assignment.bicep' = { - name: 'byo-storage-access' - params: { - resourceName: storage.name - principalId: workload.properties.principalId - roleDefinitionId: blobContributorRoleId - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-role-assignment.bicep deleted file mode 100644 index bcda5703668..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-role-assignment.bicep +++ /dev/null @@ -1,19 +0,0 @@ -targetScope = 'resourceGroup' - -param resourceName string -param principalId string -param roleDefinitionId string - -resource target 'Microsoft.ContainerService/managedClusters@2025-01-01' existing = { - name: resourceName -} - -resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(target.id, principalId, roleDefinitionId) - scope: target - properties: { - principalId: principalId - principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep deleted file mode 100644 index 91abc34a640..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep +++ /dev/null @@ -1,19 +0,0 @@ -targetScope = 'resourceGroup' - -param resourceName string -param principalId string -param roleDefinitionId string - -resource target 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { - name: resourceName -} - -resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(target.id, principalId, roleDefinitionId) - scope: target - properties: { - principalId: principalId - principalType: 'ServicePrincipal' - roleDefinitionId: roleDefinitionId - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl deleted file mode 100644 index a0b2f574237..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl +++ /dev/null @@ -1,68 +0,0 @@ -targetScope = 'resourceGroup' - -param registryName string -param projectPrincipalId string -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) -{{- if eq .AcrMode "create" }} -param location string -param tags object = {} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { - name: registryName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} -{{- else }} - -resource registry 'Microsoft.ContainerRegistry/registries@2025-11-01' existing = { - name: registryName -} -var repositoryReaderRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'b93aa761-3e63-49ed-ac28-beffa264f7ac' -) -{{- end }} - -{{- if eq .AcrMode "create" }} -resource registryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: registry - name: guid(registry.id, projectPrincipalId, acrPullRoleId) - properties: { - principalId: projectPrincipalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId - } -} -{{- else }} -// Nested so roleDefinitionId can be chosen from the registry's -// roleAssignmentMode. Bicep forbids runtime existing-resource -// properties in resource conditions/names. -module registryAccess 'acr-pull-role-assignment.bicep' = { - name: 'existing-acr-access-${uniqueString(projectPrincipalId, registryName)}' - params: { - registryName: registryName - principalId: projectPrincipalId - roleDefinitionId: registry.properties.roleAssignmentMode == 'AbacRepositoryPermissions' - ? repositoryReaderRoleId - : acrPullRoleId - } -} -{{- end }} - -output registryName string = registry.name -output endpoint string = {{ if eq .AcrMode "create" }}registry.properties.loginServer{{ else }}''{{ end }} -output resourceId string = registry.id diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep deleted file mode 100644 index 3933a4a4ce3..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep +++ /dev/null @@ -1,77 +0,0 @@ -targetScope = 'resourceGroup' - -@allowed([ - 'create' - 'reuse-connect' -]) -param mode string -param location string -param tags object = {} -param registryName string -param projectPrincipalId string -param pullAssigned bool = false -param accessOperationId string = '' - -var acrPullRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) -// Container Registry Repository Reader — required when the registry -// uses AbacRepositoryPermissions (AcrPull is not honored in -// that mode). -var repositoryReaderRoleId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'b93aa761-3e63-49ed-ac28-beffa264f7ac' -) - -resource newRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = if (mode == 'create') { - name: registryName - location: location - tags: tags - sku: { - name: 'Premium' - } - identity: { - type: 'SystemAssigned' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - zoneRedundancy: 'Disabled' - } -} - -resource existingRegistry 'Microsoft.ContainerRegistry/registries@2025-11-01' existing = if (mode == 'reuse-connect') { - name: registryName -} - -resource newRegistryAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (mode == 'create') { - scope: newRegistry - name: guid(newRegistry.id, projectPrincipalId, acrPullRoleId) - properties: { - principalId: projectPrincipalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullRoleId - } -} - -// Nested so roleDefinitionId can be chosen from the registry's -// roleAssignmentMode. Bicep forbids runtime existing-resource -// properties in resource conditions/names. -module existingRegistryAccess 'acr-pull-role-assignment.bicep' = if (mode == 'reuse-connect' && !pullAssigned) { - name: empty(accessOperationId) ? 'existing-acr-access-${uniqueString(projectPrincipalId, registryName)}' : 'azd-acr-access-${accessOperationId}' - params: { - registryName: registryName - principalId: projectPrincipalId - assignmentName: accessOperationId - ownershipDescription: empty(accessOperationId) ? '' : 'azd-foundry:${accessOperationId}' - roleDefinitionId: mode == 'reuse-connect' - ? (existingRegistry!.properties.roleAssignmentMode == 'AbacRepositoryPermissions' - ? repositoryReaderRoleId - : acrPullRoleId) - : acrPullRoleId - } -} - -output endpoint string = mode == 'create' ? newRegistry!.properties.loginServer : existingRegistry!.properties.loginServer -output resourceId string = mode == 'create' ? newRegistry!.id : existingRegistry!.id diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep deleted file mode 100644 index c6bd78114f7..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/foundry-project.bicep +++ /dev/null @@ -1,67 +0,0 @@ -targetScope = 'resourceGroup' - -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -param accountName string -param projectName string -param deployments deploymentType[] = [] -param acrName string = '' -param acrEndpoint string = '' -param acrResourceId string = '' -param createAcrConnection bool = false -param existingAcrConnectionName string = '' - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: accountName -} - -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for d in deployments: { - parent: foundryAccount - name: d.name - properties: { - model: d.model - } - sku: d.sku - } -] - -resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName - - resource project 'projects' existing = { - name: projectName - } -} - -resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (createAcrConnection) { - parent: foundryAccountPreview::project - name: '${acrName}-conn' - properties: { - category: 'ContainerRegistry' - target: acrEndpoint - authType: 'ManagedIdentity' - credentials: { - clientId: foundryAccountPreview::project.identity.principalId - resourceId: acrResourceId - } - isSharedToAll: true - metadata: { - ResourceId: acrResourceId - } - } -} - -output acrConnectionName string = createAcrConnection ? acrConnection!.name : existingAcrConnectionName diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep deleted file mode 100644 index e9ec3e7fc34..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/network.bicep +++ /dev/null @@ -1,95 +0,0 @@ -// Virtual network wiring for a network-secured (VNet-injected) Foundry account. -// -// Bring-your-own VNet only (network.mode: byo). The VNet must already exist; -// v1 references it by the ARM id supplied in azure.yaml. Each subnet follows -// the tri-state rule from the synthesizer: -// -// create=true, prefix set -> create the subnet with that prefix -// create=true, prefix empty -> create the subnet with a derived /24 prefix -// create=false -> reference an existing subnet as-is -// -// All subnet ids are deterministic ('/subnets/'), so outputs are -// valid whether the subnet was created here or already existed. - -targetScope = 'resourceGroup' - -@description('ARM resource id of the existing customer VNet.') -param vnetId string - -@description('Name of the agent (delegated) subnet.') -param agentSubnetName string - -@description('CIDR for the agent subnet. Empty derives a /24 from the VNet space.') -param agentSubnetPrefix string = '' - -@description('When true, create the agent subnet; when false, reference it.') -param createAgentSubnet bool - -@description('Name of the private-endpoint subnet.') -param peSubnetName string - -@description('CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space.') -param peSubnetPrefix string = '' - -@description('When true, create the PE subnet; when false, reference it.') -param createPESubnet bool - -// The VNet may live in a different resource group than the deployment RG. -var vnetParts = split(vnetId, '/') -var vnetSubscriptionId = vnetParts[2] -var vnetResourceGroupName = vnetParts[4] -var vnetName = last(vnetParts) - -resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { - name: vnetName - scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) -} - -var vnetAddressSpace = vnet.properties.addressSpace.addressPrefixes[0] -var agentPrefix = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix -var pePrefix = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix - -// Create the agent subnet, delegated to Microsoft.App/environments so the -// hosted agent's container app environment can be injected into it. -module agentSubnet 'subnet.bicep' = if (createAgentSubnet) { - name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' - scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) - params: { - vnetName: vnetName - subnetName: agentSubnetName - addressPrefix: agentPrefix - delegations: [ - { - name: 'Microsoft.App/environments' - properties: { - serviceName: 'Microsoft.App/environments' - } - } - ] - } -} - -// Create the private-endpoint subnet. Depends on the agent subnet so the two -// subnet PUTs against the same VNet do not race (ARM serializes subnet writes). -module peSubnet 'subnet.bicep' = if (createPESubnet) { - name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' - scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) - params: { - vnetName: vnetName - subnetName: peSubnetName - addressPrefix: pePrefix - delegations: [] - } - dependsOn: [ - agentSubnet - ] -} - -output vnetId string = vnet.id -output vnetName string = vnetName -output vnetLocation string = vnet.location -output vnetSubscriptionId string = vnetSubscriptionId -output vnetResourceGroupName string = vnetResourceGroupName -output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' -output peSubnetId string = '${vnet.id}/subnets/${peSubnetName}' -output peSubnetName string = peSubnetName diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep deleted file mode 100644 index 1167d0ad5f4..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/private-endpoint-dns.bicep +++ /dev/null @@ -1,168 +0,0 @@ -// Account private endpoint + the three AI private DNS zones for a -// network-secured Foundry account. Dependent stores stay platform-managed, so -// only the account itself gets a private endpoint here (no Search / Storage / -// Cosmos endpoints). -// -// DNS zones are created and linked to the VNet by default. When -// dnsZonesResourceGroup is set, the zones are referenced from that resource -// group (in dnsZonesSubscription, defaulting to this subscription) instead of -// being created. - -targetScope = 'resourceGroup' - -@description('Name of the Foundry (AIServices) account to bind the private endpoint to.') -param aiAccountName string - -@description('Azure region of the customer VNet.') -param location string - -@description('ARM resource id of the customer VNet.') -param vnetId string - -@description('ARM resource id of the private-endpoint subnet.') -param peSubnetId string - -@description('Suffix for unique resource/link names.') -param suffix string - -@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') -param dnsZonesResourceGroup string = '' - -@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') -param dnsZonesSubscription string = '' - -var aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' -var openAiDnsZoneName = 'privatelink.openai.azure.com' -var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' - -var useExistingZones = !empty(dnsZonesResourceGroup) -var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription - -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: aiAccountName - scope: resourceGroup() -} - -// Account private endpoint in the PE subnet, targeting the 'account' group. -resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { - name: '${aiAccountName}-private-endpoint' - location: location - properties: { - subnet: { - id: peSubnetId - } - privateLinkServiceConnections: [ - { - name: '${aiAccountName}-private-link-service-connection' - properties: { - privateLinkServiceId: aiAccount.id - groupIds: [ - 'account' - ] - } - } - ] - } -} - -// ---- Private DNS zones: create-and-link, or reference existing ---- - -resource aiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { - name: aiServicesDnsZoneName - location: 'global' -} -resource existingAiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { - name: aiServicesDnsZoneName - scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) -} -var aiServicesZoneId = useExistingZones ? existingAiServicesZone.id : aiServicesZone.id - -resource openAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { - name: openAiDnsZoneName - location: 'global' -} -resource existingOpenAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { - name: openAiDnsZoneName - scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) -} -var openAiZoneId = useExistingZones ? existingOpenAiZone.id : openAiZone.id - -resource cognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { - name: cognitiveServicesDnsZoneName - location: 'global' -} -resource existingCognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { - name: cognitiveServicesDnsZoneName - scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) -} -var cognitiveServicesZoneId = useExistingZones ? existingCognitiveServicesZone.id : cognitiveServicesZone.id - -// ---- VNet links (only when we create the zones) ---- - -resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { - parent: aiServicesZone - name: 'aiServices-${suffix}-link' - location: 'global' - properties: { - virtualNetwork: { - id: vnetId - } - registrationEnabled: false - } -} -resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { - parent: openAiZone - name: 'aiServicesOpenAI-${suffix}-link' - location: 'global' - properties: { - virtualNetwork: { - id: vnetId - } - registrationEnabled: false - } -} -resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { - parent: cognitiveServicesZone - name: 'aiServicesCognitiveServices-${suffix}-link' - location: 'global' - properties: { - virtualNetwork: { - id: vnetId - } - registrationEnabled: false - } -} - -// ---- DNS zone group binds the three zones to the account endpoint ---- - -resource aiAccountDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { - parent: aiAccountPrivateEndpoint - name: '${aiAccountName}-dns-group' - properties: { - privateDnsZoneConfigs: [ - { - name: '${aiAccountName}-dns-aiserv-config' - properties: { - privateDnsZoneId: aiServicesZoneId - } - } - { - name: '${aiAccountName}-dns-openai-config' - properties: { - privateDnsZoneId: openAiZoneId - } - } - { - name: '${aiAccountName}-dns-cogserv-config' - properties: { - privateDnsZoneId: cognitiveServicesZoneId - } - } - ] - } - dependsOn: [ - aiServicesLink - openAiLink - cognitiveServicesLink - ] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep deleted file mode 100644 index e13a3a50a86..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ /dev/null @@ -1,426 +0,0 @@ -// Resource-group-scoped resources for a microsoft.foundry service: the -// Foundry (AIServices) account, its project, model deployments, the optional -// container registry. -// -// Deployed by main.bicep into a resource group it creates at subscription -// scope. Kept as a separate module so main.bicep can target the subscription -// (and thus create the resource group) while these resources stay RG-scoped. - -targetScope = 'resourceGroup' - -// User-defined types - -@description('Shape of one model deployment entry in azure.yaml.') -type deploymentsType = deploymentType[] - -@description('Shape of a single model deployment.') -type deploymentType = { - name: string - model: { - name: string - format: string - version: string - } - sku: { - name: string - capacity: int - } -} - -type agentHostingType = { - enabled: bool - hostingType: 'ManagedCluster' | '' - name: string - clusterResourceId: string -} - -// Parameters - -@description('Azure region for all resources.') -param location string = resourceGroup().location - -@description('Tags applied to all resources.') -param tags object = {} - -@description('Optional salt to vary resource names across re-provisions.') -param resourceTokenSalt string = '' - -@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') -@minLength(3) -@maxLength(32) -param foundryProjectName string - -@description('Model deployments to provision on the Foundry account.') -param deployments deploymentsType = [] - -@description('Include an Azure Container Registry. Set true when any agent uses docker:.') -param includeAcr bool = false - -param agentHosting agentHostingType = { - enabled: false - hostingType: '' - name: '' - clusterResourceId: '' -} - -param kubeletObjectId string = '' -param federatedIdentityRoleDefinitionId string = '' - -@description('Full ARM ID of an existing container registry to reuse instead of creating one.') -param existingAcrResourceId string = '' - -param acrPullAssigned bool = false -param acrAccessOperationId string = '' - -// Network isolation parameters. All default off so an absent network: block in -// azure.yaml yields a public account identical to the pre-network template. - -@description('Master switch: when true the account is VNet-bound (private).') -param enableNetworkIsolation bool = false - -@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') -param useManagedEgress bool = false - -@description('ARM id of the existing customer VNet (byo mode).') -param vnetId string = '' - -@description('Agent (delegated) subnet name.') -param agentSubnetName string = 'agent-subnet' - -@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') -param agentSubnetPrefix string = '' - -@description('When true, create the agent subnet; when false, reference it.') -param createAgentSubnet bool = false - -@description('Private-endpoint subnet name.') -param peSubnetName string = 'pe-subnet' - -@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') -param peSubnetPrefix string = '' - -@description('When true, create the PE subnet; when false, reference it.') -param createPESubnet bool = false - -@description('Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound.') -param managedIsolationMode string = '' - -@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') -param dnsZonesResourceGroup string = '' - -@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') -param dnsZonesSubscription string = '' - -// Variables - -var resourceToken = empty(resourceTokenSalt) - ? uniqueString(subscription().id, resourceGroup().id, location) - : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) - -var abbrs = loadJsonContent('../abbreviations.json') - -var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' -var byoFoundryAccountName = 'aks-${foundryAccountName}' -var activeFoundryAccountName = agentHosting.enabled - ? byoFoundryAccountName - : foundryAccountName -var activeFoundryAccountId = resourceId('Microsoft.CognitiveServices/accounts', activeFoundryAccountName) -var activeFoundryProjectId = resourceId('Microsoft.CognitiveServices/accounts/projects', activeFoundryAccountName, foundryProjectName) -var storageAccountName = 'st${resourceToken}' -var hostingIdentityName = '${foundryAccountName}-hosting-mi' -var workloadIdentityName = '${foundryAccountName}-workload-mi' - -// Egress: byo injects the agent into a customer subnet; managed uses the -// Microsoft-managed network. Ingress: an account private endpoint is always -// provisioned when isolation is on, so the data plane is never left public. -var useByoNetwork = enableNetworkIsolation && !useManagedEgress -var useManagedNetwork = enableNetworkIsolation && useManagedEgress -var disablePublicDataPlaneAccess = enableNetworkIsolation - -// Resources - -module prerequisites 'byo-prerequisites.bicep' = if (agentHosting.enabled) { - name: 'byo-prerequisites' - params: { - location: location - tags: tags - clusterResourceId: agentHosting.clusterResourceId - storageAccountName: storageAccountName - hostingIdentityName: hostingIdentityName - workloadIdentityName: workloadIdentityName - federatedIdentityRoleDefinitionId: federatedIdentityRoleDefinitionId - } -} - -// Customer VNet wiring: reference the VNet and create or reference the agent -// (byo egress only) + private-endpoint subnets. Runs whenever isolation is on -// because the account private endpoint is always provisioned. -module network 'network.bicep' = if (enableNetworkIsolation) { - name: 'foundry-network' - params: { - vnetId: vnetId - agentSubnetName: agentSubnetName - agentSubnetPrefix: agentSubnetPrefix - createAgentSubnet: createAgentSubnet - peSubnetName: peSubnetName - peSubnetPrefix: peSubnetPrefix - createPESubnet: createPESubnet - } -} - -// networkInjections wires the account into the agent subnet (byo) or the -// Microsoft-managed network (managed). Null when isolation is off. -// -// subnetArmId is built as a concrete string from the (concrete) vnetId param -// rather than network!.outputs.agentSubnetId. The account and the network -// module deploy in the same template, so an inter-module reference() here is -// unresolved at the CognitiveServices RP preflight, which then fails to convert -// networkInjections to its typed contract (ARM what-if does not catch this). -// The deterministic id avoids the unresolved reference; an explicit dependsOn -// on the network module preserves ordering (the subnet must exist first). -var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' -var agentNetworkInjections = useByoNetwork - ? [ - { - scenario: 'agent' - subnetArmId: agentSubnetArmId - useMicrosoftManagedNetwork: false - } - ] - : (useManagedNetwork - ? [ - { - scenario: 'agent' - useMicrosoftManagedNetwork: true - } - ] - : null) - -var foundryAccountProperties = { - allowProjectManagement: true - customSubDomainName: activeFoundryAccountName - publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' - disableLocalAuth: true - networkAcls: { - defaultAction: disablePublicDataPlaneAccess ? 'Deny' : 'Allow' - bypass: disablePublicDataPlaneAccess ? 'AzureServices' : null - virtualNetworkRules: [] - ipRules: [] - } - networkInjections: agentNetworkInjections -} - -resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = if (!agentHosting.enabled) { - name: foundryAccountName - location: location - tags: tags - sku: { - name: 'S0' - } - kind: 'AIServices' - identity: { - type: 'SystemAssigned' - } - properties: foundryAccountProperties - dependsOn: useByoNetwork ? [network] : [] - - @batchSize(1) - resource modelDeployments 'deployments' = [ - for d in deployments: { - name: d.name - properties: { - model: d.model - } - sku: d.sku - } - ] - - resource project 'projects' = { - name: foundryProjectName - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - description: '${foundryProjectName} Project' - displayName: foundryProjectName - } - dependsOn: [ - modelDeployments - ] - } -} - -resource byoFoundryAccount 'Microsoft.CognitiveServices/accounts@2026-07-15-preview' = if (agentHosting.enabled) { - name: byoFoundryAccountName - location: location - tags: tags - sku: { - name: 'S0' - } - kind: 'AIServices' - identity: { - type: 'SystemAssigned, UserAssigned' - userAssignedIdentities: { - '${resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', hostingIdentityName)}': {} - } - } - properties: { - ...foundryAccountProperties - agentHostingConfigurations: [ - { - hostingType: 'ManagedCluster' - name: 'default' - clusterResourceId: agentHosting.clusterResourceId - hostingManagementIdentityResourceId: resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', hostingIdentityName) - storageAccountResourceId: resourceId('Microsoft.Storage/storageAccounts', storageAccountName) - workloadIdentityResourceId: resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', workloadIdentityName) - } - ] - } - - // The account injects into the agent subnet via a deterministic id (above), - // so Bicep cannot infer the dependency on the network module that creates - // that subnet. Declare it explicitly so the subnet exists before injection. - dependsOn: useByoNetwork ? [network, prerequisites] : [prerequisites] - - // Sequential model deployment creation; ARM throttles concurrent - // deployments on the same account. - @batchSize(1) - resource modelDeployments 'deployments' = [ - for d in deployments: { - name: d.name - properties: { - model: d.model - } - sku: d.sku - } - ] - - resource project 'projects' = { - name: foundryProjectName - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - description: '${foundryProjectName} Project' - displayName: foundryProjectName - } - // Explicit dependsOn ensures all model deployments complete before - // the project is created; the project does not reference them so - // there is no implicit dependency Bicep can infer. - dependsOn: [ - modelDeployments - ] - } -} - -// Managed-network isolation (managed egress only). Applies the chosen outbound -// isolation mode to the Microsoft-managed VNet that hosts the agent runtime. -// Only deployed when an explicit isolationMode is requested; otherwise the -// platform default applies. Note: AllowOnlyApprovedOutbound additionally -// requires approved outbound rules for the agent to reach dependent resources; -// for the platform-managed stores used here those are managed by the platform. -resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = - if (useManagedNetwork && !empty(managedIsolationMode)) { - name: '${activeFoundryAccountName}/default' - properties: { - managedNetwork: { - isolationMode: managedIsolationMode - } - } - dependsOn: [ - foundryAccount - byoFoundryAccount - ] - } - -module acr 'acr.bicep' = if (includeAcr && empty(existingAcrResourceId)) { - name: 'acr' - params: { - location: location - tags: tags - name: '${abbrs.containerRegistryRegistries}${resourceToken}' - foundryAccountName: activeFoundryAccountName - foundryProjectName: foundryProjectName - foundryProjectPrincipalId: agentHosting.enabled - ? byoFoundryAccount::project!.identity.principalId - : foundryAccount::project!.identity.principalId - enableNetworkIsolation: enableNetworkIsolation - byoAks: agentHosting.enabled - kubeletObjectId: kubeletObjectId - } -} - -resource externalRegistry 'Microsoft.ContainerRegistry/registries@2025-11-01' existing = if (includeAcr && !empty(existingAcrResourceId)) { - name: last(split(existingAcrResourceId, '/')) - scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) -} - -module existingAcr 'container-registry.bicep' = if (includeAcr && !empty(existingAcrResourceId) && !acrPullAssigned) { - name: empty(acrAccessOperationId) - ? 'existing-acr-${uniqueString(activeFoundryAccountId, activeFoundryProjectId)}' - : 'azd-acr-${acrAccessOperationId}' - scope: resourceGroup(split(existingAcrResourceId, '/')[2], split(existingAcrResourceId, '/')[4]) - params: { - mode: 'reuse-connect' - location: location - registryName: last(split(existingAcrResourceId, '/')) - projectPrincipalId: agentHosting.enabled - ? byoFoundryAccount::project!.identity.principalId - : foundryAccount::project!.identity.principalId - pullAssigned: acrPullAssigned - accessOperationId: acrAccessOperationId - } -} - -module existingAcrConnection 'foundry-project.bicep' = if (includeAcr && !empty(existingAcrResourceId)) { - name: 'existing-acr-connection-${uniqueString(activeFoundryAccountId, activeFoundryProjectId)}' - params: { - accountName: activeFoundryAccountName - projectName: foundryProjectName - acrName: last(split(existingAcrResourceId, '/')) - acrEndpoint: includeAcr && !empty(existingAcrResourceId) ? externalRegistry!.properties.loginServer : '' - acrResourceId: includeAcr && !empty(existingAcrResourceId) ? externalRegistry!.id : '' - createAcrConnection: true - } - dependsOn: [ - foundryAccount::project - byoFoundryAccount::project - existingAcr - ] -} - -// Account private endpoint + AI private DNS zones. The account is always given a -// private endpoint when isolation is on (byo or managed egress); dependent -// stores stay platform-managed, so only the account gets an endpoint. -module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { - name: 'foundry-private-endpoint-dns' - params: { - aiAccountName: activeFoundryAccountName - location: network!.outputs.vnetLocation - vnetId: network!.outputs.vnetId - peSubnetId: network!.outputs.peSubnetId - suffix: resourceToken - dnsZonesResourceGroup: dnsZonesResourceGroup - dnsZonesSubscription: dnsZonesSubscription - } - dependsOn: [ - foundryAccount - byoFoundryAccount - ] -} - -// Outputs - -output AZURE_AI_PROJECT_ID string = resourceId('Microsoft.CognitiveServices/accounts/projects', activeFoundryAccountName, foundryProjectName) -output AZURE_AI_ACCOUNT_NAME string = activeFoundryAccountName -output AZURE_AI_PROJECT_NAME string = foundryProjectName -output AZURE_OPENAI_ENDPOINT string = 'https://${activeFoundryAccountName}.openai.azure.com/' -output FOUNDRY_PROJECT_ENDPOINT string = 'https://${activeFoundryAccountName}.services.ai.azure.com/api/projects/${foundryProjectName}' -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = !includeAcr ? '' : (empty(existingAcrResourceId) ? acr!.outputs.loginServer : externalRegistry!.properties.loginServer) -output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = !includeAcr ? '' : (empty(existingAcrResourceId) ? acr!.outputs.resourceId : externalRegistry!.id) -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = !includeAcr ? '' : (empty(existingAcrResourceId) ? acr!.outputs.connectionName : existingAcrConnection!.outputs.acrConnectionName) -output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') -output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep deleted file mode 100644 index 27abadcb813..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/subnet.bicep +++ /dev/null @@ -1,28 +0,0 @@ -// Single subnet on an existing VNet. Kept as its own module so the parent can -// place subnets in the VNet's resource group (which may differ from the -// deployment RG) and serialize subnet writes via module dependsOn. - -targetScope = 'resourceGroup' - -@description('Name of the virtual network the subnet belongs to.') -param vnetName string - -@description('Name of the subnet to create.') -param subnetName string - -@description('CIDR for the subnet.') -param addressPrefix string - -@description('Subnet delegations (e.g. Microsoft.App/environments for the agent subnet).') -param delegations array = [] - -resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { - name: '${vnetName}/${subnetName}' - properties: { - addressPrefix: addressPrefix - delegations: delegations - } -} - -output subnetId string = subnet.id -output subnetName string = subnetName diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf deleted file mode 100644 index 45138d42cae..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf +++ /dev/null @@ -1,32 +0,0 @@ -data "azapi_resource" "project" { - type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" - resource_id = local.normalized_project_id - response_export_values = ["identity.principalId"] -} - -locals { - existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) - project_principal_id = data.azapi_resource.project.output.identity.principalId -} - -resource "azapi_resource" "acr_connection" { - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = "${local.existing_acr_name}-conn" - parent_id = local.normalized_project_id - - body = { - properties = { - category = "ContainerRegistry" - target = var.existing_acr_endpoint - authType = "ManagedIdentity" - credentials = { - clientId = local.project_principal_id - resourceId = var.existing_acr_resource_id - } - isSharedToAll = true - metadata = { - ResourceId = var.existing_acr_resource_id - } - } - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf deleted file mode 100644 index f72a118203a..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf +++ /dev/null @@ -1,73 +0,0 @@ -locals { - resource_token = substr(sha1(join("-", compact([ - var.subscription_id, - var.resource_group_name, - var.location, - var.resource_token_salt, - ]))), 0, 13) - container_registry_name = "cr${local.resource_token}" - acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" -} - -resource "azurerm_resource_group" "adjunct" { - name = var.resource_group_name - location = var.location - tags = merge(var.tags, { "azd-env-name" = var.environment_name }) -} - -data "azapi_resource" "project" { - type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" - resource_id = local.normalized_project_id - response_export_values = ["identity.principalId"] -} - -locals { - project_principal_id = data.azapi_resource.project.output.identity.principalId -} - -resource "azurerm_container_registry" "this" { - name = local.container_registry_name - resource_group_name = azurerm_resource_group.adjunct.name - location = azurerm_resource_group.adjunct.location - tags = var.tags - sku = "Premium" - admin_enabled = false - - identity { - type = "SystemAssigned" - } - - public_network_access_enabled = true - zone_redundancy_enabled = false -} - -resource "azurerm_role_assignment" "foundry_acr_pull" { - scope = azurerm_container_registry.this.id - role_definition_id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" - principal_id = local.project_principal_id - principal_type = "ServicePrincipal" -} - -resource "azapi_resource" "acr_connection" { - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = "${local.container_registry_name}-conn" - parent_id = local.normalized_project_id - - body = { - properties = { - category = "ContainerRegistry" - target = azurerm_container_registry.this.login_server - authType = "ManagedIdentity" - credentials = { - clientId = local.project_principal_id - resourceId = azurerm_container_registry.this.id - } - isSharedToAll = true - metadata = { - ResourceId = azurerm_container_registry.this.id - } - } - } - - depends_on = [azurerm_role_assignment.foundry_acr_pull] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf deleted file mode 100644 index 9f8c1bf857d..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf +++ /dev/null @@ -1,50 +0,0 @@ -data "azapi_resource" "project" { - type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" - resource_id = local.normalized_project_id - response_export_values = ["identity.principalId"] -} - -provider "azurerm" { - alias = "existing_acr" - subscription_id = split("/", var.existing_acr_resource_id)[2] - tenant_id = var.tenant_id - features {} -} - -locals { - existing_acr_name = element(reverse(split("/", var.existing_acr_resource_id)), 0) - project_principal_id = data.azapi_resource.project.output.identity.principalId - acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" -} - -resource "azurerm_role_assignment" "foundry_acr_pull" { - provider = azurerm.existing_acr - scope = var.existing_acr_resource_id - role_definition_id = "/subscriptions/${split("/", var.existing_acr_resource_id)[2]}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" - principal_id = local.project_principal_id - principal_type = "ServicePrincipal" -} - -resource "azapi_resource" "acr_connection" { - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = "${local.existing_acr_name}-conn" - parent_id = local.normalized_project_id - - body = { - properties = { - category = "ContainerRegistry" - target = var.existing_acr_endpoint - authType = "ManagedIdentity" - credentials = { - clientId = local.project_principal_id - resourceId = var.existing_acr_resource_id - } - isSharedToAll = true - metadata = { - ResourceId = var.existing_acr_resource_id - } - } - } - - depends_on = [azurerm_role_assignment.foundry_acr_pull] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf deleted file mode 100644 index f800d5bacc8..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/main.tf +++ /dev/null @@ -1,15 +0,0 @@ -locals { - project_id_parts = split("/", var.project_resource_id) - project_subscription_id = local.project_id_parts[2] - project_resource_group = local.project_id_parts[4] - foundry_account_name = local.project_id_parts[8] - foundry_project_name = local.project_id_parts[10] - foundry_account_id = join("/", slice(local.project_id_parts, 0, 9)) - normalized_project_id = join("/", local.project_id_parts) - project_endpoint_matches = regexall( - "(?i)^https://([^.]+)\\.services\\.ai\\.azure\\.com/(?:api/)?projects/([^/?#]+)/?$", - var.project_endpoint, - ) - project_endpoint_account = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][0] : "" - project_endpoint_project = length(local.project_endpoint_matches) == 1 ? local.project_endpoint_matches[0][1] : "" -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/model-deployments.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/model-deployments.tf deleted file mode 100644 index bbe6985b22e..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/model-deployments.tf +++ /dev/null @@ -1,15 +0,0 @@ -resource "azapi_resource_action" "model_deployment" { - for_each = { for deployment in var.deployments : deployment.name => deployment } - - type = "Microsoft.CognitiveServices/accounts/deployments@2025-06-01" - resource_id = "${local.foundry_account_id}/deployments/${each.value.name}" - method = "PUT" - locks = [local.foundry_account_id] - - body = { - properties = { - model = each.value.model - } - sku = each.value.sku - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf deleted file mode 100644 index 07b0e0a50fe..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/provider.tf +++ /dev/null @@ -1,25 +0,0 @@ -terraform { - required_version = ">= 1.11.0, < 2.0.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - azapi = { - source = "Azure/azapi" - version = "~> 2.11" - } - } -} - -provider "azurerm" { - subscription_id = var.subscription_id - tenant_id = var.tenant_id - features {} -} - -provider "azapi" { - subscription_id = local.project_subscription_id - tenant_id = var.tenant_id -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf deleted file mode 100644 index b08c6710dd5..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/variables.tf +++ /dev/null @@ -1,86 +0,0 @@ -variable "subscription_id" { - description = "Subscription where adjunct resources are created." - type = string -} - -variable "tenant_id" { - description = "Microsoft Entra tenant that owns the target subscriptions." - type = string -} - -variable "project_resource_id" { - description = "ARM resource ID of the existing Foundry project." - type = string - - validation { - condition = can(regex( - "(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.CognitiveServices/accounts/[^/]+/projects/[^/]+$", - var.project_resource_id, - )) - error_message = "project_resource_id must be a Foundry project ARM resource ID." - } -} - -variable "project_endpoint" { - description = "Endpoint of the existing Foundry project." - type = string -} - -variable "location" { - description = "Azure region for adjunct resources." - type = string -} - -variable "resource_group_name" { - description = "Resource group to create for adjunct resources such as ACR." - type = string -} - -variable "environment_name" { - description = "azd environment name. Used to tag adjunct resources." - type = string -} - -variable "tags" { - description = "Tags applied to adjunct resources." - type = map(string) - default = {} -} - -variable "resource_token_salt" { - description = "Optional salt to vary adjunct resource names." - type = string - default = "" -} - -variable "deployments" { - description = "Model deployments to provision on the existing Foundry account." - type = list(object({ - name = string - model = object({ - name = string - format = string - version = string - }) - sku = object({ - name = string - capacity = number - }) - })) - default = [] -} - -variable "existing_acr_endpoint" { - type = string - default = "" -} - -variable "existing_acr_resource_id" { - type = string - default = "" -} - -variable "existing_acr_connection_name" { - type = string - default = "" -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf deleted file mode 100644 index fc5f3242e0c..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/container-registry.tf +++ /dev/null @@ -1,63 +0,0 @@ -# Azure Container Registry for hosted agents that use docker, wired as a -# connection on the Foundry project so the project identity can pull images. -# Premium SKU is required for Foundry registry connections. - -locals { - container_registry_name = "cr${local.resource_token}" - - # https://learn.microsoft.com/azure/role-based-access-control/built-in-roles - acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" - - project_principal_id = try(azapi_resource.project.output.identity.principalId, "") -} - -resource "azurerm_container_registry" "this" { - name = local.container_registry_name - resource_group_name = azurerm_resource_group.this.name - location = azurerm_resource_group.this.location - tags = var.tags - sku = "Premium" - admin_enabled = false - - identity { - type = "SystemAssigned" - } - - public_network_access_enabled = true - zone_redundancy_enabled = false -} - -# Grants the Foundry project identity AcrPull so hosted agents can pull images. -resource "azurerm_role_assignment" "foundry_acr_pull" { - scope = azurerm_container_registry.this.id - role_definition_id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.acr_pull_role_id}" - principal_id = local.project_principal_id - principal_type = "ServicePrincipal" -} - -# Project connection so Foundry can resolve the registry by name. -# Pinned to 2025-04-01-preview: GA 2025-06-01 cannot resolve the -# projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). -resource "azapi_resource" "acr_connection" { - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = "${local.container_registry_name}-conn" - parent_id = azapi_resource.project.id - - body = { - properties = { - category = "ContainerRegistry" - target = azurerm_container_registry.this.login_server - authType = "ManagedIdentity" - credentials = { - clientId = local.project_principal_id - resourceId = azurerm_container_registry.this.id - } - isSharedToAll = true - metadata = { - ResourceId = azurerm_container_registry.this.id - } - } - } - - depends_on = [azurerm_role_assignment.foundry_acr_pull] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf deleted file mode 100644 index 496589c5f78..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/main.tf +++ /dev/null @@ -1,125 +0,0 @@ -# Foundry (AIServices) account, its project, and model deployments. - -locals { - # Resource group name. Falls back to rg-{environment_name} when not provided. - resource_group_name = ( - var.resource_group_name != "" ? var.resource_group_name : "rg-${local.derived_rg_suffix}" - ) - derived_rg_suffix = trimsuffix( - substr(trim(coalesce(var.environment_name, "env"), "-."), 0, 87), - "-", - ) - - # Token to keep globally-unique resource names stable across re-provisions. - resource_token = substr(sha1(join("-", compact([ - var.subscription_id, - local.resource_group_name, - var.location, - var.resource_token_salt, - ]))), 0, 13) - - foundry_account_name = "cog-${local.resource_token}" - - # Project name. Falls back to a sanitized environment_name (3-32 chars) when - # not provided. - foundry_project_name = ( - var.foundry_project_name != "" ? var.foundry_project_name : local.derived_project_name - ) - - sanitized_env_name = trim( - replace(lower(coalesce(var.environment_name, "")), "/[^a-z0-9-]/", "-"), - "-", - ) - # substr errors when length exceeds the string, so only truncate when needed. - capped_env_name = ( - length(local.sanitized_env_name) > 32 - ? substr(local.sanitized_env_name, 0, 32) - : local.sanitized_env_name - ) - derived_project_name = ( - length(local.capped_env_name) >= 3 - ? local.capped_env_name - : (local.capped_env_name == "" ? "foundryproject" : "${local.capped_env_name}prj") - ) - -} - -resource "azurerm_resource_group" "this" { - name = local.resource_group_name - location = var.location - tags = var.tags -} - -# azapi is used so allowProjectManagement can be set (not exposed by -# azurerm_cognitive_account). -resource "azapi_resource" "foundry_account" { - type = "Microsoft.CognitiveServices/accounts@2025-06-01" - name = local.foundry_account_name - location = azurerm_resource_group.this.location - parent_id = azurerm_resource_group.this.id - tags = var.tags - - identity { - type = "SystemAssigned" - } - - body = { - kind = "AIServices" - sku = { - name = "S0" - } - properties = { - allowProjectManagement = true - customSubDomainName = local.foundry_account_name - publicNetworkAccess = "Enabled" - disableLocalAuth = true - networkAcls = { - defaultAction = "Allow" - virtualNetworkRules = [] - ipRules = [] - } - } - } -} - -# Created one at a time; ARM throttles concurrent deployments on one account. -resource "azurerm_cognitive_deployment" "model" { - for_each = { for d in var.deployments : d.name => d } - - name = each.value.name - cognitive_account_id = azapi_resource.foundry_account.id - - model { - format = each.value.model.format - name = each.value.model.name - version = each.value.model.version - } - - sku { - name = each.value.sku.name - capacity = each.value.sku.capacity - } -} - -# Created after all model deployments complete. -resource "azapi_resource" "project" { - type = "Microsoft.CognitiveServices/accounts/projects@2025-06-01" - name = local.foundry_project_name - location = azurerm_resource_group.this.location - parent_id = azapi_resource.foundry_account.id - - identity { - type = "SystemAssigned" - } - - body = { - properties = { - description = "${local.foundry_project_name} Project" - displayName = local.foundry_project_name - } - } - - response_export_values = ["identity.principalId"] - - depends_on = [azurerm_cognitive_deployment.model] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl deleted file mode 100644 index 4692b590571..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/outputs.tf.tmpl +++ /dev/null @@ -1,46 +0,0 @@ -# Outputs consumed by azd. Each value is written into the azd environment, so -# the names must stay stable. - -{{ if not .Layer }} -output "AZURE_RESOURCE_GROUP" { - value = local.resource_group_name -} -{{ end }} - -output "AZURE_FOUNDRY_RESOURCE_GROUP" { - value = local.resource_group_name -} - -output "AZURE_AI_PROJECT_ID" { - value = azapi_resource.project.id -} - -output "AZURE_AI_ACCOUNT_NAME" { - value = azapi_resource.foundry_account.name -} - -output "AZURE_AI_PROJECT_NAME" { - value = azapi_resource.project.name -} - -output "AZURE_OPENAI_ENDPOINT" { - value = "https://${azapi_resource.foundry_account.name}.openai.azure.com/" -} - -output "FOUNDRY_PROJECT_ENDPOINT" { - value = "https://${azapi_resource.foundry_account.name}.services.ai.azure.com/api/projects/${azapi_resource.project.name}" -} - -{{ if .IncludeAcr }} -output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { - value = azurerm_container_registry.this.login_server -} - -output "AZURE_CONTAINER_REGISTRY_RESOURCE_ID" { - value = azurerm_container_registry.this.id -} - -output "AZURE_AI_PROJECT_ACR_CONNECTION_NAME" { - value = azapi_resource.acr_connection.name -} -{{- end }} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf deleted file mode 100644 index 7cdd03f6382..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/variables.tf +++ /dev/null @@ -1,59 +0,0 @@ -variable "subscription_id" { - description = "Azure subscription id." - type = string -} - -variable "location" { - description = "Azure region for all resources." - type = string -} - -variable "resource_group_name" { - description = "Name of the resource group to create and deploy resources into." - type = string -} - -variable "environment_name" { - description = "azd environment name. Used to tag resources and to derive default names." - type = string -} - -variable "tags" { - description = "Tags applied to all resources." - type = map(string) - default = {} -} - -variable "resource_token_salt" { - description = "Optional salt to vary resource names across re-provisions." - type = string - default = "" -} - -variable "foundry_project_name" { - description = "Foundry project name (3-32 alphanumeric/hyphen chars). When empty, derived from environment_name." - type = string - default = "" - - validation { - condition = var.foundry_project_name == "" || can(regex("^[a-zA-Z0-9-]{3,32}$", var.foundry_project_name)) - error_message = "foundry_project_name must be empty or 3-32 alphanumeric/hyphen characters." - } -} - -variable "deployments" { - description = "Model deployments to provision on the Foundry account." - type = list(object({ - name = string - model = object({ - name = string - format = string - version = string - }) - sku = object({ - name = string - capacity = number - }) - })) - default = [] -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go deleted file mode 100644 index 66feb2ea6d3..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates_embed.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import "embed" - -// Re-generate main.arm.json from main.bicep. Run via `go generate ./...` -// from the extension root after editing any *.bicep file under templates/. -// The compiled ARM JSON is embedded so the provisioning provider never -// needs a bicep CLI at user runtime. -// -//go:generate bicep build templates/main.bicep --outfile templates/main.arm.json -//go:generate bicep build templates/existing-project.bicep --outfile templates/existing-project.arm.json - -//go:embed templates/main.bicep -//go:embed templates/main.arm.json -//go:embed templates/existing-project.bicep -//go:embed templates/existing-project-eject.bicep.tmpl -//go:embed templates/existing-project.arm.json -//go:embed templates/abbreviations.json -//go:embed templates/modules/*.bicep -//go:embed templates/modules/*.bicep.tmpl -var templatesFS embed.FS - -// terraformTemplatesFS holds the on-disk Terraform module emitted by -// `azd ai agent init --infra=terraform`. Unlike the Bicep templates there is -// no compile step (no ARM JSON to regenerate); azd-core's built-in Terraform -// provider consumes the .tf files directly at `azd provision`. -// -// container-registry.tf is copied only when an agent uses docker:; outputs.tf is generated -// from outputs.tf.tmpl (text/template) so the ACR outputs reference the -// registry resources only when container-registry.tf is present. main.tfvars.json is likewise -// generated at eject time. -// -//go:embed templates/terraform/*.tf -//go:embed templates/terraform/outputs.tf.tmpl -var terraformTemplatesFS embed.FS - -//go:embed templates/terraform-existing-project/*.tf -//go:embed templates/terraform-existing-project/outputs.tf.tmpl -var existingProjectTerraformTemplatesFS embed.FS - -// TemplatesFS exposes the embedded provisioning templates. Callers that -// only need the ready-to-deploy ARM JSON should prefer ARMTemplate(). -func TemplatesFS() embed.FS { return templatesFS } - -// TerraformTemplatesFS exposes the embedded Terraform module (the *.tf files -// and outputs.tf.tmpl under templates/terraform/). The eject step copies the -// static .tf files to ./infra/, renders outputs.tf from outputs.tf.tmpl, and -// generates main.tfvars.json alongside them. -func TerraformTemplatesFS() embed.FS { return terraformTemplatesFS } - -// ExistingProjectTerraformTemplatesFS exposes the Terraform module for an existing project. -func ExistingProjectTerraformTemplatesFS() embed.FS { return existingProjectTerraformTemplatesFS } - -// ARMTemplate returns the compiled ARM JSON for main.bicep. -func ARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/main.arm.json") -} - -// ExistingProjectARMTemplate returns the compiled editable existing-project graph. -func ExistingProjectARMTemplate() ([]byte, error) { - return templatesFS.ReadFile("templates/existing-project.arm.json") -} diff --git a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json index 17597d02b68..85787a810f7 100644 --- a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json +++ b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json @@ -33,6 +33,22 @@ } } }, + "monitoring": { + "type": "object", + "description": "Application Insights tracing. Enabled automatically for newly created projects; existing projects require applicationInsightsResourceId, and creating a component for an existing project is not supported. Referenced components and existing compatible connections are preserved.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable monitoring setup. False skips setup without deleting or disabling existing monitoring." + }, + "applicationInsightsResourceId": { + "type": "string", + "minLength": 1, + "description": "ARM resource ID of an existing workspace-backed Application Insights component. Enables monitoring and connects the project without creating or managing the component or its workspace. Supports ${VAR}." + } + } + }, "network": { "type": "object", "description": "Private networking for the Foundry account that backs this project. When omitted, the account uses public networking. When present, azd provisions an account private endpoint and disables public data-plane access.", diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_binding.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_binding.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_binding.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_binding.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_binding_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_binding_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_binding_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_binding_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go b/cli/azd/extensions/azure.ai.projects/synthesis/condition.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/condition.go rename to cli/azd/extensions/azure.ai.projects/synthesis/condition.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/condition_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/condition_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/condition_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/condition_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/deployment_bindings.go b/cli/azd/extensions/azure.ai.projects/synthesis/deployment_bindings.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/deployment_bindings.go rename to cli/azd/extensions/azure.ai.projects/synthesis/deployment_bindings.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/deployment_bindings_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/deployment_bindings_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/deployment_bindings_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/deployment_bindings_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/envrefs.go b/cli/azd/extensions/azure.ai.projects/synthesis/envrefs.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/envrefs.go rename to cli/azd/extensions/azure.ai.projects/synthesis/envrefs.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/existing_acr_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/existing_acr_test.go similarity index 98% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/existing_acr_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/existing_acr_test.go index e2685a9c01a..58972a8f3ba 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/existing_acr_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/existing_acr_test.go @@ -141,7 +141,9 @@ func TestARMExistingRegistryShape(t *testing.T) { require.NoError(t, json.Unmarshal(raw, &main)) require.Contains(t, main.Parameters, "existingAcrResourceId") assert.Equal(t, "", main.Parameters["existingAcrResourceId"].DefaultValue) - require.Len(t, main.Resources, 2) + require.Contains(t, main.Resources, "resources") + require.Contains(t, main.Resources, "monitoring") + require.Len(t, main.Resources, 3) var resourcesModule moduleProperties require.NoError(t, json.Unmarshal(main.Resources["resources"].Properties, &resourcesModule)) var resources template diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/go.mod b/cli/azd/extensions/azure.ai.projects/synthesis/go.mod new file mode 100644 index 00000000000..c28dda1f659 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/go.mod @@ -0,0 +1,108 @@ +module azure.ai.projects/synthesis + +go 1.26.4 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 + github.com/azure/azure-dev/cli/azd v1.37.0 + github.com/google/uuid v1.6.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/stretchr/testify v1.12.1 + go.yaml.in/yaml/v3 v3.0.5 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.6.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.9 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2/v2 v2.9.1 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.13.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mark3labs/mcp-go v0.58.0 // indirect + github.com/mattn/go-colorable v0.1.16 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.31 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20260406063853-3bac975de715 // indirect + github.com/pb33f/go-yaml v0.1.0 // indirect + github.com/pb33f/ordered-map/v2 v2.3.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sethvargo/go-retry v0.5.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/xo/terminfo v1.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.uber.org/atomic v1.12.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/go.sum b/cli/azd/extensions/azure.ai.projects/synthesis/go.sum new file mode 100644 index 00000000000..9fab8085b3f --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/go.sum @@ -0,0 +1,327 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0 h1:w3gDUJlJ0BfBsHH02DCBDwbkmMSXJqBj6nBOCo9iocY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0/go.mod h1:7scl8mG/IFLVjJ9ef/ltdND2T1z0CkgVSaQoUxLvmeU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 h1:pxphC/uRZKNHNPbZ0duDDgKkefju2F03OkG5xF6byHQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0/go.mod h1:twcwRey+l1znKBL5TEzYiZMtiVkWfM7Pq8a9vY04xYc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 h1:aMFOzch6ZJo4Ct9hI4A9Y2fPen5YNRTPmkSBhe5m0ZQ= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0/go.mod h1:Oct8bx+g+DXKngU7i/LzFzYt44rmLdMu4uoofIpooVo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 h1:0O6j18nQDoIff6/mtosT5LNU2WehEwp+LqfX6699oqY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1/go.mod h1:xdYAf5bjkOpsd3auA8riiJW4vneBubt9caavL7626jQ= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.37.0 h1:9zP/RCcEctpmfDzJnhtRfHWvKU7432aINLiasgQyvgo= +github.com/azure/azure-dev/cli/azd v1.37.0/go.mod h1:M96EZKUj4fpD69Jp9aqXkBwUZaoH6lHV4oxc6s4taOM= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs= +github.com/buger/jsonparser v1.6.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= +github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og= +github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ= +github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mark3labs/mcp-go v0.58.0 h1:AWfBk8lgRR0KZYve7PaLbR2MIjpw1oK2eGpBApaNS+Q= +github.com/mark3labs/mcp-go v0.58.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A= +github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo= +github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20260406063853-3bac975de715 h1:FiSC3sz/k2SYB32YJ1bLlBVB7Li0N7n6jcYa9Knq/60= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20260406063853-3bac975de715/go.mod h1:DQpEqvLUU3b35AJUVu5P6LFn/RY3qj4WtMjvrcE8gto= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pb33f/go-yaml v0.1.0 h1:zZ6Gg+ATbumXlXZNLMHIe7+zTkVqe20lx+TSYRrXEuY= +github.com/pb33f/go-yaml v0.1.0/go.mod h1:QvWdkHP5VjFYAeHMQUkaPQqREaQnBNOXmWzAjNrnSdo= +github.com/pb33f/ordered-map/v2 v2.3.2 h1:wDyaZ2Pv9QLh64X4utCeD5Zoi9nIv2aW3lwv172O5PQ= +github.com/pb33f/ordered-map/v2 v2.3.2/go.mod h1:1OhFrXu3OYw3kM+FXF+Ug3ugmmZ9LE8z0JfQMLvtV0w= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY= +github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.8.0 h1:rm9oMkeprG/wHifkZP4EuDww9S0mvEaQ/p9cXTNT4cw= +github.com/stretchr/objx v0.8.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/hosting.go b/cli/azd/extensions/azure.ai.projects/synthesis/hosting.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/hosting.go rename to cli/azd/extensions/azure.ai.projects/synthesis/hosting.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go similarity index 96% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go index 26818962578..6023744a815 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go @@ -195,9 +195,13 @@ func TestHostingGraphUsesOneNativeRegistry(t *testing.T) { assert.Equal(t, true, properties["supportsHttpsTrafficOnly"]) assert.Equal(t, "TLS1_2", properties["minimumTlsVersion"]) case "Microsoft.CognitiveServices/accounts/projects/connections": - connections++ properties, ok := value["properties"].(map[string]any) require.True(t, ok) + // The monitoring graph adds its own AppInsights connection. + if properties["category"] == "AppInsights" { + break + } + connections++ assert.Equal(t, "ContainerRegistry", properties["category"]) assert.Equal(t, "ManagedIdentity", properties["authType"]) case "Microsoft.Authorization/roleAssignments": @@ -237,23 +241,25 @@ func TestHostingGraphBindsDefaultsBeforeAccountCreation(t *testing.T) { var graph struct { Parameters map[string]json.RawMessage Outputs map[string]json.RawMessage - Resources map[string]struct { - Properties struct { - Template struct { - Variables map[string]json.RawMessage - Resources map[string]struct { - APIVersion string - Condition string - DependsOn []string - Properties json.RawMessage - } + Resources map[string]json.RawMessage + } + require.NoError(t, json.Unmarshal(raw, &graph)) + require.Contains(t, graph.Resources, "resources") + var foundryModule struct { + Properties struct { + Template struct { + Variables map[string]json.RawMessage + Resources map[string]struct { + APIVersion string + Condition string + DependsOn []string + Properties json.RawMessage } } } } - require.NoError(t, json.Unmarshal(raw, &graph)) - require.Contains(t, graph.Resources, "resources") - foundry := graph.Resources["resources"].Properties.Template + require.NoError(t, json.Unmarshal(graph.Resources["resources"], &foundryModule)) + foundry := foundryModule.Properties.Template require.Contains(t, foundry.Resources, "prerequisites") assert.Equal(t, "[parameters('agentHosting').enabled]", foundry.Resources["prerequisites"].Condition) for name, expected := range map[string]string{ diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring.go new file mode 100644 index 00000000000..0a0635f91da --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring.go @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "go.yaml.in/yaml/v3" +) + +// monitoringSettings is the parsed monitoring block of an azure.ai.project +// service, before validation. +type monitoringSettings struct { + declared bool + enabled bool + id string + hasID bool +} + +// newProjectMonitoringParameters enables monitoring unless the block disables it. +func newProjectMonitoringParameters(node yaml.Node, in Input) (map[string]any, error) { + if in.InfrastructureOwnsMonitoring { + return map[string]any{}, nil + } + settings, err := parseMonitoringSettings(node) + if err != nil { + return nil, err + } + return buildMonitoringParameters(settings, !settings.declared || settings.enabled, in) +} + +// existingProjectMonitoringParameters enables monitoring only when the block +// is declared and does not disable it. Creating a new component for an +// existing project is not supported, so an enabled block must name one. +func existingProjectMonitoringParameters(node yaml.Node, in Input) (map[string]any, error) { + if in.InfrastructureOwnsMonitoring { + return map[string]any{}, nil + } + settings, err := parseMonitoringSettings(node) + if err != nil { + return nil, err + } + params, err := buildMonitoringParameters(settings, settings.declared && settings.enabled, in) + if err != nil { + return nil, err + } + if params["monitoringMode"] == "create" { + return nil, fmt.Errorf("monitoring for an existing Foundry project requires " + + "monitoring.applicationInsightsResourceId; creating Application Insights for an existing project is not supported") + } + return params, nil +} + +func buildMonitoringParameters(settings monitoringSettings, enabled bool, in Input) (map[string]any, error) { + params := map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} + if !enabled { + return params, nil + } + params["monitoringMode"] = "create" + if settings.id == "" { + if settings.hasID { + return nil, fmt.Errorf("monitoring.applicationInsightsResourceId must be a nonempty component ID") + } + return params, nil + } + id, err := resolveMonitoringResourceID(settings.id, in) + if err != nil { + return nil, err + } + params["monitoringMode"] = "reuse-connect" + params["applicationInsightsResourceId"] = id + return params, nil +} + +func parseMonitoringSettings(node yaml.Node) (monitoringSettings, error) { + var settings monitoringSettings + if node.Kind == 0 { + return settings, nil + } + if node.Kind != yaml.MappingNode { + return settings, fmt.Errorf("monitoring must be an object") + } + var fields map[string]yaml.Node + if err := node.Decode(&fields); err != nil { + return settings, fmt.Errorf("read monitoring configuration: %w", err) + } + settings.declared = true + settings.enabled = true + for name, value := range fields { + switch name { + case "enabled": + if value.Tag != "!!bool" { + return settings, fmt.Errorf("monitoring.enabled must be a boolean") + } + if err := value.Decode(&settings.enabled); err != nil { + return settings, fmt.Errorf("read monitoring.enabled: %w", err) + } + case "applicationInsightsResourceId": + settings.hasID = true + if value.Tag != "!!str" { + return settings, fmt.Errorf("monitoring.applicationInsightsResourceId must be a string") + } + settings.id = strings.TrimSpace(value.Value) + default: + return settings, fmt.Errorf("unknown monitoring setting %q", name) + } + } + return settings, nil +} + +// resolveMonitoringResourceID validates variable references and, unless the +// reference is deliberately preserved, resolves and validates the component ID. +func resolveMonitoringResourceID(id string, in Input) (string, error) { + if err := ValidateEnvReferences(id); err != nil { + return "", fmt.Errorf("monitoring.applicationInsightsResourceId: %w", err) + } + if in.PreserveVarRefs && containsVarRef(id) { + return id, nil + } + id, err := resolveVars(id, in.Env) + if err != nil { + return "", fmt.Errorf("resolve monitoring.applicationInsightsResourceId: %w", err) + } + id = strings.TrimSpace(id) + if id == "" { + return "", fmt.Errorf("monitoring.applicationInsightsResourceId must resolve to a nonempty component ID") + } + resource, err := arm.ParseResourceID(id) + if err != nil || resource.SubscriptionID == "" || resource.ResourceGroupName == "" || + !strings.EqualFold(resource.ResourceType.String(), "Microsoft.Insights/components") { + return "", fmt.Errorf("monitoring.applicationInsightsResourceId must identify an Application Insights component") + } + return id, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery.go new file mode 100644 index 00000000000..8b803b88597 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery.go @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" +) + +// MonitoringConnection identifies an effective connection without its credentials. +type MonitoringConnection struct { + Name string + Target string +} + +// connectionSelector accumulates the single effective AppInsights destination. +type connectionSelector struct { + reserved string + selected *MonitoringConnection +} + +func (s *connectionSelector) add(name string, properties *armcognitiveservices.ConnectionPropertiesV2) error { + isMonitoring, err := validateReusableAppInsightsConnection(name, s.reserved, properties) + if err != nil || !isMonitoring { + return err + } + if s.selected != nil && !strings.EqualFold(s.selected.Target, *properties.Target) { + return fmt.Errorf("effective AppInsights connections have conflicting destinations; no connection was changed") + } + if s.selected == nil { + s.selected = &MonitoringConnection{Name: name, Target: *properties.Target} + } + return nil +} + +// DiscoverConnection resolves effective project/inherited connections without fetching credentials. +func (p *MonitoringResolver) DiscoverConnection( + ctx context.Context, project *arm.ResourceID, projectExists, accountExists bool, +) (*MonitoringConnection, error) { + reader := p.reader() + selector := &connectionSelector{reserved: MonitoringConnectionName(project.Name)} + projectConnections := map[string]*armcognitiveservices.ConnectionPropertiesV2{} + if projectExists { + records, err := reader.ProjectConnections(ctx, project) + if err != nil { + return nil, err + } + for _, record := range records { + if err := selector.add(record.Name, record.Properties); err != nil { + return nil, err + } + projectConnections[strings.ToLower(record.Name)] = record.Properties + } + } + if accountExists { + records, err := reader.AccountConnections(ctx, project) + if err != nil { + return nil, err + } + for _, record := range records { + if err := selectAccountConnection(selector, record, projectConnections); err != nil { + return nil, err + } + } + } + if selector.selected == nil && projectExists { + exists, err := reader.ProjectConnectionExists(ctx, project, selector.reserved) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("the system monitoring connection name is already in use") + } + } + return selector.selected, nil +} + +func selectAccountConnection( + selector *connectionSelector, + record MonitoringConnectionRecord, + projectConnections map[string]*armcognitiveservices.ConnectionPropertiesV2, +) error { + name, properties := record.Name, record.Properties + if properties == nil || properties.Category == nil { + return fmt.Errorf("account connection properties are unavailable") + } + shared := properties.IsSharedToAll != nil && *properties.IsSharedToAll + if prior := projectConnections[strings.ToLower(name)]; prior != nil && !shared { + // ARM mirrors project entries at account scope with different IDs. + sameTarget := properties.Target == nil && prior.Target == nil + if properties.Target != nil && prior.Target != nil { + sameTarget = strings.EqualFold(*properties.Target, *prior.Target) + } + if !sameTarget || !strings.EqualFold(string(*properties.Category), string(*prior.Category)) { + return fmt.Errorf("project and account connection identities are ambiguous") + } + return nil + } + if !shared { + if strings.EqualFold(name, selector.reserved) { + return fmt.Errorf("the system monitoring name is protected by another account connection") + } + if strings.EqualFold(string(*properties.Category), "AppInsights") && + (properties.IsSharedToAll == nil || len(properties.SharedUserList) > 0) { + return fmt.Errorf("restricted account monitoring sharing cannot be resolved safely") + } + // An unshared connection belonging to a sibling project is not inherited. + return nil + } + return selector.add(name, properties) +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery_test.go new file mode 100644 index 00000000000..5e601bc9c1c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_discovery_test.go @@ -0,0 +1,539 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testSub = "00000000-0000-0000-0000-000000000001" + testProjectID = "/subscriptions/" + testSub + + "/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj" + testAccountID = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct" + testComponentID = "/subscriptions/" + testSub + "/resourceGroups/mon/providers/Microsoft.Insights/components/ai" + testOtherID = "/subscriptions/" + testSub + "/resourceGroups/mon/providers/Microsoft.Insights/components/other" + testWorkspaceID = "/subscriptions/" + testSub + + "/resourceGroups/mon/providers/Microsoft.OperationalInsights/workspaces/law" +) + +type fakeReader struct { + resources map[string]*armresources.GenericResource + resourceErr error + project []MonitoringConnectionRecord + projectErr error + account []MonitoringConnectionRecord + accountErr error + nameExists bool + nameExistsErr error +} + +func (f *fakeReader) Resource( + _ context.Context, id *arm.ResourceID, _ string, +) (*armresources.GenericResource, bool, error) { + if f.resourceErr != nil { + return nil, false, f.resourceErr + } + resource, ok := f.resources[strings.ToLower(id.String())] + return resource, ok, nil +} + +func (f *fakeReader) ProjectConnections(context.Context, *arm.ResourceID) ([]MonitoringConnectionRecord, error) { + return f.project, f.projectErr +} + +func (f *fakeReader) AccountConnections(context.Context, *arm.ResourceID) ([]MonitoringConnectionRecord, error) { + return f.account, f.accountErr +} + +func (f *fakeReader) ProjectConnectionExists(context.Context, *arm.ResourceID, string) (bool, error) { + return f.nameExists, f.nameExistsErr +} + +func armResource(id string, properties map[string]any) *armresources.GenericResource { + return &armresources.GenericResource{ID: &id, Properties: properties} +} + +func healthyReader() *fakeReader { + return &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testProjectID): armResource(testProjectID, map[string]any{}), + strings.ToLower(testAccountID): armResource(testAccountID, map[string]any{}), + strings.ToLower(testComponentID): armResource(testComponentID, map[string]any{ + "WorkspaceResourceId": testWorkspaceID, "ConnectionString": "InstrumentationKey=k", + }), + strings.ToLower(testWorkspaceID): armResource(testWorkspaceID, map[string]any{}), + }} +} + +func connRecord(name, category, target, auth string, shared *bool) MonitoringConnectionRecord { + cat := armcognitiveservices.ConnectionCategory(category) + properties := &armcognitiveservices.ConnectionPropertiesV2{Category: &cat, IsSharedToAll: shared} + if target != "" { + properties.Target = &target + } + if auth != "" { + authType := armcognitiveservices.ConnectionAuthType(auth) + properties.AuthType = &authType + } + return MonitoringConnectionRecord{Name: name, Properties: properties} +} + +func boolPtr(value bool) *bool { return &value } + +func TestMonitoringConnectionNameIsStableAndBounded(t *testing.T) { + t.Parallel() + name := MonitoringConnectionName("Proj") + assert.Equal(t, name, MonitoringConnectionName("proj")) + assert.NotEqual(t, name, MonitoringConnectionName("proj2")) + assert.True(t, strings.HasPrefix(name, "azd-appinsights-")) + assert.Len(t, name, len("azd-appinsights-")+13) +} + +func TestMonitoringResourceID(t *testing.T) { + t.Parallel() + _, err := MonitoringResourceID(testComponentID, "Microsoft.Insights/components") + require.NoError(t, err) + for _, bad := range []string{"", "not-an-id", testAccountID} { + _, err := MonitoringResourceID(bad, "Microsoft.Insights/components") + assert.Error(t, err, bad) + } +} + +func TestMonitoringReadErrorSanitizes(t *testing.T) { + t.Parallel() + secret := errors.New("body contains InstrumentationKey=secret") + assert.NotContains(t, MonitoringReadError("op", secret).Error(), "secret") + assert.ErrorIs(t, MonitoringReadError("op", fmt.Errorf("wrap: %w", context.Canceled)), context.Canceled) + assert.ErrorIs(t, MonitoringReadError("op", context.DeadlineExceeded), context.DeadlineExceeded) + response := &azcore.ResponseError{StatusCode: 403} + assert.Contains(t, MonitoringReadError("op", response).Error(), "HTTP 403") + assert.True(t, monitoringNotFound(&azcore.ResponseError{StatusCode: 404})) + assert.False(t, monitoringNotFound(response)) + assert.False(t, monitoringNotFound(secret)) +} + +func TestMonitoringPolicies(t *testing.T) { + t.Parallel() + _, err := MonitoringProperties(&armresources.GenericResource{}) + assert.Error(t, err) + properties, err := MonitoringProperties(armResource("x", map[string]any{"a": 1})) + require.NoError(t, err) + assert.Equal(t, 1, properties["a"]) + + for name, props := range map[string]map[string]any{ + "local auth disabled": {"DisableLocalAuth": true}, + "local auth wrong type": {"DisableLocalAuth": "yes"}, + "features wrong type": {"features": "x"}, + "workspace local disabled": {"features": map[string]any{"disableLocalAuth": true}}, + "ingestion disabled": {"publicNetworkAccessForIngestion": "Disabled"}, + "access wrong type": {"publicNetworkAccess": 5}, + "acls wrong type": {"networkAcls": "x"}, + "acl deny": {"networkAcls": map[string]any{"defaultAction": "Deny"}}, + } { + assert.Error(t, MonitoringKeyPolicy(props), name) + } + assert.NoError(t, MonitoringKeyPolicy(map[string]any{ + "DisableLocalAuth": false, "publicNetworkAccessForIngestion": "enabled", + "features": map[string]any{"disableLocalAuth": false}, + "networkAcls": map[string]any{"defaultAction": "Allow"}, + })) +} + +func TestValidateReusableAppInsightsConnection(t *testing.T) { + t.Parallel() + reserved := MonitoringConnectionName("proj") + good := connRecord("ai", "AppInsights", testComponentID, "ApiKey", nil) + isMonitoring, err := validateReusableAppInsightsConnection("ai", reserved, good.Properties) + require.NoError(t, err) + assert.True(t, isMonitoring) + + other := connRecord("storage", "AzureStorageAccount", "https://x", "AAD", nil) + isMonitoring, err = validateReusableAppInsightsConnection("storage", reserved, other.Properties) + require.NoError(t, err) + assert.False(t, isMonitoring) + + _, err = validateReusableAppInsightsConnection("x", reserved, nil) + assert.Error(t, err) + _, err = validateReusableAppInsightsConnection(reserved, reserved, other.Properties) + assert.ErrorContains(t, err, "already in use") + missing := connRecord("ai", "AppInsights", "", "ApiKey", nil) + _, err = validateReusableAppInsightsConnection("ai", reserved, missing.Properties) + assert.ErrorContains(t, err, "lacks") + badTarget := connRecord("ai", "AppInsights", testAccountID, "ApiKey", nil) + _, err = validateReusableAppInsightsConnection("ai", reserved, badTarget.Properties) + assert.Error(t, err) + mismatch := connRecord("ai", "AppInsights", testComponentID, "ApiKey", nil) + metadataID := testWorkspaceID + mismatch.Properties.Metadata = map[string]*string{"ResourceId": &metadataID} + _, err = validateReusableAppInsightsConnection("ai", reserved, mismatch.Properties) + assert.ErrorContains(t, err, "disagree") + badAuth := connRecord("ai", "AppInsights", testComponentID, "CustomKeys", nil) + _, err = validateReusableAppInsightsConnection("ai", reserved, badAuth.Properties) + assert.ErrorContains(t, err, "authentication") +} + +func TestMonitoringResolverResourceIdentity(t *testing.T) { + t.Parallel() + componentID, err := arm.ParseResourceID(testComponentID) + require.NoError(t, err) + reader := healthyReader() + resolver := &MonitoringResolver{Reader: reader} + got, found, err := resolver.Resource(t.Context(), componentID, "v") + require.NoError(t, err) + assert.True(t, found) + assert.NotNil(t, got) + + missing, err := arm.ParseResourceID(testOtherID) + require.NoError(t, err) + _, found, err = resolver.Resource(t.Context(), missing, "v") + require.NoError(t, err) + assert.False(t, found) + + wrong := "/subscriptions/other" + reader.resources[strings.ToLower(testComponentID)] = &armresources.GenericResource{ID: &wrong} + _, _, err = resolver.Resource(t.Context(), componentID, "v") + assert.ErrorContains(t, err, "unexpected resource identity") + + reader.resourceErr = errors.New("boom") + _, _, err = resolver.Resource(t.Context(), componentID, "v") + assert.Error(t, err) +} + +func TestMonitoringResolverReaderSelection(t *testing.T) { + t.Parallel() + assert.IsType(t, &armMonitoringReader{}, (&MonitoringResolver{}).reader()) + custom := &fakeReader{} + assert.Same(t, custom, (&MonitoringResolver{Reader: custom}).reader()) +} + +func TestValidateComponent(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(*fakeReader) + id string + key bool + wantErr string + }{ + {name: "healthy with key", id: testComponentID, key: true}, + {name: "healthy without key", id: testComponentID}, + {name: "bad id", id: "nope", wantErr: "resource ID"}, + { + name: "missing component", id: testComponentID, wantErr: "does not exist", + mutate: func(f *fakeReader) { delete(f.resources, strings.ToLower(testComponentID)) }, + }, + { + name: "no workspace", id: testComponentID, wantErr: "Log Analytics", + mutate: func(f *fakeReader) { + f.resources[strings.ToLower(testComponentID)] = armResource(testComponentID, map[string]any{}) + }, + }, + { + name: "missing workspace", id: testComponentID, wantErr: "workspace does not exist", + mutate: func(f *fakeReader) { delete(f.resources, strings.ToLower(testWorkspaceID)) }, + }, + { + name: "local auth disabled", id: testComponentID, key: true, wantErr: "local authentication", + mutate: func(f *fakeReader) { + f.resources[strings.ToLower(testComponentID)] = armResource(testComponentID, map[string]any{ + "WorkspaceResourceId": testWorkspaceID, "ConnectionString": "k", "DisableLocalAuth": true, + }) + }, + }, + { + name: "no connection string", id: testComponentID, key: true, wantErr: "no connection string", + mutate: func(f *fakeReader) { + f.resources[strings.ToLower(testComponentID)] = armResource(testComponentID, map[string]any{ + "WorkspaceResourceId": testWorkspaceID, + }) + }, + }, + { + name: "workspace local auth disabled", id: testComponentID, key: true, wantErr: "workspace local", + mutate: func(f *fakeReader) { + f.resources[strings.ToLower(testWorkspaceID)] = armResource(testWorkspaceID, map[string]any{ + "features": map[string]any{"disableLocalAuth": true}, + }) + }, + }, + { + name: "read failure", id: testComponentID, wantErr: "boom", + mutate: func(f *fakeReader) { f.resourceErr = errors.New("boom") }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := healthyReader() + if tt.mutate != nil { + tt.mutate(reader) + } + err := (&MonitoringResolver{Reader: reader}).ValidateComponent(t.Context(), tt.id, tt.key) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestDiscoverConnection(t *testing.T) { + t.Parallel() + project, err := arm.ParseResourceID(testProjectID) + require.NoError(t, err) + reserved := MonitoringConnectionName("proj") + appInsights := func(name string, shared *bool) MonitoringConnectionRecord { + return connRecord(name, "AppInsights", testComponentID, "ApiKey", shared) + } + storage := func(name string, shared *bool) MonitoringConnectionRecord { + return connRecord(name, "AzureStorageAccount", "https://x", "AAD", shared) + } + tests := []struct { + name string + reader *fakeReader + projectExists bool + accountExists bool + wantTarget string + wantErr string + }{ + {name: "none", reader: &fakeReader{}, projectExists: true, accountExists: true}, + { + name: "project connection", + reader: &fakeReader{project: []MonitoringConnectionRecord{appInsights("a", nil)}}, + projectExists: true, wantTarget: testComponentID, + }, + { + name: "shared account connection", + reader: &fakeReader{account: []MonitoringConnectionRecord{appInsights("a", boolPtr(true))}}, + accountExists: true, wantTarget: testComponentID, + }, + { + name: "mirrored entry is not double counted", + reader: &fakeReader{ + project: []MonitoringConnectionRecord{appInsights("a", nil)}, + account: []MonitoringConnectionRecord{appInsights("A", boolPtr(false))}, + }, + projectExists: true, accountExists: true, wantTarget: testComponentID, + }, + { + name: "mirrored entry with different target is ambiguous", + reader: &fakeReader{ + project: []MonitoringConnectionRecord{appInsights("a", nil)}, + account: []MonitoringConnectionRecord{connRecord("a", "AppInsights", testOtherID, "ApiKey", boolPtr(false))}, + }, + projectExists: true, accountExists: true, wantErr: "ambiguous", + }, + { + name: "conflicting destinations", + reader: &fakeReader{project: []MonitoringConnectionRecord{ + appInsights("a", nil), connRecord("b", "AppInsights", testOtherID, "ApiKey", nil), + }}, + projectExists: true, wantErr: "conflicting", + }, + { + name: "reserved name used by other category", + reader: &fakeReader{project: []MonitoringConnectionRecord{storage(reserved, nil)}}, + projectExists: true, wantErr: "already in use", + }, + { + name: "reserved name exists without listing", + reader: &fakeReader{nameExists: true}, + projectExists: true, wantErr: "already in use", + }, + { + name: "name check failure", + reader: &fakeReader{nameExistsErr: errors.New("boom")}, + projectExists: true, wantErr: "boom", + }, + { + name: "unshared sibling connection ignored", + reader: &fakeReader{account: []MonitoringConnectionRecord{storage("sibling", boolPtr(false))}}, + accountExists: true, + }, + { + name: "reserved name protected at account", + reader: &fakeReader{account: []MonitoringConnectionRecord{storage(reserved, boolPtr(false))}}, + accountExists: true, wantErr: "protected", + }, + { + name: "restricted account sharing", + reader: &fakeReader{account: []MonitoringConnectionRecord{appInsights("a", nil)}}, + accountExists: true, wantErr: "restricted", + }, + { + name: "account connection without properties", + reader: &fakeReader{account: []MonitoringConnectionRecord{{Name: "x"}}}, + accountExists: true, wantErr: "unavailable", + }, + { + name: "project list failure", reader: &fakeReader{projectErr: errors.New("boom")}, + projectExists: true, wantErr: "boom", + }, + { + name: "account list failure", reader: &fakeReader{accountErr: errors.New("boom")}, + accountExists: true, wantErr: "boom", + }, + {name: "absent scopes are not read", reader: &fakeReader{projectErr: errors.New("boom"), accountErr: errors.New("boom")}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := (&MonitoringResolver{Reader: tt.reader}).DiscoverConnection( + t.Context(), project, tt.projectExists, tt.accountExists) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantTarget == "" { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.Equal(t, tt.wantTarget, got.Target) + }) + } +} + +func TestResolveTerraformExport(t *testing.T) { + t.Parallel() + reuse := func() map[string]any { + return map[string]any{"monitoringMode": "reuse-connect", "applicationInsightsResourceId": testComponentID} + } + connected := func(f *fakeReader) { + f.project = []MonitoringConnectionRecord{connRecord("a", "AppInsights", testComponentID, "AAD", nil)} + } + tests := []struct { + name string + params map[string]any + environ map[string]string + project string + mutate func(*fakeReader) + wantErr string + wantMode string + wantTarget string + }{ + {name: "none is a no-op", params: map[string]any{"monitoringMode": "none"}, project: testProjectID, wantMode: "none"}, + {name: "invalid mode", params: map[string]any{"monitoringMode": "bogus"}, project: testProjectID, wantErr: "invalid monitoringMode"}, + {name: "bad project id", params: reuse(), project: "x", wantErr: "ARM resource ID"}, + { + name: "different saved project", params: reuse(), project: testProjectID, + environ: map[string]string{"AZURE_AI_PROJECT_ID": testProjectID + "2"}, wantErr: "different Foundry project", + }, + { + name: "previously created monitoring cannot be adopted", + params: map[string]any{"monitoringMode": "create", "applicationInsightsResourceId": ""}, + environ: map[string]string{ + "AZD_FOUNDRY_MONITORING_MODE": "create", "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": testComponentID, + }, + project: testProjectID, wantErr: "cannot adopt", + }, + { + name: "component must be a string", params: map[string]any{"monitoringMode": "reuse-connect"}, + project: testProjectID, wantErr: "component resource ID string", + }, + { + name: "environment reference resolves", + params: map[string]any{"monitoringMode": "reuse-connect", "applicationInsightsResourceId": "${AI_ID}"}, + environ: map[string]string{"AI_ID": testComponentID}, project: testProjectID, wantMode: "reuse-connect", + }, + { + name: "unresolved reference", + params: map[string]any{"monitoringMode": "reuse-connect", "applicationInsightsResourceId": "${MISSING_ID}"}, + project: testProjectID, wantErr: "MISSING_ID", + }, + { + name: "project missing", params: reuse(), project: testProjectID, wantErr: "project does not exist", + mutate: func(f *fakeReader) { delete(f.resources, strings.ToLower(testProjectID)) }, + }, + { + name: "account missing", params: reuse(), project: testProjectID, wantErr: "account does not exist", + mutate: func(f *fakeReader) { delete(f.resources, strings.ToLower(testAccountID)) }, + }, + { + name: "existing connection upgrades to already-connected", params: reuse(), project: testProjectID, + mutate: connected, wantMode: "already-connected", wantTarget: testComponentID, + }, + { + name: "existing connection fills missing component", + params: map[string]any{"monitoringMode": "already-connected", "applicationInsightsResourceId": ""}, + project: testProjectID, mutate: connected, wantMode: "already-connected", wantTarget: testComponentID, + }, + { + name: "component conflicts with effective connection", params: reuse(), project: testProjectID, + wantErr: "conflicts", + mutate: func(f *fakeReader) { + f.project = []MonitoringConnectionRecord{connRecord("a", "AppInsights", testOtherID, "AAD", nil)} + }, + }, + { + name: "declared connection absent", + params: map[string]any{"monitoringMode": "already-connected", "applicationInsightsResourceId": testComponentID}, + project: testProjectID, wantErr: "was not found", + }, + { + name: "restricted project network", params: reuse(), project: testProjectID, + wantErr: "restricted public ingestion", + mutate: func(f *fakeReader) { + f.resources[strings.ToLower(testProjectID)] = armResource(testProjectID, map[string]any{ + "publicNetworkAccess": "Disabled", + }) + }, + }, + { + name: "reuse-connect without component", + params: map[string]any{"monitoringMode": "reuse-connect", "applicationInsightsResourceId": ""}, + project: testProjectID, wantErr: "nonempty", + }, + { + name: "reuse-connect validates component", params: reuse(), project: testProjectID, wantErr: "does not exist", + mutate: func(f *fakeReader) { delete(f.resources, strings.ToLower(testComponentID)) }, + }, + {name: "reuse-connect happy path", params: reuse(), project: testProjectID, wantMode: "reuse-connect"}, + { + name: "read failure", params: reuse(), project: testProjectID, wantErr: "boom", + mutate: func(f *fakeReader) { f.resourceErr = errors.New("boom") }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := healthyReader() + if tt.mutate != nil { + tt.mutate(reader) + } + environ := tt.environ + if environ == nil { + environ = map[string]string{} + } + err := (&MonitoringResolver{Reader: reader}).ResolveTerraformExport( + t.Context(), tt.params, tt.project, environ) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantMode, tt.params["monitoringMode"]) + if tt.wantTarget != "" { + assert.Equal(t, tt.wantTarget, tt.params["applicationInsightsResourceId"]) + } + }) + } +} + +func TestResolveTerraformMonitoringExportSkipsWhenDisabled(t *testing.T) { + t.Parallel() + for _, parameters := range []map[string]any{{}, {"monitoringMode": "none"}} { + require.NoError(t, ResolveTerraformMonitoringExport(t.Context(), parameters, testProjectID, nil, nil)) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go new file mode 100644 index 00000000000..4049984bffe --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// MonitoringProperties requires a successfully decoded policy object. +func MonitoringProperties(resource *armresources.GenericResource) (map[string]any, error) { + properties, ok := resource.Properties.(map[string]any) + if !ok { + return nil, fmt.Errorf("monitoring resource policy is unavailable") + } + return properties, nil +} + +// MonitoringKeyPolicy rejects policy changes needed to enable ApiKey ingestion. +func MonitoringKeyPolicy(properties map[string]any) error { + if value, present := properties["DisableLocalAuth"]; present { + disabled, ok := value.(bool) + if !ok || disabled { + return fmt.Errorf("automatic ApiKey monitoring cannot change or bypass disabled local authentication") + } + } + if features, present := properties["features"]; present { + values, ok := features.(map[string]any) + if !ok { + return fmt.Errorf("monitoring workspace authentication policy is unavailable") + } + if disabled, present := values["disableLocalAuth"]; present && disabled != false { + return fmt.Errorf("automatic ApiKey monitoring cannot change or bypass disabled workspace local authentication") + } + } + return MonitoringNetworkPolicy(properties) +} + +// MonitoringNetworkPolicy rejects unsupported private/restricted ingestion. +func MonitoringNetworkPolicy(properties map[string]any) error { + for _, name := range []string{"publicNetworkAccessForIngestion", "publicNetworkAccess"} { + if value, present := properties[name]; present { + access, ok := value.(string) + if !ok || !strings.EqualFold(access, "Enabled") { + return fmt.Errorf("automatic monitoring cannot change or bypass restricted public ingestion") + } + } + } + if acls, present := properties["networkAcls"]; present { + values, ok := acls.(map[string]any) + if !ok { + return fmt.Errorf("monitoring network policy cannot be resolved") + } + if action, present := values["defaultAction"]; present && action != "Allow" { + return fmt.Errorf("automatic monitoring does not configure private-network ingestion") + } + } + return nil +} + +// validateReusableAppInsightsConnection reports whether a connection is an +// AppInsights connection and, if so, whether its target and authentication can +// be reused automatically. +func validateReusableAppInsightsConnection( + name, reserved string, properties *armcognitiveservices.ConnectionPropertiesV2, +) (isMonitoring bool, err error) { + if properties == nil || properties.Category == nil { + return false, fmt.Errorf("connection properties are unavailable; monitoring discovery is incomplete") + } + isMonitoring = strings.EqualFold(string(*properties.Category), "AppInsights") + if strings.EqualFold(name, reserved) && !isMonitoring { + return false, fmt.Errorf("the system monitoring connection name is already in use; no connection was changed") + } + if !isMonitoring { + return false, nil + } + if properties.Target == nil || properties.AuthType == nil { + return true, fmt.Errorf("existing AppInsights connection lacks its target or authentication type") + } + if _, err := MonitoringResourceID(*properties.Target, "Microsoft.Insights/components"); err != nil { + return true, err + } + if resourceID := properties.Metadata["ResourceId"]; resourceID != nil && + !strings.EqualFold(*resourceID, *properties.Target) { + return true, fmt.Errorf("existing AppInsights target and resource metadata disagree") + } + auth := string(*properties.AuthType) + if !strings.EqualFold(auth, "ApiKey") && !strings.EqualFold(auth, "AAD") && + !strings.EqualFold(auth, "ManagedIdentity") { + return true, fmt.Errorf("existing AppInsights authentication cannot be reused automatically") + } + return true, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_reader.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_reader.go new file mode 100644 index 00000000000..abdeb884fb7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_reader.go @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// MonitoringConnectionRecord is one connection returned by a list call. +type MonitoringConnectionRecord struct { + Name string + Properties *armcognitiveservices.ConnectionPropertiesV2 +} + +// MonitoringReader is the read-only Azure surface used by monitoring discovery +// and validation. Implementations must return sanitized errors and must report +// found=false / exists=false only for a genuine 404. +type MonitoringReader interface { + Resource(ctx context.Context, id *arm.ResourceID, version string) (*armresources.GenericResource, bool, error) + ProjectConnections(ctx context.Context, project *arm.ResourceID) ([]MonitoringConnectionRecord, error) + AccountConnections(ctx context.Context, project *arm.ResourceID) ([]MonitoringConnectionRecord, error) + ProjectConnectionExists(ctx context.Context, project *arm.ResourceID, name string) (bool, error) +} + +// MonitoringResourceID validates the exact ARM resource type and scope. +func MonitoringResourceID(value, resourceType string) (*arm.ResourceID, error) { + id, err := arm.ParseResourceID(value) + if err != nil || id.SubscriptionID == "" || id.ResourceGroupName == "" || + !strings.EqualFold(id.ResourceType.String(), resourceType) { + return nil, fmt.Errorf("monitoring requires a %s resource ID", resourceType) + } + return id, nil +} + +// MonitoringReadError sanitizes SDK errors, whose bodies can contain credentials. +func MonitoringReadError(operation string, err error) error { + for _, cancellation := range []error{context.Canceled, context.DeadlineExceeded} { + if errors.Is(err, cancellation) { + return fmt.Errorf("%s: %w", operation, cancellation) + } + } + if response, ok := errors.AsType[*azcore.ResponseError](err); ok { + return fmt.Errorf("%s failed (HTTP %d); verify resource access and retry", operation, response.StatusCode) + } + return fmt.Errorf("%s failed; verify resource access and retry", operation) +} + +func monitoringNotFound(err error) bool { + response, ok := errors.AsType[*azcore.ResponseError](err) + return ok && response.StatusCode == 404 +} + +// armMonitoringReader is the SDK-backed MonitoringReader. It is the only place +// that constructs Azure SDK clients for monitoring discovery. +type armMonitoringReader struct { + credential azcore.TokenCredential + options *arm.ClientOptions +} + +func (r *armMonitoringReader) Resource( + ctx context.Context, id *arm.ResourceID, version string, +) (*armresources.GenericResource, bool, error) { + client, err := armresources.NewClient(id.SubscriptionID, r.credential, r.options) + if err != nil { + return nil, false, err + } + response, err := client.GetByID(ctx, id.String(), version, nil) + if monitoringNotFound(err) { + return nil, false, nil + } + if err != nil { + return nil, false, MonitoringReadError("read monitoring resource", err) + } + return &response.GenericResource, true, nil +} + +func (r *armMonitoringReader) ProjectConnections( + ctx context.Context, project *arm.ResourceID, +) ([]MonitoringConnectionRecord, error) { + factory, err := armcognitiveservices.NewClientFactory(project.SubscriptionID, r.credential, r.options) + if err != nil { + return nil, err + } + pager := factory.NewProjectConnectionsClient().NewListPager( + project.ResourceGroupName, project.Parent.Name, project.Name, nil) + var records []MonitoringConnectionRecord + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, MonitoringReadError("inspect project monitoring connections", err) + } + if page.Value == nil { + return nil, fmt.Errorf("project connection discovery returned an incomplete list") + } + for _, connection := range page.Value { + if connection == nil || connection.Name == nil || *connection.Name == "" || connection.Properties == nil { + return nil, fmt.Errorf("project connection discovery returned incomplete metadata") + } + records = append(records, MonitoringConnectionRecord{ + Name: *connection.Name, Properties: connection.Properties.GetConnectionPropertiesV2(), + }) + } + } + return records, nil +} + +func (r *armMonitoringReader) AccountConnections( + ctx context.Context, project *arm.ResourceID, +) ([]MonitoringConnectionRecord, error) { + factory, err := armcognitiveservices.NewClientFactory(project.SubscriptionID, r.credential, r.options) + if err != nil { + return nil, err + } + pager := factory.NewAccountConnectionsClient().NewListPager(project.ResourceGroupName, project.Parent.Name, nil) + var records []MonitoringConnectionRecord + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, MonitoringReadError("inspect inherited monitoring connections", err) + } + if page.Value == nil { + return nil, fmt.Errorf("account connection discovery returned an incomplete list") + } + for _, connection := range page.Value { + if connection == nil || connection.Name == nil || *connection.Name == "" || connection.Properties == nil { + return nil, fmt.Errorf("account connection discovery returned incomplete metadata") + } + records = append(records, MonitoringConnectionRecord{ + Name: *connection.Name, Properties: connection.Properties.GetConnectionPropertiesV2(), + }) + } + } + return records, nil +} + +func (r *armMonitoringReader) ProjectConnectionExists( + ctx context.Context, project *arm.ResourceID, name string, +) (bool, error) { + factory, err := armcognitiveservices.NewClientFactory(project.SubscriptionID, r.credential, r.options) + if err != nil { + return false, err + } + _, err = factory.NewProjectConnectionsClient().Get( + ctx, project.ResourceGroupName, project.Parent.Name, project.Name, name, nil) + if err == nil { + return true, nil + } + if monitoringNotFound(err) { + return false, nil + } + return false, MonitoringReadError("confirm system monitoring connection name is available", err) +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go new file mode 100644 index 00000000000..cf8422fc792 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/google/uuid" +) + +// MonitoringConnectionName matches ARM guid() and Terraform UUID v5, including +// ARM's published namespace. Hashing the project name stays within connection +// limits without truncating sibling names that share a long prefix. +func MonitoringConnectionName(projectName string) string { + namespace := uuid.MustParse("11fb06fb-712d-4ddd-98c7-e71bbd588830") + id := uuid.NewSHA1(namespace, []byte(strings.ToLower(projectName))) + return "azd-appinsights-" + strings.ReplaceAll(id.String(), "-", "")[:13] +} + +// MonitoringResolver performs explicit read-only monitoring discovery outside +// synthesis. Reader overrides the default SDK-backed reader, mainly for tests. +type MonitoringResolver struct { + Credential azcore.TokenCredential + ClientOptions *arm.ClientOptions + Reader MonitoringReader +} + +func (p *MonitoringResolver) reader() MonitoringReader { + if p.Reader != nil { + return p.Reader + } + return &armMonitoringReader{credential: p.Credential, options: p.ClientOptions} +} + +// Resource distinguishes genuine absence from failed reads and verifies the returned identity. +func (p *MonitoringResolver) Resource( + ctx context.Context, id *arm.ResourceID, version string, +) (*armresources.GenericResource, bool, error) { + resource, found, err := p.reader().Resource(ctx, id, version) + if err != nil || !found { + return nil, false, err + } + if resource == nil || resource.ID == nil || !strings.EqualFold(*resource.ID, id.String()) { + return nil, false, fmt.Errorf("monitoring lookup returned an unexpected resource identity") + } + return resource, true, nil +} + +// ValidateComponent verifies a workspace-backed destination and, when needed, ApiKey policy. +func (p *MonitoringResolver) ValidateComponent( + ctx context.Context, componentID string, needsKey bool, +) error { + id, err := MonitoringResourceID(componentID, "Microsoft.Insights/components") + if err != nil { + return err + } + component, found, err := p.Resource(ctx, id, "2020-02-02") + if err != nil { + return err + } + if !found { + return fmt.Errorf("the selected Application Insights component does not exist") + } + properties, err := MonitoringProperties(component) + if err != nil { + return err + } + workspaceID, _ := properties["WorkspaceResourceId"].(string) + workspace, err := MonitoringResourceID(workspaceID, "Microsoft.OperationalInsights/workspaces") + if err != nil { + return fmt.Errorf("Application Insights must reference an accessible Log Analytics workspace: %w", err) + } + linked, found, err := p.Resource(ctx, workspace, "2023-09-01") + if err != nil { + return err + } + if !found { + return fmt.Errorf("the linked Log Analytics workspace does not exist") + } + if needsKey { + if err := MonitoringKeyPolicy(properties); err != nil { + return err + } + if key, _ := properties["ConnectionString"].(string); strings.TrimSpace(key) == "" { + return fmt.Errorf("the selected Application Insights component has no connection string") + } + workspaceProperties, err := MonitoringProperties(linked) + if err != nil { + return err + } + return MonitoringKeyPolicy(workspaceProperties) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver_test.go new file mode 100644 index 00000000000..ce4aadc7fbc --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver_test.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMonitoringConnectionName(t *testing.T) { + t.Parallel() + assert.Equal(t, "azd-appinsights-b35f1607b6f65", MonitoringConnectionName("project")) + assert.Equal(t, "azd-appinsights-6bf3686a1d175", MonitoringConnectionName("declmon-tf-new-20261030a")) + assert.Equal(t, MonitoringConnectionName("PROJECT"), MonitoringConnectionName("project")) + long := strings.Repeat("long-project-name-", 8) + assert.LessOrEqual(t, len(MonitoringConnectionName(long)), 32) + assert.NotEqual(t, MonitoringConnectionName(long+"a"), MonitoringConnectionName(long+"b")) + assert.Regexp(t, `^[a-zA-Z0-9][a-zA-Z0-9_-]{2,32}$`, MonitoringConnectionName(long)) +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go new file mode 100644 index 00000000000..b4d3742b645 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSynthesizeMonitoring(t *testing.T) { + const component = "/subscriptions/00000000-0000-0000-0000-000000000001" + + "/resourceGroups/monitoring/providers/Microsoft.Insights/components/traces" + tests := []struct { + name string + settings string + existing bool + env map[string]string + preserve bool + mode string + id string + wantErr string + }{ + {name: "new project defaults on", mode: "create"}, + {name: "existing project defaults off", existing: true, mode: "none"}, + {name: "explicit opt out", settings: "monitoring:\n enabled: false", mode: "none"}, + { + name: "existing project opt in without ID is unsupported", existing: true, + settings: "monitoring:\n enabled: true", wantErr: "not supported", + }, + { + name: "existing project empty block is unsupported", existing: true, + settings: "monitoring: {}", wantErr: "not supported", + }, + {name: "new project empty block opts in", settings: "monitoring: {}", mode: "create"}, + { + name: "BYO implies enabled", settings: "monitoring:\n applicationInsightsResourceId: " + component, + mode: "reuse-connect", id: component, + }, + { + name: "existing project BYO", existing: true, + settings: "monitoring:\n applicationInsightsResourceId: " + component, + mode: "reuse-connect", id: component, + }, + { + name: "selected environment", settings: "monitoring:\n applicationInsightsResourceId: ${MONITORING_TEST_ID}", + env: map[string]string{"MONITORING_TEST_ID": component}, mode: "reuse-connect", id: component, + }, + { + name: "preserved export reference", settings: "monitoring:\n applicationInsightsResourceId: ${MONITORING_TEST_ID}", + preserve: true, mode: "reuse-connect", id: "${MONITORING_TEST_ID}", + }, + { + name: "resolved empty is not create", settings: "monitoring:\n applicationInsightsResourceId: ${MONITORING_TEST_ID}", + env: map[string]string{"MONITORING_TEST_ID": ""}, wantErr: "nonempty", + }, + {name: "literal empty is not create", settings: "monitoring:\n applicationInsightsResourceId: ''", wantErr: "nonempty"}, + { + name: "process environment fallback", + settings: "monitoring:\n applicationInsightsResourceId: ${MONITORING_TEST_ID}", + mode: "reuse-connect", id: component, + }, + { + name: "default expression", settings: "monitoring:\n applicationInsightsResourceId: ${UNSET_MONITORING_ID:-" + + component + "}", env: map[string]string{"UNSET_MONITORING_ID": ""}, mode: "reuse-connect", id: component, + }, + { + name: "explicit opt out wins over BYO", + settings: "monitoring:\n enabled: false\n applicationInsightsResourceId: " + component, + mode: "none", + }, + {name: "unknown field", settings: "monitoring:\n enabld: true", wantErr: "unknown monitoring setting"}, + {name: "not an object", settings: "monitoring: true", wantErr: "monitoring must be an object"}, + {name: "null is not an object", settings: "monitoring: null", wantErr: "monitoring must be an object"}, + {name: "enabled type", settings: "monitoring:\n enabled: 'true'", wantErr: "must be a boolean"}, + {name: "ID type", settings: "monitoring:\n applicationInsightsResourceId: 42", wantErr: "must be a string"}, + { + name: "wrong resource type", settings: "monitoring:\n applicationInsightsResourceId: " + + "/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/a", + wantErr: "Application Insights component", + }, + } + + t.Setenv("MONITORING_TEST_ID", component) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + endpoint := "" + if tt.existing { + endpoint = "endpoint: https://account.services.ai.azure.com/api/projects/project\n " + } + in := Input{ + RawAzureYAML: fmt.Appendf(nil, + "services:\n foundry:\n host: azure.ai.project\n %s%s\n", endpoint, tt.settings), + ServiceName: "foundry", Env: tt.env, PreserveVarRefs: tt.preserve, + } + var result *Result + var err error + if tt.existing { + result, err = SynthesizeExistingProject(in) + } else { + result, err = Synthesize(in) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.mode, result.Parameters["monitoringMode"]) + assert.Equal(t, tt.id, result.Parameters["applicationInsightsResourceId"]) + }) + } +} + +func TestSynthesizeMonitoringInfrastructureAuthority(t *testing.T) { + t.Parallel() + for _, synthesize := range []struct { + name string + run func(Input) (*Result, error) + }{ + {"new project", Synthesize}, + {"existing project", SynthesizeExistingProject}, + } { + t.Run(synthesize.name, func(t *testing.T) { + endpoint := "" + if synthesize.name == "existing project" { + endpoint = " endpoint: https://account.services.ai.azure.com/api/projects/project\n" + } + result, err := synthesize.run(Input{ + RawAzureYAML: []byte("services:\n foundry:\n host: azure.ai.project\n" + endpoint + + " monitoring: invalid-stale-yaml\n"), + ServiceName: "foundry", InfrastructureOwnsMonitoring: true, + }) + require.NoError(t, err) + assert.NotContains(t, result.Parameters, "monitoringMode") + assert.NotContains(t, result.Parameters, "applicationInsightsResourceId") + assert.False(t, result.MonitoringExplicit) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/ownership_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/ownership_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/ownership_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/ownership_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/prompt_synthesis_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/prompt_synthesis_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/prompt_synthesis_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/prompt_synthesis_test.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/schema_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/schema_test.go index 2e715b72476..46c1a3b2b0e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/schema_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/schema_test.go @@ -26,7 +26,7 @@ import ( // schemaPath is the editor-tooling JSON schema for the Foundry project service body, // resolved from this package directory. -const schemaPath = "../../../azure.ai.projects/schemas/azure.ai.project.json" +const schemaPath = "../schemas/azure.ai.project.json" // TestSchema_NetworkStructuralInvariants guards the network surface of the // hand-maintained JSON schema against drift from the synthesizer's contract: diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go similarity index 97% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go rename to cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index d0564da6225..44e00f83744 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -69,6 +69,10 @@ type Input struct { // provision path), ${VAR} is resolved here and a missing variable fails. PreserveVarRefs bool + // InfrastructureOwnsMonitoring leaves monitoring to already-authored IaC. + // Export does not set this: it seeds the IaC from YAML exactly once. + InfrastructureOwnsMonitoring bool + // ProjectRoot is the directory holding azure.yaml. When set, $ref file // includes in the service entry (and its deployment items) are resolved // against it before synthesis, so refs become the actual content rather @@ -95,6 +99,9 @@ type Result struct { // network: block (or its absence). Exposed for telemetry. NetworkMode string + // MonitoringExplicit distinguishes authored monitoring from the new-project + // default, so an upgrade cannot opt an already-provisioned project into setup. + MonitoringExplicit bool // ACRMode is the greenfield registry mode: create, // reuse-connect, or none. ACRMode string @@ -155,6 +162,7 @@ type projectService struct { Deployments []Deployment `yaml:"deployments,omitempty"` Agents []agentBlock `yaml:"agents,omitempty"` Network *networkBlock `yaml:"network,omitempty"` + Monitoring yaml.Node `yaml:"monitoring,omitempty"` AgentHosting *AgentHostingInput `yaml:"agentHosting,omitempty"` } @@ -265,6 +273,12 @@ func Synthesize(in Input) (*Result, error) { "includeAcr": includeAcr, } maps.Copy(params, netParams) + monitoring, err := newProjectMonitoringParameters(svc.Monitoring, in) + if err != nil { + return nil, err + } + maps.Copy(params, monitoring) + if hosting != nil { params["agentHosting"] = agentHostingParameter{ Enabled: true, HostingType: "ManagedCluster", Name: "default", @@ -273,9 +287,10 @@ func Synthesize(in Input) (*Result, error) { } return &Result{ - Parameters: params, - NetworkMode: netMode, - Hosting: hosting, + Parameters: params, + NetworkMode: netMode, + MonitoringExplicit: !in.InfrastructureOwnsMonitoring && svc.Monitoring.Kind != 0, + Hosting: hosting, }, nil } @@ -399,10 +414,19 @@ func SynthesizeExistingProject(in Input) (*Result, error) { deployments = []Deployment{} } - return &Result{Parameters: map[string]any{ + params := map[string]any{ "deployments": deployments, "includeAcr": includeAcr, - }, NetworkMode: NetworkModeNone}, nil + } + monitoring, err := existingProjectMonitoringParameters(svc.Monitoring, in) + if err != nil { + return nil, err + } + maps.Copy(params, monitoring) + return &Result{ + Parameters: params, NetworkMode: NetworkModeNone, + MonitoringExplicit: !in.InfrastructureOwnsMonitoring && svc.Monitoring.Kind != 0, + }, nil } // BrownfieldDeployments returns the model deployments declared on a brownfield diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/abbreviations.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/abbreviations.json similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/abbreviations.json rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/abbreviations.json diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project-eject.bicep.tmpl similarity index 71% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project-eject.bicep.tmpl index 2e77d82aed0..1d3a72af132 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project-eject.bicep.tmpl @@ -20,12 +20,20 @@ type deploymentType = { param projectResourceId string param deployments deploymentType[] = [] param projectEndpoint string -{{- if eq .AcrMode "create" }} param resourceGroupName string param location string param resourceTokenSalt string = '' param tags object = {} -{{- else if eq .AcrMode "reuse-connect" }} +@allowed([ + 'none' + 'reuse-connect' + 'already-connected' +]) +param monitoringMode string = 'none' +param applicationInsightsResourceId string = '' +param monitoringCreateConnection bool = true +param monitoringConnectionName string = '' +{{- if eq .AcrMode "reuse-connect" }} param existingAcrResourceId string param existingAcrEndpoint string {{- else if eq .AcrMode "already-connected" }} @@ -39,6 +47,7 @@ var projectSubscriptionId = projectIdParts[2] var projectResourceGroupName = projectIdParts[4] var accountName = projectIdParts[8] var projectName = projectIdParts[10] +var createAdjunctGroup = {{ if eq .AcrMode "create" }}true{{ else }}false{{ end }} {{- if eq .AcrMode "create" }} var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' var acrName = 'cr${toLower(uniqueString(tokenSeed))}' @@ -54,13 +63,13 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview name: projectName } } -{{- if eq .AcrMode "create" }} -resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAdjunctGroup) { name: resourceGroupName location: location tags: tags } +{{- if eq .AcrMode "create" }} module containerRegistry 'modules/container-registry.bicep' = { name: 'container-registry' @@ -108,13 +117,35 @@ module projectResources 'modules/foundry-project.bicep' = { } } +module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { + name: 'foundry-monitoring' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + mode: monitoringMode + location: location + tags: tags + projectResourceId: projectResourceId + applicationInsightsResourceId: applicationInsightsResourceId + createConnection: monitoringCreateConnection + existingConnectionName: monitoringConnectionName + environmentName: contains(tags, 'azd-env-name') ? string(tags['azd-env-name']) : '' + resourceTokenSalt: resourceTokenSalt + } + dependsOn: [adjunctResourceGroup] +} + output AZURE_AI_PROJECT_ID string = projectResourceId output AZURE_AI_ACCOUNT_NAME string = accountName output AZURE_AI_PROJECT_NAME string = projectName output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint -output AZURE_FOUNDRY_RESOURCE_GROUP string = {{ if eq .AcrMode "create" }}resourceGroupName{{ else }}''{{ end }} +output AZURE_FOUNDRY_RESOURCE_GROUP string = createAdjunctGroup ? resourceGroupName : '' output AZURE_CONTAINER_REGISTRY_ENDPOINT string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.endpoint{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrEndpoint{{ else }}''{{ end }} output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = {{ if eq .AcrMode "create" }}containerRegistry.outputs.resourceId{{ else if or (eq .AcrMode "reuse-connect") (eq .AcrMode "already-connected") }}existingAcrResourceId{{ else }}''{{ end }} output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName output AZD_FOUNDRY_ACR_MODE string = '{{ .AcrMode }}' +output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' +output AZURE_APPLICATION_INSIGHTS_APP_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' +output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' +output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = monitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' +output AZD_FOUNDRY_MONITORING_MODE string = monitoringMode diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json similarity index 68% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 79e59e687bb..380a1bbcea4 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "4511924015068411233" + "templateHash": "11625777883654081427" } }, "definitions": { @@ -97,6 +97,27 @@ }, "projectEndpoint": { "type": "string" + }, + "monitoringMode": { + "type": "string", + "defaultValue": "none", + "allowedValues": [ + "none", + "reuse-connect", + "already-connected" + ] + }, + "applicationInsightsResourceId": { + "type": "string", + "defaultValue": "" + }, + "monitoringCreateConnection": { + "type": "bool", + "defaultValue": true + }, + "monitoringConnectionName": { + "type": "string", + "defaultValue": "" } }, "variables": { @@ -108,6 +129,7 @@ "tokenSeed": "[format('{0}{1}{2}', subscription().subscriptionId, parameters('resourceGroupName'), parameters('resourceTokenSalt'))]", "acrName": "[format('cr{0}', toLower(uniqueString(variables('tokenSeed'))))]", "createAcr": "[equals(parameters('acrMode'), 'create')]", + "createAdjunctGroup": "[variables('createAcr')]", "reuseAcr": "[or(equals(parameters('acrMode'), 'reuse-connect'), equals(parameters('acrMode'), 'already-connected'))]", "createAcrConnection": "[or(equals(parameters('acrMode'), 'create'), equals(parameters('acrMode'), 'reuse-connect'))]", "effectiveAcrName": "[if(variables('createAcr'), variables('acrName'), if(variables('reuseAcr'), last(split(parameters('existingAcrResourceId'), '/')), ''))]" @@ -122,7 +144,7 @@ "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]" }, "adjunctResourceGroup": { - "condition": "[variables('createAcr')]", + "condition": "[variables('createAdjunctGroup')]", "type": "Microsoft.Resources/resourceGroups", "apiVersion": "2021-04-01", "name": "[parameters('resourceGroupName')]", @@ -726,6 +748,268 @@ "existingContainerRegistry", "newContainerRegistry" ] + }, + "monitoring": { + "condition": "[not(equals(parameters('monitoringMode'), 'none'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring", + "subscriptionId": "[variables('projectSubscriptionId')]", + "resourceGroup": "[variables('projectResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "[parameters('monitoringMode')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "projectResourceId": { + "value": "[parameters('projectResourceId')]" + }, + "applicationInsightsResourceId": { + "value": "[parameters('applicationInsightsResourceId')]" + }, + "createConnection": { + "value": "[parameters('monitoringCreateConnection')]" + }, + "existingConnectionName": { + "value": "[parameters('monitoringConnectionName')]" + }, + "environmentName": "[if(contains(parameters('tags'), 'azd-env-name'), createObject('value', string(parameters('tags')['azd-env-name'])), createObject('value', ''))]", + "resourceTokenSalt": { + "value": "[parameters('resourceTokenSalt')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "13442955573686093057" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "none", + "create", + "reuse-connect", + "already-connected" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "projectResourceId": { + "type": "string" + }, + "applicationInsightsResourceId": { + "type": "string", + "defaultValue": "" + }, + "createConnection": { + "type": "bool", + "defaultValue": true + }, + "existingConnectionName": { + "type": "string", + "defaultValue": "" + }, + "environmentName": { + "type": "string" + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "" + } + }, + "variables": { + "createResources": "[equals(parameters('mode'), 'create')]", + "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", + "projectParts": "[split(parameters('projectResourceId'), '/')]", + "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", + "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', parameters('projectResourceId')))]", + "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" + }, + "resources": [ + { + "condition": "[variables('createResources')]", + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('log-{0}', variables('token'))]", + "location": "[parameters('location')]", + "tags": "[variables('monitoringTags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30 + } + }, + { + "condition": "[variables('createResources')]", + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appi-{0}', variables('token'))]", + "location": "[parameters('location')]", + "tags": "[variables('monitoringTags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[and(parameters('createConnection'), or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-connection", + "subscriptionId": "[variables('projectParts')[2]]", + "resourceGroup": "[variables('projectParts')[4]]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('projectParts')[8]]" + }, + "projectName": { + "value": "[variables('projectParts')[10]]" + }, + "applicationInsightsResourceId": { + "value": "[variables('effectiveComponentId')]" + }, + "environmentName": { + "value": "[parameters('environmentName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "12065248471814645758" + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "applicationInsightsResourceId": { + "type": "string" + }, + "environmentName": { + "type": "string" + } + }, + "variables": { + "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]" + }, + "resources": { + "account::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" + }, + "account": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]" + }, + "component": { + "existing": true, + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "subscriptionId": "[variables('componentParts')[2]]", + "resourceGroup": "[variables('componentParts')[4]]", + "name": "[variables('componentParts')[8]]" + }, + "connection": { + "@options": { + "onlyIfNotExists": [] + }, + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('azd-appinsights-{0}', take(replace(guid(toLower(parameters('projectName'))), '-', ''), 13)))]", + "properties": { + "category": "AppInsights", + "authType": "ApiKey", + "target": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])]", + "credentials": { + "key": "[reference('component').ConnectionString]" + }, + "metadata": { + "ApiType": "Azure", + "ResourceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])]", + "azd-created-by": "azure.ai.projects", + "azd-environment": "[parameters('environmentName')]", + "azd-monitoring-project": "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" + } + }, + "dependsOn": [ + "component" + ] + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + ] + } + ], + "outputs": { + "applicationInsightsResourceId": { + "type": "string", + "value": "[variables('effectiveComponentId')]" + }, + "applicationInsightsAppId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), reference(resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), '2020-02-02').AppId, reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').AppId))]" + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), reference(resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), '2020-02-02').WorkspaceResourceId, reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]" + }, + "projectConnectionName": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(and(parameters('createConnection'), not(equals(parameters('mode'), 'already-connected'))), variables('connectionName'), parameters('existingConnectionName')))]" + } + } + } + }, + "dependsOn": [ + "adjunctResourceGroup" + ] } }, "outputs": { @@ -751,7 +1035,7 @@ }, "AZURE_FOUNDRY_RESOURCE_GROUP": { "type": "string", - "value": "[if(variables('createAcr'), parameters('resourceGroupName'), '')]" + "value": "[if(variables('createAdjunctGroup'), parameters('resourceGroupName'), '')]" }, "AZURE_CONTAINER_REGISTRY_ENDPOINT": { "type": "string", @@ -768,6 +1052,26 @@ "AZD_FOUNDRY_ACR_MODE": { "type": "string", "value": "[parameters('acrMode')]" + }, + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsResourceId.value, '')]" + }, + "AZURE_APPLICATION_INSIGHTS_APP_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsAppId.value, '')]" + }, + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.logAnalyticsWorkspaceResourceId.value, '')]" + }, + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.projectConnectionName.value, '')]" + }, + "AZD_FOUNDRY_MONITORING_MODE": { + "type": "string", + "value": "[parameters('monitoringMode')]" } } } \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.bicep similarity index 72% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.bicep index 595d7dabfc1..635a08e3c4d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.bicep @@ -36,6 +36,16 @@ param existingAcrConnectionName string = '' param acrPullAssigned bool = false param projectEndpoint string +@allowed([ + 'none' + 'reuse-connect' + 'already-connected' +]) +param monitoringMode string = 'none' +param applicationInsightsResourceId string = '' +param monitoringCreateConnection bool = true +param monitoringConnectionName string = '' + var projectIdParts = split(projectResourceId, '/') var projectSubscriptionId = projectIdParts[2] var projectResourceGroupName = projectIdParts[4] @@ -44,6 +54,7 @@ var projectName = projectIdParts[10] var tokenSeed = '${subscription().subscriptionId}${resourceGroupName}${resourceTokenSalt}' var acrName = 'cr${toLower(uniqueString(tokenSeed))}' var createAcr = acrMode == 'create' +var createAdjunctGroup = createAcr var reuseAcr = acrMode == 'reuse-connect' || acrMode == 'already-connected' var createAcrConnection = acrMode == 'create' || acrMode == 'reuse-connect' var effectiveAcrName = createAcr ? acrName : (reuseAcr ? last(split(existingAcrResourceId, '/')) : '') @@ -54,7 +65,7 @@ var effectiveAcrResourceId = createAcr ? newContainerRegistry!.outputs.resourceId : (acrMode == 'reuse-connect' && !acrPullAssigned ? existingContainerRegistry!.outputs.resourceId : existingAcrResourceId) -resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAcr) { +resource adjunctResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = if (createAdjunctGroup) { name: resourceGroupName location: location tags: tags @@ -109,12 +120,29 @@ module projectResources 'modules/foundry-project.bicep' = { } } +module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { + name: 'foundry-monitoring' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + mode: monitoringMode + location: location + tags: tags + projectResourceId: projectResourceId + applicationInsightsResourceId: applicationInsightsResourceId + createConnection: monitoringCreateConnection + existingConnectionName: monitoringConnectionName + environmentName: contains(tags, 'azd-env-name') ? string(tags['azd-env-name']) : '' + resourceTokenSalt: resourceTokenSalt + } + dependsOn: [adjunctResourceGroup] +} + output AZURE_AI_PROJECT_ID string = projectResourceId output AZURE_AI_ACCOUNT_NAME string = accountName output AZURE_AI_PROJECT_NAME string = projectName output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint -output AZURE_FOUNDRY_RESOURCE_GROUP string = createAcr ? resourceGroupName : '' +output AZURE_FOUNDRY_RESOURCE_GROUP string = createAdjunctGroup ? resourceGroupName : '' output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrMode == 'none' ? '' : effectiveAcrEndpoint @@ -123,3 +151,8 @@ output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = acrMode == 'none' : effectiveAcrResourceId output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName output AZD_FOUNDRY_ACR_MODE string = acrMode +output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' +output AZURE_APPLICATION_INSIGHTS_APP_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' +output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' +output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = monitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' +output AZD_FOUNDRY_MONITORING_MODE string = monitoringMode diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json similarity index 88% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index d555b1c02b8..f99fe155865 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "2998290216332527788" + "templateHash": "1864592008397094471" } }, "definitions": { @@ -128,6 +128,31 @@ "description": "Include an Azure Container Registry. Set true when any agent uses docker:." } }, + "monitoringMode": { + "type": "string", + "defaultValue": "create", + "allowedValues": [ + "none", + "create", + "reuse-connect", + "already-connected" + ], + "metadata": { + "description": "Monitoring graph. None skips setup without deleting existing resources." + } + }, + "applicationInsightsResourceId": { + "type": "string", + "defaultValue": "" + }, + "monitoringCreateConnection": { + "type": "bool", + "defaultValue": true + }, + "monitoringConnectionName": { + "type": "string", + "defaultValue": "" + }, "agentHosting": { "$ref": "#/definitions/agentHostingType", "defaultValue": { @@ -2275,6 +2300,268 @@ "dependsOn": [ "resourceGroup" ] + }, + "monitoring": { + "condition": "[not(equals(parameters('monitoringMode'), 'none'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring", + "resourceGroup": "[parameters('resourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "mode": { + "value": "[parameters('monitoringMode')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "projectResourceId": { + "value": "[reference('resources').outputs.AZURE_AI_PROJECT_ID.value]" + }, + "applicationInsightsResourceId": { + "value": "[parameters('applicationInsightsResourceId')]" + }, + "createConnection": { + "value": "[parameters('monitoringCreateConnection')]" + }, + "existingConnectionName": { + "value": "[parameters('monitoringConnectionName')]" + }, + "environmentName": "[if(contains(parameters('tags'), 'azd-env-name'), createObject('value', string(parameters('tags')['azd-env-name'])), createObject('value', ''))]", + "resourceTokenSalt": { + "value": "[parameters('resourceTokenSalt')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "13442955573686093057" + } + }, + "parameters": { + "mode": { + "type": "string", + "allowedValues": [ + "none", + "create", + "reuse-connect", + "already-connected" + ] + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "projectResourceId": { + "type": "string" + }, + "applicationInsightsResourceId": { + "type": "string", + "defaultValue": "" + }, + "createConnection": { + "type": "bool", + "defaultValue": true + }, + "existingConnectionName": { + "type": "string", + "defaultValue": "" + }, + "environmentName": { + "type": "string" + }, + "resourceTokenSalt": { + "type": "string", + "defaultValue": "" + } + }, + "variables": { + "createResources": "[equals(parameters('mode'), 'create')]", + "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", + "projectParts": "[split(parameters('projectResourceId'), '/')]", + "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", + "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', parameters('projectResourceId')))]", + "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" + }, + "resources": [ + { + "condition": "[variables('createResources')]", + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('log-{0}', variables('token'))]", + "location": "[parameters('location')]", + "tags": "[variables('monitoringTags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30 + } + }, + { + "condition": "[variables('createResources')]", + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appi-{0}', variables('token'))]", + "location": "[parameters('location')]", + "tags": "[variables('monitoringTags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[and(parameters('createConnection'), or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-connection", + "subscriptionId": "[variables('projectParts')[2]]", + "resourceGroup": "[variables('projectParts')[4]]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('projectParts')[8]]" + }, + "projectName": { + "value": "[variables('projectParts')[10]]" + }, + "applicationInsightsResourceId": { + "value": "[variables('effectiveComponentId')]" + }, + "environmentName": { + "value": "[parameters('environmentName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "12065248471814645758" + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "applicationInsightsResourceId": { + "type": "string" + }, + "environmentName": { + "type": "string" + } + }, + "variables": { + "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]" + }, + "resources": { + "account::project": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]" + }, + "account": { + "existing": true, + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]" + }, + "component": { + "existing": true, + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "subscriptionId": "[variables('componentParts')[2]]", + "resourceGroup": "[variables('componentParts')[4]]", + "name": "[variables('componentParts')[8]]" + }, + "connection": { + "@options": { + "onlyIfNotExists": [] + }, + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), format('azd-appinsights-{0}', take(replace(guid(toLower(parameters('projectName'))), '-', ''), 13)))]", + "properties": { + "category": "AppInsights", + "authType": "ApiKey", + "target": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])]", + "credentials": { + "key": "[reference('component').ConnectionString]" + }, + "metadata": { + "ApiType": "Azure", + "ResourceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])]", + "azd-created-by": "azure.ai.projects", + "azd-environment": "[parameters('environmentName')]", + "azd-monitoring-project": "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" + } + }, + "dependsOn": [ + "component" + ] + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + ] + } + ], + "outputs": { + "applicationInsightsResourceId": { + "type": "string", + "value": "[variables('effectiveComponentId')]" + }, + "applicationInsightsAppId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), reference(resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), '2020-02-02').AppId, reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').AppId))]" + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), reference(resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), '2020-02-02').WorkspaceResourceId, reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]" + }, + "projectConnectionName": { + "type": "string", + "value": "[if(equals(parameters('mode'), 'none'), '', if(and(parameters('createConnection'), not(equals(parameters('mode'), 'already-connected'))), variables('connectionName'), parameters('existingConnectionName')))]" + } + } + } + }, + "dependsOn": [ + "resourceGroup", + "resources" + ] } }, "outputs": { @@ -2325,6 +2612,26 @@ "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { "type": "string", "value": "[reference('resources').outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE.value]" + }, + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsResourceId.value, '')]" + }, + "AZURE_APPLICATION_INSIGHTS_APP_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsAppId.value, '')]" + }, + "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.logAnalyticsWorkspaceResourceId.value, '')]" + }, + "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME": { + "type": "string", + "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.projectConnectionName.value, '')]" + }, + "AZD_FOUNDRY_MONITORING_MODE": { + "type": "string", + "value": "[parameters('monitoringMode')]" } } } \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep similarity index 81% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 8594b66fd5c..6e4965ff4f2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -64,6 +64,18 @@ param deployments deploymentsType = [] @description('Include an Azure Container Registry. Set true when any agent uses docker:.') param includeAcr bool = false +@description('Monitoring graph. None skips setup without deleting existing resources.') +@allowed([ + 'none' + 'create' + 'reuse-connect' + 'already-connected' +]) +param monitoringMode string = 'create' +param applicationInsightsResourceId string = '' +param monitoringCreateConnection bool = true +param monitoringConnectionName string = '' + @description('Existing AKS cluster hosting. Supporting resources are created automatically.') param agentHosting agentHostingType = { enabled: false @@ -165,6 +177,22 @@ module resources 'modules/resources.bicep' = { } } +module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { + name: 'foundry-monitoring' + scope: resourceGroup + params: { + mode: monitoringMode + location: location + tags: tags + projectResourceId: resources.outputs.AZURE_AI_PROJECT_ID + applicationInsightsResourceId: applicationInsightsResourceId + createConnection: monitoringCreateConnection + existingConnectionName: monitoringConnectionName + environmentName: contains(tags, 'azd-env-name') ? string(tags['azd-env-name']) : '' + resourceTokenSalt: resourceTokenSalt + } +} + // Outputs output AZURE_RESOURCE_GROUP string = resourceGroupName @@ -179,3 +207,8 @@ output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CON output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE +output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' +output AZURE_APPLICATION_INSIGHTS_APP_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' +output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' +output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = monitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' +output AZD_FOUNDRY_MONITORING_MODE string = monitoringMode diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/acr-pull-role-assignment.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr-pull-role-assignment.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/acr-pull-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/acr.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/acr.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/acr.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-identity-role-assignment.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-identity-role-assignment.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-identity-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-prerequisites.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-prerequisites.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-prerequisites.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-prerequisites.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-role-assignment.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-role-assignment.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-storage-role-assignment.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/byo-storage-role-assignment.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/byo-storage-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/container-registry-eject.bicep.tmpl similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry-eject.bicep.tmpl rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/container-registry-eject.bicep.tmpl diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/container-registry.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/container-registry.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/container-registry.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/foundry-project.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/foundry-project.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/foundry-project.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/foundry-project.bicep diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-connection.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-connection.bicep new file mode 100644 index 00000000000..84357815f38 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-connection.bicep @@ -0,0 +1,42 @@ +// Resolve the credential inside ARM and never update an existing connection. +targetScope = 'resourceGroup' + +param accountName string +param projectName string +param applicationInsightsResourceId string +param environmentName string + +var componentParts = split(applicationInsightsResourceId, '/') + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + resource project 'projects' existing = { + name: projectName + } +} + +resource component 'Microsoft.Insights/components@2020-02-02' existing = { + name: componentParts[8] + scope: resourceGroup(componentParts[2], componentParts[4]) +} + +@onlyIfNotExists() +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: account::project + name: 'azd-appinsights-${take(replace(guid(toLower(projectName)), '-', ''), 13)}' + properties: { + category: 'AppInsights' + authType: 'ApiKey' + target: component.id + credentials: { + key: component.properties.ConnectionString + } + metadata: { + ApiType: 'Azure' + ResourceId: component.id + 'azd-created-by': 'azure.ai.projects' + 'azd-environment': environmentName + 'azd-monitoring-project': account::project.id + } + } +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep new file mode 100644 index 00000000000..af238cde858 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -0,0 +1,74 @@ +targetScope = 'resourceGroup' + +@allowed([ + 'none' + 'create' + 'reuse-connect' + 'already-connected' +]) +param mode string +param location string +param tags object = {} +param projectResourceId string +param applicationInsightsResourceId string = '' +param createConnection bool = true +param existingConnectionName string = '' +param environmentName string +param resourceTokenSalt string = '' + +var createResources = mode == 'create' +var token = uniqueString(projectResourceId, resourceGroup().id, resourceTokenSalt) +var projectParts = split(projectResourceId, '/') +var componentParts = split(applicationInsightsResourceId, '/') +var connectionName = 'azd-appinsights-${take(replace(guid(toLower(projectParts[10])), '-', ''), 13)}' +var monitoringTags = union(tags, { + 'azd-created-by': 'azure.ai.projects' + 'azd-environment': environmentName + 'azd-monitoring-project': projectResourceId +}) + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (createResources) { + name: 'log-${token}' + location: location + tags: monitoringTags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource component 'Microsoft.Insights/components@2020-02-02' = if (createResources) { + name: 'appi-${token}' + location: location + tags: monitoringTags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace!.id + } +} + +resource existingComponent 'Microsoft.Insights/components@2020-02-02' existing = if (mode != 'none' && !createResources) { + name: componentParts[8] + scope: resourceGroup(componentParts[2], componentParts[4]) +} + +var effectiveComponentId = mode == 'none' ? '' : (createResources ? component!.id : existingComponent!.id) + +module connection 'monitoring-connection.bicep' = if (createConnection && (mode == 'create' || mode == 'reuse-connect')) { + name: 'foundry-monitoring-connection' + scope: resourceGroup(projectParts[2], projectParts[4]) + params: { + accountName: projectParts[8] + projectName: projectParts[10] + applicationInsightsResourceId: effectiveComponentId + environmentName: environmentName + } +} + +output applicationInsightsResourceId string = effectiveComponentId +output applicationInsightsAppId string = mode == 'none' ? '' : (createResources ? component!.properties.AppId : existingComponent!.properties.AppId) +output logAnalyticsWorkspaceResourceId string = mode == 'none' ? '' : (createResources ? component!.properties.WorkspaceResourceId : existingComponent!.properties.WorkspaceResourceId) +output projectConnectionName string = mode == 'none' ? '' : (createConnection && mode != 'already-connected' ? connectionName : existingConnectionName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/network.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/private-endpoint-dns.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/private-endpoint-dns.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/private-endpoint-dns.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/resources.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/subnet.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/subnet.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/modules/subnet.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/subnet.bicep diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/adjunct-resource-group.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/adjunct-resource-group.tf new file mode 100644 index 00000000000..051e5402b4d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/adjunct-resource-group.tf @@ -0,0 +1,5 @@ +resource "azurerm_resource_group" "adjunct" { + name = var.resource_group_name + location = var.location + tags = merge(var.tags, { "azd-env-name" = var.environment_name }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-connect.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-connect.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-connect.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-create.tf similarity index 92% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-create.tf index f72a118203a..3e0cd8f8446 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-create.tf +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-create.tf @@ -9,12 +9,6 @@ locals { acr_pull_role_id = "7f951dda-4ed3-4680-a7ca-43fe172d538d" } -resource "azurerm_resource_group" "adjunct" { - name = var.resource_group_name - location = var.location - tags = merge(var.tags, { "azd-env-name" = var.environment_name }) -} - data "azapi_resource" "project" { type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" resource_id = local.normalized_project_id diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-reuse.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/container-registry-reuse.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/container-registry-reuse.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/main.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/main.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/main.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/main.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/model-deployments.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/model-deployments.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/model-deployments.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/model-deployments.tf diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/outputs.tf.tmpl similarity index 94% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/outputs.tf.tmpl index 54d6ddc632a..b52b8b3d370 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform-existing-project/outputs.tf.tmpl +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/outputs.tf.tmpl @@ -27,7 +27,7 @@ output "FOUNDRY_PROJECT_ENDPOINT" { } output "AZURE_FOUNDRY_RESOURCE_GROUP" { - value = {{ if eq .AcrMode "create" }}var.resource_group_name{{ else }}""{{ end }} + value = {{ if .IncludeAdjunct }}var.resource_group_name{{ else }}""{{ end }} } output "AZURE_CONTAINER_REGISTRY_ENDPOINT" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/provider.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/provider.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/provider.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/provider.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/variables.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/variables.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform-existing-project/variables.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform-existing-project/variables.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/container-registry.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/container-registry.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/container-registry.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/container-registry.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/main.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/main.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/main.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/main.tf diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl new file mode 100644 index 00000000000..bdd4c3ae49c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl @@ -0,0 +1,391 @@ +variable "monitoring_mode" { + type = string + description = "Monitoring ownership: none, create, reuse-connect, or already-connected. Removing managed resources has normal Terraform destroy semantics." + + validation { + condition = contains(["none", "create", "reuse-connect", "already-connected"], var.monitoring_mode) + error_message = "monitoring_mode must be none, create, reuse-connect, or already-connected." + } + validation { + condition = var.monitoring_mode != "create" || {{ .CreateSupported }} + error_message = "Create mode requires a declared owned resource group; add that graph before changing a reuse-only export to create." + } +} + +variable "application_insights_resource_id" { + type = string + default = "" + description = "Existing Application Insights component ARM ID; credentials are resolved ephemerally." + + validation { + condition = contains(["none", "create"], var.monitoring_mode) || can(regex( + "(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.Insights/components/[^/]+$", + var.application_insights_resource_id, + )) + error_message = "Reusing monitoring requires the full Application Insights component ARM ID." + } +} + +locals { + monitoring_enabled = var.monitoring_mode != "none" + monitoring_managed = contains(["create", "reuse-connect"], var.monitoring_mode) + monitoring_project_id = {{ .ProjectID }} + monitoring_account_id = {{ .AccountID }} + monitoring_group_id = {{ .GroupID }} + monitoring_project_name = element(reverse(split("/", local.monitoring_project_id)), 0) + monitoring_name = "azd-appinsights-${substr(replace(uuidv5("11fb06fb-712d-4ddd-98c7-e71bbd588830", lower(local.monitoring_project_name)), "-", ""), 0, 13)}" + monitoring_token = substr(sha1(lower("${local.monitoring_project_id}|${local.monitoring_group_id}|${var.resource_token_salt}")), 0, 13) + monitoring_component_id = var.monitoring_mode == "create" ? "${local.monitoring_group_id}/providers/Microsoft.Insights/components/appi-${local.monitoring_token}" : var.application_insights_resource_id + monitoring_workspace_id = "${local.monitoring_group_id}/providers/Microsoft.OperationalInsights/workspaces/log-${local.monitoring_token}" + monitoring_tags = merge(var.tags, { + "azd-created-by" = "azure.ai.projects" + "azd-environment" = var.environment_name + "azd-monitoring-project" = local.monitoring_project_id + }) + monitoring_policy_fields = { + present = "properties != `null`" + resource_id = "id" + local_auth = "properties.DisableLocalAuth" + workspace_local_auth = "properties.features.disableLocalAuth" + ingestion = "properties.publicNetworkAccessForIngestion" + public_access = "properties.publicNetworkAccess" + default_action = "properties.networkAcls.defaultAction" + } + monitoring_connection_fields = { + connections = "value[].{name:name,category:properties.category,target:properties.target,auth:properties.authType,shared:properties.isSharedToAll,restricted:length(not_null(properties.sharedUserList, `[]`)),resource_id:properties.metadata.ResourceId,creator:properties.metadata.\"azd-created-by\",environment:properties.metadata.\"azd-environment\",project:properties.metadata.\"azd-project-id\"}" + } +} + +data "azapi_resource" "monitoring_project" { + count = local.monitoring_enabled ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + resource_id = local.monitoring_project_id + response_export_values = local.monitoring_policy_fields +} + +data "azapi_resource" "monitoring_account" { + count = local.monitoring_enabled ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts@2025-04-01-preview" + resource_id = local.monitoring_account_id + response_export_values = local.monitoring_policy_fields +} + +data "azapi_resource_list" "monitoring_project_connections" { + count = local.monitoring_enabled ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + parent_id = local.monitoring_project_id + response_export_values = local.monitoring_connection_fields +} + +data "azapi_resource_list" "monitoring_account_connections" { + count = local.monitoring_enabled ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/connections@2025-04-01-preview" + parent_id = local.monitoring_account_id + response_export_values = local.monitoring_connection_fields +} + +data "azapi_resource" "monitoring_component" { + count = local.monitoring_enabled ? 1 : 0 + type = "Microsoft.Insights/components@2020-02-02" + resource_id = local.monitoring_component_id + ignore_not_found = true + response_export_values = merge(local.monitoring_policy_fields, { + app_id = "properties.AppId" + workspace_id = "properties.WorkspaceResourceId" + }) + + lifecycle { + postcondition { + condition = self.exists ? ( + try(self.output.present, false) == true && + try(lower(self.output.resource_id), "") == lower(local.monitoring_component_id) && + can(regex("(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.OperationalInsights/workspaces/[^/]+$", self.output.workspace_id)) && + try(length(self.output.app_id) > 0, false) + ) : var.monitoring_mode == "create" + error_message = "Application Insights must exist at the selected identity and reference an accessible Log Analytics workspace." + } + } +} + +data "azapi_resource" "monitoring_workspace" { + count = contains(["reuse-connect", "already-connected"], var.monitoring_mode) ? 1 : 0 + type = "Microsoft.OperationalInsights/workspaces@2023-09-01" + resource_id = data.azapi_resource.monitoring_component[0].output.workspace_id + response_export_values = local.monitoring_policy_fields + + lifecycle { + postcondition { + condition = ( + try(self.output.present, false) == true && + try(lower(self.output.resource_id), "") == lower(data.azapi_resource.monitoring_component[0].output.workspace_id) + ) + error_message = "The linked Log Analytics workspace could not be verified." + } + } +} + +data "azapi_resource" "monitoring_owned_workspace" { + count = var.monitoring_mode == "create" ? 1 : 0 + type = "Microsoft.OperationalInsights/workspaces@2023-09-01" + resource_id = local.monitoring_workspace_id + ignore_not_found = true + response_export_values = local.monitoring_policy_fields +} + +data "azapi_resource" "monitoring_connection_slot" { + count = local.monitoring_managed ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + resource_id = "${local.monitoring_project_id}/connections/${local.monitoring_name}" + ignore_not_found = true + response_export_values = { + name = "name" + category = "properties.category" + target = "properties.target" + auth = "properties.authType" + shared = "properties.isSharedToAll" + restricted = "length(not_null(properties.sharedUserList, `[]`))" + resource_id = "properties.metadata.ResourceId" + creator = "properties.metadata.\"azd-created-by\"" + environment = "properties.metadata.\"azd-environment\"" + project = "properties.metadata.\"azd-project-id\"" + } +} + +locals { + monitoring_project_rows = local.monitoring_enabled ? try(data.azapi_resource_list.monitoring_project_connections[0].output.connections, null) : [] + monitoring_account_rows = local.monitoring_enabled ? try(data.azapi_resource_list.monitoring_account_connections[0].output.connections, null) : [] + monitoring_project_connections = local.monitoring_project_rows != null ? local.monitoring_project_rows : [] + monitoring_account_connections = local.monitoring_account_rows != null ? local.monitoring_account_rows : [] + monitoring_project_by_name = { + for connection in local.monitoring_project_connections : lower(connection.name) => connection + } + monitoring_effective_connections = concat( + [for connection in local.monitoring_project_connections : merge(connection, { inherited = false })], + [for connection in local.monitoring_account_connections : merge(connection, { inherited = true }) if connection.shared == true], + ) + monitoring_appinsights = [ + for connection in local.monitoring_effective_connections : connection + if try(lower(connection.category), "") == "appinsights" + ] + monitoring_existing_names = sort([for connection in local.monitoring_appinsights : connection.name]) + monitoring_network_policies = local.monitoring_managed ? [ + data.azapi_resource.monitoring_account[0].output, + data.azapi_resource.monitoring_project[0].output, + ] : [] + monitoring_key_policy_sources = { + component = local.monitoring_managed ? ( + data.azapi_resource.monitoring_component[0].exists ? data.azapi_resource.monitoring_component[0].output : null + ) : null + workspace = var.monitoring_mode == "reuse-connect" ? data.azapi_resource.monitoring_workspace[0].output : null + owned_workspace = var.monitoring_mode == "create" ? ( + data.azapi_resource.monitoring_owned_workspace[0].exists ? data.azapi_resource.monitoring_owned_workspace[0].output : null + ) : null + } + monitoring_key_policies = [ + for policy in local.monitoring_key_policy_sources : { + for field in keys(local.monitoring_policy_fields) : field => policy[field] + } if policy != null + ] + monitoring_owned_connections = concat( + local.monitoring_appinsights, + local.monitoring_managed ? ( + data.azapi_resource.monitoring_connection_slot[0].exists ? + [merge(data.azapi_resource.monitoring_connection_slot[0].output, { inherited = false })] : [] + ) : [], + ) +} + +resource "terraform_data" "monitoring_safety" { + count = local.monitoring_enabled ? 1 : 0 + + lifecycle { + precondition { + condition = ( + try(data.azapi_resource.monitoring_project[0].output.present, false) == true && + try(lower(data.azapi_resource.monitoring_project[0].output.resource_id), "") == lower(local.monitoring_project_id) && + try(data.azapi_resource.monitoring_account[0].output.present, false) == true && + try(lower(data.azapi_resource.monitoring_account[0].output.resource_id), "") == lower(local.monitoring_account_id) + ) + error_message = "Monitoring discovery must verify the selected Foundry project and account identities." + } + precondition { + condition = ( + local.monitoring_project_rows != null && local.monitoring_account_rows != null && + alltrue([for connection in concat(local.monitoring_project_connections, local.monitoring_account_connections) : + try(length(connection.name) > 0, false) && try(length(connection.category) > 0, false) + ]) + ) + error_message = "Monitoring connection discovery is incomplete; absence cannot be inferred from a failed or malformed read." + } + precondition { + condition = alltrue([for connection in local.monitoring_account_connections : + connection.shared == true ? true : ( + contains(keys(local.monitoring_project_by_name), lower(connection.name)) ? ( + try(lower(connection.target), "") == try(lower(local.monitoring_project_by_name[lower(connection.name)].target), "") && + lower(connection.category) == lower(local.monitoring_project_by_name[lower(connection.name)].category) + ) : ( + lower(connection.name) != lower(local.monitoring_name) && + (lower(connection.category) != "appinsights" || (connection.shared == false && connection.restricted == 0)) + ) + ) + ]) + error_message = "An account connection protects the monitoring name, has ambiguous identity, or uses unsupported restricted sharing." + } + precondition { + condition = alltrue([for connection in local.monitoring_effective_connections : + lower(connection.name) != lower(local.monitoring_name) || lower(connection.category) == "appinsights" + ]) && alltrue([for connection in local.monitoring_appinsights : + try(lower(connection.target), "") == lower(local.monitoring_component_id) && + contains(["apikey", "aad", "managedidentity"], try(lower(connection.auth), "")) && + (connection.resource_id == null ? true : try(lower(connection.resource_id), "") == try(lower(connection.target), "")) + ]) + error_message = "Effective AppInsights connections conflict with the selected component, authentication, or resource identity." + } + precondition { + condition = local.monitoring_managed ? alltrue([for connection in local.monitoring_owned_connections : + connection.inherited == false && connection.shared != true && connection.restricted == 0 && + try(lower(connection.name), "") == lower(local.monitoring_name) && + try(lower(connection.category), "") == "appinsights" && try(lower(connection.auth), "") == "apikey" && + try(lower(connection.target), "") == lower(local.monitoring_component_id) && + try(lower(connection.resource_id), "") == lower(local.monitoring_component_id) && + connection.creator == "azure.ai.projects" && connection.environment == var.environment_name && + try(lower(connection.project), "") == lower(local.monitoring_project_id) + ]) : length(local.monitoring_appinsights) > 0 + error_message = "Preserve external monitoring with monitoring_mode already-connected. Managed connections require the original project/environment ownership binding." + } + precondition { + condition = var.monitoring_mode == "create" ? ( + (data.azapi_resource.monitoring_component[0].exists ? ( + try(data.azapi_resource.monitoring_component[0].tags["azd-created-by"], "") == "azure.ai.projects" && + try(data.azapi_resource.monitoring_component[0].tags["azd-environment"], "") == var.environment_name && + try(lower(data.azapi_resource.monitoring_component[0].tags["azd-monitoring-project"]), "") == lower(local.monitoring_project_id) && + try(lower(data.azapi_resource.monitoring_component[0].output.workspace_id), "") == lower(local.monitoring_workspace_id) + ) : true) && + (data.azapi_resource.monitoring_owned_workspace[0].exists ? ( + try(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-created-by"], "") == "azure.ai.projects" && + try(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-environment"], "") == var.environment_name && + try(lower(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-monitoring-project"]), "") == lower(local.monitoring_project_id) && + try(lower(data.azapi_resource.monitoring_owned_workspace[0].output.resource_id), "") == lower(local.monitoring_workspace_id) + ) : true) + ) : true + error_message = "The monitoring creation graph cannot adopt, retag, or retarget an existing component/workspace; use its resource ID for reuse." + } + precondition { + condition = alltrue([for policy in concat(local.monitoring_network_policies, local.monitoring_key_policies) : + try(policy.present, false) == true && + lower(coalesce(policy.ingestion, "Enabled")) == "enabled" && + lower(coalesce(policy.public_access, "Enabled")) == "enabled" && + lower(coalesce(policy.default_action, "Allow")) == "allow" + ]) && alltrue([for policy in local.monitoring_key_policies : + (policy.local_auth == null || policy.local_auth == false) && + (policy.workspace_local_auth == null || policy.workspace_local_auth == false) + ]) + error_message = "ApiKey monitoring cannot enable local authentication or bypass restricted ingestion/network policy." + } + } +} + +# Managed counts depend only on authored mode, never on live connection existence. +resource "azapi_resource" "monitoring_workspace" { + count = var.monitoring_mode == "create" ? 1 : 0 + type = "Microsoft.OperationalInsights/workspaces@2023-09-01" + name = "log-${local.monitoring_token}" + parent_id = local.monitoring_group_id + location = var.location + tags = local.monitoring_tags + body = { + properties = { + retentionInDays = 30 + sku = { name = "PerGB2018" } + } + } + response_export_values = ["id"] + depends_on = [terraform_data.monitoring_safety] +} + +resource "azapi_resource" "monitoring_component" { + count = var.monitoring_mode == "create" ? 1 : 0 + type = "Microsoft.Insights/components@2020-02-02" + name = "appi-${local.monitoring_token}" + parent_id = local.monitoring_group_id + location = var.location + tags = local.monitoring_tags + body = { + kind = "web" + properties = { + Application_Type = "web" + WorkspaceResourceId = azapi_resource.monitoring_workspace[0].id + } + } + response_export_values = ["properties.AppId", "properties.WorkspaceResourceId"] + depends_on = [terraform_data.monitoring_safety] +} + +# The component credential is neither a data-source output nor a state attribute. +ephemeral "azapi_resource_action" "monitoring_credential" { + count = local.monitoring_managed ? 1 : 0 + type = "Microsoft.Insights/components@2020-02-02" + resource_id = var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].id : local.monitoring_component_id + method = "GET" + response_export_values = ["properties.ConnectionString"] + depends_on = [terraform_data.monitoring_safety] +} + +resource "azapi_resource" "monitoring_connection" { + count = local.monitoring_managed ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = local.monitoring_name + parent_id = local.monitoring_project_id + body = { + properties = { + category = "AppInsights" + target = var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].id : local.monitoring_component_id + authType = "ApiKey" + isSharedToAll = false + metadata = { + ApiType = "Azure" + ResourceId = var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].id : local.monitoring_component_id + "azd-created-by" = "azure.ai.projects" + "azd-environment" = var.environment_name + "azd-project-id" = local.monitoring_project_id + } + } + } + sensitive_body = { + properties = { + credentials = { + key = ephemeral.azapi_resource_action.monitoring_credential[0].output.properties.ConnectionString + } + } + } + sensitive_body_version = { "properties.credentials.key" = "1" } + response_export_values = ["name", "properties.category", "properties.target", "properties.authType"] +} + +output "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID" { + value = local.monitoring_enabled ? ( + var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].id : data.azapi_resource.monitoring_component[0].output.resource_id + ) : "" +} + +output "AZURE_APPLICATION_INSIGHTS_APP_ID" { + value = local.monitoring_enabled ? ( + var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].output.properties.AppId : data.azapi_resource.monitoring_component[0].output.app_id + ) : "" +} + +output "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID" { + value = local.monitoring_enabled ? ( + var.monitoring_mode == "create" ? azapi_resource.monitoring_component[0].output.properties.WorkspaceResourceId : data.azapi_resource.monitoring_workspace[0].output.resource_id + ) : "" +} + +output "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME" { + value = local.monitoring_enabled ? ( + local.monitoring_managed ? azapi_resource.monitoring_connection[0].name : try(local.monitoring_existing_names[0], "") + ) : "" +} + +output "AZD_FOUNDRY_MONITORING_MODE" { + value = var.monitoring_mode +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/outputs.tf.tmpl similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/outputs.tf.tmpl rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/outputs.tf.tmpl diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/provider-monitoring.tf.tmpl similarity index 82% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/provider-monitoring.tf.tmpl index 6d70aae0bc7..816bf58e253 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/terraform/provider.tf +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/provider-monitoring.tf.tmpl @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0, < 2.0.0" + required_version = ">= 1.11.0, < 2.0.0" required_providers { azurerm = { @@ -8,7 +8,7 @@ terraform { } azapi = { source = "Azure/azapi" - version = "~> 2.0" + version = "~> 2.7" } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/provider.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/provider.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/provider.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/variables.tf similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/terraform/variables.tf rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/variables.tf diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go b/cli/azd/extensions/azure.ai.projects/synthesis/templates_embed.go similarity index 98% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go rename to cli/azd/extensions/azure.ai.projects/synthesis/templates_embed.go index 66feb2ea6d3..33dcc7d83e8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates_embed.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates_embed.go @@ -34,7 +34,7 @@ var templatesFS embed.FS // generated at eject time. // //go:embed templates/terraform/*.tf -//go:embed templates/terraform/outputs.tf.tmpl +//go:embed templates/terraform/*.tf.tmpl var terraformTemplatesFS embed.FS //go:embed templates/terraform-existing-project/*.tf diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring.go b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring.go new file mode 100644 index 00000000000..566f77d978c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring.go @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "bytes" + "context" + "fmt" + "strings" + "text/template" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// ResolveTerraformMonitoringExport selects a stable data-only or managed graph +// before export. Terraform independently rechecks policy and connections later. +func ResolveTerraformMonitoringExport( + ctx context.Context, parameters map[string]any, projectID string, environment map[string]string, + options *arm.ClientOptions, +) error { + if parameters["monitoringMode"] == nil || parameters["monitoringMode"] == "none" { + return nil + } + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: environment["AZURE_TENANT_ID"], AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return MonitoringReadError("authenticate for monitoring export", err) + } + resolver := &MonitoringResolver{Credential: credential, ClientOptions: options} + return resolver.ResolveTerraformExport(ctx, parameters, projectID, environment) +} + +// ResolveTerraformExport preserves compatible external connections without +// importing them or changing managed resource counts based on live existence. +func (p *MonitoringResolver) ResolveTerraformExport( + ctx context.Context, parameters map[string]any, projectID string, environment map[string]string, +) error { + mode, ok := parameters["monitoringMode"].(string) + if !ok || (mode != "create" && mode != "reuse-connect" && mode != "already-connected" && mode != "none") { + return fmt.Errorf("invalid monitoringMode for Terraform export") + } + if mode == "none" { + return nil + } + project, err := MonitoringResourceID(projectID, "Microsoft.CognitiveServices/accounts/projects") + if err != nil { + return fmt.Errorf("monitoring export requires the selected existing project's ARM resource ID: %w", err) + } + if saved := environment["AZURE_AI_PROJECT_ID"]; saved != "" && !strings.EqualFold(saved, project.String()) { + return fmt.Errorf("monitoring export environment identifies a different Foundry project") + } + if mode == "create" && environment["AZD_FOUNDRY_MONITORING_MODE"] == "create" && + environment["AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"] != "" { + return fmt.Errorf("Terraform export cannot adopt monitoring previously created by microsoft.foundry; " + + "keep Bicep/native infrastructure or explicitly reference the component as an existing resource") + } + rawComponentID, ok := parameters["applicationInsightsResourceId"].(string) + if !ok { + return fmt.Errorf("monitoring export requires a component resource ID string") + } + componentID, err := resolveVars(rawComponentID, environment) + if err != nil { + return fmt.Errorf("resolve monitoring destination for export: %w", err) + } + componentID = strings.TrimSpace(componentID) + projectResource, found, err := p.Resource(ctx, project, "2025-04-01-preview") + if err != nil { + return err + } + if !found { + return fmt.Errorf("the selected existing Foundry project does not exist") + } + account, found, err := p.Resource(ctx, project.Parent, "2025-04-01-preview") + if err != nil { + return err + } + if !found { + return fmt.Errorf("the selected existing Foundry account does not exist") + } + connection, err := p.DiscoverConnection(ctx, project, true, true) + if err != nil { + return err + } + if connection != nil { + if componentID != "" && !strings.EqualFold(componentID, connection.Target) { + return fmt.Errorf("the selected component conflicts with the effective AppInsights connection") + } + if err := p.ValidateComponent(ctx, connection.Target, false); err != nil { + return err + } + parameters["monitoringMode"] = "already-connected" + if componentID == "" { + parameters["applicationInsightsResourceId"] = connection.Target + } + return nil + } + if mode == "already-connected" { + return fmt.Errorf("the declared existing AppInsights connection was not found") + } + for _, resource := range []*armresources.GenericResource{account, projectResource} { + properties, err := MonitoringProperties(resource) + if err != nil { + return err + } + if err := MonitoringNetworkPolicy(properties); err != nil { + return err + } + } + if mode == "reuse-connect" { + if componentID == "" { + return fmt.Errorf("monitoring destination must resolve to a nonempty component resource ID") + } + return p.ValidateComponent(ctx, componentID, true) + } + return nil +} + +// TerraformMonitoring contains the monitoring portion of an infrastructure export. +// Rendering is pure; discovery belongs at the export and provisioning boundaries. +type TerraformMonitoring struct { + Files map[string][]byte + Variables map[string]any + CreatesResources bool +} + +// RenderTerraformMonitoring renders first-class Terraform without reading Azure +// or resolving credentials. A disabled export retains the baseline tool requirements. +func RenderTerraformMonitoring(parameters map[string]any, existingProject bool) (TerraformMonitoring, error) { + result := TerraformMonitoring{Files: map[string][]byte{}, Variables: map[string]any{}} + mode, present := parameters["monitoringMode"] + if !present || mode == "none" { + return result, nil + } + modeText, ok := mode.(string) + if !ok || (modeText != "create" && modeText != "reuse-connect" && modeText != "already-connected") { + return result, fmt.Errorf("monitoringMode must be none, create, reuse-connect, or already-connected") + } + componentID, ok := parameters["applicationInsightsResourceId"].(string) + if !ok || (modeText != "create" && strings.TrimSpace(componentID) == "") { + return result, fmt.Errorf("monitoring requires a component resource ID unless monitoringMode is create") + } + result.CreatesResources = modeText == "create" + result.Variables["monitoring_mode"] = modeText + result.Variables["application_insights_resource_id"] = componentID + + values := struct { + ProjectID, AccountID, GroupID string + CreateSupported bool + }{ + ProjectID: "azapi_resource.project.id", + AccountID: "azapi_resource.foundry_account.id", + GroupID: "azurerm_resource_group.this.id", + CreateSupported: true, + } + if existingProject { + if result.CreatesResources { + return result, fmt.Errorf("creating Application Insights for an existing project is not supported") + } + values.ProjectID = "local.normalized_project_id" + values.AccountID = "local.foundry_account_id" + values.GroupID = `join("/", slice(split("/", local.normalized_project_id), 0, 5))` + values.CreateSupported = false + } else { + provider, err := terraformTemplatesFS.ReadFile("templates/terraform/provider-monitoring.tf.tmpl") + if err != nil { + return result, fmt.Errorf("read monitoring provider requirements: %w", err) + } + result.Files["provider.tf"] = provider + } + source, err := terraformTemplatesFS.ReadFile("templates/terraform/monitoring.tf.tmpl") + if err != nil { + return result, fmt.Errorf("read monitoring Terraform: %w", err) + } + tmpl, err := template.New("monitoring.tf").Option("missingkey=error").Parse(string(source)) + if err != nil { + return result, fmt.Errorf("parse monitoring Terraform: %w", err) + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, values); err != nil { + return result, fmt.Errorf("render monitoring Terraform: %w", err) + } + result.Files["monitoring.tf"] = rendered.Bytes() + return result, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go new file mode 100644 index 00000000000..9942d9f0780 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "crypto/sha1" //nolint:gosec // Mirrors Terraform resource naming, not a security primitive. + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Terraform 1.11 cannot mock ephemeral resources. Exercise the unchanged read-only +// preflight graph here; full exports and credential wiring require live acceptance. +func TestTerraformMonitoringSafetyPlans(t *testing.T) { + terraform := os.Getenv("AZD_TEST_TERRAFORM_PATH") + if terraform == "" { + t.Skip("set AZD_TEST_TERRAFORM_PATH to opt in to Terraform/provider integration tests") + } + rendered, err := RenderTerraformMonitoring(map[string]any{ + "monitoringMode": "create", "applicationInsightsResourceId": "", + }, true) + require.NoError(t, err) + preflight, managed, found := strings.Cut(string(rendered.Files["monitoring.tf"]), "# Managed counts") + require.True(t, found) + require.Contains(t, managed, `ephemeral "azapi_resource_action"`) + require.Contains(t, managed, `resource "azapi_resource" "monitoring_connection"`) + preflight = strings.ReplaceAll(preflight, "azurerm_resource_group.adjunct.id", "local.test_group_id") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "monitoring.tf"), []byte(preflight), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(monitoringPlanTestRoot), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "monitoring.tftest.hcl"), + []byte(monitoringPlanCases(t)), 0o600)) + run := func(arguments ...string) { + t.Helper() + //nolint:gosec // This opt-in test explicitly selects the Terraform executable. + cmd := exec.CommandContext(t.Context(), terraform, arguments...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", output) + t.Logf("%s", output) + } + initArguments := []string{"init", "-backend=false", "-input=false", "-no-color"} + if pluginDir := os.Getenv("AZD_TEST_TERRAFORM_PLUGIN_DIR"); pluginDir != "" { + initArguments = append(initArguments, "-plugin-dir="+pluginDir) + } + run(initArguments...) + run("test", "-no-color") +} + +const ( + monitoringPlanAccount = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/project/" + + "providers/Microsoft.CognitiveServices/accounts/account" + monitoringPlanProject = monitoringPlanAccount + "/projects/project" + monitoringPlanGroup = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/monitoring" + monitoringPlanTestRoot = ` +terraform { + required_version = ">= 1.11.0, < 2.0.0" + required_providers { + azapi = { source = "Azure/azapi", version = "~> 2.7" } + } +} +variable "resource_token_salt" { default = "" } +variable "environment_name" { default = "guard-test" } +variable "tags" { default = {} } +locals { + normalized_project_id = "` + monitoringPlanProject + `" + foundry_account_id = "` + monitoringPlanAccount + `" + test_group_id = "` + monitoringPlanGroup + `" +} +` +) + +func monitoringPlanCases(t *testing.T) string { + t.Helper() + //nolint:gosec // Mirrors the production Terraform naming expression. + digest := sha1.Sum([]byte(strings.ToLower(monitoringPlanProject + "|" + monitoringPlanGroup + "|"))) + token := hex.EncodeToString(digest[:])[:13] + component := monitoringPlanGroup + "/providers/Microsoft.Insights/components/appi-" + token + workspace := monitoringPlanGroup + "/providers/Microsoft.OperationalInsights/workspaces/log-" + token + ownedTags := map[string]any{ + "azd-created-by": "azure.ai.projects", "azd-environment": "guard-test", + "azd-monitoring-project": monitoringPlanProject, + } + connection := func(name, target, auth string, shared bool) map[string]any { + return map[string]any{ + "name": name, "category": "AppInsights", "target": target, "auth": auth, "shared": shared, + "restricted": 0, "resource_id": nil, "creator": nil, "environment": nil, "project": nil, + } + } + name := MonitoringConnectionName("project") + owned := connection(name, component, "ApiKey", false) + maps.Copy(owned, map[string]any{ + "resource_id": component, "creator": "azure.ai.projects", "environment": "guard-test", + "project": monitoringPlanProject, + }) + changed := func(row map[string]any, key string, value any) map[string]any { + result := maps.Clone(row) + result[key] = value + return result + } + type planCase struct { + name string + mode string + project []any + account []any + slot map[string]any + component map[string]any + workspace map[string]any + accountRule map[string]any + owned bool + missingRows bool + failure string + } + cases := []planCase{ + {name: "fresh_create", mode: "create"}, + {name: "missing_connection_reuse", mode: "reuse-connect"}, + {name: "disabled_mode", mode: "none"}, + { + name: "preserve_managed_identity", mode: "already-connected", + project: []any{connection("external", component, "ManagedIdentity", false)}, + component: map[string]any{"local_auth": true, "ingestion": "Disabled"}, + }, + { + name: "preserve_inherited_aad", mode: "already-connected", + account: []any{connection("shared", component, "AAD", true)}, + component: map[string]any{"local_auth": true}, + }, + { + name: "preserve_account_mirror", mode: "already-connected", + project: []any{connection("external", component, "ApiKey", false)}, + account: []any{connection("external", component, "ApiKey", false)}, + }, + { + name: "ignore_unshared_sibling", mode: "create", + account: []any{connection("azd-appinsights-sibling", component+"-sibling", "ApiKey", false)}, + }, + { + name: "owned_repeat_create", mode: "create", owned: true, + project: []any{owned}, account: []any{owned}, slot: owned, + }, + {name: "missing_discovery_list", mode: "create", missingRows: true, failure: "terraform_data.monitoring_safety[0]"}, + { + name: "component_identity_mismatch", mode: "reuse-connect", + component: map[string]any{"resource_id": component + "-different"}, + failure: "data.azapi_resource.monitoring_component[0]", + }, + { + name: "workspace_identity_mismatch", mode: "reuse-connect", + workspace: map[string]any{"resource_id": workspace + "-different"}, + failure: "data.azapi_resource.monitoring_workspace[0]", + }, + { + name: "component_auth_disabled", mode: "reuse-connect", + component: map[string]any{"local_auth": true}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "component_ingestion_restricted", mode: "reuse-connect", + component: map[string]any{"ingestion": "Disabled"}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "workspace_ingestion_restricted", mode: "reuse-connect", + workspace: map[string]any{"public_access": "Disabled"}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "workspace_auth_disabled", mode: "reuse-connect", + workspace: map[string]any{"workspace_local_auth": true}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "account_identity_mismatch", mode: "create", + accountRule: map[string]any{"resource_id": monitoringPlanAccount + "-different"}, + failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "account_network_restricted", mode: "create", + accountRule: map[string]any{"default_action": "Deny"}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "protected_project_name", mode: "create", + project: []any{changed(owned, "category", "AzureOpenAI")}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "protected_account_name", mode: "create", + account: []any{connection(name, component, "ApiKey", false)}, + failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "protected_slot", mode: "create", + slot: connection(name, component, "ApiKey", false), + failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "restricted_account_sharing", mode: "create", + account: []any{changed(connection("restricted", component, "ApiKey", false), "restricted", 1)}, + failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "inherited_conflict", mode: "already-connected", + project: []any{connection("external", component, "ApiKey", false)}, + account: []any{connection("shared", component+"-different", "AAD", true)}, + failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "external_not_adopted", mode: "create", + project: []any{connection("external", component, "ApiKey", false)}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "existing_connection_missing", mode: "already-connected", failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "changed_owned_auth", mode: "create", owned: true, + project: []any{changed(owned, "auth", "AAD")}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "changed_owned_environment", mode: "create", owned: true, + project: []any{changed(owned, "environment", "other")}, failure: "terraform_data.monitoring_safety[0]", + }, + { + name: "existing_unowned_component", mode: "create", owned: true, + component: map[string]any{"test_unowned": true}, failure: "terraform_data.monitoring_safety[0]", + }, + } + var result strings.Builder + result.WriteString("mock_provider \"azapi\" { override_during = plan }\n") + encode := func(value any) string { + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) + } + policy := func(id string, overrides map[string]any) map[string]any { + value := map[string]any{ + "present": true, "resource_id": id, "local_auth": nil, "ingestion": nil, + "public_access": nil, "default_action": nil, "workspace_local_auth": nil, + } + maps.Copy(value, overrides) + return value + } + for _, tt := range cases { + fmt.Fprintf(&result, "run %q {\n command = plan\n variables {\n monitoring_mode = %q\n"+ + " application_insights_resource_id = %q\n }\n", tt.name, tt.mode, component) + if tt.mode != "none" { + componentPolicy := policy(component, tt.component) + maps.Copy(componentPolicy, map[string]any{"app_id": "test-app-id", "workspace_id": workspace}) + if tt.project == nil { + tt.project = []any{} + } + if tt.account == nil { + tt.account = []any{} + } + var rows any = tt.project + if tt.missingRows { + rows = nil + } + tags := map[string]any{} + if tt.owned && tt.component["test_unowned"] != true { + tags = ownedTags + } + values := map[string]any{ + "data.azapi_resource.monitoring_project[0]": map[string]any{ + "output": policy(monitoringPlanProject, nil), + }, + "data.azapi_resource.monitoring_account[0]": map[string]any{ + "output": policy(monitoringPlanAccount, tt.accountRule), + }, + "data.azapi_resource_list.monitoring_project_connections[0]": map[string]any{ + "output": map[string]any{"connections": rows}, + }, + "data.azapi_resource_list.monitoring_account_connections[0]": map[string]any{ + "output": map[string]any{"connections": tt.account}, + }, + "data.azapi_resource.monitoring_component[0]": map[string]any{ + "exists": tt.mode != "create" || tt.owned, "output": componentPolicy, "tags": tags, + }, + } + if tt.mode == "create" { + values["data.azapi_resource.monitoring_owned_workspace[0]"] = map[string]any{ + "exists": tt.owned, "output": policy(workspace, tt.workspace), "tags": ownedTags, + } + } else { + values["data.azapi_resource.monitoring_workspace[0]"] = map[string]any{ + "exists": true, "output": policy(workspace, tt.workspace), + } + } + if tt.mode != "already-connected" { + values["data.azapi_resource.monitoring_connection_slot[0]"] = map[string]any{ + "exists": tt.slot != nil, "output": tt.slot, + } + } + for _, target := range slices.Sorted(maps.Keys(values)) { + fmt.Fprintf(&result, " override_data {\n target = %s\n values = %s\n }\n", target, encode(values[target])) + } + } + if tt.failure != "" { + fmt.Fprintf(&result, " expect_failures = [%s]\n", tt.failure) + } else { + count := 1 + if tt.mode == "none" { + count = 0 + } + fmt.Fprintf(&result, " assert {\n condition = length(terraform_data.monitoring_safety) == %d\n"+ + " error_message = \"The monitoring guard count changed.\"\n }\n", count) + } + result.WriteString("}\n") + } + return result.String() +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_test.go new file mode 100644 index 00000000000..be9ca1cab12 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderTerraformMonitoring(t *testing.T) { + const component = "/subscriptions/sub/resourceGroups/monitoring/providers/Microsoft.Insights/components/component" + for _, existing := range []bool{false, true} { + for _, mode := range []string{"none", "create", "reuse-connect", "already-connected"} { + name := mode + if existing { + name = "existing/" + name + } + t.Run(name, func(t *testing.T) { + result, err := RenderTerraformMonitoring(map[string]any{ + "monitoringMode": mode, "applicationInsightsResourceId": component, + }, existing) + if existing && mode == "create" { + require.ErrorContains(t, err, "not supported") + return + } + require.NoError(t, err) + if mode == "none" { + assert.Empty(t, result.Files) + assert.Empty(t, result.Variables) + return + } + source := string(result.Files["monitoring.tf"]) + assert.Contains(t, source, `ephemeral "azapi_resource_action" "monitoring_credential"`) + assert.Contains(t, source, `sensitive_body_version = { "properties.credentials.key" = "1" }`) + assert.Contains(t, source, `ignore_not_found`) + assert.NotContains(t, source, `response_export_values = ["*"]`) + assert.NotContains(t, source, `ApplicationInsightsConnectionString`) + assert.Contains(t, source, `count = local.monitoring_managed ? 1 : 0`) + assert.NotContains(t, source, `count = length(local.monitoring_appinsights)`) + assert.Equal(t, mode, result.Variables["monitoring_mode"]) + assert.Equal(t, component, result.Variables["application_insights_resource_id"]) + assert.Equal(t, mode == "create", result.CreatesResources) + if existing { + assert.Contains(t, source, "= local.normalized_project_id") + assert.NotContains(t, result.Files, "provider.tf", "retain the existing >=2.11 provider baseline") + assert.NotContains(t, result.Files, "adjunct-resource-group.tf") + } else { + assert.Contains(t, string(result.Files["provider.tf"]), `required_version = ">= 1.11.0, < 2.0.0"`) + assert.Contains(t, string(result.Files["provider.tf"]), `version = "~> 2.7"`) + assert.Contains(t, source, "= azapi_resource.project.id") + } + }) + } + } +} + +func TestRenderTerraformMonitoringRejectsInvalidModes(t *testing.T) { + for _, parameters := range []map[string]any{ + {"monitoringMode": false}, + {"monitoringMode": "invalid"}, + {"monitoringMode": "create"}, + {"monitoringMode": "reuse-connect", "applicationInsightsResourceId": ""}, + {"monitoringMode": "already-connected", "applicationInsightsResourceId": 42}, + } { + _, err := RenderTerraformMonitoring(parameters, false) + assert.Error(t, err) + } + result, err := RenderTerraformMonitoring(map[string]any{}, false) + require.NoError(t, err) + assert.Empty(t, result.Files, "older callers without monitoring retain their existing Terraform graph") +} From 28f72f7ea8553d309f8f090709fc9045219ea650 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 09:01:54 -0800 Subject: [PATCH 02/14] test(foundry): cover eject with a module named monitoring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../internal/cmd/init_infra_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 0632ff7d996..3099650e8de 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 @@ -555,6 +555,41 @@ services: assert.NoFileExists(t, filepath.Join(dir, "custom", "foundry", "main.bicep")) } +func TestEjectInfra_ModuleNamedMonitoringDoesNotCollideWithMonitoringTemplate(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "azure.yaml"), `name: my-project +infra: + provider: bicep + layers: + - name: app + path: infra/app + - name: foundry + path: infra/foundry + module: monitoring + provider: microsoft.foundry + dependsOn: [app] +services: + my-foundry: + host: azure.ai.project +`) + + require.NoError(t, ejectInfra(dir, "bicep")) + entrypoint, err := os.ReadFile(filepath.Join(dir, "infra", "foundry", "monitoring.bicep")) + require.NoError(t, err) + moduleTemplate, err := os.ReadFile(filepath.Join(dir, "infra", "foundry", "modules", "monitoring.bicep")) + require.NoError(t, err) + embedded, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/monitoring.bicep") + require.NoError(t, err) + mainTemplate, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/main.bicep") + require.NoError(t, err) + + assert.Equal(t, string(mainTemplate), string(entrypoint), "entrypoint must stay the renamed main.bicep") + assert.Equal(t, string(embedded), string(moduleTemplate), "monitoring module must be the embedded template") + assert.Contains(t, string(entrypoint), "modules/monitoring.bicep") + assert.FileExists(t, filepath.Join(dir, "infra", "foundry", "monitoring.parameters.json")) +} + func TestEjectInfra_RefusesNonEmptyDeclaredFoundryTarget(t *testing.T) { t.Parallel() dir := t.TempDir() From e1b76f671c598a3d74233286787c243b9edc0c75 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 09:04:01 -0800 Subject: [PATCH 03/14] test(foundry): cover existing-project eject with a module named monitoring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../internal/cmd/project_transaction_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go index 0901021a409..bc75f839a4d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go @@ -6,6 +6,7 @@ package cmd import ( "context" "errors" + "io/fs" "net" "os" "os/exec" @@ -889,6 +890,25 @@ func TestExistingProjectEjectionDoesNotEmitReplacedRegistryState(t *testing.T) { } } +func TestWriteExistingProjectBicepModuleNamedMonitoringDoesNotCollide(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeExistingProjectBicep( + dir, "monitoring", map[string]any{"deployments": []synthesis.Deployment{}}, projectEjectAcrNone, nil, + )) + + entrypoint, err := os.ReadFile(filepath.Join(dir, "monitoring.bicep")) + require.NoError(t, err) + moduleTemplate, err := os.ReadFile(filepath.Join(dir, "modules", "monitoring.bicep")) + require.NoError(t, err) + embedded, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/monitoring.bicep") + require.NoError(t, err) + + assert.Equal(t, string(embedded), string(moduleTemplate), "monitoring module must be the embedded template") + assert.NotEqual(t, string(embedded), string(entrypoint), "entrypoint must not be the monitoring module") + assert.Contains(t, string(entrypoint), "modules/monitoring.bicep") + assert.FileExists(t, filepath.Join(dir, "modules", "monitoring-connection.bicep")) +} + func TestWriteExistingProjectBicepAcrModes(t *testing.T) { params := map[string]any{ "deployments": []synthesis.Deployment{}, From b16cddb6254b69c0742b0aeebdedbca84891bbd3 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 09:09:48 -0800 Subject: [PATCH 04/14] test(foundry): annotate temp-dir reads for gosec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.projects/internal/cmd/project_transaction_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go index bc75f839a4d..ef4af76fb18 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_transaction_test.go @@ -896,8 +896,10 @@ func TestWriteExistingProjectBicepModuleNamedMonitoringDoesNotCollide(t *testing dir, "monitoring", map[string]any{"deployments": []synthesis.Deployment{}}, projectEjectAcrNone, nil, )) + // #nosec G304 -- test reads from its own temp directory entrypoint, err := os.ReadFile(filepath.Join(dir, "monitoring.bicep")) require.NoError(t, err) + // #nosec G304 -- test reads from its own temp directory moduleTemplate, err := os.ReadFile(filepath.Join(dir, "modules", "monitoring.bicep")) require.NoError(t, err) embedded, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/monitoring.bicep") From bf01f71189d1f8de0276070385364ffc28b5f77d Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 09:30:57 -0800 Subject: [PATCH 05/14] Trigger Agents release pipeline on shared synthesis module changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- eng/pipelines/release-ext-azure-ai-agents.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eng/pipelines/release-ext-azure-ai-agents.yml b/eng/pipelines/release-ext-azure-ai-agents.yml index 8743a5c5942..42a50c9f14a 100644 --- a/eng/pipelines/release-ext-azure-ai-agents.yml +++ b/eng/pipelines/release-ext-azure-ai-agents.yml @@ -6,6 +6,7 @@ trigger: paths: include: - cli/azd/extensions/azure.ai.agents + - cli/azd/extensions/azure.ai.projects/synthesis - cli/azd/extensions/scripts/ci-build.ps1 - /eng/pipelines/templates/stages/release-azd-extension.yml - /eng/pipelines/templates/stages/publish-extension-pr.yml @@ -18,6 +19,7 @@ pr: paths: include: - cli/azd/extensions/azure.ai.agents + - cli/azd/extensions/azure.ai.projects/synthesis - cli/azd/extensions/scripts/ci-build.ps1 - eng/pipelines/release-ext-azure-ai-agents.yml - /eng/pipelines/templates/stages/release-azd-extension.yml From ed60b915a68c28201c8879b6cb5d028d906c5743 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 10:19:04 -0800 Subject: [PATCH 06/14] Reject automatic monitoring for network-isolated new projects Automatic App Insights setup creates public ingestion, which a network-isolated project cannot use. Fail synthesis with the same message as the existing-project path unless monitoring is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 + .../internal/cmd/init_infra_test.go | 8 ++++ .../synthesis/hosting_test.go | 2 + .../synthesis/monitoring_test.go | 43 +++++++++++++++++++ .../synthesis/synthesizer.go | 6 +++ .../synthesis/synthesizer_test.go | 12 ++++-- 6 files changed, 69 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 23511c58cc8..5c2ffa28084 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,6 +8,8 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. +Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore fails synthesis unless you set `monitoring: {enabled: false}` on the project service. + For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, and cannot be combined with private networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. Legacy `network.mode`, `network.byo`, and `network.managed` fields are no 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 3099650e8de..4f5d2175e75 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 @@ -2186,6 +2186,8 @@ services: dns: resourceGroup: rg-dns subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" + monitoring: + enabled: false deployments: [] agents: - name: my-agent @@ -2247,6 +2249,8 @@ services: vnet: "${AZURE_VNET_ID}" name: pe-subnet prefix: 192.168.11.0/24 + monitoring: + enabled: false deployments: [] agents: - name: my-agent @@ -2325,6 +2329,8 @@ services: network: agentSubnet: {vnet: "${VNET_ID}", name: agents} peSubnet: {vnet: "${VNET_ID}", name: endpoints} + monitoring: + enabled: false ` t.Run("Bicep preserves the provider and unresolved inputs", func(t *testing.T) { dir := t.TempDir() @@ -3743,6 +3749,8 @@ services: host: azure.ai.project network: peSubnet: {vnet: "${AZURE_VNET_ID}", name: pe-subnet} + monitoring: + enabled: false deployments: [] agents: - name: my-agent diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go index 6023744a815..4935f045dc2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go @@ -29,6 +29,8 @@ func hostingYAML() string { network: agentSubnet: {vnet: ` + hostingVNetID + `, name: agents} peSubnet: {vnet: ` + hostingVNetID + `, name: endpoints} + monitoring: + enabled: false ` } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index b4d3742b645..a030280b3e6 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -114,6 +114,49 @@ func TestSynthesizeMonitoring(t *testing.T) { } } +func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { + t.Parallel() + const vnet = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/net/providers/" + + "Microsoft.Network/virtualNetworks/vnet" + const component = "/subscriptions/00000000-0000-0000-0000-000000000001" + + "/resourceGroups/monitoring/providers/Microsoft.Insights/components/traces" + networks := map[string]string{ + "managed egress": " network:\n isolationMode: AllowInternetOutbound\n" + + " peSubnet: {vnet: " + vnet + ", name: pe-subnet, prefix: 192.168.1.0/24}\n", + "BYO egress": " network:\n" + + " agentSubnet: {vnet: " + vnet + ", name: agent-subnet, prefix: 192.168.0.0/24}\n" + + " peSubnet: {vnet: " + vnet + ", name: pe-subnet, prefix: 192.168.1.0/24}\n", + } + monitoring := []struct { + name string + settings string + wantErr bool + }{ + {name: "default", wantErr: true}, + {name: "empty block", settings: " monitoring: {}\n", wantErr: true}, + {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", wantErr: true}, + {name: "explicit opt out", settings: " monitoring:\n enabled: false\n"}, + } + for networkName, network := range networks { + for _, tt := range monitoring { + t.Run(networkName+" "+tt.name, func(t *testing.T) { + t.Parallel() + result, err := Synthesize(Input{ + RawAzureYAML: []byte("services:\n foundry:\n host: azure.ai.project\n" + network + tt.settings), + ServiceName: "foundry", + }) + if tt.wantErr { + require.ErrorContains(t, err, "does not configure private-network ingestion") + require.ErrorContains(t, err, "monitoring.enabled: false") + return + } + require.NoError(t, err) + assert.Equal(t, "none", result.Parameters["monitoringMode"]) + }) + } + } +} + func TestSynthesizeMonitoringInfrastructureAuthority(t *testing.T) { t.Parallel() for _, synthesize := range []struct { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 44e00f83744..81a06dc998b 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -277,6 +277,12 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { + return nil, errors.New( + "synthesis: automatic monitoring does not configure private-network ingestion; " + + "set monitoring.enabled: false on the project service", + ) + } maps.Copy(params, monitoring) if hosting != nil { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go index d38c5022a47..3b3d9cac3c2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer_test.go @@ -616,7 +616,7 @@ services: dns: resourceGroup: rg-dns subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" -` +` + networkTestMonitoringOff res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", @@ -944,6 +944,10 @@ func TestARMTemplate_IsValidJSONWithExpectedShape(t *testing.T) { "private endpoint location must come from the customer VNet") } +// networkTestMonitoringOff keeps network tests focused on networking: automatic +// monitoring is rejected for private networks. +const networkTestMonitoringOff = " monitoring: {enabled: false}\n" + func TestSynthesize_Network(t *testing.T) { t.Setenv("AZURE_VNET_ID", "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/"+ @@ -1107,7 +1111,7 @@ services: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { res, err := Synthesize(Input{ - RawAzureYAML: []byte(tt.yaml), + RawAzureYAML: []byte(tt.yaml + networkTestMonitoringOff), ServiceName: "my-project", AcceptedHosts: []string{"azure.ai.project"}, }) @@ -1581,7 +1585,7 @@ services: peSubnet: {vnet: "${MISSING_VNET_XYZ:-` + fallbackVNet + `}", name: pe-subnet} dns: subscription: "${MISSING_SUB_XYZ:-` + fallbackSub + `}" -` +` + networkTestMonitoringOff res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", @@ -1606,7 +1610,7 @@ services: host: azure.ai.project network: peSubnet: {vnet: "` + ref + `", name: pe-subnet} -` +` + networkTestMonitoringOff res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", From 5c975fd6ec0a50bd6c22665846d6f93d2c78716d Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 10:22:13 -0800 Subject: [PATCH 07/14] Document monitoring limit for network-isolated projects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.projects/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 02f8e66ed8d..910fde2a92a 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -187,7 +187,9 @@ No monitoring RBAC roles are added. Discovery rejects conflicting destinations, protected names, ambiguous sharing, unreadable resources, and authentication or ingestion policies that would need to be weakened. ApiKey setup does not enable local authentication or configure -private-network ingestion. Such failures can fail normal provisioning; there +private-network ingestion. A new project with a `network:` block is rejected +unless you set `monitoring.enabled: false`, because automatic monitoring cannot +yet use private ingestion. Such failures can fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. Monitoring creates billable Azure resources and can store agent telemetry, From ae1d13ac50f6aa4ff379214a62243c922387369b Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 12:08:56 -0800 Subject: [PATCH 08/14] Render greenfield graph in Terraform monitoring plan test Existing-project creation is no longer supported, so the opt-in test now renders the new-project graph and maps its resource references to the test locals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../synthesis/terraform_monitoring_plan_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go index 9942d9f0780..f3f79fe8665 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go @@ -28,13 +28,21 @@ func TestTerraformMonitoringSafetyPlans(t *testing.T) { } rendered, err := RenderTerraformMonitoring(map[string]any{ "monitoringMode": "create", "applicationInsightsResourceId": "", - }, true) + }, false) require.NoError(t, err) preflight, managed, found := strings.Cut(string(rendered.Files["monitoring.tf"]), "# Managed counts") require.True(t, found) require.Contains(t, managed, `ephemeral "azapi_resource_action"`) require.Contains(t, managed, `resource "azapi_resource" "monitoring_connection"`) - preflight = strings.ReplaceAll(preflight, "azurerm_resource_group.adjunct.id", "local.test_group_id") + // Greenfield graphs reference resources the preflight-only test root does not declare. + for reference, local := range map[string]string{ + "azapi_resource.project.id": "local.normalized_project_id", + "azapi_resource.foundry_account.id": "local.foundry_account_id", + "azurerm_resource_group.this.id": "local.test_group_id", + } { + require.Contains(t, preflight, reference) + preflight = strings.ReplaceAll(preflight, reference, local) + } dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "monitoring.tf"), []byte(preflight), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(monitoringPlanTestRoot), 0o600)) From fdb3d0666c7226620a3388909dfc5c34ca088413 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 12:57:19 -0800 Subject: [PATCH 09/14] Document Log Analytics Reader requirement for trace queries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.projects/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 910fde2a92a..9f40f2c821b 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -183,7 +183,9 @@ azd-owned supporting resource group's cleanup binding. New connections use ApiKey authentication, are scoped to one project, and are named `azd-appinsights-` to stay within resource name limits. -No monitoring RBAC roles are added. +No monitoring RBAC roles are added. Each user who queries traces needs the +Log Analytics Reader role on the Application Insights resource; azd does not +grant it. Discovery rejects conflicting destinations, protected names, ambiguous sharing, unreadable resources, and authentication or ingestion policies that would need to be weakened. ApiKey setup does not enable local authentication or configure From 3cd422dcf553c3ac4d3a95c1c42540c4dac5b74a Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Tue, 3 Nov 2026 08:20:23 -0800 Subject: [PATCH 10/14] Skip automatic monitoring by default for private-network projects Projects with a network: block that omit monitoring: no longer fail synthesis; explicitly enabling monitoring there is still rejected. Fix stale existing-project wording in the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.agents/docs/private-networking.md | 2 +- cli/azd/extensions/azure.ai.projects/README.md | 14 ++++++++------ .../azure.ai.projects/synthesis/monitoring_test.go | 2 +- .../azure.ai.projects/synthesis/synthesizer.go | 12 ++++++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 5c2ffa28084..6a6c49565a8 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,7 +8,7 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. -Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore fails synthesis unless you set `monitoring: {enabled: false}` on the project service. +Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring when `monitoring:` is omitted, so existing private-network projects keep provisioning unchanged. Explicitly enabling monitoring on such a project fails synthesis; set `monitoring: {enabled: false}` (or remove the block). For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, and cannot be combined with private networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 9f40f2c821b..1ec5dbeb598 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -189,9 +189,11 @@ grant it. Discovery rejects conflicting destinations, protected names, ambiguous sharing, unreadable resources, and authentication or ingestion policies that would need to be weakened. ApiKey setup does not enable local authentication or configure -private-network ingestion. A new project with a `network:` block is rejected -unless you set `monitoring.enabled: false`, because automatic monitoring cannot -yet use private ingestion. Such failures can fail normal provisioning; there +private-network ingestion. A project with a `network:` block skips automatic +monitoring when `monitoring:` is omitted. Explicitly enabling monitoring (an +empty `monitoring: {}` block, or `applicationInsightsResourceId`) is rejected, +because automatic monitoring cannot yet use private ingestion. Such failures can +fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. Monitoring creates billable Azure resources and can store agent telemetry, @@ -217,9 +219,9 @@ YAML and resolvable local references, even for values no longer used by IaC. Generated Bicep calls `modules/monitoring.bicep`; Terraform declares monitoring in `monitoring.tf`. Monitoring modes are `none`, `create`, `reuse-connect`, and `already-connected`. The last mode references compatible external monitoring -without managing its connection. Existing-project creation shares the normal -supporting resource group used by ACR rather than creating a separate monitoring -resource group. Native/Bicep preview uses normal ARM What-If. +without managing its connection. Creating monitoring for a new project shares +the normal supporting resource group used by ACR rather than creating a separate +monitoring resource group. Native/Bicep preview uses normal ARM What-If. Monitoring-enabled Terraform exports require Terraform 1.11 or later and AzAPI 2.7 or later (existing-project exports retain their higher provider baseline). diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index a030280b3e6..a6b8510cbe5 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -132,7 +132,7 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { settings string wantErr bool }{ - {name: "default", wantErr: true}, + {name: "default"}, {name: "empty block", settings: " monitoring: {}\n", wantErr: true}, {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", wantErr: true}, {name: "explicit opt out", settings: " monitoring:\n enabled: false\n"}, diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 81a06dc998b..429130c8808 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -278,10 +278,14 @@ func Synthesize(in Input) (*Result, error) { return nil, err } if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { - return nil, errors.New( - "synthesis: automatic monitoring does not configure private-network ingestion; " + - "set monitoring.enabled: false on the project service", - ) + if svc.Monitoring.Kind != 0 { + return nil, errors.New( + "synthesis: automatic monitoring does not configure private-network ingestion; " + + "set monitoring.enabled: false on the project service", + ) + } + // Monitoring was not requested, so keep existing private-network projects working. + monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} } maps.Copy(params, monitoring) From c20b8152f21525302c543c6041370b01dbca8995 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Tue, 3 Nov 2026 08:30:03 -0800 Subject: [PATCH 11/14] Never fail provisioning for private-network projects over monitoring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.agents/docs/private-networking.md | 2 +- cli/azd/extensions/azure.ai.projects/README.md | 9 ++++----- .../azure.ai.projects/synthesis/monitoring_test.go | 11 +++-------- .../azure.ai.projects/synthesis/synthesizer.go | 9 ++------- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 6a6c49565a8..191aabac783 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,7 +8,7 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. -Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring when `monitoring:` is omitted, so existing private-network projects keep provisioning unchanged. Explicitly enabling monitoring on such a project fails synthesis; set `monitoring: {enabled: false}` (or remove the block). +Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring, whether `monitoring:` is omitted, empty, or names a component, so provisioning is never blocked. Set up monitoring for such projects outside azd. For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, and cannot be combined with private networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 1ec5dbeb598..03636c041ac 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -189,11 +189,10 @@ grant it. Discovery rejects conflicting destinations, protected names, ambiguous sharing, unreadable resources, and authentication or ingestion policies that would need to be weakened. ApiKey setup does not enable local authentication or configure -private-network ingestion. A project with a `network:` block skips automatic -monitoring when `monitoring:` is omitted. Explicitly enabling monitoring (an -empty `monitoring: {}` block, or `applicationInsightsResourceId`) is rejected, -because automatic monitoring cannot yet use private ingestion. Such failures can -fail normal provisioning; there +private-network ingestion. A project with a `network:` block always skips +automatic monitoring, whether `monitoring:` is omitted, empty, or names a +component, so provisioning is never blocked; set up monitoring for such +projects outside azd. Other monitoring failures can fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. Monitoring creates billable Azure resources and can store agent telemetry, diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index a6b8510cbe5..b66e01c10f7 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -130,11 +130,10 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { monitoring := []struct { name string settings string - wantErr bool }{ {name: "default"}, - {name: "empty block", settings: " monitoring: {}\n", wantErr: true}, - {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", wantErr: true}, + {name: "empty block", settings: " monitoring: {}\n"}, + {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n"}, {name: "explicit opt out", settings: " monitoring:\n enabled: false\n"}, } for networkName, network := range networks { @@ -145,13 +144,9 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { RawAzureYAML: []byte("services:\n foundry:\n host: azure.ai.project\n" + network + tt.settings), ServiceName: "foundry", }) - if tt.wantErr { - require.ErrorContains(t, err, "does not configure private-network ingestion") - require.ErrorContains(t, err, "monitoring.enabled: false") - return - } require.NoError(t, err) assert.Equal(t, "none", result.Parameters["monitoringMode"]) + assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) }) } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 429130c8808..28be237bb04 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -278,13 +278,8 @@ func Synthesize(in Input) (*Result, error) { return nil, err } if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { - if svc.Monitoring.Kind != 0 { - return nil, errors.New( - "synthesis: automatic monitoring does not configure private-network ingestion; " + - "set monitoring.enabled: false on the project service", - ) - } - // Monitoring was not requested, so keep existing private-network projects working. + // Automatic monitoring cannot use private ingestion yet. Skip it rather than + // failing so private-network projects keep provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} } maps.Copy(params, monitoring) From 638afac14733555d1198f1cc764f2ef96b8cc3f7 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Tue, 3 Nov 2026 08:30:59 -0800 Subject: [PATCH 12/14] Skip monitoring in preflight for network-isolated templates instead of failing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../provisioning/monitoring_preflight.go | 5 +++-- .../provisioning/monitoring_preflight_test.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index 466cb8828e5..cd2f23c104d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -134,8 +134,9 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return fmt.Errorf("the declared existing AppInsights connection was not found; no connection was created") } if enabled, present := templateParameter(source, "enableNetworkIsolation"); present && enabled != false { - return fmt.Errorf("automatic monitoring does not configure private-network ingestion; " + - "preserve the existing connection or disable monitoring setup") + // Private-network ingestion is unsupported; skip monitoring instead of failing provisioning. + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + return nil } for _, resource := range []*armresources.GenericResource{account, projectResource} { if resource == nil { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index 8ec14d60b40..8b1ac2a1ccb 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -473,6 +473,22 @@ func TestMonitoringPreflightImplicitExistingProjectIsNotConsent(t *testing.T) { assert.Len(t, fixture.requests, 3, "only deployment binding, What-If and project identity are read; no setup or discovery") } +func TestMonitoringPreflightPrivateNetworkSkipsMonitoring(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("create") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "none", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) +} + func TestMonitoringPreflightOwnedExistingProjectRetainsCreateGraph(t *testing.T) { t.Parallel() source := monitoringTestSource("create") From 9f94c196629ae1fa71e0563cc28926c5dfacc1bf Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Tue, 3 Nov 2026 08:36:33 -0800 Subject: [PATCH 13/14] Warn when automatic monitoring is skipped for private-network projects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.agents/docs/private-networking.md | 2 +- .../azure.ai.agents/internal/cmd/init_infra.go | 3 +++ cli/azd/extensions/azure.ai.projects/README.md | 3 ++- .../internal/provisioning/monitoring_preflight.go | 6 ++++++ .../azure.ai.projects/synthesis/monitoring_test.go | 5 +++++ .../azure.ai.projects/synthesis/synthesizer.go | 13 +++++++++++-- 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 191aabac783..02f6783a276 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,7 +8,7 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. -Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring, whether `monitoring:` is omitted, empty, or names a component, so provisioning is never blocked. Set up monitoring for such projects outside azd. +Automatic Application Insights monitoring does not configure private-network ingestion yet. A project with `network:` therefore skips automatic monitoring, whether `monitoring:` is omitted, empty, or names a component, so provisioning is never blocked, and azd prints a warning when it skips monitoring. Set up monitoring for such projects outside azd. For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, and cannot be combined with private networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. 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 1742ea51d8c..cbda5b0e99b 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 @@ -606,6 +606,9 @@ func prepareInfraEject( "check the endpoint, deployments, and network fields under your azure.ai.project service", ) } + for _, warning := range res.Warnings { + fmt.Fprintln(os.Stderr, color.YellowString("Warning: "+warning)) + } deployments, present, err := project.DecodeProjectDeploymentsEnv(environment[projectDeploymentsEnvKey]) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 03636c041ac..923b456451a 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -191,7 +191,8 @@ unreadable resources, and authentication or ingestion policies that would need to be weakened. ApiKey setup does not enable local authentication or configure private-network ingestion. A project with a `network:` block always skips automatic monitoring, whether `monitoring:` is omitted, empty, or names a -component, so provisioning is never blocked; set up monitoring for such +component, so provisioning is never blocked; azd prints a warning when it +skips monitoring. Set up monitoring for such projects outside azd. Other monitoring failures can fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index cd2f23c104d..6206f9f6505 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -57,6 +57,11 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( client *armresources.DeploymentsClient, progress grpcbroker.ProgressFunc, ) error { + if p.synthResult != nil { + for _, warning := range p.synthResult.Warnings { + progress("Warning: " + warning) + } + } mode, err := monitoringParameter(source, "monitoringMode") if err != nil { return err @@ -136,6 +141,7 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if enabled, present := templateParameter(source, "enableNetworkIsolation"); present && enabled != false { // Private-network ingestion is unsupported; skip monitoring instead of failing provisioning. source.parameters["monitoringMode"] = map[string]any{"value": "none"} + progress("Warning: " + synthesis.PrivateNetworkMonitoringWarning) return nil } for _, resource := range []*armresources.GenericResource{account, projectResource} { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index b66e01c10f7..3fb5165ddec 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -147,6 +147,11 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { require.NoError(t, err) assert.Equal(t, "none", result.Parameters["monitoringMode"]) assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) + if tt.name == "explicit opt out" { + assert.Empty(t, result.Warnings) + } else { + assert.Equal(t, []string{PrivateNetworkMonitoringWarning}, result.Warnings) + } }) } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 28be237bb04..626c10c6d79 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -102,6 +102,9 @@ type Result struct { // MonitoringExplicit distinguishes authored monitoring from the new-project // default, so an upgrade cannot opt an already-provisioned project into setup. MonitoringExplicit bool + // Warnings are user-facing notices about settings that were adjusted, such as + // skipped automatic monitoring. + Warnings []string // ACRMode is the greenfield registry mode: create, // reuse-connect, or none. ACRMode string @@ -277,10 +280,11 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } + var warnings []string if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { - // Automatic monitoring cannot use private ingestion yet. Skip it rather than - // failing so private-network projects keep provisioning. + // Private-network ingestion is not supported yet; skip rather than fail provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} + warnings = append(warnings, PrivateNetworkMonitoringWarning) } maps.Copy(params, monitoring) @@ -296,9 +300,14 @@ func Synthesize(in Input) (*Result, error) { NetworkMode: netMode, MonitoringExplicit: !in.InfrastructureOwnsMonitoring && svc.Monitoring.Kind != 0, Hosting: hosting, + Warnings: warnings, }, nil } +// PrivateNetworkMonitoringWarning is shown when automatic monitoring is skipped for a private network. +const PrivateNetworkMonitoringWarning = "Automatic Application Insights monitoring is not supported for projects " + + "with a network: block yet, so monitoring was not configured. Agent traces will not be collected automatically." + // GreenfieldACRMode distinguishes selectors from deployed outputs. func GreenfieldACRMode(in Input) (string, error) { mode, _, err := ResolveGreenfieldACRSelection(in) From 4f930b5879a95a536a3f57fd8a79e03e8c479818 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Wed, 4 Nov 2026 13:11:36 -0800 Subject: [PATCH 14/14] Bound monitoring ownership tag to a hash The raw project resource ID can exceed Azure's 256-character tag value limit and fail provisioning. Tag the workspace and component with a UUIDv5 of the lowercased project ID, using the same namespace in Bicep and Terraform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.projects/synthesis/monitoring_test.go | 14 ++++++++++++++ .../synthesis/templates/existing-project.arm.json | 6 +++--- .../synthesis/templates/main.arm.json | 6 +++--- .../synthesis/templates/modules/monitoring.bicep | 3 ++- .../templates/terraform/monitoring.tf.tmpl | 9 ++++++--- .../synthesis/terraform_monitoring_plan_test.go | 4 +++- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index 3fb5165ddec..3b77b187d60 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -5,12 +5,26 @@ package synthesis import ( "fmt" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// Azure tag values are limited to 256 characters and a valid project resource ID can exceed that. +func TestMonitoringOwnershipTagIsBounded(t *testing.T) { + bicep, err := os.ReadFile("templates/modules/monitoring.bicep") + require.NoError(t, err) + assert.Contains(t, string(bicep), "'azd-monitoring-project': guid(toLower(projectResourceId))") + + tf, err := os.ReadFile("templates/terraform/monitoring.tf.tmpl") + require.NoError(t, err) + assert.Contains(t, string(tf), `"azd-monitoring-project" = local.monitoring_project_tag`) + assert.NotContains(t, strings.ReplaceAll(string(tf), "\r\n", "\n"), `"azd-monitoring-project" = local.monitoring_project_id`) +} + func TestSynthesizeMonitoring(t *testing.T) { const component = "/subscriptions/00000000-0000-0000-0000-000000000001" + "/resourceGroups/monitoring/providers/Microsoft.Insights/components/traces" diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 380a1bbcea4..ceba6435160 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "11625777883654081427" + "templateHash": "1719455477080304736" } }, "definitions": { @@ -795,7 +795,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "3471042481280362115" } }, "parameters": { @@ -844,7 +844,7 @@ "projectParts": "[split(parameters('projectResourceId'), '/')]", "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", - "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', parameters('projectResourceId')))]", + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]", "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" }, "resources": [ diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index faf0857d1a5..75c637662cd 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "5696676974240649629" + "templateHash": "10056195470861368533" } }, "definitions": { @@ -2451,7 +2451,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "3471042481280362115" } }, "parameters": { @@ -2500,7 +2500,7 @@ "projectParts": "[split(parameters('projectResourceId'), '/')]", "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", - "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', parameters('projectResourceId')))]", + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]", "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" }, "resources": [ diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index af238cde858..7557b7a2e01 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -24,7 +24,8 @@ var connectionName = 'azd-appinsights-${take(replace(guid(toLower(projectParts[1 var monitoringTags = union(tags, { 'azd-created-by': 'azure.ai.projects' 'azd-environment': environmentName - 'azd-monitoring-project': projectResourceId + // A bounded hash: the raw project ID can exceed the 256-character tag value limit. + 'azd-monitoring-project': guid(toLower(projectResourceId)) }) resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (createResources) { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl index bdd4c3ae49c..4dd12ef745b 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl @@ -37,10 +37,13 @@ locals { monitoring_token = substr(sha1(lower("${local.monitoring_project_id}|${local.monitoring_group_id}|${var.resource_token_salt}")), 0, 13) monitoring_component_id = var.monitoring_mode == "create" ? "${local.monitoring_group_id}/providers/Microsoft.Insights/components/appi-${local.monitoring_token}" : var.application_insights_resource_id monitoring_workspace_id = "${local.monitoring_group_id}/providers/Microsoft.OperationalInsights/workspaces/log-${local.monitoring_token}" + # A bounded hash: the raw project ID can exceed the 256-character tag value limit. + # Same namespace as the Bicep guid() so ejected Bicep and Terraform agree. + monitoring_project_tag = uuidv5("11fb06fb-712d-4ddd-98c7-e71bbd588830", lower(local.monitoring_project_id)) monitoring_tags = merge(var.tags, { "azd-created-by" = "azure.ai.projects" "azd-environment" = var.environment_name - "azd-monitoring-project" = local.monitoring_project_id + "azd-monitoring-project" = local.monitoring_project_tag }) monitoring_policy_fields = { present = "properties != `null`" @@ -258,13 +261,13 @@ resource "terraform_data" "monitoring_safety" { (data.azapi_resource.monitoring_component[0].exists ? ( try(data.azapi_resource.monitoring_component[0].tags["azd-created-by"], "") == "azure.ai.projects" && try(data.azapi_resource.monitoring_component[0].tags["azd-environment"], "") == var.environment_name && - try(lower(data.azapi_resource.monitoring_component[0].tags["azd-monitoring-project"]), "") == lower(local.monitoring_project_id) && + try(lower(data.azapi_resource.monitoring_component[0].tags["azd-monitoring-project"]), "") == local.monitoring_project_tag && try(lower(data.azapi_resource.monitoring_component[0].output.workspace_id), "") == lower(local.monitoring_workspace_id) ) : true) && (data.azapi_resource.monitoring_owned_workspace[0].exists ? ( try(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-created-by"], "") == "azure.ai.projects" && try(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-environment"], "") == var.environment_name && - try(lower(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-monitoring-project"]), "") == lower(local.monitoring_project_id) && + try(lower(data.azapi_resource.monitoring_owned_workspace[0].tags["azd-monitoring-project"]), "") == local.monitoring_project_tag && try(lower(data.azapi_resource.monitoring_owned_workspace[0].output.resource_id), "") == lower(local.monitoring_workspace_id) ) : true) ) : true diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go index f3f79fe8665..3ead8e36b09 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go @@ -16,6 +16,7 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -97,7 +98,8 @@ func monitoringPlanCases(t *testing.T) string { workspace := monitoringPlanGroup + "/providers/Microsoft.OperationalInsights/workspaces/log-" + token ownedTags := map[string]any{ "azd-created-by": "azure.ai.projects", "azd-environment": "guard-test", - "azd-monitoring-project": monitoringPlanProject, + "azd-monitoring-project": uuid.NewSHA1(uuid.MustParse("11fb06fb-712d-4ddd-98c7-e71bbd588830"), + []byte(strings.ToLower(monitoringPlanProject))).String(), } connection := func(name, target, auth string, shared bool) map[string]any { return map[string]any{