diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 527c39b2ce2..a76b427b43b 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -1,6 +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 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 30d86cce38d..6f281bca5a5 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 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, including with private account 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/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 2d64e810286..23c36402056 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/acr_feature_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/acr_feature_test.go index 0b78386890c..3a954b47916 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/acr_feature_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/acr_feature_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "azureaiagent/internal/synthesis" + "azure.ai.projects/synthesis" "azureaiagent/internal/testutil" "github.com/azure/azure-dev/cli/azd/pkg/azdext" 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 97d21f19e05..0e4cb231175 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -982,12 +982,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_acr.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr.go index 672a557c372..e390e11b375 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr.go @@ -13,10 +13,10 @@ import ( "path/filepath" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/config" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_regression_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_regression_test.go index a23bfc6b16a..033631103b6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_regression_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_regression_test.go @@ -20,8 +20,8 @@ import ( "testing" "time" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_test.go index 2a54e6ed733..32519c807fc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_acr_test.go @@ -16,7 +16,7 @@ import ( "strings" "testing" - "azureaiagent/internal/synthesis" + "azure.ai.projects/synthesis" "azureaiagent/internal/testutil" "github.com/azure/azure-dev/cli/azd/pkg/azdext" 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 6e7322c299b..0001b70e649 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 @@ -14,9 +14,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_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 271a5894a27..3d7319c7057 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -4,13 +4,13 @@ package cmd import ( + "azure.ai.projects/synthesis" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/featureflags" "azureaiagent/internal/inittelemetry" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "context" "errors" "fmt" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index c249e141161..8905645b0f8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -18,11 +18,11 @@ import ( "strings" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/featureflags" "azureaiagent/internal/pkg/agents/agent_yaml" "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 3427a3fb72b..fef2f1788a2 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 @@ -486,7 +495,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 @@ -507,15 +530,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 { @@ -523,7 +555,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", @@ -532,17 +564,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 @@ -561,7 +593,7 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) PreserveVarRefs: true, } if err := synthesis.RequireACRReuseForEnvironment(context.Background(), nil, "", synthesisInput); err != nil { - return err + return nil, err } if !existingProject { environment = maps.Clone(environment) @@ -585,10 +617,10 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) if !existingProject && provider == project.TerraformProviderName { mode, err := synthesis.GreenfieldACRMode(synthesisInput) if err != nil { - return err + return nil, err } if mode == "reuse-connect" { - return exterrors.Validation( + return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "reuse mode with AZURE_CONTAINER_REGISTRY_RESOURCE_ID is not supported with Terraform eject", "eject Bicep instead with `azd ai agent init --infra=bicep`, "+ @@ -604,19 +636,22 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) } if err != nil { if local, ok := errors.AsType[*azdext.LocalError](err); ok { - return local + return nil, local } // 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", ) } + for _, warning := range res.Warnings { + fmt.Fprintln(os.Stderr, color.YellowString("Warning: "+warning)) + } deployments, present, err := project.DecodeProjectDeploymentsEnv(environment[projectDeploymentsEnvKey]) if err != nil { - return err + return nil, err } if present { res.Parameters["deployments"] = deployments @@ -626,14 +661,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", @@ -642,7 +677,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", @@ -654,7 +689,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`), "+ @@ -663,6 +698,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. @@ -676,16 +720,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 @@ -696,7 +740,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( @@ -787,12 +832,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"] @@ -821,11 +865,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 @@ -854,6 +906,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 || @@ -873,6 +929,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 @@ -886,6 +946,7 @@ func ejectExistingProjectTerraform( synthesis.ExistingProjectTerraformTemplatesFS(), false, string(acrMode), + acrMode == infraEjectAcrCreate, ) if err != nil { return nil, err @@ -899,6 +960,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 @@ -1216,6 +1301,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( @@ -2077,7 +2164,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) @@ -2129,6 +2217,7 @@ func writeOutputsFile( synthesis.TerraformTemplatesFS(), layer, "", + false, ) } @@ -2140,6 +2229,7 @@ func writeTerraformOutputsFile( tfs fs.FS, layer bool, acrMode string, + includeAdjunct bool, ) (ejectArtifact, error) { raw, err := fs.ReadFile(tfs, tmplPath) if err != nil { @@ -2159,10 +2249,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), @@ -2208,6 +2301,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) } @@ -2397,12 +2495,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_environment_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_environment_test.go index 20d293f2681..161af2e7846 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_environment_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra_environment_test.go @@ -13,7 +13,7 @@ import ( "path/filepath" "testing" - "azureaiagent/internal/synthesis" + "azure.ai.projects/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_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 61595547638..c82eb3d99ed 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 @@ -18,10 +18,10 @@ import ( "strings" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/inittelemetry" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "azureaiagent/internal/testutil" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -557,6 +557,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() @@ -1473,6 +1508,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_ExistingProjectTerraformPrebuiltOmitsAcr(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -2324,6 +2393,8 @@ services: dns: resourceGroup: rg-dns subscription: "${AZURE_DNS_SUBSCRIPTION_ID}" + monitoring: + enabled: false deployments: [] agents: - name: my-agent @@ -2385,6 +2456,8 @@ services: vnet: "${AZURE_VNET_ID}" name: pe-subnet prefix: 192.168.11.0/24 + monitoring: + enabled: false deployments: [] agents: - name: my-agent @@ -2463,6 +2536,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() @@ -4031,6 +4106,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.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go index a8f087fe4db..186086f9e30 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -17,8 +17,8 @@ import ( "strings" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/pkg/agents/agent_yaml" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 610ce829cb5..9567b70ae9a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -16,13 +16,13 @@ import ( "strings" "sync" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/featureflags" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/optimize_api" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" agentTelemetry "azureaiagent/internal/telemetry" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_environment_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_environment_test.go index bde7b0a099f..3cef49ceb23 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_environment_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_environment_test.go @@ -13,9 +13,9 @@ import ( "strings" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/pkg/recordproxy" - "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/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 5bba6faaadb..11e90539dd9 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,11 +12,11 @@ import ( "slices" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/featureflags" "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/cmd/project_workflow.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_workflow.go index ad85bdcf117..47fb49a8520 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_workflow.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_workflow.go @@ -16,10 +16,10 @@ import ( "strings" "time" + "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/azure/azure-dev/cli/azd/pkg/config" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 090b75ddad7..cbc7eb2f790 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -8,11 +8,11 @@ import ( "fmt" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/featureflags/agents.go b/cli/azd/extensions/azure.ai.agents/internal/featureflags/agents.go index d460d2b67c7..be565a6b7c3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/featureflags/agents.go +++ b/cli/azd/extensions/azure.ai.agents/internal/featureflags/agents.go @@ -9,8 +9,8 @@ import ( "os" "sync" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/output" ) 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 970779e07ab..2feadcafe46 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 and shared Foundry preview is listed by All. var ( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/acr_feature_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/acr_feature_test.go index 5d54124e915..8eae1e13826 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/acr_feature_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/acr_feature_test.go @@ -12,8 +12,8 @@ import ( "path/filepath" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/featureflags" - "azureaiagent/internal/synthesis" "azureaiagent/internal/testutil" "github.com/azure/azure-dev/cli/azd/pkg/azdext" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/phase_telemetry_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/phase_telemetry_test.go index f634b513a15..3fa3f440f71 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/phase_telemetry_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/phase_telemetry_test.go @@ -15,10 +15,10 @@ import ( "sync" "testing" + "azure.ai.projects/synthesis" "azureaiagent/internal/phasetelemetry" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/recordproxy" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" foundryTelemetry "github.com/azure/azure-dev/cli/azd/pkg/foundry/telemetry" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/preview_features.go b/cli/azd/extensions/azure.ai.agents/internal/project/preview_features.go index 79ef3a7ca1d..301afdbedae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/preview_features.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/preview_features.go @@ -8,10 +8,10 @@ import ( "io" "strings" + "azure.ai.projects/synthesis" "azureaiagent/internal/featureflags" "azureaiagent/internal/phasetelemetry" "azureaiagent/internal/pkg/agents/agent_api" - "azureaiagent/internal/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) 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_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 896d90c2d97..de94f846fd3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -29,6 +29,7 @@ import ( "time" "unicode/utf8" + "azure.ai.projects/synthesis" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" "azureaiagent/internal/phasetelemetry" @@ -40,7 +41,6 @@ import ( "azureaiagent/internal/pkg/containerref" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/pkg/paths" - "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/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index 7d9d00f8890..b754bac73d9 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,12 +17,12 @@ import ( "strings" "time" + "azure.ai.projects/synthesis" "azureaiagent/internal/exterrors" "azureaiagent/internal/phasetelemetry" "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/remote_acr_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/remote_acr_test.go deleted file mode 100644 index e0c47d2204c..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/remote_acr_test.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRequiresRemoteACRBuild(t *testing.T) { - for _, tt := range []struct { - name, service string - want bool - }{ - {"remote source", "kind: hosted\ndocker: {remoteBuild: true}", true}, - {"nested remote source", "config:\n kind: hosted\n docker: {remoteBuild: true}", true}, - {"service docker wins", "docker: {imagePassthrough: true}\n" + - "config:\n kind: hosted\n docker: {remoteBuild: true}", false}, - {"service remote build wins", "docker: {remoteBuild: true}\n" + - "config:\n kind: hosted\n docker: {imagePassthrough: true}", true}, - {"service explicit false wins", "docker: {remoteBuild: false}\n" + - "config:\n kind: hosted\n docker: {remoteBuild: true}", false}, - {"empty service docker wins", "docker: {}\n" + - "config:\n kind: hosted\n docker: {remoteBuild: true}", false}, - {"service docker path wins", "docker: {path: Dockerfile}\n" + - "config:\n kind: hosted\n docker: {remoteBuild: true}", false}, - {"local source", "kind: hosted\ndocker: {remoteBuild: false}", false}, - {"core local default", "kind: hosted", false}, - {"image defaults to remote source", "image: registry/image:v1\ndocker: {remoteBuild: true}", true}, - {"prebuilt", "image: registry/image:v1\ndocker: {remoteBuild: true, imagePassthrough: true}", false}, - {"registry connection", "image: registry/image:v1\nregistryConnectionId: private\n" + - "docker: {remoteBuild: true}", false}, - {"passthrough", "docker: {remoteBuild: true, imagePassthrough: true}", false}, - {"code", "codeConfiguration: {}\ndocker: {remoteBuild: true}", false}, - {"prompt", "kind: prompt\ndocker: {remoteBuild: true}", false}, - {"disabled", "condition: false\ndocker: {remoteBuild: true}", false}, - {"environment condition", "condition: ${BUILD_AGENT}\ndocker: {remoteBuild: true}", true}, - } { - t.Run(tt.name, func(t *testing.T) { - root := t.TempDir() - service := "host: azure.ai.agent\n" + tt.service + "\n" - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), []byte("host: azure.ai.agent\n"), 0o600)) - for _, ref := range []bool{false, true} { - var body strings.Builder - body.WriteString("services:\n project:\n host: azure.ai.project\n agent:\n") - if ref { - body.WriteString(" $ref: agent.yaml\n") - for line := range strings.SplitSeq(tt.service, "\n") { - body.WriteString(" " + line + "\n") - } - } else { - for line := range strings.SplitSeq(strings.TrimSuffix(service, "\n"), "\n") { - body.WriteString(" " + line + "\n") - } - } - got, err := RequiresRemoteACRBuild(Input{ - RawAzureYAML: []byte(body.String()), ProjectRoot: root, - Env: map[string]string{"BUILD_AGENT": "true"}, - }) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - } - }) - } - for _, raw := range []string{ - "services:\n project:\n host: azure.ai.project\n", - "services:\n project:\n host: azure.ai.project\n agents:\n" + - " - kind: hosted\n docker: {remoteBuild: false}\n", - } { - got, err := RequiresRemoteACRBuild(Input{RawAzureYAML: []byte(raw)}) - require.NoError(t, err) - assert.False(t, got) - } - got, err := RequiresRemoteACRBuild(Input{RawAzureYAML: []byte( - "services:\n project:\n host: azure.ai.project\n agents:\n" + - " - kind: hosted\n docker: {remoteBuild: true}\n")}) - require.NoError(t, err) - assert.True(t, got) -} - -func TestRequiresRemoteACRBuildReferencedDockerPrecedence(t *testing.T) { - for _, tt := range []struct { - name, docker string - want bool - }{ - {name: "nested remote only", want: true}, - {name: "explicit local", docker: "docker: {remoteBuild: false}\n"}, - {name: "empty block", docker: "docker: {}\n"}, - {name: "other Docker options", docker: "docker: {path: Dockerfile}\n"}, - {name: "passthrough", docker: "docker: {imagePassthrough: true}\n"}, - {name: "explicit remote", docker: "docker: {remoteBuild: true}\n", want: true}, - } { - for _, ref := range []string{"service", "config"} { - t.Run(tt.name+"/"+ref, func(t *testing.T) { - root := t.TempDir() - config := "kind: hosted\ndocker: {remoteBuild: true}\n" - service := "host: azure.ai.agent\nconfig:\n $ref: agent.yaml\n" - if ref == "service" { - config = "host: azure.ai.agent\nconfig:\n kind: hosted\n docker: {remoteBuild: true}\n" - service = "$ref: agent.yaml\n" - } - require.NoError(t, os.WriteFile(filepath.Join(root, "agent.yaml"), []byte(config), 0o600)) - var raw strings.Builder - raw.WriteString("services:\n project:\n host: azure.ai.project\n agent:\n") - for line := range strings.SplitSeq(strings.TrimSuffix(service+tt.docker, "\n"), "\n") { - raw.WriteString(" " + line + "\n") - } - got, err := RequiresRemoteACRBuild(Input{RawAzureYAML: []byte(raw.String()), ProjectRoot: root}) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } - } -} 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 e1c9fc05c08..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/schema_test.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "bytes" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "slices" - "testing" - - "azureaiagent/internal/testutil" - - "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 no supported Bicep CLI is auto-discovered. -func TestARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := testutil.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"), "--no-restore", "--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 no supported Bicep CLI is auto-discovered. -func TestExistingProjectARMTemplate_MatchesBicepBuild(t *testing.T) { - bicep := testutil.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"), "--no-restore", "--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) - } - } - -} 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 b9ccc218f26..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ /dev/null @@ -1,1527 +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 image defaults to source => 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: true, - }, - { - name: "legacy inline image defaults to source => 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: true, - }, - { - 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 image without passthrough defaults to source", - 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: true, - }, - { - name: "legacy configured image defaults to source => 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: true, - }, - { - 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.True(t, includeAcr) -} - -func TestSynthesize_BareImageHonorsSkipAcrEnv(t *testing.T) { - t.Parallel() - - const raw = `services: - my-project: - host: azure.ai.project - assistant: - host: azure.ai.agent - image: myprivacr.azurecr.io/agents/assistant:v1 -` - - tests := []struct { - name string - env map[string]string - wantIncludeAcr bool - }{ - { - name: "without skip flag includeAcr is true", - env: nil, - wantIncludeAcr: true, - }, - { - name: "with AZD_AGENT_SKIP_ACR=true includeAcr is false", - env: map[string]string{ - "AZD_AGENT_SKIP_ACR": "true", - }, - wantIncludeAcr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - result, err := Synthesize(Input{ - RawAzureYAML: []byte(raw), - ServiceName: "my-project", - AcceptedHosts: []string{"azure.ai.project"}, - Env: tt.env, - }) - - require.NoError(t, err) - includeAcr, ok := result.Parameters["includeAcr"].(bool) - require.True(t, ok) - assert.Equal(t, tt.wantIncludeAcr, 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.arm.json b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json deleted file mode 100644 index 2f49e05296d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.arm.json +++ /dev/null @@ -1,781 +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": "18175900106630812612" - } - }, - "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": "11513645951317576548" - } - }, - "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": "" - }, - "pullRoleDefinitionId": { - "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(not(empty(parameters('accessOperationId'))), createObject('value', parameters('pullRoleDefinitionId')), 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": "11513645951317576548" - } - }, - "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": "" - }, - "pullRoleDefinitionId": { - "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(not(empty(parameters('accessOperationId'))), createObject('value', parameters('pullRoleDefinitionId')), 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/existing-project.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/existing-project.bicep deleted file mode 100644 index 595d7dabfc1..00000000000 --- a/cli/azd/extensions/azure.ai.agents/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.agents/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep deleted file mode 100644 index 8457170b6d0..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/templates/main.bicep +++ /dev/null @@ -1,203 +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 - hostingManagementIdentityResourceId: string? - storageAccountResourceId: string? - workloadIdentityResourceId: 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 hosting with independently optional Storage and managed identity references.') -param agentHosting agentHostingType = { - enabled: false - hostingType: '' - name: '' - clusterResourceId: '' - hostingManagementIdentityResourceId: '' - storageAccountResourceId: '' - workloadIdentityResourceId: '' -} - -@description('AKS kubelet principal ID for registry access; resolved by microsoft.foundry when omitted.') -param kubeletObjectId string = '' - -@description('Federation-management role; resolved by microsoft.foundry when omitted.') -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 = '' - -@description('Principal ID of the supplied hosting-management identity; resolved by microsoft.foundry when omitted.') -param hostingPrincipalId string = '' - -@description('Principal ID of the supplied workload identity; resolved by microsoft.foundry when omitted.') -param workloadPrincipalId string = '' - -@description('Registry pull role persisted with the access operation.') -param acrPullRoleDefinitionId string = '' - -@description('Owned registry grants retained for recovery after local state loss.') -#disable-next-line no-unused-params -param acrOwnedAssignments array = [] - -// 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 - hostingPrincipalId: hostingPrincipalId - workloadPrincipalId: workloadPrincipalId - acrPullRoleDefinitionId: acrPullRoleDefinitionId - 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.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 2d50bc32ae5..9562dbbe1fc 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -134,6 +134,113 @@ 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. 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 +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; 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. + +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. 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). +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 @@ -606,7 +713,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 @@ -619,8 +727,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. @@ -644,8 +752,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 59d552585fa..b8ce78b663a 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -14,7 +14,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 @@ -113,3 +113,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/acr_feature_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/acr_feature_test.go index 5dea4aac9d1..54bc0a3431d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/acr_feature_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/acr_feature_test.go @@ -13,8 +13,8 @@ import ( "path/filepath" "testing" - "azure.ai.projects/internal/synthesis" "azure.ai.projects/internal/testutil" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/help.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/help.go index 3d56c528c6c..c85ca247679 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/help.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/help.go @@ -6,7 +6,7 @@ package cmd import ( "strings" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/spf13/cobra" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/help_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/help_test.go index b19fd4d1e99..358e154275b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/help_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/help_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/test/snapshot" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr.go index 902a1e59d9b..2982862fab6 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr.go @@ -12,7 +12,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" "go.yaml.in/yaml/v3" diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_capability_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_capability_test.go index bd84b1ffd5b..2cef698b862 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_capability_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_capability_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/stretchr/testify/require" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_test.go index 424661d40cb..7a3d994d3c2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_acr_test.go @@ -15,7 +15,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/cmd/project_add.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_add.go index f394a9c8c05..5bd2c9e0e50 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 @@ -24,7 +24,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" "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" @@ -2174,9 +2174,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) } @@ -2229,7 +2237,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{}{} } @@ -2237,6 +2247,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 548beaa5cf8..3dacc9a32f3 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 @@ -12,7 +12,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" - "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 8c9204f85d8..41ff6f0cfa8 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 @@ -15,7 +15,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" "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 c9543b72aad..e3e0e8e35f0 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 935a8c8134f..450d4c24590 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 @@ -14,7 +14,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" "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 37d7202d1e5..ff2e37c34a8 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 @@ -18,7 +18,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" 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 c8a2a5c40db..5d8f9067aee 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 ef01864c812..ecc3a974926 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 a4252d1bbc3..7888776e6a3 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 @@ -15,7 +15,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" "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 637f63b4f67..ac36a776dc5 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" @@ -14,7 +15,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 +685,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) @@ -875,6 +890,27 @@ 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, + )) + + // #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") + 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{}, diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index 80d15deaa97..956ce92cfec 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -9,7 +9,7 @@ import ( "azure.ai.projects/internal/helpformat" "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/provisioning/acr_access_role_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_access_role_test.go index cae560cb0a7..c1ef9f02320 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_access_role_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_access_role_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "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" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check.go index 52601c97a3c..86c1800faa7 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check.go @@ -13,7 +13,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" "go.yaml.in/yaml/v3" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check_test.go index a8cc30ad652..651abbcea59 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_build_check_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-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -81,7 +81,7 @@ func TestACRRemoteBuildPreflight(t *testing.T) { provider = FoundryProviderName } var raw strings.Builder - raw.WriteString("infra:\n provider: " + provider + "\nservices:\n project:\n host: azure.ai.project\n") + raw.WriteString("infra:\n provider: " + provider + "\nservices:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n") if tt.endpoint != "" { raw.WriteString(" endpoint: " + tt.endpoint + "\n") } @@ -179,7 +179,7 @@ func TestACRRemoteBuildPreflightUsesOnDiskSelection(t *testing.T) { raw = "infra:\n layers:\n - name: foundry\n provider: microsoft.foundry\n" + " path: infra/foundry\n module: project\n" } - raw += "services:\n project:\n host: azure.ai.project\n" + + raw += "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " agent:\n host: azure.ai.agent\n docker: {remoteBuild: true}\n" require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(raw), 0o600)) require.NoError(t, os.MkdirAll(filepath.Join(root, path), 0o750)) @@ -273,7 +273,7 @@ func TestACROnDiskNetworkingUsesEffectiveInputs(t *testing.T) { " path: " + route.path + "\n module: " + route.module + "\n" options.Name = "foundry" } - raw += "services:\n project:\n host: azure.ai.project\n" + + raw += "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " network:\n peSubnet:\n" + " vnet: /subscriptions/sub/resourceGroups/network/" + "providers/Microsoft.Network/virtualNetworks/vnet\n name: pe-subnet\n" + @@ -564,7 +564,7 @@ func TestACRRemoteBuildRejectsBeforePersistingPromptChoices(t *testing.T) { for _, test := range []struct{ abac, onDisk bool }{{false, false}, {true, false}, {false, true}, {true, true}} { t.Run(fmt.Sprintf("condition=%s/ABAC=%t/onDisk=%t", condition, test.abac, test.onDisk), func(t *testing.T) { root := t.TempDir() - raw := "infra:\n provider: microsoft.foundry\nservices:\n project:\n host: azure.ai.project\n" + + raw := "infra:\n provider: microsoft.foundry\nservices:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " agent:\n host: azure.ai.agent\n kind: hosted\n docker: {remoteBuild: true}\n" if condition != "" { raw += " condition: " + condition + "\n" @@ -674,7 +674,7 @@ func TestACROnDiskLocalBuildValidatesPromptedScopeBeforeWrites(t *testing.T) { if route.name != "root" { options.Path, options.Module = filepath.FromSlash(route.path), route.module } - raw += "services:\n project:\n host: azure.ai.project\n" + + raw += "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " agent:\n host: azure.ai.agent\n kind: hosted\n docker: {remoteBuild: false}\n" infra := filepath.Join(root, filepath.FromSlash(route.path)) require.NoError(t, os.MkdirAll(infra, 0o750)) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_feature_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_feature_test.go index b869d0fcc5f..8636559ccf2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_feature_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_feature_test.go @@ -17,8 +17,8 @@ import ( "strings" "testing" - "azure.ai.projects/internal/synthesis" "azure.ai.projects/internal/testutil" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -116,7 +116,7 @@ func TestACRRemoteBuildLocalInputBeforeGate(t *testing.T) { t.Setenv(synthesis.ACRReuseEnvVar, "") root := t.TempDir() testutil.ACRParameters(t, root, false, "json", testACRID) - raw := "infra:\n provider: microsoft.foundry\nservices:\n project:\n host: azure.ai.project\n" + + raw := "infra:\n provider: microsoft.foundry\nservices:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " agent:\n host: azure.ai.agent\n docker: {remoteBuild: invalid}\n" require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(raw), 0o600)) project := &azdext.ProjectConfig{Path: root} @@ -292,7 +292,7 @@ func TestACRReuseDisabledBeforeLifecycleWrites(t *testing.T) { p, env := acrTestProvider(t) t.Setenv(synthesis.ACRReuseEnvVar, "") root := t.TempDir() - raw := "services:\n project:\n host: azure.ai.project\n" + + raw := "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " agent:\n host: azure.ai.agent\n kind: hosted\n" options := &azdext.ProvisioningOptions{Provider: FoundryProviderName} if layer { @@ -592,7 +592,7 @@ func acrRolloutProvider( "AZURE_AI_PROJECT_NAME": "project", }, config: map[string][]byte{"sentinel": []byte(`{"keep":true}`)}} p.azdClient = newResolveEnvTestClient(t, env, &resolveEnvStubPromptServer{}) - raw := "name: test\nservices:\n project:\n host: azure.ai.project\n" + raw := "name: test\nservices:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" if agent != "" { raw += " agent:\n host: azure.ai.agent\n" + agent } @@ -1001,7 +1001,7 @@ func TestACRLegacyExistingProjectInitialize(t *testing.T) { p, env := acrTestProvider(t) t.Setenv(synthesis.ACRReuseEnvVar, "false") root := t.TempDir() - raw := "services:\n project:\n host: azure.ai.project\n" + + raw := "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" + " endpoint: https://account.services.ai.azure.com/api/projects/project\n" require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(raw), 0o600)) env.get = map[string]string{ diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle.go index ada4d747c41..668b652ee52 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle.go @@ -19,7 +19,7 @@ import ( "strings" "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" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle_test.go index fb3d1ce411e..551aafded8b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/acr_lifecycle_test.go @@ -19,7 +19,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" @@ -2035,7 +2035,7 @@ func TestACRProjectOnlyProvisionPreservesPendingSelector(t *testing.T) { tt.mode, tt.down, tt.refresh, tt.agentBeforeRefresh) t.Run(name, func(t *testing.T) { root := t.TempDir() - manifest := "services:\n project:\n host: azure.ai.project\n" + manifest := "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(manifest), 0o600)) p, env := acrTestProvider(t) env.get = map[string]string{ @@ -2219,7 +2219,7 @@ func TestACRStatePreservesDeployedRegistryOutputs(t *testing.T) { t.Run(fmt.Sprintf("%s/alias=%t/read=%s", mode, alias, failRead), func(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte( - "services:\n project:\n host: azure.ai.project\n"), 0o600)) + "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n"), 0o600)) p, env := acrTestProvider(t) binding := acrTestBinding() binding.Mode = mode @@ -2317,7 +2317,7 @@ func TestACRFailedDeploymentRecoversIndependentOwnership(t *testing.T) { t.Run(tt.name, func(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte( - "services:\n project:\n host: azure.ai.project\n"+ + "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n"+ " agent:\n host: azure.ai.agent\n kind: hosted\n"), 0o600)) p, env := acrTestProvider(t) env.get = map[string]string{ @@ -2995,7 +2995,7 @@ func TestACRProjectOnlyEjectionRemainsDemandDriven(t *testing.T) { raw = "infra:\n layers:\n - name: foundry\n provider: microsoft.foundry\n" + " path: infra/foundry\n module: project\n" } - raw += "services:\n project:\n host: azure.ai.project\n" + raw += "services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n" values := map[string]string{ "AZURE_SUBSCRIPTION_ID": "sub", "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": testACRID, } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting.go index c66195fd5df..28bfcc2c918 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/aks_hosting.go @@ -16,7 +16,7 @@ import ( "sync" "azure.ai.projects/internal/exterrors" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2" 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 0dc22ba36f9..b28e0e5e9ab 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 @@ -21,7 +21,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" @@ -44,6 +44,7 @@ const offlineBYOAzureYAML = ` services: project: host: azure.ai.project + monitoring: {enabled: false} agentHosting: {clusterResourceId: ` + offlineClusterID + `} ` @@ -530,6 +531,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"}}, }, @@ -570,7 +572,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 @@ -607,7 +612,7 @@ func TestNativeFoundryDeployKeepsStandardOutputsWithoutAKSReads(t *testing.T) { p, fake := offlineBYOProvider(t) t.Setenv(synthesis.BYOAKSEnvVar, "false") 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 2441382b34e..6c8db57c14e 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 @@ -20,7 +20,7 @@ import ( "azure.ai.projects/internal/exterrors" "azure.ai.projects/internal/phasetelemetry" - "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" @@ -388,11 +388,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) @@ -413,15 +419,16 @@ func (p *FoundryProvisioningProvider) initializeInfrastructure( } input := synthesis.Input{ - RawAzureYAML: rawYAML, - ServiceName: svcName, - AcceptedHosts: FoundryProvisioningServiceHosts, - Env: maps.Clone(p.synthesisEnv), - ProjectRoot: projectRoot, - ResourceGroupName: p.rgName, - ACRBinding: p.acrBinding, - ACRPending: p.acrPending != nil, - DeferACRNetworkValidation: onDisk, + RawAzureYAML: rawYAML, + ServiceName: svcName, + AcceptedHosts: FoundryProvisioningServiceHosts, + Env: maps.Clone(p.synthesisEnv), + ProjectRoot: projectRoot, + ResourceGroupName: p.rgName, + ACRBinding: p.acrBinding, + ACRPending: p.acrPending != nil, + DeferACRNetworkValidation: onDisk, + InfrastructureOwnsMonitoring: onDisk, } input.Env[envKeySubscriptionID] = p.subID input.Env[envKeyLocation] = p.location @@ -536,7 +543,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 { @@ -889,10 +897,11 @@ func (p *FoundryProvisioningProvider) withTenantOutput( func (p *FoundryProvisioningProvider) normalizeOutputs( outputs map[string]*azdext.ProvisioningOutputParameter, ) map[string]*azdext.ProvisioningOutputParameter { - trackSupportingResourceGroup := p.isLayer || - (p.brownfieldEndpoint == "" && p.greenfieldAcrMode == "reuse-connect") || - (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{} } @@ -902,10 +911,24 @@ 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.greenfieldAcrMode == "reuse-connect") || + (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. @@ -1055,22 +1078,34 @@ func (p *FoundryProvisioningProvider) resolveEnv(ctx context.Context) error { } func (p *FoundryProvisioningProvider) resolveEnvName(ctx context.Context) error { - currEnv, err := synthesis.CurrentEnvironment(ctx, p.azdClient) - 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 selects the missing subscription, like azd up. // On-disk templates and external registries save after validation. // Other flows persist the choice immediately. @@ -1372,10 +1407,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.greenfieldAcrMode == "reuse-connect") || - (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 { @@ -1567,6 +1621,11 @@ func (p *FoundryProvisioningProvider) resolveTemplateSource( 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) source := &templateSource{ mode: p.onDiskSource.mode, @@ -1695,6 +1754,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{ @@ -1770,11 +1833,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", @@ -1882,8 +1945,10 @@ 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, p.armClientOptions) @@ -2075,7 +2140,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, @@ -2269,7 +2334,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", @@ -2282,7 +2347,7 @@ func invalidatedEnvKeysResult() *azdext.ProvisioningDestroyResult { "AZD_FOUNDRY_ACR_MODE", "AZD_FOUNDRY_ACR_PULL_ASSIGNED", envKeyFoundryRGOwner, - }, + }, monitoringOutputKeys()...), } } @@ -2397,6 +2462,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{ @@ -2412,6 +2482,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{ @@ -2425,6 +2500,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 --- @@ -2866,7 +2946,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 9c53f09ba53..f12e67eb807 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 @@ -15,7 +15,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/azure/azure-dev/cli/azd/pkg/tools/bicep" @@ -36,6 +36,7 @@ type resolveEnvStubEnvServer struct { getErr map[string]error set map[string]string process map[string]string + environments map[string]*resolveEnvStubEnvServer config map[string][]byte configErr error currentErr error @@ -90,8 +91,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 } @@ -103,8 +111,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) + } s.valuesN++ if s.valuesErr != nil { return nil, s.valuesErr @@ -117,8 +132,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{} } @@ -188,6 +210,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{}), + } + err := p.resolveEnv(t.Context()) + if selected == "missing" { + require.ErrorContains(t, err, "environment not found") + 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" + } + values, err := p.synthesisEnvMap(t.Context()) + require.NoError(t, err) + 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 b90d7cd46d1..a6287b4ae53 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) } }) } @@ -1218,6 +1220,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..6206f9f6505 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -0,0 +1,282 @@ +// 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 { + if p.synthResult != nil { + for _, warning := range p.synthResult.Warnings { + progress("Warning: " + warning) + } + } + 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 { + // 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} { + 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..8b1ac2a1ccb --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -0,0 +1,622 @@ +// 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 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") + 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/ondisk_template.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go index a3743637723..9a4d24edd3e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template.go @@ -14,7 +14,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/tools/bicep" diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go index 88f96c6a020..dffdfd71add 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/ondisk_template_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/azure/azure-dev/cli/azd/pkg/tools/bicep" 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/provisioning/supplied_resources_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/supplied_resources_test.go index eca9ae44d68..d150407948e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/supplied_resources_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/supplied_resources_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "azure.ai.projects/internal/synthesis" + "azure.ai.projects/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -81,7 +81,7 @@ func (f *suppliedARMFake) Do(request *http.Request) (*http.Response, error) { func suppliedYAML(mask int) string { var raw strings.Builder - raw.WriteString("services:\n project:\n host: azure.ai.project\n agentHosting:\n" + + raw.WriteString("services:\n project:\n host: azure.ai.project\n monitoring: {enabled: false}\n agentHosting:\n" + " clusterResourceId: " + offlineClusterID + "\n") for i, selection := range []struct{ name, id string }{ {"storageAccountResourceId", suppliedStorageID}, @@ -238,7 +238,10 @@ func TestBYOSuppliedResourcesDeployIndependently(t *testing.T) { p.bicepCliInstance = compiler p.onDiskSource = &templateSource{ mode: templateModeBicep, armTemplate: p.armTemplate, - parameters: map[string]any{"includeAcr": map[string]any{"value": registry}}, + parameters: map[string]any{ + "includeAcr": map[string]any{"value": registry}, + "monitoringMode": map[string]any{"value": "none"}, + }, } if preview { result, err := p.Preview(t.Context(), func(string) {}) @@ -419,6 +422,7 @@ func TestBYOSuppliedUsesEffectiveDeploymentInputs(t *testing.T) { parameters[binding] = map[string]any{"value": "explicit-" + binding} } } + parameters["monitoringMode"] = map[string]any{"value": "none"} p.onDiskSource = &templateSource{ mode: templateModeBicep, armTemplate: p.armTemplate, parameters: parameters, } 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 d42b45f9e63..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding.go +++ /dev/null @@ -1,336 +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" - -// ErrACRReuseAgentHosting rejects unmanaged AKS pull access. -var ErrACRReuseAgentHosting = errors.New( - "existing registry reuse is not supported with agentHosting; " + - "use an azd-created registry or remove agentHosting", -) - -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 -} - -// ValidateACRNetworking checks registry and network compatibility. -func ValidateACRNetworking(includeACR bool, registryID string, isolated, hosting bool) error { - if includeACR && registryID == "" && isolated && !hosting { - return errors.New( - "synthesis: private networking does not support an auto-created Azure Container Registry; " + - "use docker.imagePassthrough: true with a prebuilt image, remove source-build demand, " + - "or set AZURE_CONTAINER_REGISTRY_RESOURCE_ID for Foundry-only reuse without agentHosting", - ) - } - return nil -} - -// HasLegacyACRDeployment requires identity and registry outputs. -func HasLegacyACRDeployment(env map[string]string) bool { - return env["AZURE_AI_PROJECT_ID"] != "" && - env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] != "" && - (env["AZURE_CONTAINER_REGISTRY_ENDPOINT"] != "" || - env["AZURE_AI_PROJECT_ACR_CONNECTION_NAME"] != "" || - env["AZD_FOUNDRY_ACR_PULL_ASSIGNED"] != "") -} - -// ValidateDeployedACRReuse preserves deployed mode and identity. -func ValidateDeployedACRReuse(in Input, selectedID string) error { - binding := in.ACRBinding - if binding == nil { - var err error - binding, err = ParseACRBinding(in.Env[ACRBindingEnvKey]) - if err != nil { - return err - } - } - if binding != nil { - return ValidateACRBinding(binding, "reuse-connect", selectedID) - } - if !HasLegacyACRDeployment(in.Env) { - return nil - } - mode, id, err := ResolveGreenfieldACRSelection(in) - if err != nil { - return err - } - if in.Env["AZD_FOUNDRY_ACR_MODE"] == "create" || mode != "reuse-connect" || !strings.EqualFold(id, selectedID) { - return errors.New("cannot change the registry of a provisioned Foundry project; " + - "retain the deployed registry selection or use a new azd environment") - } - 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 " + - "and every other resource group managed by azd because azd down deletes those groups") - } - 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 - } - return ValidateDeployedACRReuse(Input{Env: env}, selectedID) -} - -// GreenfieldACRResourceGroup resolves the Foundry teardown group. -func GreenfieldACRResourceGroup(in Input) (string, error) { - groups, _, err := greenfieldACRResourceGroups(in) - if err != nil { - return "", err - } - return groups[0], nil -} - -func greenfieldACRResourceGroups(in Input) ([]string, bool, error) { - 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 nil, false, fmt.Errorf("parse infrastructure scope in azure.yaml: %w", err) - } - layered := false - rootManaged := len(doc.Infra.Layers) == 0 && - strings.TrimSpace(doc.Infra.Provider) != "" && - strings.TrimSpace(doc.Infra.Provider) != "microsoft.foundry" - for _, layer := range doc.Infra.Layers { - provider := strings.TrimSpace(layer.Provider) - if provider == "" { - provider = strings.TrimSpace(doc.Infra.Provider) - } - if provider != "microsoft.foundry" { - rootManaged = true - continue - } - if strings.TrimSpace(layer.Name) == "" || layered { - return nil, false, 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" - } - group := in.ResourceGroupName - if group == "" { - group = in.Env[key] - } - if group == "" && in.Env["AZURE_ENV_NAME"] != "" { - group = "rg-" + in.Env["AZURE_ENV_NAME"] + suffix - } - groups := []string{group} - if rootManaged { - root := in.Env["AZURE_RESOURCE_GROUP"] - if root == "" && in.Env["AZURE_ENV_NAME"] != "" { - root = "rg-" + in.Env["AZURE_ENV_NAME"] - } - if root != "" && !strings.EqualFold(root, group) { - groups = append(groups, root) - } - } - return groups, rootManaged, nil -} - -// ValidateACRSelectorForInput requires separate teardown scopes. -func ValidateACRSelectorForInput(in Input, selectedID string) error { - groups, otherInfrastructure, err := greenfieldACRResourceGroups(in) - if err != nil { - return err - } - subscription := in.Env["AZURE_SUBSCRIPTION_ID"] - for _, group := range groups { - 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 environment's resource groups; " + - "configure AZURE_SUBSCRIPTION_ID before selecting a registry with the same resource-group name") - } - } - if otherInfrastructure { - return errors.New("cannot establish application infrastructure teardown scopes for external registry reuse; " + - "AZURE_RESOURCE_GROUP does not describe every group deleted by azd down. " + - "Use Foundry-only infrastructure, or an azd-created registry in a new environment") - } - _, projects, err := acrProjectServices(in) - if err != nil { - return err - } - for _, project := range projects { - if project.AgentHosting != nil { - return ErrACRReuseAgentHosting - } - } - 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) - } - } - if err := ValidateACRSelectorForInput(in, selectedID); err != nil { - return err - } - return ValidateDeployedACRReuse(in, 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 c1b0cfec63c..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_binding_test.go +++ /dev/null @@ -1,454 +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 TestValidateDeployedACRReuse(t *testing.T) { - const registryID = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - const projectID = "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project" - for _, tt := range []struct { - name, mode, output string - changed, native, noProject, wantError bool - }{ - {name: "create matching", mode: "create", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT", wantError: true}, - {name: "create changed", mode: "create", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT", changed: true, wantError: true}, - {name: "reuse changed", mode: "reuse-connect", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT", changed: true, wantError: true}, - {name: "connection output", mode: "reuse-connect", output: "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", - changed: true, wantError: true}, - {name: "pull output", mode: "reuse-connect", output: "AZD_FOUNDRY_ACR_PULL_ASSIGNED", changed: true, wantError: true}, - {name: "stable reuse", mode: "reuse-connect", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT"}, - {name: "inferred reuse", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT"}, - {name: "inferred create", native: true, output: "AZURE_CONTAINER_REGISTRY_ENDPOINT", wantError: true}, - {name: "pending create intent", mode: "create", changed: true}, - {name: "pending reuse intent", mode: "reuse-connect", changed: true}, - {name: "registry output alone", mode: "create", output: "AZURE_CONTAINER_REGISTRY_ENDPOINT", noProject: true, changed: true}, - } { - t.Run(tt.name, func(t *testing.T) { - env := map[string]string{ - "AZURE_SUBSCRIPTION_ID": "sub", - "AZURE_RESOURCE_GROUP": "foundry", - "AZURE_AI_PROJECT_ID": projectID, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - "AZD_FOUNDRY_ACR_MODE": tt.mode, - } - if tt.noProject { - delete(env, "AZURE_AI_PROJECT_ID") - } - if tt.output != "" { - env[tt.output] = map[string]string{ - "AZURE_CONTAINER_REGISTRY_ENDPOINT": "registry.azurecr.io", - "AZURE_AI_PROJECT_ACR_CONNECTION_NAME": "project-registry", - "AZD_FOUNDRY_ACR_PULL_ASSIGNED": "true", - }[tt.output] - } - if tt.native { - env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = strings.Replace(registryID, "/shared/", "/foundry/", 1) - } - selectedID := strings.ToUpper(env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]) - if tt.changed { - selectedID += "other" - } - before := maps.Clone(env) - assert.Equal(t, tt.output != "" && !tt.noProject, HasLegacyACRDeployment(env)) - err := ValidateDeployedACRReuse(Input{Env: env}, selectedID) - if tt.wantError { - require.ErrorContains(t, err, "provisioned Foundry project") - require.Error(t, ValidateGreenfieldACRSelectionForInput(Input{Env: env}, selectedID)) - require.Error(t, ValidateGreenfieldACRSelection(env, selectedID)) - } else { - require.NoError(t, err) - require.NoError(t, ValidateGreenfieldACRSelectionForInput(Input{Env: env}, selectedID)) - require.NoError(t, ValidateGreenfieldACRSelection(env, selectedID)) - } - assert.Equal(t, before, env) - }) - } -} - -func TestACRBindingSynthesisGuard(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "true") - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - in := Input{ - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n" + - " agent:\n host: azure.ai.agent\n kind: hosted\n"), - ServiceName: "project", - Env: map[string]string{ - "AZD_FOUNDRY_ACR_MODE": "reuse-connect", "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": id, - }, - ACRBinding: &ACRBinding{ - Version: 1, Mode: "reuse-connect", RegistryResourceID: id, PrincipalID: "principal", - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - }, - } - _, err := Synthesize(in) - require.NoError(t, err) - in.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = id + "other" - _, err = Synthesize(in) - require.ErrorContains(t, err, "migration") - in.Env["AZD_FOUNDRY_ACR_MODE"] = "create" - _, err = Synthesize(in) - require.ErrorContains(t, err, "migration") - in.ACRBinding = nil - in.Env["AZD_FOUNDRY_ACR_MODE"] = "none" - mode, err := GreenfieldACRMode(in) - require.NoError(t, err) - assert.Equal(t, "reuse-connect", mode) - in.Env[ACRBindingEnvKey] = "{}" - _, err = GreenfieldACRMode(in) - require.ErrorContains(t, err, "invalid deployed ACR binding") -} - -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 defaults to source", "services:\n agent:\n host: azure.ai.agent\n image: registry/image:v1", true, true}, - {"image passthrough", "services:\n agent:\n host: azure.ai.agent\n image: registry/image:v1\n" + - " docker: {imagePassthrough: true}", 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, true}, - {"legacy passthrough", "services:\n agent:\n host: azure.ai.agent\n agents:\n" + - " - name: agent\n image: registry/image:v1\n" + - " docker: {imagePassthrough: true}", 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 TestACRSelectionChecksEveryManagedResourceGroup(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "true") - const registryID = "/subscriptions/sub/resourceGroups/application/" + - "providers/Microsoft.ContainerRegistry/registries/registry" - for _, tt := range []struct { - name, infra, group, root, subscription, want string - }{ - {name: "application layer", infra: " layers:\n" + - " - name: application\n provider: bicep\n" + - " - name: foundry\n provider: microsoft.foundry\n", - root: "application", subscription: "sub", want: "azd down deletes"}, - {name: "inherited application provider", infra: " provider: bicep\n layers:\n" + - " - name: application\n" + - " - name: foundry\n provider: microsoft.foundry\n", - root: "APPLICATION", subscription: "SUB", want: "azd down deletes"}, - {name: "application default", infra: " layers:\n" + - " - name: application\n provider: bicep\n" + - " - name: foundry\n provider: microsoft.foundry\n", - subscription: "sub", want: "cannot establish application infrastructure teardown scopes"}, - {name: "eject retains application scope", infra: " provider: bicep\n", - group: "foundry", root: "application", subscription: "sub", want: "azd down deletes"}, - {name: "unknown subscription", infra: " layers:\n" + - " - name: application\n provider: bicep\n" + - " - name: foundry\n provider: microsoft.foundry\n", - root: "application", want: "configure AZURE_SUBSCRIPTION_ID"}, - {name: "unused root group", infra: " layers:\n" + - " - name: foundry\n provider: microsoft.foundry\n", - root: "application", subscription: "sub"}, - {name: "outside all groups", infra: " layers:\n" + - " - name: application\n provider: bicep\n" + - " - name: foundry\n provider: microsoft.foundry\n", - root: "another-group", subscription: "sub", want: "cannot establish application infrastructure teardown scopes"}, - } { - t.Run(tt.name, func(t *testing.T) { - in := Input{ - RawAzureYAML: []byte("infra:\n" + tt.infra + "services:\n" + - " project:\n host: azure.ai.project\n" + - " agent:\n host: azure.ai.agent\n kind: hosted\n"), - ServiceName: "project", ResourceGroupName: tt.group, - Env: map[string]string{ - "AZURE_ENV_NAME": "test", "AZURE_SUBSCRIPTION_ID": tt.subscription, - "AZURE_RESOURCE_GROUP": tt.root, "AZURE_FOUNDRY_RESOURCE_GROUP": "foundry", - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - }, - } - err := ValidateGreenfieldACRSelectionForInput(in, registryID) - _, synthErr := Synthesize(in) - if tt.want != "" { - require.ErrorContains(t, err, tt.want) - require.ErrorContains(t, synthErr, tt.want) - } else { - require.NoError(t, err) - require.NoError(t, synthErr) - } - }) - } -} - -func TestACRSelectionRejectsUnverifiedApplicationScopes(t *testing.T) { - const registryID = "/subscriptions/sub/resourceGroups/custom/" + - "providers/Microsoft.ContainerRegistry/registries/registry" - for _, provider := range []string{"bicep", "terraform", "custom"} { - for _, inherited := range []bool{false, true} { - t.Run(provider+"/"+map[bool]string{false: "explicit", true: "inherited"}[inherited], func(t *testing.T) { - root := t.TempDir() - template := "targetScope = 'subscription'\n" + - "resource first 'Microsoft.Resources/resourceGroups@2024-03-01' = {\n" + - " name: 'custom'\n location: 'eastus2'\n}\n" + - "resource second 'Microsoft.Resources/resourceGroups@2024-03-01' = {\n" + - " name: 'additional'\n location: 'eastus2'\n}\n" - require.NoError(t, os.WriteFile(filepath.Join(root, "application.bicep"), []byte(template), 0o600)) - infra := "infra:\n layers:\n - name: application\n provider: " + provider + - "\n path: .\n module: application\n" + - " - name: foundry\n provider: microsoft.foundry\n" - if inherited { - infra = strings.Replace(infra, " layers:", " provider: "+provider+"\n layers:", 1) - infra = strings.Replace(infra, " provider: "+provider+"\n", "", 1) - } - in := Input{ - RawAzureYAML: []byte(infra + "services:\n project:\n host: azure.ai.project\n"), - ServiceName: "project", ProjectRoot: root, - Env: map[string]string{ - "AZURE_ENV_NAME": "test", "AZURE_SUBSCRIPTION_ID": "sub", - "AZURE_RESOURCE_GROUP": "unrelated", "AZURE_FOUNDRY_RESOURCE_GROUP": "foundry", - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - }, - } - before := maps.Clone(in.Env) - require.ErrorContains(t, ValidateGreenfieldACRSelectionForInput(in, registryID), - "cannot establish application infrastructure teardown scopes") - for _, preserve := range []bool{false, true} { - in.PreserveVarRefs = preserve - _, err := Synthesize(in) - require.ErrorContains(t, err, "cannot establish application infrastructure teardown scopes") - } - assert.Equal(t, before, in.Env) - //nolint:gosec // Test-owned path. - data, err := os.ReadFile(filepath.Join(root, "application.bicep")) - require.NoError(t, err) - assert.Equal(t, template, string(data)) - }) - } - } -} - -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/acr_feature.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_feature.go deleted file mode 100644 index 51da0a73908..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_feature.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// ACRReuseFeatureID identifies the shared Foundry preview. -const ACRReuseFeatureID = "foundry.acr_reuse" - -// ACRReuseEnvVar is the process-only opt-in for external ACR reuse. -const ACRReuseEnvVar = "AZD_ALPHA_ENABLE_FOUNDRY_ACR_REUSE" - -// ACRPendingConfigKey stores unfinished external registry access. -const ACRPendingConfigKey = "extensions.ai-projects.acrPending" - -// ACRReuseEnabled reads the process, including host injection. -func ACRReuseEnabled() bool { - enabled, err := strconv.ParseBool(os.Getenv(ACRReuseEnvVar)) - return err == nil && enabled -} - -// RequireACRReuse rejects reuse without changing deployment state. -func RequireACRReuse() error { - if ACRReuseEnabled() { - return nil - } - return &azdext.LocalError{ - Code: "feature_not_enabled", - Category: azdext.LocalErrorCategoryCompatibility, - Message: fmt.Sprintf("preview feature %q is disabled; set %s=true before running azd", - ACRReuseFeatureID, ACRReuseEnvVar), - Suggestion: "Set " + ACRReuseEnvVar + "=true in your shell or CI for setup, provision, preview, " + - "deploy, down, recovery, and eject. Existing resources and saved state are unchanged.", - } -} - -// RequireACRReuseForInput distinguishes reuse from create. -func RequireACRReuseForInput(in Input) error { - if ACRReuseEnabled() { - return nil - } - binding := in.ACRBinding - if binding == nil { - var err error - binding, err = ParseACRBinding(in.Env[ACRBindingEnvKey]) - if err != nil { - return err - } - } - if err := ValidateACRBinding(binding, - in.Env["AZD_FOUNDRY_ACR_MODE"], in.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]); err != nil { - return err - } - if in.ACRPending || binding != nil && binding.Mode == "reuse-connect" { - return RequireACRReuse() - } - // Existing-project connection workflows predate this preview. - if strings.EqualFold(in.Env["USE_EXISTING_AI_PROJECT"], "true") { - return nil - } - if binding == nil && in.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] == "" && - in.Env["AZD_FOUNDRY_ACR_MODE"] != "reuse-connect" { - return nil - } - existing, err := acrExistingProject(in) - if err != nil || existing { - return err - } - if in.Env["AZD_FOUNDRY_ACR_MODE"] == "reuse-connect" { - return RequireACRReuse() - } - mode, id, err := ResolveGreenfieldACRSelection(in) - if err != nil { - return err - } - if mode != "reuse-connect" { - return nil - } - if err := ValidateACRSelector(id, in.Env["AZURE_SUBSCRIPTION_ID"], ""); err != nil { - return err - } - return RequireACRReuse() -} - -func acrExistingProject(in Input) (bool, error) { - if strings.EqualFold(in.Env["USE_EXISTING_AI_PROJECT"], "true") { - return true, nil - } - if len(in.RawAzureYAML) == 0 { - return false, nil - } - if in.ServiceName != "" { - endpoint, err := ProjectEndpoint(in.RawAzureYAML, in.ServiceName, in.ProjectRoot, in.Env) - return endpoint != "", err - } - _, projects, err := acrProjectServices(in) - if err != nil { - return false, err - } - for _, project := range projects { - if strings.TrimSpace(project.Endpoint) != "" { - return true, nil - } - } - return false, nil -} - -// RequireACRReuseForEnvironment checks saved reuse intent. -// Reads use the host; the opt-in itself never loads config or dotenv. -func RequireACRReuseForEnvironment( - ctx context.Context, client *azdext.AzdClient, envName string, in Input, -) error { - return requireACRReuseForEnvironment(ctx, client, envName, in, true) -} - -func requireACRReuseForEnvironment( - ctx context.Context, client *azdext.AzdClient, envName string, in Input, includeParameters bool, -) error { - if ACRReuseEnabled() { - return nil - } - if client != nil && envName != "" { - if in.ACRBinding == nil { - response, err := client.Environment().GetConfig(ctx, &azdext.GetConfigRequest{ - EnvName: envName, Path: ACRBindingConfigKey, - }) - if err != nil { - return fmt.Errorf("read deployed registry binding before checking reuse: %w", err) - } - if response.GetFound() { - in.ACRBinding, err = ParseACRBinding(response.GetValue()) - if err != nil { - return err - } - } - } - response, err := client.Environment().GetConfig(ctx, &azdext.GetConfigRequest{ - EnvName: envName, Path: ACRPendingConfigKey, - }) - if err != nil { - return fmt.Errorf("read unfinished registry access before checking reuse: %w", err) - } - in.ACRPending = in.ACRPending || response.GetFound() - } - if err := RequireACRReuseForInput(in); err != nil { - return err - } - if !includeParameters { - return nil - } - return requireACRReuseForOnDiskInput(ctx, in, NewBicepCompiler()) -} - -// RequireACRReuseForProject guards hooks before any handler writes. -func RequireACRReuseForProject( - ctx context.Context, client *azdext.AzdClient, project *azdext.ProjectConfig, -) error { - return requireACRReuseForProject(ctx, client, project, true) -} - -// RequireACRReuseForProjectState guards compiler-free teardown. -func RequireACRReuseForProjectState( - ctx context.Context, client *azdext.AzdClient, project *azdext.ProjectConfig, -) error { - return requireACRReuseForProject(ctx, client, project, false) -} - -func requireACRReuseForProject( - ctx context.Context, client *azdext.AzdClient, project *azdext.ProjectConfig, includeParameters bool, -) error { - if ACRReuseEnabled() { - return nil - } - var envName string - values := map[string]string{} - // Direct service-target callers may construct a provider without an azd client. - // Still inspect project/on-disk reuse intent with empty host values. - if client != nil { - current, err := CurrentEnvironment(ctx, client) - if err != nil { - if os.Getenv("AZD_ENVIRONMENT") != "" || !isMissingCurrentEnvironment(err) { - return fmt.Errorf("read current environment before checking registry reuse: %w", err) - } - } else { - envName = current.GetEnvironment().GetName() - values["AZURE_ENV_NAME"] = envName - if envName != "" { - response, err := client.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName}) - if err != nil { - return fmt.Errorf("read environment before checking registry reuse: %w", err) - } - for _, entry := range response.GetKeyValues() { - values[entry.GetKey()] = entry.GetValue() - } - } - } - } - in := Input{Env: values, ProjectRoot: project.GetPath()} - if in.ProjectRoot != "" { - for _, name := range []string{"azure.yaml", "azure.yml"} { - // #nosec G304 -- the host supplies the project root. - raw, err := os.ReadFile(filepath.Join(in.ProjectRoot, name)) - if err == nil { - in.RawAzureYAML = raw - break - } - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("read project before checking registry reuse: %w", err) - } - } - } - if len(in.RawAzureYAML) == 0 { - for _, service := range project.GetServices() { - if service.GetHost() != "azure.ai.project" && service.GetHost() != "azure.ai.agents" { - continue - } - props := service.GetAdditionalProperties() - if props == nil { - props = service.GetConfig() - } - if props == nil { - continue - } - if endpoint := props.GetFields()["endpoint"]; endpoint != nil && endpoint.GetStringValue() != "" { - values["USE_EXISTING_AI_PROJECT"] = "true" - } - } - } - return requireACRReuseForEnvironment(ctx, client, envName, in, includeParameters) -} - -// CurrentEnvironment honors the host's explicit environment selection. -func CurrentEnvironment(ctx context.Context, client *azdext.AzdClient) (*azdext.EnvironmentResponse, error) { - if name := os.Getenv("AZD_ENVIRONMENT"); name != "" { - return client.Environment().Get(ctx, &azdext.GetEnvironmentRequest{Name: name}) - } - return client.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) -} - -func isMissingCurrentEnvironment(err error) bool { - st, ok := status.FromError(err) - return ok && st.Code() == codes.Unknown && st.Message() == "default environment not found" -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_feature_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_feature_test.go deleted file mode 100644 index ea37b2edf01..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_feature_test.go +++ /dev/null @@ -1,306 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "encoding/json" - "errors" - "maps" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/stretchr/testify/require" -) - -func TestACRReuseProcessSwitch(t *testing.T) { - t.Setenv("AZD_ALPHA_ENABLE_AGENTS_VOICE", "true") - t.Setenv("AZD_ALPHA_ENABLE_AGENTS_MANAGED_HARNESS", "true") - for _, value := range []string{"", "false", "0", "on", "yes", " true", "True", "TRUE", "t", "T", "1", "true"} { - t.Run(value, func(t *testing.T) { - t.Setenv(ACRReuseEnvVar, value) - enabled := value == "True" || value == "TRUE" || value == "t" || - value == "T" || value == "1" || value == "true" - require.Equal(t, enabled, ACRReuseEnabled()) - err := RequireACRReuse() - if enabled { - require.NoError(t, err) - } else { - requireACRDisabled(t, err) - } - }) - } -} - -func requireACRDisabled(t *testing.T, err error) { - t.Helper() - local, ok := errors.AsType[*azdext.LocalError](err) - require.True(t, ok, "%v", err) - require.Equal(t, "feature_not_enabled", local.Code) - require.Equal(t, azdext.LocalErrorCategoryCompatibility, local.Category) - require.Contains(t, local.Message, ACRReuseEnvVar+"=true") - require.Contains(t, local.Suggestion, ACRReuseEnvVar+"=true") -} - -func TestACRReuseForEnvironmentOnDiskParameters(t *testing.T) { - const registryID = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - for _, id := range []string{"", registryID} { - t.Run(id, func(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "") - root := t.TempDir() - infra := filepath.Join(root, "infra") - require.NoError(t, os.Mkdir(infra, 0o750)) - require.NoError(t, os.WriteFile(filepath.Join(infra, "main.bicep"), nil, 0o600)) - parameters := []byte(`{"parameters":{"existingAcrResourceId":{"value":"` + id + `"}}}`) - path := filepath.Join(infra, "main.parameters.json") - require.NoError(t, os.WriteFile(path, parameters, 0o600)) - in := Input{ - ProjectRoot: root, - RawAzureYAML: []byte("infra:\n provider: microsoft.foundry\n"), - Env: map[string]string{"AZURE_SUBSCRIPTION_ID": "sub", "AZURE_RESOURCE_GROUP": "foundry"}, - } - err := RequireACRReuseForEnvironment(t.Context(), nil, "", in) - if id == "" { - require.NoError(t, err) - } else { - requireACRDisabled(t, err) - } - t.Setenv(ACRReuseEnvVar, "true") - require.NoError(t, RequireACRReuseForEnvironment(t.Context(), nil, "", in)) - //nolint:gosec // Test-owned temporary parameter file. - after, err := os.ReadFile(path) - require.NoError(t, err) - require.Equal(t, parameters, after) - }) - } -} - -func TestSynthesizeACRReuseOptIn(t *testing.T) { - const registryID = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - const projectID = "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project" - binding := &ACRBinding{ - Version: 1, Mode: "reuse-connect", RegistryResourceID: registryID, - ProjectResourceID: projectID, PrincipalID: "principal", - } - snapshot, err := json.Marshal(binding) - require.NoError(t, err) - for _, test := range []struct { - name string - values map[string]string - binding *ACRBinding - pending bool - agent string - gated bool - enabledMode string - }{ - {name: "automatic create", agent: " kind: hosted\n", enabledMode: "create"}, - {name: "project only", enabledMode: "none"}, - {name: "code without reuse", agent: " kind: hosted\n codeConfiguration: {runtime: python}\n", - enabledMode: "none"}, - {name: "image without reuse", agent: " kind: hosted\n image: registry.example/agent:v1\n", - enabledMode: "create"}, - {name: "prebuilt image without reuse", agent: " kind: hosted\n image: registry.example/agent:v1\n" + - " docker: {imagePassthrough: true}\n", - enabledMode: "none"}, - {name: "environment selector", values: map[string]string{ - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - }, agent: " kind: hosted\n", gated: true, enabledMode: "reuse-connect"}, - {name: "project only pending selector", values: map[string]string{ - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - }, gated: true, enabledMode: "none"}, - {name: "canonical without aliases", binding: binding, - agent: " kind: hosted\n", gated: true, enabledMode: "reuse-connect"}, - {name: "saved canonical without aliases", values: map[string]string{ACRBindingEnvKey: string(snapshot)}, - agent: " kind: hosted\n", gated: true, enabledMode: "reuse-connect"}, - {name: "unfinished access without aliases", pending: true, - agent: " kind: hosted\n", gated: true, enabledMode: "create"}, - {name: "snapshot cannot opt in", values: map[string]string{ - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, ACRReuseEnvVar: "true", - }, agent: " kind: hosted\n", gated: true, enabledMode: "reuse-connect"}, - {name: "legacy created registry", values: map[string]string{ - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.ContainerRegistry/registries/created", - "AZURE_CONTAINER_REGISTRY_ENDPOINT": "created.azurecr.io", - "AZURE_AI_PROJECT_ID": projectID, "AZD_FOUNDRY_ACR_MODE": "create", - }, agent: " kind: hosted\n", enabledMode: "create"}, - } { - t.Run(test.name, func(t *testing.T) { - values := map[string]string{"AZURE_SUBSCRIPTION_ID": "sub", "AZURE_RESOURCE_GROUP": "foundry"} - maps.Copy(values, test.values) - before := maps.Clone(values) - raw := "services:\n project:\n host: azure.ai.project\n" - if test.agent != "" { - raw += " agent:\n host: azure.ai.agent\n" + test.agent - } - in := Input{RawAzureYAML: []byte(raw), ServiceName: "project", - Env: values, ACRBinding: test.binding, ACRPending: test.pending} - t.Setenv(ACRReuseEnvVar, "") - result, err := Synthesize(in) - if test.gated { - requireACRDisabled(t, err) - require.Nil(t, result) - } else { - require.NoError(t, err) - require.Equal(t, test.enabledMode, result.ACRMode) - } - require.Equal(t, before, values) - t.Setenv(ACRReuseEnvVar, "true") - result, err = Synthesize(in) - require.NoError(t, err) - require.Equal(t, test.enabledMode, result.ACRMode) - require.Equal(t, before, values) - }) - } -} - -func TestACRReuseBindingCannotBecomeBrownfieldWhileDisabled(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "") - in := Input{ - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n" + - " endpoint: https://account.services.ai.azure.com/api/projects/project\n"), - ServiceName: "project", Env: map[string]string{"USE_EXISTING_AI_PROJECT": "true"}, - ACRBinding: &ACRBinding{ - Version: 1, Mode: "reuse-connect", - RegistryResourceID: "/subscriptions/sub/resourceGroups/shared/" + - "providers/Microsoft.ContainerRegistry/registries/registry", - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - }, - } - _, err := SynthesizeExistingProject(in) - requireACRDisabled(t, err) - in.ACRBinding = nil - in.Env["AZD_FOUNDRY_ACR_MODE"] = "reuse-connect" - require.NoError(t, RequireACRReuseForInput(in)) - in.ACRPending = true - requireACRDisabled(t, RequireACRReuseForInput(in)) -} - -func TestACRDemandConsistencyAcrossFeatureStates(t *testing.T) { - for _, tt := range []struct { - name, body string - legacy, acr bool - }{ - {"bare image", "image: registry.example/agent:v1\n", false, true}, - {"source without image", "", false, true}, - {"prebuilt", "image: registry.example/agent:v1\n" + - "docker: {imagePassthrough: true, remoteBuild: true}\n", false, false}, - {"code", "codeConfiguration: {runtime: python}\n", false, false}, - {"connection", "registryConnectionId: private-registry\n", false, false}, - {"legacy prebuilt", "image: registry.example/agent:v1\n", true, false}, - {"legacy without image", "", true, true}, - } { - for _, style := range []string{"inline", "service", "ref"} { - for _, enabled := range []string{"false", "true"} { - t.Run(tt.name+"/"+style+"/"+enabled, func(t *testing.T) { - t.Setenv(ACRReuseEnvVar, enabled) - root := t.TempDir() - body := "kind: hosted\n" + tt.body - if !strings.Contains(body, "docker:") { - body += "docker: {remoteBuild: true}\n" - } - raw := "services:\n project:\n host: azure.ai.project\n" - switch style { - case "inline": - raw += " agents:\n - " + - strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ") + "\n" - case "service": - raw += " agent:\n host: azure.ai.agent\n " + - strings.ReplaceAll(body, "\n", "\n ") - case "ref": - raw += " $ref: project.yaml\n" - referenced := "agents:\n - " + - strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ") + "\n" - require.NoError(t, os.WriteFile(filepath.Join(root, "project.yaml"), []byte(referenced), 0o600)) - } - values := map[string]string{} - if tt.legacy { - values["AZD_AGENT_SKIP_ACR"] = "true" - } - in := Input{RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: root, Env: values} - hasAgents, needed, err := ACRRequirements(in) - require.NoError(t, err) - require.True(t, hasAgents) - require.Equal(t, tt.acr, needed) - needed, err = RequiresACR(in) - require.NoError(t, err) - require.Equal(t, tt.acr, needed) - remote, err := RequiresRemoteACRBuild(in) - require.NoError(t, err) - require.Equal(t, tt.acr, remote) - result, err := Synthesize(in) - require.NoError(t, err) - require.Equal(t, tt.acr, result.Parameters["includeAcr"]) - require.Equal(t, map[bool]string{false: "none", true: "create"}[tt.acr], result.ACRMode) - }) - } - } - } -} - -func TestACRLegacyExistingProjectModeBoundary(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "false") - const registryID = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - binding := &ACRBinding{ - Version: 1, Mode: "reuse-connect", RegistryResourceID: registryID, - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/providers/" + - "Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - } - encoded, err := json.Marshal(binding) - require.NoError(t, err) - for _, project := range []string{"greenfield", "explicit existing", "endpoint", "ref endpoint"} { - for _, state := range []string{"legacy", "binding", "saved binding", "pending", "invalid binding"} { - t.Run(project+"/"+state, func(t *testing.T) { - root := t.TempDir() - raw := "services:\n project:\n host: azure.ai.project\n" - endpoint := "endpoint: https://account.services.ai.azure.com/api/projects/project\n" - values := map[string]string{ - "AZD_FOUNDRY_ACR_MODE": "reuse-connect", "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": registryID, - } - switch project { - case "explicit existing": - values["USE_EXISTING_AI_PROJECT"] = "true" - case "endpoint": - raw += " " + endpoint - case "ref endpoint": - raw += " $ref: project.yaml\n" - require.NoError(t, os.WriteFile(filepath.Join(root, "project.yaml"), []byte(endpoint), 0o600)) - } - in := Input{RawAzureYAML: []byte(raw), ServiceName: "project", ProjectRoot: root, Env: values} - switch state { - case "binding": - in.ACRBinding = binding - case "saved binding": - values[ACRBindingEnvKey] = string(encoded) - case "pending": - in.ACRPending = true - case "invalid binding": - values[ACRBindingEnvKey] = "{" - } - before := maps.Clone(values) - err := RequireACRReuseForInput(in) - switch { - case state == "invalid binding": - require.Error(t, err) - require.NotContains(t, err.Error(), ACRReuseEnvVar) - case state != "legacy" || project == "greenfield": - requireACRDisabled(t, err) - default: - require.NoError(t, err) - if project == "endpoint" || project == "ref endpoint" { - result, err := SynthesizeExistingProject(in) - require.NoError(t, err) - require.NotNil(t, result) - } - } - require.Equal(t, before, values) - }) - } - } -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters.go deleted file mode 100644 index 934c1934b30..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters.go +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/azure/azure-dev/cli/azd/pkg/osutil" - "go.yaml.in/yaml/v3" -) - -func requireACRReuseForOnDiskInput( - ctx context.Context, in Input, compiler BicepParamCompiler, -) error { - if ACRReuseEnabled() || in.ProjectRoot == "" || len(in.RawAzureYAML) == 0 { - return nil - } - target, err := foundryParameterTarget(in.RawAzureYAML) - if err != nil || target == nil { - return err - } - sourcePath, err := foundryParameterPath(in.ProjectRoot, *target, ".bicepparam") - if err != nil { - return err - } - bicepParam := sourcePath != "" - if !bicepParam { - sourcePath, err = foundryParameterPath(in.ProjectRoot, *target, ".bicep") - if err != nil || sourcePath == "" { - return err - } - sourcePath, err = foundryParameterPath(in.ProjectRoot, *target, ".parameters.json") - if err != nil || sourcePath == "" { - return err - } - } - existing, err := acrExistingProject(in) - if err != nil || existing { - return err - } - var template, parameters map[string]any - if bicepParam { - template, parameters, err = CompileBicepParameters(ctx, compiler, sourcePath, in.Env) - } else { - parameters, err = LoadARMParametersFile(sourcePath, in.Env) - } - if err != nil { - return err - } - for name, raw := range parameters { - if !strings.EqualFold(name, "existingAcrResourceId") { - continue - } - entry, valid := raw.(map[string]any) - if !valid || entry["value"] == nil { - return invalidACRParameter(sourcePath) - } - } - value := ARMParameterValue(template, parameters, "existingAcrResourceId") - if value == nil { - return nil - } - id, stringValue := value.(string) - if !stringValue { - return invalidACRParameter(sourcePath) - } - if id == "" { - return nil - } - group, err := GreenfieldACRResourceGroup(in) - if err != nil { - return err - } - if value, ok := ARMParameterValue(template, parameters, "resourceGroupName").(string); ok && value != "" { - group = value - } - if err := ValidateACRSelector(id, in.Env["AZURE_SUBSCRIPTION_ID"], group); err != nil { - return err - } - return RequireACRReuse() -} - -func invalidACRParameter(sourcePath string) error { - return &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("existingAcrResourceId in %s must contain a string value", sourcePath), - Suggestion: "use parameters.existingAcrResourceId.value in ARM parameters JSON, " + - "or a string-valued parameter in Bicep", - } -} - -type foundryParameterInfra struct { - Provider string `yaml:"provider"` - Path string `yaml:"path"` - Module string `yaml:"module"` -} - -func foundryParameterTarget(raw []byte) (*foundryParameterInfra, error) { - var document struct { - Infra struct { - foundryParameterInfra `yaml:",inline"` - Layers []foundryParameterInfra `yaml:"layers"` - } `yaml:"infra"` - } - if err := yaml.Unmarshal(raw, &document); err != nil { - return nil, &azdext.LocalError{ - Code: "invalid_azure_yaml", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("parse azure.yaml infrastructure configuration: %s", err), - Suggestion: "verify azure.yaml is valid YAML", - } - } - root := document.Infra.foundryParameterInfra - root.Provider = strings.TrimSpace(root.Provider) - var target *foundryParameterInfra - if len(document.Infra.Layers) == 0 { - if root.Provider != "microsoft.foundry" { - return nil, nil - } - target = &root - } else { - if root.Provider == "microsoft.foundry" { - return nil, invalidFoundryParameterTarget("a root Foundry provider cannot also declare infrastructure layers") - } - for _, layer := range document.Infra.Layers { - provider := strings.TrimSpace(layer.Provider) - if provider == "" { - provider = root.Provider - } - if provider != "microsoft.foundry" { - continue - } - if target != nil { - return nil, invalidFoundryParameterTarget("multiple infrastructure layers use microsoft.foundry") - } - selected := layer - selected.Provider = provider - target = &selected - } - } - if target == nil { - return nil, nil - } - target.Path = strings.TrimSpace(target.Path) - if target.Path == "" { - target.Path = "infra" - } - target.Path = strings.ReplaceAll(target.Path, "\\", "/") - if filepath.IsAbs(target.Path) || strings.HasPrefix(target.Path, "/") || - strings.Contains(target.Path, ":") || filepath.Clean(filepath.FromSlash(target.Path)) == "." || - strings.Contains("/"+target.Path+"/", "/../") { - return nil, invalidFoundryParameterTarget("the Foundry infrastructure path must stay inside the project") - } - target.Module = strings.TrimSpace(target.Module) - if target.Module == "" { - target.Module = "main" - } - if filepath.Ext(target.Module) != "" || target.Module == "." || target.Module == ".." || - strings.ContainsAny(target.Module, `/\`) { - return nil, invalidFoundryParameterTarget("the Foundry module must be a file name without an extension") - } - return target, nil -} - -func invalidFoundryParameterTarget(message string) error { - return &azdext.LocalError{ - Code: "invalid_service_config", Category: azdext.LocalErrorCategoryValidation, - Message: message, Suggestion: "verify the microsoft.foundry infrastructure path, module, and layer configuration", - } -} - -func foundryParameterPath(root string, target foundryParameterInfra, extension string) (string, error) { - root, err := filepath.Abs(root) - if err != nil { - return "", err - } - path := filepath.Join(root, filepath.FromSlash(target.Path), target.Module+extension) - if !osutil.IsPathContained(root, path) { - return "", invalidFoundryParameterTarget("the Foundry parameter source must stay inside the project") - } - info, err := os.Stat(path) - if errors.Is(err, os.ErrNotExist) { - return "", nil - } - if err != nil { - return "", invalidFoundryParameterTarget(fmt.Sprintf("inspect Foundry parameter source: %s", err)) - } - rootReal, err := filepath.EvalSymlinks(root) - if err != nil { - return "", err - } - pathReal, err := filepath.EvalSymlinks(path) - if err != nil { - return "", err - } - if !osutil.IsPathContained(rootReal, pathReal) { - return "", invalidFoundryParameterTarget("the Foundry parameter source escapes the project through a symbolic link") - } - if !info.Mode().IsRegular() { - return "", invalidFoundryParameterTarget(fmt.Sprintf("Foundry parameter source %q must be a regular file", path)) - } - return path, nil -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters.go deleted file mode 100644 index 852125429c4..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "os" - "slices" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/input" - "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" - "github.com/drone/envsubst" -) - -// NewBicepCompiler retains normal tool setup and module restoration. -func NewBicepCompiler() *bicep.Cli { - console := input.NewConsole( - false, - true, - input.Writers{Output: os.Stdout}, - input.ConsoleHandles{ - Stderr: os.Stderr, - Stdin: os.Stdin, - Stdout: os.Stdout, - }, - nil, - nil, - ) - return bicep.NewCli(console, exec.NewCommandRunner(nil)) -} - -// BicepParamCompiler resolves expressions and environment reads. -type BicepParamCompiler interface { - BuildBicepParam(context.Context, string, []string) (bicep.BuildResult, error) -} - -// ARMParameterValue resolves ARM names case-insensitively. -func ARMParameterValue(template, parameters map[string]any, name string) any { - for key, value := range parameters { - if strings.EqualFold(key, name) { - if entry, ok := value.(map[string]any); ok { - return entry["value"] - } - } - } - defaults, _ := template["parameters"].(map[string]any) - for key, value := range defaults { - if strings.EqualFold(key, name) { - if entry, ok := value.(map[string]any); ok { - return entry["defaultValue"] - } - } - } - return nil -} - -// CompileBicepParameters reads the template and parameter envelope. -func CompileBicepParameters( - ctx context.Context, - compiler BicepParamCompiler, - sourcePath string, - envValues map[string]string, -) (map[string]any, map[string]any, error) { - env := make([]string, 0, len(envValues)) - for _, key := range slices.Sorted(maps.Keys(envValues)) { - env = append(env, key+"="+envValues[key]) - } - result, err := compiler.BuildBicepParam(ctx, sourcePath, env) - if err != nil { - if _, structured := errors.AsType[*azdext.LocalError](err); structured { - return nil, nil, err - } - return nil, nil, &azdext.LocalError{ - Code: "ondisk_bicep_compile_failed", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("compile %s: %s", sourcePath, err), - Suggestion: "fix the bicep errors above and re-run `azd provision`", - } - } - var envelope struct { - TemplateJSON string `json:"templateJson"` - ParametersJSON string `json:"parametersJson"` - } - if err := json.Unmarshal([]byte(result.Compiled), &envelope); err != nil { - return nil, nil, &azdext.LocalError{ - Code: "ondisk_bicep_parse_failed", Category: azdext.LocalErrorCategoryInternal, - Message: fmt.Sprintf("parse bicepparam envelope from %s: %s", sourcePath, err), - } - } - if envelope.TemplateJSON == "" { - return nil, nil, &azdext.LocalError{ - Code: "ondisk_bicep_parse_failed", Category: azdext.LocalErrorCategoryInternal, - Message: fmt.Sprintf("compiled ARM JSON from %s is empty", sourcePath), - } - } - var template map[string]any - if err := json.Unmarshal([]byte(envelope.TemplateJSON), &template); err != nil { - return nil, nil, &azdext.LocalError{ - Code: "ondisk_bicep_parse_failed", Category: azdext.LocalErrorCategoryInternal, - Message: fmt.Sprintf("parse compiled ARM JSON from %s: %s", sourcePath, err), - } - } - parameters, err := parseARMParameters([]byte(envelope.ParametersJSON), sourcePath) - return template, parameters, err -} - -// LoadARMParametersFile substitutes each parameter independently. -// Explicit envValues entries take precedence over process environment values. -// Missing variables drop only strings that resolve to an empty value. -func LoadARMParametersFile(sourcePath string, envValues map[string]string) (map[string]any, error) { - //nolint:gosec // validated project infrastructure parameter path - raw, err := os.ReadFile(sourcePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return map[string]any{}, nil - } - return nil, &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryInternal, - Message: fmt.Sprintf("read %s: %s", sourcePath, err), - } - } - parameters, err := parseARMParameters(raw, sourcePath) - if err != nil { - return nil, err - } - resolved := make(map[string]any, len(parameters)) - for name, entry := range parameters { - value, err := substituteParameter(entry, sourcePath, name, envValues) - if err != nil { - return nil, err - } - if value != nil { - resolved[name] = value - } - } - return resolved, nil -} - -func substituteParameter( - raw any, sourcePath, name string, envValues map[string]string, -) (any, error) { - encoded, err := json.Marshal(raw) - if err != nil { - return nil, &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryInternal, - Message: fmt.Sprintf("re-encode parameter %q in %s: %s", name, sourcePath, err), - } - } - hasUnsetEnvVar := false - substituted, err := envsubst.Eval(string(encoded), func(key string) string { - value, found := envValues[key] - if !found { - value, found = os.LookupEnv(key) - } - if !found { - hasUnsetEnvVar = true - return "" - } - // Escape values inside the JSON-encoded parameter entry. - escaped, _ := json.Marshal(value) - return string(escaped[1 : len(escaped)-1]) - }) - if err != nil { - return nil, &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("substitute env vars in parameter %q of %s: %s", name, sourcePath, err), - Suggestion: "check for malformed ${VAR} references in the parameters file", - } - } - var resolved any - if err := json.Unmarshal([]byte(substituted), &resolved); err != nil { - return nil, &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("parse parameter %q in %s after substitution: %s", name, sourcePath, err), - Suggestion: "ensure the substituted value is valid JSON", - } - } - if entry, ok := resolved.(map[string]any); ok && hasUnsetEnvVar { - if value, ok := entry["value"].(string); ok && value == "" { - return nil, nil - } - } - return resolved, nil -} - -func parseARMParameters(raw []byte, sourcePath string) (map[string]any, error) { - if len(raw) == 0 { - return map[string]any{}, nil - } - var document struct { - Parameters map[string]any `json:"parameters"` - } - if err := json.Unmarshal(raw, &document); err != nil { - return nil, &azdext.LocalError{ - Code: "ondisk_parameters_invalid", Category: azdext.LocalErrorCategoryValidation, - Message: fmt.Sprintf("parse %s as ARM parameters JSON: %s", sourcePath, err), - Suggestion: "verify the file matches the ARM deploymentParameters schema", - } - } - if document.Parameters == nil { - return map[string]any{}, nil - } - return document.Parameters, nil -} diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters_test.go deleted file mode 100644 index 704167b515e..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/bicep_parameters_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package synthesis - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestLoadARMParametersFileEnvironmentPrecedence(t *testing.T) { - for _, test := range []struct { - name string - env map[string]string - process string - want string - }{ - {name: "process fallback", process: "registry", want: "registry"}, - {name: "escaped process value", process: "registry\"\\\n", want: "registry\"\\\n"}, - {name: "explicit empty", env: map[string]string{"SHARED_REGISTRY": ""}, process: "registry"}, - {name: "persisted value", env: map[string]string{"SHARED_REGISTRY": "persisted"}, - process: "registry", want: "persisted"}, - {name: "empty process value"}, - } { - t.Run(test.name, func(t *testing.T) { - t.Setenv("SHARED_REGISTRY", test.process) - path := filepath.Join(t.TempDir(), "main.parameters.json") - require.NoError(t, os.WriteFile(path, []byte( - `{"parameters":{"existingAcrResourceId":{"value":"${SHARED_REGISTRY}"}}}`), 0o600)) - parameters, err := LoadARMParametersFile(path, test.env) - require.NoError(t, err) - require.Equal(t, map[string]any{ - "existingAcrResourceId": map[string]any{"value": test.want}, - }, parameters) - }) - } - t.Run("unset in both sources", func(t *testing.T) { - t.Setenv("SHARED_REGISTRY", "") - require.NoError(t, os.Unsetenv("SHARED_REGISTRY")) - resolved, err := substituteParameter(map[string]any{"value": "${SHARED_REGISTRY}"}, - "main.parameters.json", "existingAcrResourceId", nil) - require.NoError(t, err) - require.Nil(t, resolved) - }) -} 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 cb81c967c71..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/existing_acr_test.go +++ /dev/null @@ -1,466 +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 TestSynthesizeExistingRegistry(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "true") - const subscription = "11111111-1111-1111-1111-111111111111" - const id = "/subscriptions/" + subscription + "/resourceGroups/shared/" + - "providers/Microsoft.ContainerRegistry/registries/sharedregistry" - const raw = `services: - my-project: - host: azure.ai.project - assistant: - host: azure.ai.agent - kind: hosted -` - tests := []struct { - name string - mode string - selector string - group string - network bool - wantError string - outputOnly bool - preserve bool - deferNetwork bool - }{ - {name: "unset"}, - {name: "environment selector", selector: id}, - {name: "environment selector overrides undeployed create", mode: "create", selector: id}, - {name: "reuse", mode: "reuse-connect", selector: id}, - {name: "eject unset", preserve: true}, - {name: "eject reuse", mode: "reuse-connect", selector: id, preserve: true}, - {name: "eject malformed", mode: "reuse-connect", selector: "sharedregistry", preserve: true, wantError: "full ARM ID"}, - {name: "eject same group", mode: "reuse-connect", selector: id, group: "SHARED", - preserve: true, wantError: "azd down deletes"}, - {name: "eject private networking reuse", mode: "reuse-connect", selector: id, network: true, preserve: true}, - {name: "legacy deployment outputs", outputOnly: true}, - {name: "create preserves deployment outputs", mode: "create", outputOnly: true}, - {name: "eject preserves deployment outputs", mode: "create", outputOnly: true, preserve: true}, - {name: "reuse missing ID", mode: "reuse-connect", wantError: "requires AZURE_CONTAINER_REGISTRY_RESOURCE_ID"}, - {name: "unsupported mode", mode: "already-connected", wantError: "AZD_FOUNDRY_ACR_MODE"}, - {name: "none becomes create when a registry is required", mode: "none"}, - {name: "malformed", mode: "reuse-connect", selector: "sharedregistry", wantError: "full ARM ID"}, - {name: "wrong type", mode: "reuse-connect", - selector: strings.Replace(id, "registries", "other", 1), wantError: "full ARM ID"}, - {name: "child resource", mode: "reuse-connect", selector: id + "/other/child", wantError: "full ARM ID"}, - {name: "empty subscription", mode: "reuse-connect", - selector: strings.Replace(id, subscription, "", 1), wantError: "full ARM ID"}, - {name: "same group", mode: "reuse-connect", selector: id, group: "SHARED", wantError: "azd down deletes"}, - {name: "same group without mode", selector: id, group: "SHARED", wantError: "azd down deletes"}, - {name: "same group with create intent", mode: "create", selector: id, group: "SHARED", - wantError: "azd down deletes"}, - {name: "different subscription", mode: "reuse-connect", - selector: strings.Replace(id, subscription, "other-sub", 1), group: "shared", - wantError: "cross-subscription registry reuse is not supported"}, - {name: "different subscription environment selector", - selector: strings.Replace(id, subscription, "other-sub", 1), wantError: "same subscription"}, - {name: "eject different subscription", mode: "reuse-connect", preserve: true, - selector: strings.Replace(id, subscription, "other-sub", 1), wantError: "same subscription"}, - {name: "private networking reuse", mode: "reuse-connect", selector: id, network: true}, - {name: "private networking creation", network: true, wantError: "auto-created Azure Container Registry"}, - {name: "eject private networking creation", network: true, preserve: true, - wantError: "auto-created Azure Container Registry"}, - {name: "on-disk deferred networking", network: true, deferNetwork: true}, - {name: "on-disk selector validation", network: true, deferNetwork: true, selector: "sharedregistry", - wantError: "full ARM ID"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := map[string]string{ - "AZD_FOUNDRY_ACR_MODE": tt.mode, - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": tt.selector, - "AZURE_SUBSCRIPTION_ID": subscription, - "AZURE_RESOURCE_GROUP": "foundry", - } - if tt.outputOnly { - env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = strings.Replace(id, "/shared/", "/foundry/", 1) - env["AZURE_AI_PROJECT_ID"] = "/subscriptions/" + subscription + "/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project" - env["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = "sharedregistry.azurecr.io" - } - yaml := raw - if tt.network { - yaml = strings.Replace(yaml, "host: azure.ai.project", `host: azure.ai.project - network: - peSubnet: - vnet: /subscriptions/`+subscription+`/resourceGroups/network/providers/Microsoft.Network/virtualNetworks/vnet - name: pe-subnet`, 1) - } - result, err := Synthesize(Input{ - RawAzureYAML: []byte(yaml), ServiceName: "my-project", - Env: env, ResourceGroupName: tt.group, - PreserveVarRefs: tt.preserve, - DeferACRNetworkValidation: tt.deferNetwork, - }) - if tt.wantError != "" { - require.ErrorContains(t, err, tt.wantError) - return - } - require.NoError(t, err) - assert.Equal(t, true, result.Parameters["includeAcr"]) - if tt.selector != "" { - expected := tt.selector - if tt.preserve { - expected = "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID}" - } - assert.Equal(t, expected, result.Parameters["existingAcrResourceId"]) - } else { - assert.NotContains(t, result.Parameters, "existingAcrResourceId") - } - }) - } -} - -func TestSynthesizeRegistryUsesOnlySnapshotAndMode(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "true") - const id = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/sharedregistry" - t.Setenv("AZURE_CONTAINER_REGISTRY_RESOURCE_ID", id) - input := Input{ - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n"), - ServiceName: "project", - } - result, err := Synthesize(input) - require.NoError(t, err) - assert.NotContains(t, result.Parameters, "existingAcrResourceId") - input.Env = map[string]string{"AZURE_CONTAINER_REGISTRY_RESOURCE_ID": id} - result, err = Synthesize(input) - require.NoError(t, err) - assert.NotContains(t, result.Parameters, "existingAcrResourceId") - input.Env["AZURE_SUBSCRIPTION_ID"] = "other" - _, err = Synthesize(input) - require.ErrorContains(t, err, "same subscription") - input.Env["AZURE_SUBSCRIPTION_ID"] = "sub" - input.ResourceGroupName = "shared" - _, err = Synthesize(input) - require.ErrorContains(t, err, "azd down deletes") - - input.RawAzureYAML = []byte("services:\n project:\n host: azure.ai.project\n" + - " agent:\n host: azure.ai.agent\n kind: hosted\n") - input.Env = map[string]string{ - "AZURE_SUBSCRIPTION_ID": "SUB", - "AZD_FOUNDRY_ACR_MODE": "reuse-connect", - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID": id, - } - input.ResourceGroupName = "SHARED" - _, err = Synthesize(input) - require.ErrorContains(t, err, "azd down deletes") - input.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = "" - _, err = Synthesize(input) - require.ErrorContains(t, err, "requires AZURE_CONTAINER_REGISTRY_RESOURCE_ID") -} - -func TestSynthesizeCanonicalReuseBinding(t *testing.T) { - t.Setenv(ACRReuseEnvVar, "true") - const registryID = "/subscriptions/sub/resourceGroups/shared/providers/Microsoft.ContainerRegistry/registries/registry" - for _, alias := range []string{"", registryID, strings.ToUpper(registryID)} { - for _, preserve := range []bool{false, true} { - for _, configSnapshot := range []bool{false, true} { - t.Run(alias+"/"+map[bool]string{false: "resolved", true: "eject"}[preserve]+ - "/"+map[bool]string{false: "input binding", true: "config snapshot"}[configSnapshot], func(t *testing.T) { - binding := &ACRBinding{ - Version: 1, Mode: "reuse-connect", RegistryResourceID: registryID, - ProjectResourceID: "/subscriptions/sub/resourceGroups/foundry/" + - "providers/Microsoft.CognitiveServices/accounts/account/projects/project", - PrincipalID: "principal", - } - input := Input{ - RawAzureYAML: []byte("services:\n project:\n host: azure.ai.project\n" + - " agent:\n host: azure.ai.agent\n kind: hosted\n"), - ServiceName: "project", PreserveVarRefs: preserve, ACRBinding: binding, - Env: map[string]string{ - "AZURE_SUBSCRIPTION_ID": "sub", "AZURE_RESOURCE_GROUP": "foundry", - }, - } - if alias != "" { - input.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] = alias - } - if configSnapshot { - raw, err := json.Marshal(binding) - require.NoError(t, err) - input.Env[ACRBindingEnvKey], input.ACRBinding = string(raw), nil - } - result, err := Synthesize(input) - require.NoError(t, err) - assert.Equal(t, "reuse-connect", result.ACRMode) - assert.Equal(t, true, result.Parameters["includeAcr"]) - expected := registryID - if preserve && alias != "" { - expected = "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID}" - } - assert.Equal(t, expected, result.Parameters["existingAcrResourceId"]) - assert.NotContains(t, input.Env, "AZURE_AI_PROJECT_ID") - assert.Equal(t, alias, input.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"]) - }) - } - } - } -} - -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"` - Type string `json:"type"` - } `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.Contains(t, main.Parameters, "acrPullRoleDefinitionId") - assert.Equal(t, "", main.Parameters["acrPullRoleDefinitionId"].DefaultValue) - require.Contains(t, main.Parameters, "acrOwnedAssignments") - assert.Equal(t, "array", main.Parameters["acrOwnedAssignments"].Type) - assert.Equal(t, []any{}, main.Parameters["acrOwnedAssignments"].DefaultValue) - require.Len(t, main.Resources, 2) - var resourcesModule moduleProperties - require.NoError(t, json.Unmarshal(main.Resources["resources"].Properties, &resourcesModule)) - assert.Equal(t, map[string]any{"value": "[parameters('acrPullRoleDefinitionId')]"}, - resourcesModule.Parameters["acrPullRoleDefinitionId"]) - 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": - var properties moduleProperties - require.NoError(t, json.Unmarshal(resource.Properties, &properties)) - 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" { - assert.Equal(t, map[string]any{"value": "[parameters('acrPullRoleDefinitionId')]"}, - properties.Parameters["pullRoleDefinitionId"]) - assert.Equal(t, "[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))]", - properties.Parameters["projectPrincipalId"]) - 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" { - 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(not(empty(parameters('accessOperationId'))), "+ - "createObject('value', parameters('pullRoleDefinitionId')), "+ - "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 ec2d4071e08..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting.go +++ /dev/null @@ -1,139 +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 existing-AKS -// initialization, adoption, provisioning, and provisioning preview. -const BYOAKSEnvVar = "AZD_ALPHA_ENABLE_AGENTS_BYO_AKS" - -// AgentHostingInput selects the existing cluster and optional supporting resources. -type AgentHostingInput struct { - ClusterResourceID string `yaml:"clusterResourceId"` - StorageAccountResourceID string `yaml:"storageAccountResourceId"` - HostingManagementIdentityResourceID string `yaml:"hostingManagementIdentityResourceId"` - WorkloadIdentityResourceID string `yaml:"workloadIdentityResourceId"` -} - -type agentHostingParameter struct { - Enabled bool `json:"enabled"` - HostingType string `json:"hostingType"` - Name string `json:"name"` - ClusterResourceID string `json:"clusterResourceId"` - StorageAccountResourceID string `json:"storageAccountResourceId"` - HostingManagementIdentityResourceID string `json:"hostingManagementIdentityResourceId"` - WorkloadIdentityResourceID string `json:"workloadIdentityResourceId"` -} - -// ValidateAgentHostingInputs checks hosting selections without synthesizing unrelated service inputs. -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 - switch name { - case "clusterResourceId", "storageAccountResourceId", - "hostingManagementIdentityResourceId", "workloadIdentityResourceId": - default: - return fmt.Errorf("services.%s.agentHosting.%s: unsupported property", service, name) - } - value := hosting.Content[i+1] - if name != "clusterResourceId" && - (value.Tag == "!!null" || value.Tag == "!!str" && value.Value == "") { - continue - } - if value.Kind != yaml.ScalarNode || value.Tag != "!!str" || strings.TrimSpace(value.Value) == "" { - return fmt.Errorf("services.%s.agentHosting.%s: expected a nonempty resource ID", service, name) - } - } - var input AgentHostingInput - if err := hosting.Decode(&input); err != nil { - return err - } - _, err := synthesizeAgentHosting(&input, service, nil, false) - return err -} - -func synthesizeAgentHosting( - input *AgentHostingInput, service string, env map[string]string, resolve bool, -) (*AgentHostingInput, error) { - if input == nil { - return nil, nil - } - result := *input - for _, selection := range []struct { - name, kind string - value *string - required bool - }{ - {"clusterResourceId", "Microsoft.ContainerService/managedClusters", &result.ClusterResourceID, true}, - {"storageAccountResourceId", "Microsoft.Storage/storageAccounts", &result.StorageAccountResourceID, false}, - {"hostingManagementIdentityResourceId", "Microsoft.ManagedIdentity/userAssignedIdentities", - &result.HostingManagementIdentityResourceID, false}, - {"workloadIdentityResourceId", "Microsoft.ManagedIdentity/userAssignedIdentities", - &result.WorkloadIdentityResourceID, false}, - } { - if *selection.value == "" && !selection.required { - continue - } - value, err := resolveHostingResourceID(*selection.value, - fmt.Sprintf("services.%s.agentHosting.%s", service, selection.name), - selection.kind, env, resolve, selection.required) - if err != nil { - return nil, err - } - *selection.value = value - } - return &result, nil -} - -func resolveHostingResourceID(value, field, kind string, env map[string]string, resolve, required bool) (string, error) { - value = strings.TrimSpace(value) - if err := ValidateEnvReferences(value); err != nil { - return "", fmt.Errorf("%s: %w", field, err) - } - if resolve { - resolved, err := resolveVars(value, env) - if err != nil { - return "", fmt.Errorf("%s: %w", field, err) - } - value = strings.TrimSpace(resolved) - } - if value == "" { - if !required { - return "", nil - } - return "", 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(), kind) || - len(strings.Split(value, "/")) != 9 || strings.ContainsAny(value, "?#%\\@:\x00 \t\r\n") { - return "", fmt.Errorf("%s: expected a complete %s resource ID", field, kind) - } - } - return 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/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go deleted file mode 100644 index fa29b47a3b5..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ /dev/null @@ -1,1375 +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 - - // DeferACRNetworkValidation leaves registry/network policy checks - // to the caller after on-disk deployment inputs are resolved. - DeferACRNetworkValidation 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 - - // ACRPending marks unfinished external registry access. - ACRPending bool -} - -// 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"` - RegistryConnectionID string `yaml:"registryConnectionId,omitempty"` - CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` - Docker dockerBuildBlock `yaml:"docker,omitempty"` -} - -type dockerBuildBlock struct { - RemoteBuild bool `yaml:"remoteBuild,omitempty"` - ImagePassthrough bool `yaml:"imagePassthrough,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"` - RegistryConnectionID string `yaml:"registryConnectionId,omitempty"` - CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` - Config *agentBlock `yaml:"config,omitempty"` - Agents []agentBlock `yaml:"agents,omitempty"` - Docker *dockerBuildBlock `yaml:"docker,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") - } - if in.ACRPending || in.ACRBinding != nil || in.Env[ACRBindingEnvKey] != "" { - if err := RequireACRReuseForInput(in); err != nil { - return nil, err - } - } - - 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 - } - mode, err := GreenfieldACRMode(in) - if err != nil { - return nil, err - } - var existingAcrResourceID string - switch mode { - case "create", "none": - case "reuse-connect": - existingAcrResourceID, err = existingContainerRegistry(in) - if err != nil { - return nil, err - } - if err := RequireACRReuse(); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("synthesis: AZD_FOUNDRY_ACR_MODE has unsupported greenfield value %q", mode) - } - pendingRegistry := !includeAcr && mode == "reuse-connect" && in.PreserveVarRefs - if in.ACRPending { - if err := RequireACRReuse(); err != nil { - return nil, err - } - } - if !includeAcr { - mode = "none" - if !pendingRegistry { - existingAcrResourceID = "" - } - } - - 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 !in.DeferACRNetworkValidation { - if err := ValidateACRNetworking( - includeAcr, existingAcrResourceID, netMode != NetworkModeNone, hosting != nil, - ); err != nil { - return nil, err - } - } - - params := map[string]any{ - "deployments": deployments, - "includeAcr": includeAcr, - } - if pendingRegistry { - // Leave includeAcr demand-driven until provision. - delete(params, "includeAcr") - } - if existingAcrResourceID != "" { - params["existingAcrResourceId"] = existingAcrResourceID - if in.PreserveVarRefs && in.Env["AZURE_CONTAINER_REGISTRY_RESOURCE_ID"] != "" { - params["existingAcrResourceId"] = "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID}" - } - } - maps.Copy(params, netParams) - if hosting != nil { - params["agentHosting"] = agentHostingParameter{ - Enabled: true, HostingType: "ManagedCluster", Name: "default", - ClusterResourceID: hosting.ClusterResourceID, - StorageAccountResourceID: hosting.StorageAccountResourceID, - HostingManagementIdentityResourceID: hosting.HostingManagementIdentityResourceID, - WorkloadIdentityResourceID: hosting.WorkloadIdentityResourceID, - } - } - - return &Result{ - Parameters: params, - NetworkMode: netMode, - ACRMode: mode, - 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]+$`, -) - -func existingContainerRegistry(in Input) (string, error) { - _, id, err := ResolveGreenfieldACRSelection(in) - if err != nil { - return "", err - } - if id == "" { - return "", errors.New("synthesis: AZD_FOUNDRY_ACR_MODE reuse-connect requires " + - "AZURE_CONTAINER_REGISTRY_RESOURCE_ID; set it to an external registry ARM ID or use create mode") - } - if err := ValidateACRSelectorForInput(in, id); err != nil { - return "", fmt.Errorf("synthesis: %w", err) - } - return id, nil -} - -// 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") - } - if err := RequireACRReuseForInput(in); err != nil { - return nil, err - } - - 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 needs 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 -// - explicit pre-built image selection -> no ACR -// - otherwise (hosted) -> source build, even with image set -func deriveIncludeAcr( - services map[string]yaml.Node, - svc projectService, - projectRoot string, - env map[string]string, -) (bool, error) { - _, needed, err := deriveACRRequirements(services, svc, projectRoot, env) - return needed, err -} - -// 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) { - return acrRequirements(in, func(agent agentBlock) bool { - return agentNeedsAcr(agent, in.Env) - }) -} - -// RequiresRemoteACRBuild reports enabled remote source builds. -func RequiresRemoteACRBuild(in Input) (bool, error) { - _, needed, err := acrRequirements(in, func(agent agentBlock) bool { - return agentNeedsAcr(agent, in.Env) && agent.Docker.RemoteBuild - }) - return needed, err -} - -func acrRequirements(in Input, needsACR func(agentBlock) bool) (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 deriveAgentBuildRequirements(root.Services, combined, in.ProjectRoot, in.Env, needsACR) -} - -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) { - return deriveAgentBuildRequirements(services, svc, projectRoot, env, func(agent agentBlock) bool { - return agentNeedsAcr(agent, env) - }) -} - -func deriveAgentBuildRequirements( - services map[string]yaml.Node, - svc projectService, - projectRoot string, - env map[string]string, - needsACR func(agentBlock) bool, -) (bool, bool, error) { - hasAgents := len(svc.Agents) > 0 - includeAcr := false - lookup := projectConditionLookup(env) - checkAgent := func(agent agentBlock) error { - image, err := foundry.ExpandEnv(agent.Image, lookup) - if err != nil { - return fmt.Errorf("expand agent image: %w", err) - } - agent.Image = image - includeAcr = needsACR(agent) || includeAcr - return nil - } - for _, agent := range svc.Agents { - if err := checkAgent(agent); err != nil { - return false, false, err - } - } - 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 { - for _, agent := range service.Agents { - if err := checkAgent(agent); err != nil { - return false, false, fmt.Errorf("service %q: %w", serviceName, err) - } - } - continue - } - agent := agentBlock{ - Kind: service.Kind, - Image: service.Image, - RegistryConnectionID: service.RegistryConnectionID, - CodeConfiguration: service.CodeConfiguration, - } - if service.Docker != nil { - agent.Docker = *service.Docker - } - if service.Config != nil { - if agent.Kind == "" { - agent.Kind = service.Config.Kind - } - if agent.Image == "" { - agent.Image = service.Config.Image - } - if agent.RegistryConnectionID == "" { - agent.RegistryConnectionID = service.Config.RegistryConnectionID - } - if agent.CodeConfiguration == nil { - agent.CodeConfiguration = service.Config.CodeConfiguration - } - if service.Docker == nil { - agent.Docker = service.Config.Docker - } - } - if err := checkAgent(agent); err != nil { - return false, false, fmt.Errorf("service %q: %w", serviceName, err) - } - } - return hasAgents, includeAcr, nil -} - -// agentNeedsAcr follows explicit prebuilt selection. -func agentNeedsAcr(a agentBlock, env map[string]string) bool { - if a.CodeConfiguration != nil || a.Docker.ImagePassthrough || a.RegistryConnectionID != "" { - return false - } - if strings.TrimSpace(a.Image) != "" && strings.EqualFold(strings.TrimSpace(env["AZD_AGENT_SKIP_ACR"]), "true") { - 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.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-eject.bicep.tmpl b/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/existing-project-eject.bicep.tmpl deleted file mode 100644 index 2e77d82aed0..00000000000 --- a/cli/azd/extensions/azure.ai.projects/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.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 15a2801365e..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.arm.json +++ /dev/null @@ -1,2463 +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": "11444310712805509197" - } - }, - "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" - }, - "hostingManagementIdentityResourceId": { - "type": "string", - "nullable": true - }, - "storageAccountResourceId": { - "type": "string", - "nullable": true - }, - "workloadIdentityResourceId": { - "type": "string", - "nullable": true - } - } - } - }, - "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": "", - "hostingManagementIdentityResourceId": "", - "storageAccountResourceId": "", - "workloadIdentityResourceId": "" - }, - "metadata": { - "description": "Existing AKS hosting with independently optional Storage and managed identity references." - } - }, - "kubeletObjectId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "AKS kubelet principal ID for registry access; resolved by microsoft.foundry when omitted." - } - }, - "federatedIdentityRoleDefinitionId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Federation-management role; resolved by microsoft.foundry when omitted." - } - }, - "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." - } - }, - "hostingPrincipalId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Principal ID of the supplied hosting-management identity; resolved by microsoft.foundry when omitted." - } - }, - "workloadPrincipalId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Principal ID of the supplied workload identity; resolved by microsoft.foundry when omitted." - } - }, - "acrPullRoleDefinitionId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Registry pull role persisted with the access operation." - } - }, - "acrOwnedAssignments": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Owned registry grants retained for recovery after local state loss." - } - }, - "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')]" - }, - "hostingPrincipalId": { - "value": "[parameters('hostingPrincipalId')]" - }, - "workloadPrincipalId": { - "value": "[parameters('workloadPrincipalId')]" - }, - "acrPullRoleDefinitionId": { - "value": "[parameters('acrPullRoleDefinitionId')]" - }, - "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": "12875653734830163232" - } - }, - "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" - }, - "hostingManagementIdentityResourceId": { - "type": "string", - "nullable": true - }, - "storageAccountResourceId": { - "type": "string", - "nullable": true - }, - "workloadIdentityResourceId": { - "type": "string", - "nullable": true - } - } - } - }, - "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": "", - "hostingManagementIdentityResourceId": "", - "storageAccountResourceId": "", - "workloadIdentityResourceId": "" - } - }, - "kubeletObjectId": { - "type": "string", - "defaultValue": "" - }, - "federatedIdentityRoleDefinitionId": { - "type": "string", - "defaultValue": "" - }, - "hostingPrincipalId": { - "type": "string", - "defaultValue": "" - }, - "workloadPrincipalId": { - "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": "" - }, - "acrPullRoleDefinitionId": { - "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'))]", - "suppliedHostingIdentityResourceId": "[coalesce(tryGet(parameters('agentHosting'), 'hostingManagementIdentityResourceId'), '')]", - "suppliedWorkloadIdentityResourceId": "[coalesce(tryGet(parameters('agentHosting'), 'workloadIdentityResourceId'), '')]", - "suppliedStorageAccountResourceId": "[coalesce(tryGet(parameters('agentHosting'), 'storageAccountResourceId'), '')]", - "hostingIdentityResourceId": "[if(empty(variables('suppliedHostingIdentityResourceId')), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('hostingIdentityName')), variables('suppliedHostingIdentityResourceId'))]", - "workloadIdentityResourceId": "[if(empty(variables('suppliedWorkloadIdentityResourceId')), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('workloadIdentityName')), variables('suppliedWorkloadIdentityResourceId'))]", - "storageAccountResourceId": "[if(empty(variables('suppliedStorageAccountResourceId')), resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), variables('suppliedStorageAccountResourceId'))]", - "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}', variables('hostingIdentityResourceId'))]": {} - } - }, - "properties": "[shallowMerge(createArray(variables('foundryAccountProperties'), createObject('agentHostingConfigurations', createArray(createObject('hostingType', 'ManagedCluster', 'name', 'default', 'clusterResourceId', parameters('agentHosting').clusterResourceId, 'hostingManagementIdentityResourceId', variables('hostingIdentityResourceId'), 'storageAccountResourceId', variables('storageAccountResourceId'), 'workloadIdentityResourceId', variables('workloadIdentityResourceId'))))))]", - "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')]" - }, - "hostingIdentityResourceId": { - "value": "[variables('suppliedHostingIdentityResourceId')]" - }, - "workloadIdentityResourceId": { - "value": "[variables('suppliedWorkloadIdentityResourceId')]" - }, - "storageAccountResourceId": { - "value": "[variables('suppliedStorageAccountResourceId')]" - }, - "suppliedHostingPrincipalId": { - "value": "[parameters('hostingPrincipalId')]" - }, - "suppliedWorkloadPrincipalId": { - "value": "[parameters('workloadPrincipalId')]" - } - }, - "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": "17798698122283979447" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "clusterResourceId": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "hostingIdentityName": { - "type": "string" - }, - "workloadIdentityName": { - "type": "string" - }, - "federatedIdentityRoleDefinitionId": { - "type": "string" - }, - "hostingIdentityResourceId": { - "type": "string", - "defaultValue": "" - }, - "workloadIdentityResourceId": { - "type": "string", - "defaultValue": "" - }, - "storageAccountResourceId": { - "type": "string", - "defaultValue": "" - }, - "suppliedHostingPrincipalId": { - "type": "string", - "defaultValue": "" - }, - "suppliedWorkloadPrincipalId": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "clusterParts": "[split(parameters('clusterResourceId'), '/')]", - "workloadParts": "[split(if(empty(parameters('workloadIdentityResourceId')), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('workloadIdentityName')), parameters('workloadIdentityResourceId')), '/')]", - "storageParts": "[split(if(empty(parameters('storageAccountResourceId')), resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('storageAccountResourceId')), '/')]", - "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(variables('storageParts')[2], 'Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]" - }, - "resources": [ - { - "condition": "[empty(parameters('hostingIdentityResourceId'))]", - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[parameters('hostingIdentityName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "condition": "[empty(parameters('workloadIdentityResourceId'))]", - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[parameters('workloadIdentityName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "condition": "[empty(parameters('storageAccountResourceId'))]", - "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')))]" - }, - "condition": "[empty(parameters('hostingIdentityResourceId'))]", - "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": "[if(empty(parameters('hostingIdentityResourceId')), createObject('value', reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName')), '2023-01-31').principalId), createObject('value', parameters('suppliedHostingPrincipalId')))]", - "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'))]" - ] - }, - { - "condition": "[or(empty(parameters('hostingIdentityResourceId')), empty(parameters('workloadIdentityResourceId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('workloadIdentityResourceId')), 'byo-federation-access', format('byo-federation-access-{0}', uniqueString(resourceGroup().id, parameters('hostingIdentityName'))))]", - "subscriptionId": "[variables('workloadParts')[2]]", - "resourceGroup": "[variables('workloadParts')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "resourceName": { - "value": "[last(variables('workloadParts'))]" - }, - "principalId": "[if(empty(parameters('hostingIdentityResourceId')), createObject('value', reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('hostingIdentityName')), '2023-01-31').principalId), createObject('value', parameters('suppliedHostingPrincipalId')))]", - "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'))]" - ] - }, - { - "condition": "[or(empty(parameters('workloadIdentityResourceId')), empty(parameters('storageAccountResourceId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[if(empty(parameters('storageAccountResourceId')), 'byo-storage-access', format('byo-storage-access-{0}', uniqueString(resourceGroup().id, parameters('workloadIdentityName'))))]", - "subscriptionId": "[variables('storageParts')[2]]", - "resourceGroup": "[variables('storageParts')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "resourceName": { - "value": "[last(variables('storageParts'))]" - }, - "principalId": "[if(empty(parameters('workloadIdentityResourceId')), createObject('value', reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('workloadIdentityName')), '2023-01-31').principalId), createObject('value', parameters('suppliedWorkloadPrincipalId')))]", - "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')]" - }, - "pullRoleDefinitionId": { - "value": "[parameters('acrPullRoleDefinitionId')]" - } - }, - "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": "11513645951317576548" - } - }, - "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": "" - }, - "pullRoleDefinitionId": { - "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(not(empty(parameters('accessOperationId'))), createObject('value', parameters('pullRoleDefinitionId')), 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/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 233f2382175..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/byo-prerequisites.bicep +++ /dev/null @@ -1,123 +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 -param hostingIdentityResourceId string = '' -param workloadIdentityResourceId string = '' -param storageAccountResourceId string = '' -param suppliedHostingPrincipalId string = '' -param suppliedWorkloadPrincipalId string = '' - -var clusterParts = split(clusterResourceId, '/') -var workloadParts = split(empty(workloadIdentityResourceId) - ? resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', workloadIdentityName) - : workloadIdentityResourceId, '/') -var storageParts = split(empty(storageAccountResourceId) - ? resourceId('Microsoft.Storage/storageAccounts', storageAccountName) - : storageAccountResourceId, '/') -var hostingPrincipalId = empty(hostingIdentityResourceId) ? hosting!.properties.principalId : suppliedHostingPrincipalId -var workloadPrincipalId = empty(workloadIdentityResourceId) ? workload!.properties.principalId : suppliedWorkloadPrincipalId -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( - storageParts[2], 'Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' -) - -resource hosting 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (empty(hostingIdentityResourceId)) { - name: hostingIdentityName - location: location - tags: tags -} - -resource workload 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (empty(workloadIdentityResourceId)) { - name: workloadIdentityName - location: location - tags: tags -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = if (empty(storageAccountResourceId)) { - 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 - ]: if (empty(hostingIdentityResourceId)) { - name: 'byo-cluster-${uniqueString(resourceGroup().id, hostingIdentityName)}-${role}' - scope: resourceGroup(clusterParts[2], clusterParts[4]) - params: { - resourceName: last(clusterParts) - principalId: hostingPrincipalId - roleDefinitionId: subscriptionResourceId(clusterParts[2], 'Microsoft.Authorization/roleDefinitions', role) - } - } -] - -module federationGrant 'byo-identity-role-assignment.bicep' = if (empty(hostingIdentityResourceId) || empty(workloadIdentityResourceId)) { - name: empty(workloadIdentityResourceId) - ? 'byo-federation-access' - : 'byo-federation-access-${uniqueString(resourceGroup().id, hostingIdentityName)}' - scope: resourceGroup(workloadParts[2], workloadParts[4]) - params: { - resourceName: last(workloadParts) - principalId: hostingPrincipalId - roleDefinitionId: federatedIdentityRoleDefinitionId - } - dependsOn: [workload] -} - -module storageGrant 'byo-storage-role-assignment.bicep' = if (empty(workloadIdentityResourceId) || empty(storageAccountResourceId)) { - name: empty(storageAccountResourceId) - ? 'byo-storage-access' - : 'byo-storage-access-${uniqueString(resourceGroup().id, workloadIdentityName)}' - scope: resourceGroup(storageParts[2], storageParts[4]) - params: { - resourceName: last(storageParts) - principalId: workloadPrincipalId - roleDefinitionId: blobContributorRoleId - } - dependsOn: [storage] -} 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 d197366a891..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/container-registry.bicep +++ /dev/null @@ -1,80 +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 = '' -param pullRoleDefinitionId 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: !empty(accessOperationId) - ? pullRoleDefinitionId - : (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 b8395961c66..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/modules/resources.bicep +++ /dev/null @@ -1,453 +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 - hostingManagementIdentityResourceId: string? - storageAccountResourceId: string? - workloadIdentityResourceId: 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: '' - hostingManagementIdentityResourceId: '' - storageAccountResourceId: '' - workloadIdentityResourceId: '' -} - -param kubeletObjectId string = '' -param federatedIdentityRoleDefinitionId string = '' -param hostingPrincipalId string = '' -param workloadPrincipalId 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 = '' -param acrPullRoleDefinitionId 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' -var suppliedHostingIdentityResourceId = agentHosting.?hostingManagementIdentityResourceId ?? '' -var suppliedWorkloadIdentityResourceId = agentHosting.?workloadIdentityResourceId ?? '' -var suppliedStorageAccountResourceId = agentHosting.?storageAccountResourceId ?? '' -var hostingIdentityResourceId = empty(suppliedHostingIdentityResourceId) - ? resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', hostingIdentityName) - : suppliedHostingIdentityResourceId -var workloadIdentityResourceId = empty(suppliedWorkloadIdentityResourceId) - ? resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', workloadIdentityName) - : suppliedWorkloadIdentityResourceId -var storageAccountResourceId = empty(suppliedStorageAccountResourceId) - ? resourceId('Microsoft.Storage/storageAccounts', storageAccountName) - : suppliedStorageAccountResourceId - -// 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 - hostingIdentityResourceId: suppliedHostingIdentityResourceId - workloadIdentityResourceId: suppliedWorkloadIdentityResourceId - storageAccountResourceId: suppliedStorageAccountResourceId - suppliedHostingPrincipalId: hostingPrincipalId - suppliedWorkloadPrincipalId: workloadPrincipalId - } -} - -// 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: { - '${hostingIdentityResourceId}': {} - } - } - properties: { - ...foundryAccountProperties - agentHostingConfigurations: [ - { - hostingType: 'ManagedCluster' - name: 'default' - clusterResourceId: agentHosting.clusterResourceId - hostingManagementIdentityResourceId: hostingIdentityResourceId - storageAccountResourceId: storageAccountResourceId - workloadIdentityResourceId: workloadIdentityResourceId - } - ] - } - - // 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 - pullRoleDefinitionId: acrPullRoleDefinitionId - } -} - -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 e3469510ae8..4097cdacda4 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 @@ -45,6 +45,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/acr_feature.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_feature.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_feature.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_feature.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_feature_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_feature_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_feature_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_feature_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_parameters.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_parameters.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/acr_parameters.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_parameters.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/acr_parameters_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/acr_parameters_test.go index 5b026555c3b..9897b3f51f1 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/acr_parameters_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/acr_parameters_test.go @@ -17,7 +17,7 @@ import ( "strings" "testing" - "azure.ai.projects/internal/testutil" + "azure.ai.projects/synthesis/internal/testutil" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/bicep_parameters.go b/cli/azd/extensions/azure.ai.projects/synthesis/bicep_parameters.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/bicep_parameters.go rename to cli/azd/extensions/azure.ai.projects/synthesis/bicep_parameters.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/bicep_parameters_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/bicep_parameters_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/bicep_parameters_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/bicep_parameters_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 99% 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 b6a9830d4b0..46d49a0081f 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 @@ -331,12 +331,14 @@ 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.Contains(t, main.Resources, "resources") + require.Contains(t, main.Resources, "monitoring") require.Contains(t, main.Parameters, "acrPullRoleDefinitionId") assert.Equal(t, "", main.Parameters["acrPullRoleDefinitionId"].DefaultValue) require.Contains(t, main.Parameters, "acrOwnedAssignments") assert.Equal(t, "array", main.Parameters["acrOwnedAssignments"].Type) assert.Equal(t, []any{}, main.Parameters["acrOwnedAssignments"].DefaultValue) - require.Len(t, main.Resources, 2) + require.Len(t, main.Resources, 3) var resourcesModule moduleProperties require.NoError(t, json.Unmarshal(main.Resources["resources"].Properties, &resourcesModule)) assert.Equal(t, map[string]any{"value": "[parameters('acrPullRoleDefinitionId')]"}, 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 97% 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 4585d7ac060..6e5de45949f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go @@ -34,6 +34,8 @@ func hostingYAML() string { network: agentSubnet: {vnet: ` + hostingVNetID + `, name: agents} peSubnet: {vnet: ` + hostingVNetID + `, name: endpoints} + monitoring: + enabled: false ` } @@ -355,9 +357,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": @@ -397,23 +403,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/internal/testutil/acr_parameters.go b/cli/azd/extensions/azure.ai.projects/synthesis/internal/testutil/acr_parameters.go new file mode 100644 index 00000000000..fc93365a956 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/internal/testutil/acr_parameters.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package testutil + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// ACRParameters writes a supported parameter-only Foundry project. +func ACRParameters(t testing.TB, root string, layer bool, format, id string) { + t.Helper() + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + manifest := "infra:\n provider: microsoft.foundry\n" + dir, module := filepath.Join(root, "infra"), "main" + if layer { + manifest = "infra:\n layers:\n" + + " - name: foundry\n provider: microsoft.foundry\n" + + " path: infra/foundry\n module: project\n" + dir, module = filepath.Join(dir, "foundry"), "project" + } + manifest += "services:\n project:\n host: azure.ai.project\n" + + " agent:\n host: azure.ai.agent\n kind: hosted\n name: agent\n" + require.NoError(t, os.WriteFile(filepath.Join(root, "azure.yaml"), []byte(manifest), 0o600)) + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, module+".bicep"), + []byte("param existingAcrResourceId string = ''\n"), 0o600)) + parameters, err := json.Marshal(map[string]any{ + "parameters": map[string]any{"existingAcrResourceId": map[string]any{"value": id}}, + }) + require.NoError(t, err) + if format == "json" { + require.NoError(t, os.WriteFile(filepath.Join(dir, module+".parameters.json"), parameters, 0o600)) + return + } + require.NoError(t, os.WriteFile(filepath.Join(dir, module+".bicepparam"), + []byte("using './"+module+".bicep'\nparam existingAcrResourceId = '"+id+"'\n"), 0o600)) + StubBicepParameters(t, string(parameters)) +} + +// StubBicepParameters replaces compilation with a local test fixture. +func StubBicepParameters(t testing.TB, parameters string) { + t.Helper() + dir := t.TempDir() + output, err := json.Marshal(map[string]string{ + "templateJson": `{"parameters":{"existingAcrResourceId":{"defaultValue":""}},"resources":[]}`, + "parametersJson": parameters, + }) + require.NoError(t, err) + result := filepath.Join(dir, "result.json") + require.NoError(t, os.WriteFile(result, output, 0o600)) + name, script := "bicep", "#!/bin/sh\ncat \"$AZD_TEST_BICEP_OUTPUT\"\n" + if runtime.GOOS == "windows" { + name, script = "bicep.cmd", "@echo off\r\ntype \"%AZD_TEST_BICEP_OUTPUT%\"\r\n" + } + tool := filepath.Join(dir, name) + //nolint:gosec // local executable test fixture + require.NoError(t, os.WriteFile(tool, []byte(script), 0o700)) + t.Setenv("AZD_BICEP_TOOL_PATH", tool) + t.Setenv("AZD_TEST_BICEP_OUTPUT", result) +} + +// Snapshot records all project files to detect preflight mutations. +func Snapshot(t testing.TB, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + files[path] = "" + } else { + //nolint:gosec // isolated test project + raw, err := os.ReadFile(path) + if err != nil { + return err + } + files[path] = string(raw) + } + return nil + }) + require.NoError(t, err) + return files +} 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..3b77b187d60 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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" + 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 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 + }{ + {name: "default"}, + {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 { + 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", + }) + 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) + } + }) + } + } +} + +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/remote_acr_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/remote_acr_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/remote_acr_test.go rename to cli/azd/extensions/azure.ai.projects/synthesis/remote_acr_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 a3dad4ade3c..e5f3410fe76 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.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go similarity index 95% rename from cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go rename to cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index fa29b47a3b5..137e15b4b80 100644 --- a/cli/azd/extensions/azure.ai.agents/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 + // DeferACRNetworkValidation leaves registry/network policy checks // to the caller after on-disk deployment inputs are resolved. DeferACRNetworkValidation bool @@ -102,6 +106,12 @@ 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 + // 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 @@ -171,6 +181,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"` } @@ -327,6 +338,18 @@ func Synthesize(in Input) (*Result, error) { } } maps.Copy(params, netParams) + monitoring, err := newProjectMonitoringParameters(svc.Monitoring, in) + if err != nil { + return nil, err + } + var warnings []string + if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { + // 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) + if hosting != nil { params["agentHosting"] = agentHostingParameter{ Enabled: true, HostingType: "ManagedCluster", Name: "default", @@ -338,13 +361,19 @@ func Synthesize(in Input) (*Result, error) { } return &Result{ - Parameters: params, - NetworkMode: netMode, - ACRMode: mode, - Hosting: hosting, + Parameters: params, + NetworkMode: netMode, + ACRMode: mode, + 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) @@ -483,10 +512,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 99% 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 index c22e3780196..45bcd32b14f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/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", @@ -993,6 +993,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/"+ @@ -1156,7 +1160,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"}, }) @@ -1630,7 +1634,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", @@ -1655,7 +1659,7 @@ services: host: azure.ai.project network: peSubnet: {vnet: "` + ref + `", name: pe-subnet} -` +` + networkTestMonitoringOff res, err := Synthesize(Input{ RawAzureYAML: []byte(yaml), ServiceName: "my-project", 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.agents/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.agents/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.agents/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 2f49e05296d..121e3a9f355 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": "18175900106630812612" + "templateHash": "17391728415161597698" } }, "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')]", @@ -734,6 +756,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": "3471042481280362115" + } + }, + "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', 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": [ + { + "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": { @@ -759,7 +1043,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", @@ -776,6 +1060,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.projects/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.projects/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.projects/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 89% 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 15a2801365e..f2306051c37 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": "11444310712805509197" + "templateHash": "8956748682766475904" } }, "definitions": { @@ -140,6 +140,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": { @@ -412,7 +437,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "12875653734830163232" + "templateHash": "9059128905136687232" } }, "definitions": { @@ -2408,6 +2433,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": "3471042481280362115" + } + }, + "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', 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": [ + { + "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": { @@ -2458,6 +2745,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.projects/internal/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep similarity index 83% rename from cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep rename to cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 8457170b6d0..69e789b863b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -67,6 +67,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 hosting with independently optional Storage and managed identity references.') param agentHosting agentHostingType = { enabled: false @@ -187,6 +199,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 @@ -201,3 +229,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..7557b7a2e01 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -0,0 +1,75 @@ +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 + // 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) { + 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..4dd12ef745b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/terraform/monitoring.tf.tmpl @@ -0,0 +1,394 @@ +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}" + # 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_tag + }) + 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"]), "") == 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"]), "") == local.monitoring_project_tag && + 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..3ead8e36b09 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/terraform_monitoring_plan_test.go @@ -0,0 +1,325 @@ +// 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/google/uuid" + "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": "", + }, 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"`) + // 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)) + 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": 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{ + "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") +} diff --git a/eng/pipelines/release-ext-azure-ai-agents.yml b/eng/pipelines/release-ext-azure-ai-agents.yml index 9ddd8931d66..4f855731eee 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 @@ -23,6 +24,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