From 3459d431d868f446d60d07fc520b62b71c6db218 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 13:17:02 -0800 Subject: [PATCH 01/23] Add private Azure Monitor path for VNet-injected Foundry projects Adds AMPLS, azuremonitor private endpoint, private DNS zones, project capability host, and a deterministic App Insights component re-sync after the AMPLS scoped resources so ingestion is reliable on fresh environments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 4 +- .../internal/cmd/init_infra.go | 2 + .../extensions/azure.ai.projects/README.md | 24 +- .../internal/cmd/project_existing_infra.go | 4 +- .../internal/provisioning/monitoring_graph.go | 13 +- .../provisioning/monitoring_preflight.go | 73 ++- .../provisioning/monitoring_preflight_test.go | 2 +- .../synthesis/monitoring_policy.go | 10 + .../synthesis/monitoring_private_link.go | 109 +++++ .../synthesis/monitoring_private_link_test.go | 124 +++++ .../synthesis/monitoring_resolver.go | 7 +- .../synthesis/monitoring_test.go | 33 +- .../synthesis/synthesizer.go | 11 +- .../templates/existing-project.arm.json | 393 +++++++++++++++- .../synthesis/templates/main.arm.json | 438 +++++++++++++++++- .../synthesis/templates/main.bicep | 6 + .../modules/monitor-private-link.bicep | 149 ++++++ .../templates/modules/monitoring-resync.bicep | 33 ++ .../templates/modules/monitoring.bicep | 41 +- .../templates/modules/resources.bicep | 19 + .../synthesis/vnet_requirements_test.go | 28 ++ 21 files changed, 1455 insertions(+), 68 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go 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 6f281bca5a5..c8500806167 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,7 +8,9 @@ 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. +Automatic Application Insights monitoring works only for a private network that uses your own agent subnet (BYO egress, `agentSubnet` set) and lets azd create the component. azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. + +Provisioning is never blocked by monitoring in unsupported cases. Managed egress (no `agentSubnet`), connecting an existing Application Insights resource to a private project, or a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope) all skip automatic monitoring, and azd prints a warning. Set `monitoring: {enabled: false}` to opt out without the warning. 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. 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 fef2f1788a2..b3266209e5c 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 @@ -1303,6 +1303,8 @@ func writeExistingProjectBicepTemplates( {"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"}, + {"templates/modules/monitor-private-link.bicep", "modules/monitor-private-link.bicep"}, + {"templates/modules/monitoring-resync.bicep", "modules/monitoring-resync.bicep"}, } if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) { registrySource, err := fs.ReadFile( diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 9562dbbe1fc..36613bfd51e 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -189,13 +189,23 @@ 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. +to be weakened. ApiKey setup does not enable local authentication. A new +project with a `network:` block that uses your own agent subnet gets a private +monitoring path that matches the Foundry samples: the component disables public +ingestion and keeps public query enabled, and azd adds an Azure Monitor Private +Link Scope (ingestion Private Only, query Open), an `azuremonitor` private +endpoint, and the four Azure Monitor private DNS zones. Ingestion is private +only; trace reads are not restricted to the private network. Right after a +fresh deployment, private ingestion can return 403 for a while (observed up to +about 20 minutes) before traces start to arrive. Live testing confirmed that +Foundry service-side traces from a deployed prompt agent were read from inside +the network without any post-deployment change. +Unsupported private-network cases never block provisioning: managed egress, +connecting an existing component, or a virtual network that already has an +Azure Monitor private endpoint skip automatic monitoring and azd prints a +warning. Set `monitoring.enabled: false` to opt out silently. 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, 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 f9abc75853d..33cc2a11534 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 @@ -971,7 +971,9 @@ func writeExistingProjectBicep( } } } - for _, name := range []string{"monitoring.bicep", "monitoring-connection.bicep"} { + for _, name := range []string{ + "monitoring.bicep", "monitoring-connection.bicep", "monitor-private-link.bicep", "monitoring-resync.bicep", + } { data, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/"+name) if err != nil { return fmt.Errorf("read monitoring Bicep module: %w", err) 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 index 65e38a80862..dc1fad02573 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go @@ -15,7 +15,7 @@ import ( ) func monitoringPlannedResource( - result *armresources.WhatIfOperationResult, resourceType, subscriptionID, resourceGroup string, + result *armresources.WhatIfOperationResult, resourceType, subscriptionID, resourceGroup string, privateNetwork bool, ) (string, error) { if result == nil || result.Properties == nil { return "", fmt.Errorf("ARM What-If did not resolve monitoring resources; no monitoring writes were authorized") @@ -44,7 +44,7 @@ func monitoringPlannedResource( } 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 { + if err := synthesis.MonitoringKeyPolicyFor(properties, privateNetwork); err != nil { return "", err } } @@ -92,7 +92,9 @@ func (p *FoundryProvisioningProvider) validateMonitoringCreateGraph( if err != nil { return err } - resolver := synthesis.MonitoringResolver{Credential: p.credential, ClientOptions: p.armClientOptions} + resolver := synthesis.MonitoringResolver{ + Credential: p.credential, ClientOptions: p.armClientOptions, PrivateNetwork: monitoringPrivateNetwork(source), + } outputs := armOutputsToProto(binding.outputs) for _, resource := range []struct { resourceType string @@ -102,7 +104,8 @@ func (p *FoundryProvisioningProvider) validateMonitoringCreateGraph( {"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) + id, err := monitoringPlannedResource( + binding.plan, resource.resourceType, p.subID, group, monitoringPrivateNetwork(source)) if err != nil { return err } @@ -130,7 +133,7 @@ func (p *FoundryProvisioningProvider) validateMonitoringCreateGraph( if err != nil { return err } - if err := synthesis.MonitoringKeyPolicy(properties); err != nil { + if err := synthesis.MonitoringKeyPolicyFor(properties, monitoringPrivateNetwork(source)); err != nil { return err } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index 6206f9f6505..c0f7158075d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -7,7 +7,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "log" "strings" "azure.ai.projects/synthesis" @@ -49,6 +51,36 @@ func monitoringParameter(source *templateSource, name string) (string, error) { return strings.TrimSpace(text), nil } +func monitoringPrivateNetwork(source *templateSource) bool { + enabled, present := templateParameter(source, "enableNetworkIsolation") + return present && enabled != false +} + +// The conflict check is advisory when it cannot read the network: ARM remains +// the authority, so a read failure warns and continues. +func (p *FoundryProvisioningProvider) checkMonitorPrivateLinkConflict( + ctx context.Context, + source *templateSource, + resolver *synthesis.MonitoringResolver, + progress grpcbroker.ProgressFunc, +) error { + vnetID, err := monitoringParameter(source, "vnetId") + if err != nil || vnetID == "" { + return err + } + group, err := monitoringParameter(source, "resourceGroupName") + if err != nil { + return err + } + err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, group) + if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict || err == nil { + return err + } + log.Printf("[warn] foundry provider: could not check for an existing Azure Monitor private endpoint: %v", err) + progress("Warning: could not check the virtual network for an existing Azure Monitor private endpoint") + return 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( @@ -87,7 +119,9 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( 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} + resolver := synthesis.MonitoringResolver{ + Credential: p.credential, ClientOptions: p.armClientOptions, PrivateNetwork: monitoringPrivateNetwork(source), + } projectResource, projectExists, err := resolver.Resource(ctx, project, "2025-04-01-preview") if err != nil { return err @@ -138,22 +172,33 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( 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 + if monitoringPrivateNetwork(source) { + if mode != "create" { + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + progress("Warning: " + synthesis.PrivateNetworkMonitoringWarning) + return nil } - properties, err := synthesis.MonitoringProperties(resource) - if err != nil { + if err := p.checkMonitorPrivateLinkConflict(ctx, source, &resolver, progress); err != nil { + if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { + // Skip rather than fail so an existing private network keeps provisioning. + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + progress("Warning: automatic monitoring was skipped. " + err.Error()) + return nil + } return err } - if err := synthesis.MonitoringNetworkPolicy(properties); err != nil { - return err + } else { + 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" { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index 8b1ac2a1ccb..90a2bf72418 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -478,7 +478,7 @@ func TestMonitoringPreflightPrivateNetworkSkipsMonitoring(t *testing.T) { fixture := monitoringCloudFixture{} p := newMonitoringCloudFixture(t, &fixture) p.synthResult = &synthesis.Result{MonitoringExplicit: true} - source := monitoringTestSource("create") + source := monitoringTestSource("reuse-connect") source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} client, err := p.deploymentsClient(t.Context()) require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go index 4049984bffe..e6f63881887 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_policy.go @@ -22,6 +22,13 @@ func MonitoringProperties(resource *armresources.GenericResource) (map[string]an // MonitoringKeyPolicy rejects policy changes needed to enable ApiKey ingestion. func MonitoringKeyPolicy(properties map[string]any) error { + return MonitoringKeyPolicyFor(properties, false) +} + +// MonitoringKeyPolicyFor is MonitoringKeyPolicy for a deployment that owns its +// private monitoring path. Disabled public ingestion is then the intended +// configuration, so only the local-authentication checks apply. +func MonitoringKeyPolicyFor(properties map[string]any, privateNetwork bool) error { if value, present := properties["DisableLocalAuth"]; present { disabled, ok := value.(bool) if !ok || disabled { @@ -37,6 +44,9 @@ func MonitoringKeyPolicy(properties map[string]any) error { return fmt.Errorf("automatic ApiKey monitoring cannot change or bypass disabled workspace local authentication") } } + if privateNetwork { + return nil + } return MonitoringNetworkPolicy(properties) } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go new file mode 100644 index 00000000000..7613762c61d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go @@ -0,0 +1,109 @@ +// 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/resources/armresources" +) + +// ownedPrivateLinkScopePrefix is the name prefix monitor-private-link.bicep and +// the Terraform equivalent give the Azure Monitor Private Link Scope they create. +const ownedPrivateLinkScopePrefix = "ampls-" + +// MonitorPrivateLinkConflictError reports an Azure Monitor private endpoint that +// already serves the VNet. A VNet can use only one Azure Monitor Private Link +// Scope; a second one overrides the shared Azure Monitor DNS zones. +type MonitorPrivateLinkConflictError struct { + PrivateEndpointID string +} + +func (e *MonitorPrivateLinkConflictError) Error() string { + return fmt.Sprintf( + "the virtual network already has an Azure Monitor private endpoint (%s); a network can use only one "+ + "Azure Monitor Private Link Scope, so automatic monitoring would override its DNS; "+ + "set monitoring.enabled: false on the project service", + e.PrivateEndpointID) +} + +// CheckMonitorPrivateLinkConflict looks for an existing azuremonitor private +// endpoint on the VNet that this deployment does not own. It is read-only. A +// *MonitorPrivateLinkConflictError means a conflict; any other error means the +// check could not complete and ARM remains the authority. +func (p *MonitoringResolver) CheckMonitorPrivateLinkConflict( + ctx context.Context, vnetID, ownedResourceGroup string, +) error { + id, err := MonitoringResourceID(vnetID, "Microsoft.Network/virtualNetworks") + if err != nil { + return err + } + vnet, found, err := p.Resource(ctx, id, "2023-09-01") + if err != nil || !found { + return err + } + for _, endpointID := range vnetPrivateEndpointIDs(vnet) { + parsed, err := MonitoringResourceID(endpointID, "Microsoft.Network/privateEndpoints") + if err != nil { + return err + } + endpoint, found, err := p.Resource(ctx, parsed, "2023-09-01") + if err != nil { + return err + } + if !found { + continue + } + scopeID, isMonitor := azureMonitorLinkTarget(endpoint) + if !isMonitor { + continue + } + if scope, err := arm.ParseResourceID(scopeID); err == nil && + strings.EqualFold(scope.ResourceGroupName, ownedResourceGroup) && + strings.HasPrefix(strings.ToLower(scope.Name), ownedPrivateLinkScopePrefix) { + continue + } + return &MonitorPrivateLinkConflictError{PrivateEndpointID: endpointID} + } + return nil +} + +func vnetPrivateEndpointIDs(vnet *armresources.GenericResource) []string { + properties, _ := vnet.Properties.(map[string]any) + subnets, _ := properties["subnets"].([]any) + var ids []string + for _, subnet := range subnets { + subnetProperties, _ := subnet.(map[string]any)["properties"].(map[string]any) + endpoints, _ := subnetProperties["privateEndpoints"].([]any) + for _, endpoint := range endpoints { + if id, _ := endpoint.(map[string]any)["id"].(string); id != "" { + ids = append(ids, id) + } + } + } + return ids +} + +// azureMonitorLinkTarget returns the private link service id when the endpoint +// connects to Azure Monitor (group id azuremonitor). +func azureMonitorLinkTarget(endpoint *armresources.GenericResource) (string, bool) { + properties, _ := endpoint.Properties.(map[string]any) + for _, key := range []string{"privateLinkServiceConnections", "manualPrivateLinkServiceConnections"} { + connections, _ := properties[key].([]any) + for _, connection := range connections { + connectionProperties, _ := connection.(map[string]any)["properties"].(map[string]any) + groups, _ := connectionProperties["groupIds"].([]any) + for _, group := range groups { + if name, _ := group.(string); strings.EqualFold(name, "azuremonitor") { + target, _ := connectionProperties["privateLinkServiceId"].(string) + return target, true + } + } + } + } + return "", false +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go new file mode 100644 index 00000000000..bd4d1965d8c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "errors" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testVNetID = "/subscriptions/" + testSub + "/resourceGroups/net/providers/Microsoft.Network/virtualNetworks/vnet" + testPEID = "/subscriptions/" + testSub + "/resourceGroups/net/providers/Microsoft.Network/privateEndpoints/pe" +) + +func privateLinkReader(group, scopeID string) *fakeReader { + connection := func(groupID string) []any { + return []any{map[string]any{"properties": map[string]any{ + "groupIds": []any{groupID}, "privateLinkServiceId": scopeID, + }}} + } + return &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testVNetID): armResource(testVNetID, map[string]any{"subnets": []any{ + map[string]any{"properties": map[string]any{"privateEndpoints": []any{map[string]any{"id": testPEID}}}}, + }}), + strings.ToLower(testPEID): armResource(testPEID, map[string]any{ + "privateLinkServiceConnections": connection(group), + }), + }} +} + +func TestMonitorPrivateLinkAccessModes(t *testing.T) { + t.Parallel() + // Matches the Foundry samples: ingestion PrivateOnly, query Open. + for _, name := range []string{"templates/main.arm.json", "templates/existing-project.arm.json"} { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + text := string(raw) + assert.Contains(t, text, `"ingestionAccessMode": "PrivateOnly"`, name) + assert.Contains(t, text, `"queryAccessMode": "Open"`, name) + assert.NotContains(t, text, `"ingestionAccessMode": "Open"`, name) + assert.NotContains(t, text, "azd-ampls-linked", name) + } + bicep, err := templatesFS.ReadFile("templates/modules/monitor-private-link.bicep") + require.NoError(t, err) + assert.Contains(t, string(bicep), "ingestionAccessMode: 'PrivateOnly'") + assert.Contains(t, string(bicep), "queryAccessMode: 'Open'") + assert.NotContains(t, string(bicep), "touchApplicationInsights") +} + +func TestMonitoringResyncMatchesComponent(t *testing.T) { + t.Parallel() + read := func(name string) string { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + return string(raw) + } + monitoring := read("templates/modules/monitoring.bicep") + resync := read("templates/modules/monitoring-resync.bicep") + // The second component update must carry the original properties, run only on + // private networks, and be ordered after the scope through its output. + assert.Contains(t, monitoring, "module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork)") + assert.Contains(t, monitoring, "privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId") + assert.Contains(t, monitoring, "resync!.outputs.id") + assert.Contains(t, resync, "publicNetworkAccessForQuery: publicNetworkAccessForQuery") + assert.Contains(t, resync, "uniqueString(privateLinkScopeResourceId, name)") + assert.NotContains(t, resync, "utcNow") + assert.Contains(t, monitoring, "publicNetworkAccessForQuery: 'Enabled'") +} + +func TestCheckMonitorPrivateLinkConflict(t *testing.T) { + t.Parallel() + const ownedScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" + const foreignScope = "/subscriptions/" + testSub + "/resourceGroups/shared/providers/Microsoft.Insights/privateLinkScopes/corp" + const sameGroupForeign = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/corp" + + tests := []struct { + name string + reader *fakeReader + conflict bool + err bool + }{ + {name: "no private endpoints", reader: &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testVNetID): armResource(testVNetID, map[string]any{}), + }}}, + {name: "virtual network does not exist yet", reader: &fakeReader{}}, + {name: "unrelated private endpoint", reader: privateLinkReader("blob", foreignScope)}, + {name: "owned scope from a previous run", reader: privateLinkReader("azuremonitor", ownedScope)}, + {name: "foreign scope", reader: privateLinkReader("azuremonitor", foreignScope), conflict: true}, + {name: "foreign scope in the deployment group", reader: privateLinkReader("azuremonitor", sameGroupForeign), conflict: true}, + {name: "read failure", reader: &fakeReader{resourceErr: errors.New("boom")}, err: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := (&MonitoringResolver{Reader: tt.reader}).CheckMonitorPrivateLinkConflict(t.Context(), testVNetID, "rg") + var conflict *MonitorPrivateLinkConflictError + switch { + case tt.conflict: + require.ErrorAs(t, err, &conflict) + assert.Equal(t, testPEID, conflict.PrivateEndpointID) + assert.Contains(t, err.Error(), "monitoring.enabled: false") + case tt.err: + require.Error(t, err) + assert.False(t, errors.As(err, &conflict)) + default: + require.NoError(t, err) + } + }) + } +} + +func TestMonitoringKeyPolicyForPrivateNetwork(t *testing.T) { + t.Parallel() + disabled := map[string]any{"publicNetworkAccessForIngestion": "Disabled"} + assert.Error(t, MonitoringKeyPolicyFor(disabled, false)) + assert.NoError(t, MonitoringKeyPolicyFor(disabled, true)) + assert.Error(t, MonitoringKeyPolicyFor(map[string]any{"DisableLocalAuth": true}, true)) +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go index cf8422fc792..e88361d6313 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go @@ -29,6 +29,9 @@ type MonitoringResolver struct { Credential azcore.TokenCredential ClientOptions *arm.ClientOptions Reader MonitoringReader + // PrivateNetwork accepts components whose public ingestion is disabled + // because the deployment configures the private path itself. + PrivateNetwork bool } func (p *MonitoringResolver) reader() MonitoringReader { @@ -84,7 +87,7 @@ func (p *MonitoringResolver) ValidateComponent( return fmt.Errorf("the linked Log Analytics workspace does not exist") } if needsKey { - if err := MonitoringKeyPolicy(properties); err != nil { + if err := MonitoringKeyPolicyFor(properties, p.PrivateNetwork); err != nil { return err } if key, _ := properties["ConnectionString"].(string); strings.TrimSpace(key) == "" { @@ -94,7 +97,7 @@ func (p *MonitoringResolver) ValidateComponent( if err != nil { return err } - return MonitoringKeyPolicy(workspaceProperties) + return MonitoringKeyPolicyFor(workspaceProperties, p.PrivateNetwork) } return nil } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index 3b77b187d60..366e42e54e4 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -142,13 +142,19 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { " peSubnet: {vnet: " + vnet + ", name: pe-subnet, prefix: 192.168.1.0/24}\n", } monitoring := []struct { - name string - settings string + name string + settings string + wantMode string + wantWarning bool }{ - {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"}, + {name: "default", wantMode: "create"}, + {name: "empty block", settings: " monitoring: {}\n", wantMode: "create"}, + { + name: "BYO component", + settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", + wantMode: "none", wantWarning: true, + }, + {name: "explicit opt out", settings: " monitoring:\n enabled: false\n", wantMode: "none"}, } for networkName, network := range networks { for _, tt := range monitoring { @@ -159,13 +165,18 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { 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 { + wantMode, wantWarning := tt.wantMode, tt.wantWarning + if networkName == "managed egress" && wantMode == "create" { + wantMode, wantWarning = "none", true + } + assert.Equal(t, wantMode, result.Parameters["monitoringMode"]) + if wantWarning { assert.Equal(t, []string{PrivateNetworkMonitoringWarning}, result.Warnings) + assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) + } else { + assert.Empty(t, result.Warnings) } + assert.Equal(t, true, result.Parameters["enableNetworkIsolation"]) }) } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 137e15b4b80..40c2201daf8 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -343,8 +343,10 @@ func Synthesize(in Input) (*Result, error) { 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. + monitoringMode, _ := monitoring["monitoringMode"].(string) + if netMode != NetworkModeNone && monitoringMode != "" && monitoringMode != "none" && + !(netMode == NetworkModeByo && monitoringMode == "create") { + // This private-network setup is not supported yet; skip rather than fail provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} warnings = append(warnings, PrivateNetworkMonitoringWarning) } @@ -371,8 +373,9 @@ func Synthesize(in Input) (*Result, error) { } // 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." +const PrivateNetworkMonitoringWarning = "Automatic Application Insights monitoring does not support this " + + "private-network configuration (managed egress, or connecting an existing component), so monitoring was not " + + "configured. Agent traces will not be collected automatically." // GreenfieldACRMode distinguishes selectors from deployed outputs. func GreenfieldACRMode(in Input) (string, error) { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 121e3a9f355..a676b92b51e 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "17391728415161597698" + "templateHash": "1470382681626266510" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3471042481280362115" + "templateHash": "7468204480995557245" } }, "parameters": { @@ -844,16 +844,43 @@ "resourceTokenSalt": { "type": "string", "defaultValue": "" + }, + "privateNetwork": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "" + }, + "vnetLocation": { + "type": "string", + "defaultValue": "" + }, + "peSubnetId": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "" } }, "variables": { "createResources": "[equals(parameters('mode'), 'create')]", + "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "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])))]" + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]" }, "resources": [ { @@ -880,12 +907,359 @@ "kind": "web", "properties": { "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]", + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" }, "dependsOn": [ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-private-link", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('vnetLocation')]" + }, + "suffix": { + "value": "[variables('token')]" + }, + "applicationInsightsResourceId": { + "value": "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + }, + "logAnalyticsWorkspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "peSubnetId": { + "value": "[parameters('peSubnetId')]" + }, + "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": "10577939142835646204" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet; the private endpoint must be in the same region." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource and link names." + } + }, + "applicationInsightsResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Application Insights component to scope." + } + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Log Analytics workspace to scope." + } + }, + "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." + } + }, + "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": { + "copy": [ + { + "name": "zoneIds", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]), resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]))]" + } + ], + "monitorDnsZoneNames": [ + "privatelink.monitor.azure.com", + "privatelink.oms.opinsights.azure.com", + "privatelink.ods.opinsights.azure.com", + "privatelink.agentsvc.azure-automation.net" + ], + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "scopeName": "[format('ampls-{0}', parameters('suffix'))]" + }, + "resources": [ + { + "type": "microsoft.insights/privateLinkScopes", + "apiVersion": "2021-07-01-preview", + "name": "[variables('scopeName')]", + "location": "global", + "properties": { + "accessModeSettings": { + "ingestionAccessMode": "PrivateOnly", + "queryAccessMode": "Open" + } + } + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'appinsights-scoped')]", + "properties": { + "linkedResourceId": "[parameters('applicationInsightsResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + ] + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'workspace-scoped')]", + "properties": { + "linkedResourceId": "[parameters('logAnalyticsWorkspaceResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]" + ] + }, + { + "copy": { + "name": "zones", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('monitorDnsZoneNames')[copyIndex()]]", + "location": "global" + }, + { + "copy": { + "name": "zoneLinks", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('monitorDnsZoneNames')[copyIndex()], format('{0}-{1}-link', replace(variables('monitorDnsZoneNames')[copyIndex()], '.', '-'), parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex()])]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', variables('scopeName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', variables('scopeName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "groupIds": [ + "azuremonitor" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'workspace-scoped')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', variables('scopeName')), format('{0}-dns-group', variables('scopeName')))]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": { + "name": "[replace(variables('monitorDnsZoneNames')[copyIndex('privateDnsZoneConfigs')], '.', '-')]", + "properties": { + "privateDnsZoneId": "[variables('zoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', variables('scopeName')))]", + "zoneLinks" + ] + } + ], + "outputs": { + "azureMonitorPrivateLinkScopeResourceId": { + "type": "string", + "value": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-resync", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('appi-{0}', variables('token'))]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('monitoringTags')]" + }, + "workspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "publicNetworkAccessForIngestion": { + "value": "[variables('publicNetworkAccess')]" + }, + "publicNetworkAccessForQuery": { + "value": "Enabled" + }, + "privateLinkScopeResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.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": "10336562090588378730" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "workspaceResourceId": { + "type": "string" + }, + "publicNetworkAccessForIngestion": { + "type": "string" + }, + "publicNetworkAccessForQuery": { + "type": "string" + }, + "privateLinkScopeResourceId": { + "type": "string", + "metadata": { + "description": "The private link scope resource ID. Hashing it into a tag orders this deployment after the scope." + } + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-ampls-link', uniqueString(parameters('privateLinkScopeResourceId'), parameters('name'))))]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[parameters('workspaceResourceId')]", + "publicNetworkAccessForIngestion": "[parameters('publicNetworkAccessForIngestion')]", + "publicNetworkAccessForQuery": "[parameters('publicNetworkAccessForQuery')]" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]", + "[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", @@ -905,9 +1279,7 @@ "projectName": { "value": "[variables('projectParts')[10]]" }, - "applicationInsightsResourceId": { - "value": "[variables('effectiveComponentId')]" - }, + "applicationInsightsResourceId": "[if(equals(parameters('mode'), 'none'), createObject('value', ''), if(variables('createResources'), if(parameters('privateNetwork'), createObject('value', reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]))))]", "environmentName": { "value": "[parameters('environmentName')]" } @@ -991,14 +1363,15 @@ } }, "dependsOn": [ - "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync')]" ] } ], "outputs": { "applicationInsightsResourceId": { "type": "string", - "value": "[variables('effectiveComponentId')]" + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, 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])))]" }, "applicationInsightsAppId": { "type": "string", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index f2306051c37..96135bc352c 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "8956748682766475904" + "templateHash": "15309812462098058788" } }, "definitions": { @@ -437,7 +437,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "9059128905136687232" + "templateHash": "7637784606052942916" } }, "definitions": { @@ -853,6 +853,19 @@ "prerequisites" ] }, + "byoProjectCapabilityHost": { + "condition": "[variables('useByoNetwork')]", + "type": "Microsoft.CognitiveServices/accounts/projects/capabilityHosts", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/agents', variables('activeFoundryAccountName'), parameters('foundryProjectName'))]", + "properties": { + "capabilityHostKind": "Agents" + }, + "dependsOn": [ + "byoFoundryAccount::project", + "foundryAccount::project" + ] + }, "foundryManagedNetwork": { "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks", @@ -2426,6 +2439,18 @@ "AZURE_FOUNDRY_MANAGED_ISOLATION_MODE": { "type": "string", "value": "[if(variables('useManagedNetwork'), parameters('managedIsolationMode'), '')]" + }, + "NETWORK_VNET_ID": { + "type": "string", + "value": "[if(parameters('enableNetworkIsolation'), reference('network').outputs.vnetId.value, '')]" + }, + "NETWORK_VNET_LOCATION": { + "type": "string", + "value": "[if(parameters('enableNetworkIsolation'), reference('network').outputs.vnetLocation.value, '')]" + }, + "NETWORK_PE_SUBNET_ID": { + "type": "string", + "value": "[if(parameters('enableNetworkIsolation'), reference('network').outputs.peSubnetId.value, '')]" } } } @@ -2470,6 +2495,24 @@ "environmentName": "[if(contains(parameters('tags'), 'azd-env-name'), createObject('value', string(parameters('tags')['azd-env-name'])), createObject('value', ''))]", "resourceTokenSalt": { "value": "[parameters('resourceTokenSalt')]" + }, + "privateNetwork": { + "value": "[parameters('enableNetworkIsolation')]" + }, + "vnetId": { + "value": "[reference('resources').outputs.NETWORK_VNET_ID.value]" + }, + "vnetLocation": { + "value": "[reference('resources').outputs.NETWORK_VNET_LOCATION.value]" + }, + "peSubnetId": { + "value": "[reference('resources').outputs.NETWORK_PE_SUBNET_ID.value]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" } }, "template": { @@ -2479,7 +2522,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3471042481280362115" + "templateHash": "7468204480995557245" } }, "parameters": { @@ -2520,16 +2563,43 @@ "resourceTokenSalt": { "type": "string", "defaultValue": "" + }, + "privateNetwork": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "" + }, + "vnetLocation": { + "type": "string", + "defaultValue": "" + }, + "peSubnetId": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "" } }, "variables": { "createResources": "[equals(parameters('mode'), 'create')]", + "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "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])))]" + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]" }, "resources": [ { @@ -2556,9 +2626,356 @@ "kind": "web", "properties": { "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]", + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-private-link", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('vnetLocation')]" + }, + "suffix": { + "value": "[variables('token')]" + }, + "applicationInsightsResourceId": { + "value": "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + }, + "logAnalyticsWorkspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "peSubnetId": { + "value": "[parameters('peSubnetId')]" + }, + "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": "10577939142835646204" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet; the private endpoint must be in the same region." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource and link names." + } + }, + "applicationInsightsResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Application Insights component to scope." + } + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Log Analytics workspace to scope." + } + }, + "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." + } + }, + "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": { + "copy": [ + { + "name": "zoneIds", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]), resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]))]" + } + ], + "monitorDnsZoneNames": [ + "privatelink.monitor.azure.com", + "privatelink.oms.opinsights.azure.com", + "privatelink.ods.opinsights.azure.com", + "privatelink.agentsvc.azure-automation.net" + ], + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "scopeName": "[format('ampls-{0}', parameters('suffix'))]" + }, + "resources": [ + { + "type": "microsoft.insights/privateLinkScopes", + "apiVersion": "2021-07-01-preview", + "name": "[variables('scopeName')]", + "location": "global", + "properties": { + "accessModeSettings": { + "ingestionAccessMode": "PrivateOnly", + "queryAccessMode": "Open" + } + } + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'appinsights-scoped')]", + "properties": { + "linkedResourceId": "[parameters('applicationInsightsResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + ] + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'workspace-scoped')]", + "properties": { + "linkedResourceId": "[parameters('logAnalyticsWorkspaceResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]" + ] + }, + { + "copy": { + "name": "zones", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('monitorDnsZoneNames')[copyIndex()]]", + "location": "global" + }, + { + "copy": { + "name": "zoneLinks", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('monitorDnsZoneNames')[copyIndex()], format('{0}-{1}-link', replace(variables('monitorDnsZoneNames')[copyIndex()], '.', '-'), parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex()])]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', variables('scopeName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', variables('scopeName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "groupIds": [ + "azuremonitor" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'workspace-scoped')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', variables('scopeName')), format('{0}-dns-group', variables('scopeName')))]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": { + "name": "[replace(variables('monitorDnsZoneNames')[copyIndex('privateDnsZoneConfigs')], '.', '-')]", + "properties": { + "privateDnsZoneId": "[variables('zoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', variables('scopeName')))]", + "zoneLinks" + ] + } + ], + "outputs": { + "azureMonitorPrivateLinkScopeResourceId": { + "type": "string", + "value": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + } + } + } }, "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-resync", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('appi-{0}', variables('token'))]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('monitoringTags')]" + }, + "workspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "publicNetworkAccessForIngestion": { + "value": "[variables('publicNetworkAccess')]" + }, + "publicNetworkAccessForQuery": { + "value": "Enabled" + }, + "privateLinkScopeResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.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": "10336562090588378730" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "workspaceResourceId": { + "type": "string" + }, + "publicNetworkAccessForIngestion": { + "type": "string" + }, + "publicNetworkAccessForQuery": { + "type": "string" + }, + "privateLinkScopeResourceId": { + "type": "string", + "metadata": { + "description": "The private link scope resource ID. Hashing it into a tag orders this deployment after the scope." + } + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-ampls-link', uniqueString(parameters('privateLinkScopeResourceId'), parameters('name'))))]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[parameters('workspaceResourceId')]", + "publicNetworkAccessForIngestion": "[parameters('publicNetworkAccessForIngestion')]", + "publicNetworkAccessForQuery": "[parameters('publicNetworkAccessForQuery')]" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]", "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, @@ -2581,9 +2998,7 @@ "projectName": { "value": "[variables('projectParts')[10]]" }, - "applicationInsightsResourceId": { - "value": "[variables('effectiveComponentId')]" - }, + "applicationInsightsResourceId": "[if(equals(parameters('mode'), 'none'), createObject('value', ''), if(variables('createResources'), if(parameters('privateNetwork'), createObject('value', reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]))))]", "environmentName": { "value": "[parameters('environmentName')]" } @@ -2667,14 +3082,15 @@ } }, "dependsOn": [ - "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync')]" ] } ], "outputs": { "applicationInsightsResourceId": { "type": "string", - "value": "[variables('effectiveComponentId')]" + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, 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])))]" }, "applicationInsightsAppId": { "type": "string", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 69e789b863b..7283d70cafb 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -212,6 +212,12 @@ module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { existingConnectionName: monitoringConnectionName environmentName: contains(tags, 'azd-env-name') ? string(tags['azd-env-name']) : '' resourceTokenSalt: resourceTokenSalt + privateNetwork: enableNetworkIsolation + vnetId: resources.outputs.NETWORK_VNET_ID + vnetLocation: resources.outputs.NETWORK_VNET_LOCATION + peSubnetId: resources.outputs.NETWORK_PE_SUBNET_ID + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep new file mode 100644 index 00000000000..6ffbb538043 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep @@ -0,0 +1,149 @@ +// Private Azure Monitor path for a network-secured Foundry project: an Azure +// Monitor Private Link Scope (AMPLS) holding the App Insights component and its +// Log Analytics workspace, an 'azuremonitor' private endpoint in the PE subnet, +// and the four Azure Monitor private DNS zones. +// +// Matches the Foundry samples (15/17/19): ingestion is PrivateOnly, query access +// is Open, and the component's own public ingestion is disabled while its public +// query stays enabled. Like private-endpoint-dns.bicep, zones +// are created and linked to the VNet by default; when dnsZonesResourceGroup is +// set they are referenced from that resource group (in dnsZonesSubscription, +// defaulting to this subscription) instead of being created. +// +// A VNet can connect to only one AMPLS. The caller checks that the VNet has no +// 'azuremonitor' private endpoint before deploying this module. + +targetScope = 'resourceGroup' + +@description('Azure region of the customer VNet; the private endpoint must be in the same region.') +param location string + +@description('Suffix for unique resource and link names.') +param suffix string + +@description('ARM resource id of the Application Insights component to scope.') +param applicationInsightsResourceId string + +@description('ARM resource id of the Log Analytics workspace to scope.') +param logAnalyticsWorkspaceResourceId 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('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 monitorDnsZoneNames = [ + 'privatelink.monitor.azure.com' + 'privatelink.oms.opinsights.azure.com' + 'privatelink.ods.opinsights.azure.com' + 'privatelink.agentsvc.azure-automation.net' +] + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription +var scopeName = 'ampls-${suffix}' + +resource ampls 'Microsoft.Insights/privateLinkScopes@2021-07-01-preview' = { + name: scopeName + location: 'global' + properties: { + accessModeSettings: { + ingestionAccessMode: 'PrivateOnly' + queryAccessMode: 'Open' + } + } +} + +resource scopedApplicationInsights 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = { + parent: ampls + name: 'appinsights-scoped' + properties: { + linkedResourceId: applicationInsightsResourceId + } +} + +resource scopedWorkspace 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = { + parent: ampls + name: 'workspace-scoped' + properties: { + linkedResourceId: logAnalyticsWorkspaceResourceId + } + dependsOn: [ + scopedApplicationInsights + ] +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource zones 'Microsoft.Network/privateDnsZones@2020-06-01' = [for zone in monitorDnsZoneNames: if (!useExistingZones) { + name: zone + location: 'global' +}] + +resource zoneLinks 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = [for (zone, i) in monitorDnsZoneNames: if (!useExistingZones) { + parent: zones[i] + name: '${replace(zone, '.', '-')}-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +}] + +var zoneIds = [for zone in monitorDnsZoneNames: useExistingZones + ? extensionResourceId('/subscriptions/${existingZonesSubscription}/resourceGroups/${dnsZonesResourceGroup}', 'Microsoft.Network/privateDnsZones', zone) + : resourceId('Microsoft.Network/privateDnsZones', zone)] + +// ---- Private endpoint to the AMPLS, group 'azuremonitor' ---- + +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${scopeName}-private-endpoint' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${scopeName}-private-link-service-connection' + properties: { + privateLinkServiceId: ampls.id + groupIds: [ + 'azuremonitor' + ] + } + } + ] + } + dependsOn: [ + scopedApplicationInsights + scopedWorkspace + ] +} + +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: privateEndpoint + name: '${scopeName}-dns-group' + properties: { + privateDnsZoneConfigs: [for (zone, i) in monitorDnsZoneNames: { + name: replace(zone, '.', '-') + properties: { + privateDnsZoneId: zoneIds[i] + } + }] + } + dependsOn: [ + zoneLinks + ] +} + +output azureMonitorPrivateLinkScopeResourceId string = ampls.id diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep new file mode 100644 index 00000000000..427144977c0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep @@ -0,0 +1,33 @@ +targetScope = 'resourceGroup' + +// Re-applies the Application Insights component after its Azure Monitor private link +// scope association exists. A fresh ingestion path can keep rejecting traffic with +// "Component private link id does not match incoming link" until the component is +// updated again, and ARM reports the scoped resource as Succeeded before then. +// Every property must match the original component declaration in monitoring.bicep. +param name string +param location string +param tags object +param workspaceResourceId string +param publicNetworkAccessForIngestion string +param publicNetworkAccessForQuery string + +@description('The private link scope resource ID. Hashing it into a tag orders this deployment after the scope.') +param privateLinkScopeResourceId string + +resource component 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: union(tags, { + 'azd-ampls-link': uniqueString(privateLinkScopeResourceId, name) + }) + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspaceResourceId + publicNetworkAccessForIngestion: publicNetworkAccessForIngestion + publicNetworkAccessForQuery: publicNetworkAccessForQuery + } +} + +output id string = component.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 index 7557b7a2e01..108752da5cf 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -16,7 +16,16 @@ param existingConnectionName string = '' param environmentName string param resourceTokenSalt string = '' +@description('When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link.') +param privateNetwork bool = false +param vnetId string = '' +param vnetLocation string = '' +param peSubnetId string = '' +param dnsZonesResourceGroup string = '' +param dnsZonesSubscription string = '' + var createResources = mode == 'create' +var publicNetworkAccess = privateNetwork ? 'Disabled' : 'Enabled' var token = uniqueString(projectResourceId, resourceGroup().id, resourceTokenSalt) var projectParts = split(projectResourceId, '/') var componentParts = split(applicationInsightsResourceId, '/') @@ -48,6 +57,35 @@ resource component 'Microsoft.Insights/components@2020-02-02' = if (createResour properties: { Application_Type: 'web' WorkspaceResourceId: workspace!.id + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' + } +} + +module privateLink 'monitor-private-link.bicep' = if (createResources && privateNetwork) { + name: 'foundry-monitoring-private-link' + params: { + location: vnetLocation + suffix: token + applicationInsightsResourceId: component!.id + logAnalyticsWorkspaceResourceId: workspace!.id + vnetId: vnetId + peSubnetId: peSubnetId + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork) { + name: 'foundry-monitoring-resync' + params: { + name: 'appi-${token}' + location: location + tags: monitoringTags + workspaceResourceId: workspace!.id + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' + privateLinkScopeResourceId: privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId } } @@ -56,7 +94,8 @@ resource existingComponent 'Microsoft.Insights/components@2020-02-02' existing = scope: resourceGroup(componentParts[2], componentParts[4]) } -var effectiveComponentId = mode == 'none' ? '' : (createResources ? component!.id : existingComponent!.id) +// Routing through the resync output makes the connection wait for the second component update. +var effectiveComponentId = mode == 'none' ? '' : (createResources ? (privateNetwork ? resync!.outputs.id : component!.id) : existingComponent!.id) module connection 'monitoring-connection.bicep' = if (createConnection && (mode == 'create' || mode == 'reuse-connect')) { name: 'foundry-monitoring-connection' diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep index b8395961c66..2cb38d4c331 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep @@ -342,6 +342,22 @@ resource byoFoundryAccount 'Microsoft.CognitiveServices/accounts@2026-07-15-prev } } +// VNet-injected (BYO egress) projects need a project-level Agents capability +// host so agent traffic and traces route through the private network. The +// platform creates only the account-level host. +#disable-next-line BCP081 +resource byoProjectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = if (useByoNetwork) { + name: '${activeFoundryAccountName}/${foundryProjectName}/agents' + properties: { + #disable-next-line BCP037 + capabilityHostKind: 'Agents' + } + dependsOn: [ + foundryAccount::project + byoFoundryAccount::project + ] +} + // 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 @@ -451,3 +467,6 @@ output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = !includeAcr ? '' : (empty(e 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 : '' +output NETWORK_VNET_ID string = enableNetworkIsolation ? network!.outputs.vnetId : '' +output NETWORK_VNET_LOCATION string = enableNetworkIsolation ? network!.outputs.vnetLocation : '' +output NETWORK_PE_SUBNET_ID string = enableNetworkIsolation ? network!.outputs.peSubnetId : '' diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go new file mode 100644 index 00000000000..846a28de636 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// VNet-injected Foundry projects need a project-level Agents capability host +// for private tracing to work; the platform only creates the account host. +func TestResourcesBicep_BYOProjectCapabilityHost(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("templates", "modules", "resources.bicep")) + require.NoError(t, err) + text := string(raw) + + require.Contains(t, text, "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@") + require.Contains(t, text, "= if (useByoNetwork) {", "host must be BYO-egress only") + require.Contains(t, text, "capabilityHostKind: 'Agents'") + require.Contains(t, text, "foundryAccount::project", "host must wait for the project") + require.Contains(t, text, "byoFoundryAccount::project", "host must wait for the project") +} From c7801e82eb1932f8f5d4b5f17a8fe36043e185c6 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 13:17:03 -0800 Subject: [PATCH 02/23] Test private link conflict check edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../synthesis/monitoring_private_link_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go index bd4d1965d8c..283fddd6527 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -94,6 +94,16 @@ func TestCheckMonitorPrivateLinkConflict(t *testing.T) { {name: "foreign scope", reader: privateLinkReader("azuremonitor", foreignScope), conflict: true}, {name: "foreign scope in the deployment group", reader: privateLinkReader("azuremonitor", sameGroupForeign), conflict: true}, {name: "read failure", reader: &fakeReader{resourceErr: errors.New("boom")}, err: true}, + {name: "private endpoint deleted since listing", reader: func() *fakeReader { + r := privateLinkReader("azuremonitor", foreignScope) + delete(r.resources, strings.ToLower(testPEID)) + return r + }()}, + {name: "malformed private endpoint id", reader: &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testVNetID): armResource(testVNetID, map[string]any{"subnets": []any{ + map[string]any{"properties": map[string]any{"privateEndpoints": []any{map[string]any{"id": "not-an-id"}}}}, + }}), + }}, err: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From f6e424cfdbfce20e09e32c4793786c64ed766466 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 13:17:03 -0800 Subject: [PATCH 03/23] Add Azure Monitor terms to cspell dictionaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 + cli/azd/extensions/azure.ai.projects/cspell.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index b097a8be4a9..6109ff2806d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -74,6 +74,7 @@ words: - abbrs - acrpush - alnum + - azuremonitor - foundryproject - agentignore - ADLS diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 9362e773e82..6985be18418 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -6,6 +6,8 @@ overrides: words: - ABAC - alnum + - ampls + - azuremonitor - exterrors - foundryproject - idempotently From 1c20aa446d0298d34f59264f833567f85dc439dc Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 13:17:03 -0800 Subject: [PATCH 04/23] Connect existing App Insights component on BYO-egress private networks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 6 ++- .../extensions/azure.ai.projects/README.md | 15 ++++-- .../provisioning/monitoring_preflight.go | 5 -- .../provisioning/monitoring_preflight_test.go | 52 ++++++++++++++----- .../synthesis/monitoring_private_link_test.go | 15 ++++++ .../synthesis/monitoring_test.go | 7 ++- .../synthesis/synthesizer.go | 4 +- .../templates/existing-project.arm.json | 14 ++--- .../synthesis/templates/main.arm.json | 14 ++--- .../templates/modules/monitoring.bicep | 6 +-- 10 files changed, 89 insertions(+), 49 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index c8500806167..a107e284c5f 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,9 +8,11 @@ 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 works only for a private network that uses your own agent subnet (BYO egress, `agentSubnet` set) and lets azd create the component. azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. +Automatic Application Insights monitoring works only for a private network that uses your own agent subnet (BYO egress, `agentSubnet` set). For a component azd creates, azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. -Provisioning is never blocked by monitoring in unsupported cases. Managed egress (no `agentSubnet`), connecting an existing Application Insights resource to a private project, or a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope) all skip automatic monitoring, and azd prints a warning. Set `monitoring: {enabled: false}` to opt out without the warning. +Provisioning is never blocked by monitoring in unsupported cases. With your own agent subnet you can also connect an existing Application Insights resource: azd creates its own Private Link Scope, private endpoint and DNS zones and links the component and its workspace to the scope without modifying the component (local authentication must stay enabled). Because azd does not own the component, it cannot re-update it; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. + +Managed egress (no `agentSubnet`) or a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one) skip automatic monitoring, and azd prints a warning. Set `monitoring: {enabled: false}` to opt out without the warning. 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. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 36613bfd51e..246687cfbfc 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -200,10 +200,17 @@ fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces start to arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent were read from inside the network without any post-deployment change. -Unsupported private-network cases never block provisioning: managed egress, -connecting an existing component, or a virtual network that already has an -Azure Monitor private endpoint skip automatic monitoring and azd prints a -warning. Set `monitoring.enabled: false` to opt out silently. Other monitoring +With your own agent subnet you can also connect an existing component +(`reuse-connect`): azd builds its own Private Link Scope, private endpoint and +DNS zones and links the component and its workspace to the scope, without +modifying the component. The component must keep local authentication enabled. +azd does not re-update a component it does not own; if ingestion returns 403 +after provisioning, touching the component (for example, updating a tag) can +clear it. +Unsupported private-network cases never block provisioning: managed egress, or +a virtual network that already has an Azure Monitor private endpoint (azd does +not adopt an existing scope or endpoint) skip automatic monitoring and azd +prints a warning. Set `monitoring.enabled: false` to opt out silently. Other monitoring failures can fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index c0f7158075d..dba7014f570 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -173,11 +173,6 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return fmt.Errorf("the declared existing AppInsights connection was not found; no connection was created") } if monitoringPrivateNetwork(source) { - if mode != "create" { - source.parameters["monitoringMode"] = map[string]any{"value": "none"} - progress("Warning: " + synthesis.PrivateNetworkMonitoringWarning) - return nil - } if err := p.checkMonitorPrivateLinkConflict(ctx, source, &resolver, progress); err != nil { if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { // Skip rather than fail so an existing private network keeps provisioning. diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index 90a2bf72418..ca99e2e582b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -473,20 +473,46 @@ func TestMonitoringPreflightImplicitExistingProjectIsNotConsent(t *testing.T) { assert.Len(t, fixture.requests, 3, "only deployment binding, What-If and project identity are read; no setup or discovery") } -func TestMonitoringPreflightPrivateNetworkSkipsMonitoring(t *testing.T) { +func TestMonitoringPreflightPrivateNetworkConnectsExistingComponent(t *testing.T) { t.Parallel() - fixture := monitoringCloudFixture{} - p := newMonitoringCloudFixture(t, &fixture) - p.synthResult = &synthesis.Result{MonitoringExplicit: true} - source := monitoringTestSource("reuse-connect") - 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) + tests := []struct { + name string + component map[string]any + wantErr string + }{ + { + name: "component with public ingestion disabled", + component: map[string]any{"publicNetworkAccessForIngestion": "Disabled"}, + }, + {name: "component with public ingestion enabled"}, + { + name: "local authentication disabled", + component: map[string]any{"DisableLocalAuth": true}, + wantErr: "disabled local authentication", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{componentPolicy: tt.component} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + err = p.prepareMonitoring(t.Context(), source, client, func(string) {}) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, "reuse-connect", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) + }) + } } func TestMonitoringPreflightOwnedExistingProjectRetainsCreateGraph(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go index 283fddd6527..c8d77b558dc 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -73,6 +73,21 @@ func TestMonitoringResyncMatchesComponent(t *testing.T) { assert.Contains(t, monitoring, "publicNetworkAccessForQuery: 'Enabled'") } +func TestMonitoringPrivateLinkScopesExistingComponent(t *testing.T) { + t.Parallel() + raw, err := templatesFS.ReadFile("templates/modules/monitoring.bicep") + require.NoError(t, err) + monitoring := string(raw) + // A connected existing component is scoped to the new AMPLS, but never rewritten. + assert.Contains(t, monitoring, + "module privateLink 'monitor-private-link.bicep' = if ((createResources || mode == 'reuse-connect') && privateNetwork)") + assert.Contains(t, monitoring, "createResources ? component!.id : existingComponent!.id") + assert.Contains(t, monitoring, + "createResources ? workspace!.id : existingComponent!.properties.WorkspaceResourceId") + assert.Contains(t, monitoring, "module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork)") + assert.NotContains(t, monitoring, "resource existingComponent 'Microsoft.Insights/components@2020-02-02' = ") +} + func TestCheckMonitorPrivateLinkConflict(t *testing.T) { t.Parallel() const ownedScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index 366e42e54e4..44085ced9eb 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -152,7 +152,7 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { { name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", - wantMode: "none", wantWarning: true, + wantMode: "reuse-connect", }, {name: "explicit opt out", settings: " monitoring:\n enabled: false\n", wantMode: "none"}, } @@ -166,10 +166,13 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { }) require.NoError(t, err) wantMode, wantWarning := tt.wantMode, tt.wantWarning - if networkName == "managed egress" && wantMode == "create" { + if networkName == "managed egress" && (wantMode == "create" || wantMode == "reuse-connect") { wantMode, wantWarning = "none", true } assert.Equal(t, wantMode, result.Parameters["monitoringMode"]) + if wantMode == "reuse-connect" { + assert.Equal(t, component, result.Parameters["applicationInsightsResourceId"]) + } if wantWarning { assert.Equal(t, []string{PrivateNetworkMonitoringWarning}, result.Warnings) assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 40c2201daf8..230b0853e06 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -345,7 +345,7 @@ func Synthesize(in Input) (*Result, error) { var warnings []string monitoringMode, _ := monitoring["monitoringMode"].(string) if netMode != NetworkModeNone && monitoringMode != "" && monitoringMode != "none" && - !(netMode == NetworkModeByo && monitoringMode == "create") { + !(netMode == NetworkModeByo && (monitoringMode == "create" || monitoringMode == "reuse-connect")) { // This private-network setup is not supported yet; skip rather than fail provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} warnings = append(warnings, PrivateNetworkMonitoringWarning) @@ -374,7 +374,7 @@ func Synthesize(in Input) (*Result, error) { // PrivateNetworkMonitoringWarning is shown when automatic monitoring is skipped for a private network. const PrivateNetworkMonitoringWarning = "Automatic Application Insights monitoring does not support this " + - "private-network configuration (managed egress, or connecting an existing component), so monitoring was not " + + "private-network configuration (managed egress), so monitoring was not " + "configured. Agent traces will not be collected automatically." // GreenfieldACRMode distinguishes selectors from deployed outputs. diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index a676b92b51e..d1064f9036c 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "1470382681626266510" + "templateHash": "8339999164769765153" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "7468204480995557245" + "templateHash": "6075082788325261439" } }, "parameters": { @@ -916,7 +916,7 @@ ] }, { - "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-monitoring-private-link", @@ -932,12 +932,8 @@ "suffix": { "value": "[variables('token')]" }, - "applicationInsightsResourceId": { - "value": "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" - }, - "logAnalyticsWorkspaceResourceId": { - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" - }, + "applicationInsightsResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]", + "logAnalyticsWorkspaceResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]", "vnetId": { "value": "[parameters('vnetId')]" }, diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index 96135bc352c..d6155935737 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "15309812462098058788" + "templateHash": "1030136728286432684" } }, "definitions": { @@ -2522,7 +2522,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "7468204480995557245" + "templateHash": "6075082788325261439" } }, "parameters": { @@ -2635,7 +2635,7 @@ ] }, { - "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-monitoring-private-link", @@ -2651,12 +2651,8 @@ "suffix": { "value": "[variables('token')]" }, - "applicationInsightsResourceId": { - "value": "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" - }, - "logAnalyticsWorkspaceResourceId": { - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" - }, + "applicationInsightsResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]", + "logAnalyticsWorkspaceResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]", "vnetId": { "value": "[parameters('vnetId')]" }, diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 108752da5cf..ef55a77db73 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -62,13 +62,13 @@ resource component 'Microsoft.Insights/components@2020-02-02' = if (createResour } } -module privateLink 'monitor-private-link.bicep' = if (createResources && privateNetwork) { +module privateLink 'monitor-private-link.bicep' = if ((createResources || mode == 'reuse-connect') && privateNetwork) { name: 'foundry-monitoring-private-link' params: { location: vnetLocation suffix: token - applicationInsightsResourceId: component!.id - logAnalyticsWorkspaceResourceId: workspace!.id + applicationInsightsResourceId: createResources ? component!.id : existingComponent!.id + logAnalyticsWorkspaceResourceId: createResources ? workspace!.id : existingComponent!.properties.WorkspaceResourceId vnetId: vnetId peSubnetId: peSubnetId dnsZonesResourceGroup: dnsZonesResourceGroup From ee24b66393afafca5c0fe76cbae46ef14546f225 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 13:17:04 -0800 Subject: [PATCH 05/23] Address review: gate private monitoring, exact owned scope, workspace ingestion, docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 4 +- .../extensions/azure.ai.projects/README.md | 18 +++- .../provisioning/monitoring_preflight.go | 94 ++++++++++++++----- .../provisioning/monitoring_preflight_test.go | 38 ++++++++ .../synthesis/monitoring_private_link.go | 18 ++-- .../synthesis/monitoring_private_link_test.go | 40 +++++++- .../templates/existing-project.arm.json | 12 ++- .../synthesis/templates/main.arm.json | 41 ++++++-- .../synthesis/templates/main.bicep | 22 +++-- .../templates/modules/monitoring.bicep | 3 + 10 files changed, 227 insertions(+), 63 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index a107e284c5f..31028afc03c 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -12,7 +12,9 @@ Automatic Application Insights monitoring works only for a private network that Provisioning is never blocked by monitoring in unsupported cases. With your own agent subnet you can also connect an existing Application Insights resource: azd creates its own Private Link Scope, private endpoint and DNS zones and links the component and its workspace to the scope without modifying the component (local authentication must stay enabled). Because azd does not own the component, it cannot re-update it; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. -Managed egress (no `agentSubnet`) or a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one) skip automatic monitoring, and azd prints a warning. Set `monitoring: {enabled: false}` to opt out without the warning. +Managed egress (no `agentSubnet`), shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). + +The Private Link Scope changes Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, workloads in the network (or in networks sharing its DNS zones) can no longer send telemetry to Application Insights or Log Analytics resources that are not in the scope. Check for such workloads first, and set `monitoring: {enabled: false}` to opt out. Set `monitoring: {enabled: false}` to opt out without the warning. 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. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 246687cfbfc..18bfdec2774 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -207,10 +207,20 @@ modifying the component. The component must keep local authentication enabled. azd does not re-update a component it does not own; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. -Unsupported private-network cases never block provisioning: managed egress, or -a virtual network that already has an Azure Monitor private endpoint (azd does -not adopt an existing scope or endpoint) skip automatic monitoring and azd -prints a warning. Set `monitoring.enabled: false` to opt out silently. Other monitoring +Unsupported private-network cases never block provisioning: managed egress, +shared private DNS zones (`network.dns`), a virtual network that already has an +Azure Monitor private endpoint (azd does not adopt an existing scope or +endpoint), or a network azd cannot inspect skip automatic monitoring and azd +prints a warning (also on stderr). Ejected or hand-authored Bicep that bypasses +azd's checks never creates the private path unless +`monitoringPrivateLinkApproved` is set. Set `monitoring.enabled: false` to opt +out silently. + +The Private Link Scope changes Azure Monitor routing for the whole virtual +network, not only this project. With ingestion Private Only, workloads in the +network (or in networks sharing its DNS zones) can no longer send telemetry to +Application Insights or Log Analytics resources that are not in the scope. Check +for such workloads first, and opt out with `monitoring.enabled: false`. Other monitoring failures can fail normal provisioning; there is no second best-effort deployment or agent deployment repair hook. diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index dba7014f570..3bc3e9fdaf5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "log" + "os" "strings" "azure.ai.projects/synthesis" @@ -56,29 +57,62 @@ func monitoringPrivateNetwork(source *templateSource) bool { return present && enabled != false } -// The conflict check is advisory when it cannot read the network: ARM remains -// the authority, so a read failure warns and continues. -func (p *FoundryProvisioningProvider) checkMonitorPrivateLinkConflict( +// A private-link graph is only deployed after the VNet is proven free of other +// Azure Monitor private endpoints. Anything inconclusive skips monitoring, +// because ARM would accept a second scope and rewrite the shared Azure Monitor DNS. +func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( ctx context.Context, source *templateSource, resolver *synthesis.MonitoringResolver, - progress grpcbroker.ProgressFunc, -) error { - vnetID, err := monitoringParameter(source, "vnetId") - if err != nil || vnetID == "" { - return err + previous *monitoringTargetBinding, +) string { + if managed, _ := templateParameter(source, "useManagedEgress"); managed == true { + return "managed egress cannot reach a private Azure Monitor endpoint in your virtual network" } - group, err := monitoringParameter(source, "resourceGroupName") + if zones, _ := templateParameter(source, "dnsZonesResourceGroup"); zones != nil && zones != "" { + return "shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope" + } + vnetID, err := monitoringParameter(source, "vnetId") if err != nil { - return err + return err.Error() } - err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, group) - if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict || err == nil { - return err + if vnetID == "" { + return "" + } + ownedScopeID := "" + if previous != nil { + if output := armOutputsToProto(previous.outputs)[synthesis.OwnedPrivateLinkScopeOutput]; output != nil { + ownedScopeID = output.Value + } + } + err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, ownedScopeID) + if err == nil { + return "" + } + if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { + return err.Error() } log.Printf("[warn] foundry provider: could not check for an existing Azure Monitor private endpoint: %v", err) - progress("Warning: could not check the virtual network for an existing Azure Monitor private endpoint") - return nil + return "the virtual network could not be checked for an existing Azure Monitor private endpoint: " + err.Error() +} + +func skipPrivateMonitoring(source *templateSource, progress grpcbroker.ProgressFunc, reason string) { + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + monitoringWarning(progress, "automatic monitoring was skipped. "+reason) +} + +// Progress is a replaceable spinner message, so a skip is also written to stderr. +func monitoringWarning(progress grpcbroker.ProgressFunc, message string) { + progress("Warning: " + message) + fmt.Fprintln(os.Stderr, "WARNING: "+message) +} + +// Templates without the gate parameter (older or hand-authored) are left untouched. +func approvePrivateMonitoring(source *templateSource) { + parameters, _ := source.armTemplate["parameters"].(map[string]any) + if _, declared := parameters["monitoringPrivateLinkApproved"]; declared { + source.parameters["monitoringPrivateLinkApproved"] = map[string]any{"value": true} + } } // Both Preview and Deploy resolve the same graph. This is read-only and never @@ -91,7 +125,7 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( ) error { if p.synthResult != nil { for _, warning := range p.synthResult.Warnings { - progress("Warning: " + warning) + monitoringWarning(progress, warning) } } mode, err := monitoringParameter(source, "monitoringMode") @@ -104,6 +138,12 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if mode != "create" && mode != "reuse-connect" && mode != "already-connected" { return fmt.Errorf("monitoringMode must be none, create, reuse-connect, or already-connected") } + if managed, _ := templateParameter(source, "useManagedEgress"); managed == true && monitoringPrivateNetwork(source) && + (mode == "create" || mode == "reuse-connect") { + skipPrivateMonitoring(source, progress, + "managed egress cannot reach a private Azure Monitor endpoint in your virtual network") + return nil + } componentID, err := monitoringParameter(source, "applicationInsightsResourceId") if err != nil { return err @@ -153,6 +193,13 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if err := p.validateMonitoringCreateGraph(ctx, source, previous, connection); err != nil { return err } + if monitoringPrivateNetwork(source) { + if reason := p.privateMonitoringSkipReason(ctx, source, &resolver, previous); reason != "" { + skipPrivateMonitoring(source, progress, reason) + return nil + } + approvePrivateMonitoring(source) + } } 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") @@ -173,14 +220,10 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return fmt.Errorf("the declared existing AppInsights connection was not found; no connection was created") } if monitoringPrivateNetwork(source) { - if err := p.checkMonitorPrivateLinkConflict(ctx, source, &resolver, progress); err != nil { - if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { - // Skip rather than fail so an existing private network keeps provisioning. - source.parameters["monitoringMode"] = map[string]any{"value": "none"} - progress("Warning: automatic monitoring was skipped. " + err.Error()) - return nil - } - return err + if reason := p.privateMonitoringSkipReason(ctx, source, &resolver, previous); reason != "" { + // Skip rather than fail so an existing private network keeps provisioning. + skipPrivateMonitoring(source, progress, reason) + return nil } } else { for _, resource := range []*armresources.GenericResource{account, projectResource} { @@ -206,6 +249,9 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return err } } + if monitoringPrivateNetwork(source) && (mode == "create" || mode == "reuse-connect") { + approvePrivateMonitoring(source) + } return nil } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index ca99e2e582b..afcb13d3913 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -515,6 +515,44 @@ func TestMonitoringPreflightPrivateNetworkConnectsExistingComponent(t *testing.T } } +func TestMonitoringPreflightPrivateNetworkGates(t *testing.T) { + t.Parallel() + tests := []struct { + name string + extra map[string]any + declared bool + wantMode string + approved bool + }{ + {name: "managed egress is skipped", extra: map[string]any{"useManagedEgress": true}, wantMode: "none"}, + {name: "shared DNS zones are skipped", extra: map[string]any{"dnsZonesResourceGroup": "dns-rg"}, wantMode: "none"}, + {name: "verified network is approved", declared: true, wantMode: "reuse-connect", approved: true}, + {name: "template without the gate is left alone", wantMode: "reuse-connect"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + for name, value := range tt.extra { + source.parameters[name] = map[string]any{"value": value} + } + if tt.declared { + source.armTemplate["parameters"] = map[string]any{"monitoringPrivateLinkApproved": map[string]any{}} + } + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, tt.wantMode, source.parameters["monitoringMode"].(map[string]any)["value"]) + approved, present := source.parameters["monitoringPrivateLinkApproved"].(map[string]any) + assert.Equal(t, tt.approved, present && approved["value"] == true) + }) + } +} + func TestMonitoringPreflightOwnedExistingProjectRetainsCreateGraph(t *testing.T) { t.Parallel() source := monitoringTestSource("create") diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go index 7613762c61d..09144e487b8 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go @@ -8,13 +8,12 @@ import ( "fmt" "strings" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" ) -// ownedPrivateLinkScopePrefix is the name prefix monitor-private-link.bicep and -// the Terraform equivalent give the Azure Monitor Private Link Scope they create. -const ownedPrivateLinkScopePrefix = "ampls-" +// OwnedPrivateLinkScopeOutput is the deployment output carrying the exact +// Azure Monitor Private Link Scope ID a previous deployment of this project created. +const OwnedPrivateLinkScopeOutput = "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID" // MonitorPrivateLinkConflictError reports an Azure Monitor private endpoint that // already serves the VNet. A VNet can use only one Azure Monitor Private Link @@ -32,11 +31,12 @@ func (e *MonitorPrivateLinkConflictError) Error() string { } // CheckMonitorPrivateLinkConflict looks for an existing azuremonitor private -// endpoint on the VNet that this deployment does not own. It is read-only. A +// endpoint on the VNet other than the exact scope (ownedScopeID) a previous +// deployment of this project created. It is read-only. A // *MonitorPrivateLinkConflictError means a conflict; any other error means the -// check could not complete and ARM remains the authority. +// check could not complete. func (p *MonitoringResolver) CheckMonitorPrivateLinkConflict( - ctx context.Context, vnetID, ownedResourceGroup string, + ctx context.Context, vnetID, ownedScopeID string, ) error { id, err := MonitoringResourceID(vnetID, "Microsoft.Network/virtualNetworks") if err != nil { @@ -62,9 +62,7 @@ func (p *MonitoringResolver) CheckMonitorPrivateLinkConflict( if !isMonitor { continue } - if scope, err := arm.ParseResourceID(scopeID); err == nil && - strings.EqualFold(scope.ResourceGroupName, ownedResourceGroup) && - strings.HasPrefix(strings.ToLower(scope.Name), ownedPrivateLinkScopePrefix) { + if ownedScopeID != "" && strings.EqualFold(scopeID, ownedScopeID) { continue } return &MonitorPrivateLinkConflictError{PrivateEndpointID: endpointID} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go index c8d77b558dc..5576a3e2c12 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -88,15 +88,39 @@ func TestMonitoringPrivateLinkScopesExistingComponent(t *testing.T) { assert.NotContains(t, monitoring, "resource existingComponent 'Microsoft.Insights/components@2020-02-02' = ") } +func TestPrivateMonitoringTemplateGates(t *testing.T) { + t.Parallel() + read := func(name string) string { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + return string(raw) + } + main := read("templates/main.bicep") + // Without azd's verified-network approval, or on managed egress, the private path is never created. + assert.Contains(t, main, "param monitoringPrivateLinkApproved bool = false") + assert.Contains(t, main, "enableNetworkIsolation && (useManagedEgress || !monitoringPrivateLinkApproved)") + assert.Contains(t, main, "module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'none')") + assert.Contains(t, main, "output AZD_MONITORING_PRIVATE_LINK_SCOPE_ID") + // The workspace closes public ingestion like the component does. + monitoring := read("templates/modules/monitoring.bicep") + workspace := monitoring[strings.Index(monitoring, "resource workspace"):strings.Index(monitoring, "resource component")] + assert.Contains(t, workspace, "publicNetworkAccessForIngestion: publicNetworkAccess") + assert.Contains(t, workspace, "publicNetworkAccessForQuery: 'Enabled'") +} + func TestCheckMonitorPrivateLinkConflict(t *testing.T) { t.Parallel() const ownedScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" const foreignScope = "/subscriptions/" + testSub + "/resourceGroups/shared/providers/Microsoft.Insights/privateLinkScopes/corp" const sameGroupForeign = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/corp" + const lookalikeScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-other" + const otherSubScope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" + tests := []struct { name string reader *fakeReader + owned string conflict bool err bool }{ @@ -105,9 +129,17 @@ func TestCheckMonitorPrivateLinkConflict(t *testing.T) { }}}, {name: "virtual network does not exist yet", reader: &fakeReader{}}, {name: "unrelated private endpoint", reader: privateLinkReader("blob", foreignScope)}, - {name: "owned scope from a previous run", reader: privateLinkReader("azuremonitor", ownedScope)}, - {name: "foreign scope", reader: privateLinkReader("azuremonitor", foreignScope), conflict: true}, - {name: "foreign scope in the deployment group", reader: privateLinkReader("azuremonitor", sameGroupForeign), conflict: true}, + {name: "owned scope from a previous run", reader: privateLinkReader("azuremonitor", ownedScope), owned: ownedScope}, + {name: "owned scope matched case-insensitively", reader: privateLinkReader("azuremonitor", ownedScope), + owned: strings.ToUpper(ownedScope)}, + {name: "scope not yet created by this project", reader: privateLinkReader("azuremonitor", ownedScope), conflict: true}, + {name: "ampls-prefixed scope of another project", reader: privateLinkReader("azuremonitor", lookalikeScope), + owned: ownedScope, conflict: true}, + {name: "same scope name in another subscription", reader: privateLinkReader("azuremonitor", otherSubScope), + owned: ownedScope, conflict: true}, + {name: "foreign scope", reader: privateLinkReader("azuremonitor", foreignScope), owned: ownedScope, conflict: true}, + {name: "foreign scope in the deployment group", reader: privateLinkReader("azuremonitor", sameGroupForeign), + owned: ownedScope, conflict: true}, {name: "read failure", reader: &fakeReader{resourceErr: errors.New("boom")}, err: true}, {name: "private endpoint deleted since listing", reader: func() *fakeReader { r := privateLinkReader("azuremonitor", foreignScope) @@ -123,7 +155,7 @@ func TestCheckMonitorPrivateLinkConflict(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := (&MonitoringResolver{Reader: tt.reader}).CheckMonitorPrivateLinkConflict(t.Context(), testVNetID, "rg") + err := (&MonitoringResolver{Reader: tt.reader}).CheckMonitorPrivateLinkConflict(t.Context(), testVNetID, tt.owned) var conflict *MonitorPrivateLinkConflictError switch { case tt.conflict: diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index d1064f9036c..34c0bfe63e3 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "8339999164769765153" + "templateHash": "4222368514653268958" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "6075082788325261439" + "templateHash": "2583294824551726700" } }, "parameters": { @@ -894,7 +894,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" } }, { @@ -1365,6 +1367,10 @@ } ], "outputs": { + "privateLinkScopeResourceId": { + "type": "string", + "value": "[if(and(or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork')), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value, '')]" + }, "applicationInsightsResourceId": { "type": "string", "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, 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])))]" diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index d6155935737..86eb0e98efd 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "1030136728286432684" + "templateHash": "3000994132333516536" } }, "definitions": { @@ -326,8 +326,19 @@ "metadata": { "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." } + }, + "monitoringPrivateLinkApproved": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set by azd after it verified the VNet has no conflicting Azure Monitor private endpoint. A network-isolated deployment without it never creates the private monitoring path." + } } }, + "variables": { + "privateMonitoringBlocked": "[and(and(parameters('enableNetworkIsolation'), or(parameters('useManagedEgress'), not(parameters('monitoringPrivateLinkApproved')))), or(equals(parameters('monitoringMode'), 'create'), equals(parameters('monitoringMode'), 'reuse-connect')))]", + "effectiveMonitoringMode": "[if(variables('privateMonitoringBlocked'), 'none', parameters('monitoringMode'))]" + }, "resources": { "resourceGroup": { "type": "Microsoft.Resources/resourceGroups", @@ -2460,7 +2471,7 @@ ] }, "monitoring": { - "condition": "[not(equals(parameters('monitoringMode'), 'none'))]", + "condition": "[not(equals(variables('effectiveMonitoringMode'), 'none'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-monitoring", @@ -2472,7 +2483,7 @@ "mode": "Incremental", "parameters": { "mode": { - "value": "[parameters('monitoringMode')]" + "value": "[variables('effectiveMonitoringMode')]" }, "location": { "value": "[parameters('location')]" @@ -2522,7 +2533,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "6075082788325261439" + "templateHash": "2583294824551726700" } }, "parameters": { @@ -2613,7 +2624,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" } }, { @@ -3084,6 +3097,10 @@ } ], "outputs": { + "privateLinkScopeResourceId": { + "type": "string", + "value": "[if(and(or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork')), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value, '')]" + }, "applicationInsightsResourceId": { "type": "string", "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, 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])))]" @@ -3160,23 +3177,27 @@ }, "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": { "type": "string", - "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsResourceId.value, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), '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, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), '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, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), '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, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.projectConnectionName.value, '')]" }, "AZD_FOUNDRY_MONITORING_MODE": { "type": "string", - "value": "[parameters('monitoringMode')]" + "value": "[variables('effectiveMonitoringMode')]" + }, + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID": { + "type": "string", + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.privateLinkScopeResourceId.value, '')]" } } } \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 7283d70cafb..1a3629f8736 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -157,6 +157,13 @@ param dnsZonesResourceGroup string = '' @description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') param dnsZonesSubscription string = '' +@description('Set by azd after it verified the VNet has no conflicting Azure Monitor private endpoint. A network-isolated deployment without it never creates the private monitoring path.') +param monitoringPrivateLinkApproved bool = false + +// Private monitoring needs a customer VNet (not managed egress) and a verified network. +var privateMonitoringBlocked = enableNetworkIsolation && (useManagedEgress || !monitoringPrivateLinkApproved) && (monitoringMode == 'create' || monitoringMode == 'reuse-connect') +var effectiveMonitoringMode = privateMonitoringBlocked ? 'none' : monitoringMode + // Resources resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { @@ -199,11 +206,11 @@ module resources 'modules/resources.bicep' = { } } -module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { +module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'none') { name: 'foundry-monitoring' scope: resourceGroup params: { - mode: monitoringMode + mode: effectiveMonitoringMode location: location tags: tags projectResourceId: resources.outputs.AZURE_AI_PROJECT_ID @@ -235,8 +242,9 @@ 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 +output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' +output AZURE_APPLICATION_INSIGHTS_APP_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' +output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' +output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' +output AZD_FOUNDRY_MONITORING_MODE string = effectiveMonitoringMode +output AZD_MONITORING_PRIVATE_LINK_SCOPE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.privateLinkScopeResourceId : '' diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index ef55a77db73..63418d2d6f1 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -46,6 +46,8 @@ resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (c name: 'PerGB2018' } retentionInDays: 30 + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' } } @@ -108,6 +110,7 @@ module connection 'monitoring-connection.bicep' = if (createConnection && (mode } } +output privateLinkScopeResourceId string = (mode == 'create' || mode == 'reuse-connect') && privateNetwork ? privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId : '' 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) From b7313f7f261fa38c09f9d1c05de1e1281d97f771 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 14:48:25 -0800 Subject: [PATCH 06/23] Approve private monitoring before planning, keep BYO private link reconciled, fix ingestion-only wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../internal/provisioning/monitoring_graph.go | 25 +++++ .../provisioning/monitoring_preflight.go | 99 ++++++++++++++----- .../provisioning/monitoring_preflight_test.go | 57 +++++++++++ .../templates/existing-project.arm.json | 6 +- .../synthesis/templates/main.arm.json | 6 +- .../templates/modules/monitoring.bicep | 2 +- 6 files changed, 162 insertions(+), 33 deletions(-) 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 index dc1fad02573..90d904e1bcd 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go @@ -14,6 +14,31 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" ) +// monitoringPlannedPrivateLinkScope returns the Azure Monitor Private Link Scope +// ARM plans in the owned resource group, or "" unless there is exactly one. +func monitoringPlannedPrivateLinkScope(result *armresources.WhatIfOperationResult, subscriptionID, resourceGroup string) string { + if result == nil || result.Properties == nil { + return "" + } + 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(), "Microsoft.Insights/privateLinkScopes") || + !strings.EqualFold(id.SubscriptionID, subscriptionID) || !strings.EqualFold(id.ResourceGroupName, resourceGroup) { + continue + } + if selected != "" && !strings.EqualFold(selected, id.String()) { + return "" + } + selected = id.String() + } + return selected +} + func monitoringPlannedResource( result *armresources.WhatIfOperationResult, resourceType, subscriptionID, resourceGroup string, privateNetwork bool, ) (string, error) { diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index 3bc3e9fdaf5..d8b1dca2972 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -57,47 +57,74 @@ func monitoringPrivateNetwork(source *templateSource) bool { return present && enabled != false } +// ownedPrivateLinkScope is the exact Azure Monitor Private Link Scope this +// environment already created. A succeeded deployment records it as an output; +// a verified failed retry has no outputs, so the scope comes from the What-If plan. +func (p *FoundryProvisioningProvider) ownedPrivateLinkScope( + source *templateSource, previous *monitoringTargetBinding, +) string { + if previous == nil { + return "" + } + for name, output := range armOutputsToProto(previous.outputs) { + if output != nil && output.Value != "" && strings.EqualFold(name, synthesis.OwnedPrivateLinkScopeOutput) { + return output.Value + } + } + if previous.retry { + group, err := monitoringParameter(source, "resourceGroupName") + if err == nil { + return monitoringPlannedPrivateLinkScope(previous.plan, p.subID, group) + } + } + return "" +} + // A private-link graph is only deployed after the VNet is proven free of other -// Azure Monitor private endpoints. Anything inconclusive skips monitoring, -// because ARM would accept a second scope and rewrite the shared Azure Monitor DNS. +// Azure Monitor private endpoints. For a new environment anything inconclusive +// skips monitoring, because ARM would accept a second scope and rewrite the +// shared Azure Monitor DNS. An environment that already owns monitoring gets a +// retryable error instead, so a transient read cannot drop its binding. func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( ctx context.Context, source *templateSource, resolver *synthesis.MonitoringResolver, previous *monitoringTargetBinding, -) string { +) (string, error) { if managed, _ := templateParameter(source, "useManagedEgress"); managed == true { - return "managed egress cannot reach a private Azure Monitor endpoint in your virtual network" + return "managed egress cannot reach a private Azure Monitor endpoint in your virtual network", nil } if zones, _ := templateParameter(source, "dnsZonesResourceGroup"); zones != nil && zones != "" { - return "shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope" + return "shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope", nil } vnetID, err := monitoringParameter(source, "vnetId") if err != nil { - return err.Error() + return err.Error(), nil } if vnetID == "" { - return "" - } - ownedScopeID := "" - if previous != nil { - if output := armOutputsToProto(previous.outputs)[synthesis.OwnedPrivateLinkScopeOutput]; output != nil { - ownedScopeID = output.Value - } + return "", nil } + ownedScopeID := p.ownedPrivateLinkScope(source, previous) err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, ownedScopeID) if err == nil { - return "" + return "", nil } if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { - return err.Error() + return err.Error(), nil } log.Printf("[warn] foundry provider: could not check for an existing Azure Monitor private endpoint: %v", err) - return "the virtual network could not be checked for an existing Azure Monitor private endpoint: " + err.Error() + if ownedScopeID != "" || (previous != nil && previous.owned) { + return "", fmt.Errorf("could not verify the Azure Monitor private endpoint this environment already uses; "+ + "retry provisioning: %w", err) + } + return "the virtual network could not be checked for an existing Azure Monitor private endpoint: " + err.Error(), nil } func skipPrivateMonitoring(source *templateSource, progress grpcbroker.ProgressFunc, reason string) { source.parameters["monitoringMode"] = map[string]any{"value": "none"} + if _, approved := source.parameters["monitoringPrivateLinkApproved"]; approved { + source.parameters["monitoringPrivateLinkApproved"] = map[string]any{"value": false} + } monitoringWarning(progress, "automatic monitoring was skipped. "+reason) } @@ -151,6 +178,10 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if mode != "create" && componentID == "" { return fmt.Errorf("monitoring mode %s requires applicationInsightsResourceId", mode) } + if monitoringPrivateNetwork(source) && (mode == "create" || mode == "reuse-connect") { + // The planning What-If must include the monitoring graph. A skip below withdraws this approval. + approvePrivateMonitoring(source) + } projectID, previous, err := p.resolveMonitoringTarget(ctx, source, client, progress) if err != nil { return err @@ -194,11 +225,25 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return err } if monitoringPrivateNetwork(source) { - if reason := p.privateMonitoringSkipReason(ctx, source, &resolver, previous); reason != "" { + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) + if err != nil { + return err + } + if reason != "" { skipPrivateMonitoring(source, progress, reason) return nil } - approvePrivateMonitoring(source) + } + } else if mode == "reuse-connect" && monitoringPrivateNetwork(source) { + // Keep the private-link graph reconciled on every provision, not only the first, + // so a retry after a partial failure still completes it. + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) + if err != nil { + return err + } + if reason != "" { + skipPrivateMonitoring(source, progress, reason) + return nil } } else if mode == "create" && source.mode != templateModeEmbedded { return fmt.Errorf("a compatible AppInsights connection already exists; preserve it with " + @@ -207,7 +252,7 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( mode = "already-connected" componentID = connection.Target } - if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create"); err != nil { + if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create" || mode == "reuse-connect"); err != nil { return err } source.parameters["monitoringMode"] = map[string]any{"value": mode} @@ -220,7 +265,11 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return fmt.Errorf("the declared existing AppInsights connection was not found; no connection was created") } if monitoringPrivateNetwork(source) { - if reason := p.privateMonitoringSkipReason(ctx, source, &resolver, previous); reason != "" { + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) + if err != nil { + return err + } + if reason != "" { // Skip rather than fail so an existing private network keeps provisioning. skipPrivateMonitoring(source, progress, reason) return nil @@ -249,9 +298,6 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( return err } } - if monitoringPrivateNetwork(source) && (mode == "create" || mode == "reuse-connect") { - approvePrivateMonitoring(source) - } return nil } @@ -333,7 +379,8 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( previous = &monitoringTargetBinding{projectID: id, legacy: true} } previous.plan = &result.WhatIfOperationResult - if !previous.owned && mode == "create" && monitoringFailedCreateAttempt(bindingParameters, response.Properties) { + if !previous.owned && (mode == "create" || (mode == "reuse-connect" && monitoringPrivateNetwork(source))) && + monitoringFailedCreateAttempt(bindingParameters, response.Properties, mode) { exported, err := client.ExportTemplateAtSubscriptionScope(ctx, p.deploymentName(), nil) if err != nil { return "", nil, synthesis.MonitoringReadError("verify the previous failed monitoring graph", err) @@ -352,7 +399,7 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( // 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, + parameters map[string]any, previous *armresources.DeploymentPropertiesExtended, mode string, ) bool { if previous == nil || previous.ProvisioningState == nil || (*previous.ProvisioningState != armresources.ProvisioningStateFailed && @@ -361,7 +408,7 @@ func monitoringFailedCreateAttempt( return false } old, _ := previous.Parameters.(map[string]any) - if mode, _ := armStringParameter(old, "monitoringMode"); mode != "create" { + if oldMode, _ := armStringParameter(old, "monitoringMode"); oldMode != mode { 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 index afcb13d3913..cd2e34193e0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -21,6 +21,7 @@ import ( "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/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -641,6 +642,62 @@ func TestMonitoringPreflightFreshNativeTargetComesFromARM(t *testing.T) { } } +func TestMonitoringPreflightPrivateCreatePlansWithApproval(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{} + source := monitoringTestSource("create") + delete(source.parameters, "projectResourceId") + source.parameters["applicationInsightsResourceId"] = map[string]any{"value": ""} + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + source.armTemplate["parameters"] = map[string]any{"monitoringPrivateLinkApproved": map[string]any{}} + 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() + require.Len(t, fixture.plans, 1) + planned := fixture.plans[0]["properties"].(map[string]any)["parameters"].(map[string]any) + assert.Equal(t, true, planned["monitoringPrivateLinkApproved"].(map[string]any)["value"], + "the planning What-If must include the monitoring graph") +} + +func TestMonitoringPlannedPrivateLinkScope(t *testing.T) { + t.Parallel() + prefix := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/" + result := func(changes ...*armresources.WhatIfChange) *armresources.WhatIfOperationResult { + return &armresources.WhatIfOperationResult{Properties: &armresources.WhatIfOperationProperties{Changes: changes}} + } + change := func(id string, kind armresources.ChangeType) *armresources.WhatIfChange { + return &armresources.WhatIfChange{ResourceID: &id, ChangeType: &kind} + } + assert.Equal(t, prefix+"a", monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate)), "SUB", "RG")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate), change(prefix+"b", armresources.ChangeTypeCreate)), "sub", "rg")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeIgnore)), "sub", "rg")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate)), "sub", "other")) + assert.Empty(t, monitoringPlannedPrivateLinkScope(nil, "sub", "rg")) +} + func TestMonitoringPreflightFailedNativeAttemptCanRetryWithoutTrustingOutputs(t *testing.T) { t.Parallel() source := monitoringTestSource("create") diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 34c0bfe63e3..7dad5ae32ea 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "4222368514653268958" + "templateHash": "6131771428442677627" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "2583294824551726700" + "templateHash": "4682671837567762900" } }, "parameters": { @@ -849,7 +849,7 @@ "type": "bool", "defaultValue": false, "metadata": { - "description": "When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link." + "description": "When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public." } }, "vnetId": { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index 86eb0e98efd..14d7520781c 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3000994132333516536" + "templateHash": "16542737369431655029" } }, "definitions": { @@ -2533,7 +2533,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "2583294824551726700" + "templateHash": "4682671837567762900" } }, "parameters": { @@ -2579,7 +2579,7 @@ "type": "bool", "defaultValue": false, "metadata": { - "description": "When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link." + "description": "When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public." } }, "vnetId": { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 63418d2d6f1..3853400c8b2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -16,7 +16,7 @@ param existingConnectionName string = '' param environmentName string param resourceTokenSalt string = '' -@description('When true, new monitoring resources accept ingestion and queries only through an Azure Monitor private link.') +@description('When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public.') param privateNetwork bool = false param vnetId string = '' param vnetLocation string = '' From 98d3177167fd7e2193a672c66fe8e1de74138823 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 15:16:33 -0800 Subject: [PATCH 07/23] Keep owned private monitoring on conflict, reuse preserved keyless connections, add preflight tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../provisioning/monitoring_preflight.go | 20 +++- .../provisioning/monitoring_preflight_test.go | 113 ++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index d8b1dca2972..84d850b3f91 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -94,8 +94,18 @@ func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( if managed, _ := templateParameter(source, "useManagedEgress"); managed == true { return "managed egress cannot reach a private Azure Monitor endpoint in your virtual network", nil } + ownedScopeID := p.ownedPrivateLinkScope(source, previous) + owned := ownedScopeID != "" || (previous != nil && previous.owned) + // Skipping would record monitoringMode none and drop the binding of an owned graph. + ownedBlock := func(reason string) (string, error) { + if owned { + return "", fmt.Errorf("automatic monitoring cannot continue for this environment: %s; "+ + "resolve this or set monitoring.enabled: false", reason) + } + return reason, nil + } if zones, _ := templateParameter(source, "dnsZonesResourceGroup"); zones != nil && zones != "" { - return "shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope", nil + return ownedBlock("shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope") } vnetID, err := monitoringParameter(source, "vnetId") if err != nil { @@ -104,16 +114,15 @@ func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( if vnetID == "" { return "", nil } - ownedScopeID := p.ownedPrivateLinkScope(source, previous) err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, ownedScopeID) if err == nil { return "", nil } if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { - return err.Error(), nil + return ownedBlock(err.Error()) } log.Printf("[warn] foundry provider: could not check for an existing Azure Monitor private endpoint: %v", err) - if ownedScopeID != "" || (previous != nil && previous.owned) { + if owned { return "", fmt.Errorf("could not verify the Azure Monitor private endpoint this environment already uses; "+ "retry provisioning: %w", err) } @@ -252,7 +261,8 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( mode = "already-connected" componentID = connection.Target } - if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create" || mode == "reuse-connect"); err != nil { + // A preserved connection is reused as is, so only a new one needs key authentication. + if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create"); err != nil { return err } source.parameters["monitoringMode"] = map[string]any{"value": mode} diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index cd2e34193e0..c62c5d3396f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -72,8 +72,15 @@ type monitoringCloudFixture struct { deploymentStatus int deploymentOutputs map[string]any deploymentResources []any + vnetStatus int + foreignEndpointScope string } +const ( + monitoringTestVNet = "/subscriptions/sub/resourceGroups/net-rg/providers/Microsoft.Network/virtualNetworks/vnet" + monitoringTestEndpoint = "/subscriptions/sub/resourceGroups/net-rg/providers/Microsoft.Network/privateEndpoints/pe" +) + func newMonitoringCloudFixture(t *testing.T, fixture *monitoringCloudFixture) *FoundryProvisioningProvider { t.Helper() server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -91,6 +98,24 @@ func newMonitoringCloudFixture(t *testing.T, fixture *monitoringCloudFixture) *F }}) } switch { + case strings.Contains(request.URL.Path, "/providers/Microsoft.Network/virtualNetworks/"): + if fixture.vnetStatus != 0 { + failure(fixture.vnetStatus) + return + } + endpoints := []any{} + if fixture.foreignEndpointScope != "" { + endpoints = append(endpoints, map[string]any{"id": monitoringTestEndpoint}) + } + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "subnets": []any{map[string]any{"properties": map[string]any{"privateEndpoints": endpoints}}}, + }}) + case strings.EqualFold(request.URL.Path, monitoringTestEndpoint): + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "privateLinkServiceConnections": []any{map[string]any{"properties": map[string]any{ + "groupIds": []any{"azuremonitor"}, "privateLinkServiceId": fixture.foreignEndpointScope, + }}}, + }}) case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg"): if request.Method == http.MethodDelete { fixture.deletions = append(fixture.deletions, request.URL.Path) @@ -678,6 +703,94 @@ func TestMonitoringPreflightPrivateCreatePlansWithApproval(t *testing.T) { "the planning What-If must include the monitoring graph") } +func TestMonitoringPrivateMonitoringSkipReasonDecisions(t *testing.T) { + t.Parallel() + owned := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.Insights/privateLinkScopes/owned" + foreign := "/subscriptions/sub/resourceGroups/other/providers/Microsoft.Insights/privateLinkScopes/foreign" + ownedOutputs := map[string]any{"azd_monitoring_private_link_scope_id": map[string]any{"value": owned}} + planWith := func(id string) *armresources.WhatIfOperationResult { + kind := armresources.ChangeTypeCreate + return &armresources.WhatIfOperationResult{Properties: &armresources.WhatIfOperationProperties{ + Changes: []*armresources.WhatIfChange{{ResourceID: &id, ChangeType: &kind}}, + }} + } + tests := []struct { + name string + foreign string + vnetStatus int + previous *monitoringTargetBinding + extra map[string]any + wantSkip bool + wantErr bool + }{ + {name: "new environment with a free network proceeds"}, + {name: "new environment conflict skips", foreign: foreign, wantSkip: true}, + {name: "new environment read failure skips", vnetStatus: http.StatusInternalServerError, wantSkip: true}, + { + name: "owned environment conflict errors", foreign: foreign, + previous: &monitoringTargetBinding{owned: true}, wantErr: true, + }, + { + name: "owned environment read failure errors", vnetStatus: http.StatusInternalServerError, + previous: &monitoringTargetBinding{owned: true}, wantErr: true, + }, + { + name: "owned environment shared DNS errors", previous: &monitoringTargetBinding{owned: true}, + extra: map[string]any{"dnsZonesResourceGroup": "dns-rg"}, wantErr: true, + }, + { + name: "own scope from mixed-case outputs is exempt", foreign: owned, + previous: &monitoringTargetBinding{outputs: ownedOutputs}, + }, + { + name: "own scope from a failed retry plan is exempt", foreign: owned, + previous: &monitoringTargetBinding{retry: true, plan: planWith(owned)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{foreignEndpointScope: tt.foreign, vnetStatus: tt.vnetStatus} + p := newMonitoringCloudFixture(t, &fixture) + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + source.parameters["vnetId"] = map[string]any{"value": monitoringTestVNet} + source.parameters["resourceGroupName"] = map[string]any{"value": "project-rg"} + for name, value := range tt.extra { + source.parameters[name] = map[string]any{"value": value} + } + resolver := synthesis.MonitoringResolver{ + Credential: p.credential, ClientOptions: p.armClientOptions, PrivateNetwork: true, + } + reason, err := p.privateMonitoringSkipReason(t.Context(), source, &resolver, tt.previous) + if tt.wantErr { + require.Error(t, err) + assert.Empty(t, reason) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantSkip, reason != "", reason) + }) + } +} + +func TestMonitoringPreflightPrivateNetworkPreservesKeylessConnection(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{ + componentPolicy: map[string]any{"DisableLocalAuth": true}, + accountConnections: []any{monitoringTestConnection("shared-monitoring", monitoringTestComponent, "AAD", true)}, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + 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, "reuse-connect", source.parameters["monitoringMode"].(map[string]any)["value"]) + assert.Equal(t, false, source.parameters["monitoringCreateConnection"].(map[string]any)["value"]) +} + func TestMonitoringPlannedPrivateLinkScope(t *testing.T) { t.Parallel() prefix := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/" From 87a41540d3026fd56ea1f6dfcd71cea41e4c30e7 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 15:41:35 -0800 Subject: [PATCH 08/23] Give a reuse-specific recovery message when a failed private reuse-connect retry no longer matches the template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../provisioning/monitoring_preflight.go | 5 +++ .../provisioning/monitoring_preflight_test.go | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index 84d850b3f91..948c7cdb621 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -398,6 +398,11 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( before, beforeErr := json.Marshal(exported.Template) after, afterErr := json.Marshal(source.armTemplate) if beforeErr != nil || afterErr != nil || !bytes.Equal(before, after) { + if mode == "reuse-connect" { + return "", nil, fmt.Errorf("the failed deployment used a different monitoring graph; " + + "existing-component reuse is already selected, so restore the previous infrastructure " + + "template or disable monitoring (monitoring.enabled: false) before retrying") + } return "", nil, fmt.Errorf("the failed deployment used a different monitoring graph; " + "restore its infrastructure or explicitly configure existing-component reuse before retrying") } diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index c62c5d3396f..0587bb5450a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -811,6 +811,37 @@ func TestMonitoringPlannedPrivateLinkScope(t *testing.T) { assert.Empty(t, monitoringPlannedPrivateLinkScope(nil, "sub", "rg")) } +func TestMonitoringPreflightPrivateReuseConnectRetryMismatchSuggestsRecovery(t *testing.T) { + t.Parallel() + source := monitoringTestSource("reuse-connect") + delete(source.parameters, "projectResourceId") + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "project-rg", "foundryProjectName": "project", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + fixture := monitoringCloudFixture{ + plannedStatus: http.StatusOK, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestProject, "changeType": "NoChange"}, + }, + previous: map[string]any{ + "provisioningState": "Failed", "parameters": source.parameters, + "template": map[string]any{"resources": []any{"different"}}, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + err = p.prepareMonitoring(t.Context(), source, client, func(string) {}) + require.Error(t, err) + assert.Contains(t, err.Error(), "different monitoring graph") + assert.Contains(t, err.Error(), "monitoring.enabled: false") + assert.NotContains(t, err.Error(), "explicitly configure existing-component reuse") +} + func TestMonitoringPreflightFailedNativeAttemptCanRetryWithoutTrustingOutputs(t *testing.T) { t.Parallel() source := monitoringTestSource("create") From 7c625d2e112d80cb9990bb7cea7f170b028124a7 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 15:51:28 -0800 Subject: [PATCH 09/23] Document shared-VNet impact of the Azure Monitor private link scope and how to keep other telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 +- .../extensions/azure.ai.projects/README.md | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 31028afc03c..68fd23e7cd7 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -14,7 +14,7 @@ Provisioning is never blocked by monitoring in unsupported cases. With your own Managed egress (no `agentSubnet`), shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). -The Private Link Scope changes Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, workloads in the network (or in networks sharing its DNS zones) can no longer send telemetry to Application Insights or Log Analytics resources that are not in the scope. Check for such workloads first, and set `monitoring: {enabled: false}` to opt out. Set `monitoring: {enabled: false}` to opt out without the warning. +**Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. 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. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 18bfdec2774..0bd9e959f9b 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -216,13 +216,18 @@ azd's checks never creates the private path unless `monitoringPrivateLinkApproved` is set. Set `monitoring.enabled: false` to opt out silently. -The Private Link Scope changes Azure Monitor routing for the whole virtual -network, not only this project. With ingestion Private Only, workloads in the -network (or in networks sharing its DNS zones) can no longer send telemetry to -Application Insights or Log Analytics resources that are not in the scope. Check -for such workloads first, and opt out with `monitoring.enabled: false`. Other monitoring -failures can fail normal provisioning; there is no second best-effort -deployment or agent deployment repair hook. +**Impact on a shared virtual network.** The Azure Monitor private DNS zones and +Private Link Scope change Azure Monitor routing for the whole virtual network, +not only this project. With ingestion Private Only, any workload in the network +(or in a network sharing its DNS zones) can send telemetry only to Application +Insights or Log Analytics resources that are in the scope; telemetry to +resources outside it stops arriving. A virtual network can use only one Private +Link Scope, so to keep other workloads' telemetry, add their Application +Insights components or Log Analytics workspaces as scoped resources of the +Private Link Scope that azd created. To avoid any change to Azure Monitor +routing, set `monitoring.enabled: false`. Check for existing workloads before +you provision. 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, From 6b1e6334f78fdd6757e7eb6be4029e7388ec7689 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 16:25:21 -0800 Subject: [PATCH 10/23] Support private monitoring on managed egress Add an ampls-monitor-rule outbound rule to the managed network, grant the account identity the Network Connection Approver role, and stop skipping monitoring for managed egress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 6 ++- .../extensions/azure.ai.projects/README.md | 11 ++-- .../provisioning/monitoring_preflight.go | 9 ---- .../provisioning/monitoring_preflight_test.go | 2 +- .../synthesis/hosting_test.go | 3 +- .../synthesis/monitoring_private_link_test.go | 7 ++- .../synthesis/monitoring_test.go | 3 -- .../synthesis/synthesizer.go | 5 +- .../templates/existing-project.arm.json | 41 ++++++++++++++- .../synthesis/templates/main.arm.json | 52 +++++++++++++++++-- .../synthesis/templates/main.bicep | 10 ++-- .../templates/modules/monitoring.bicep | 33 ++++++++++++ 12 files changed, 149 insertions(+), 33 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 68fd23e7cd7..68190a5f040 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,11 +8,13 @@ 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 works only for a private network that uses your own agent subnet (BYO egress, `agentSubnet` set). For a component azd creates, azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. +Automatic Application Insights monitoring works for a private network with your own agent subnet (BYO egress, `agentSubnet` set) and for managed egress (see below). For a component azd creates, azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. Provisioning is never blocked by monitoring in unsupported cases. With your own agent subnet you can also connect an existing Application Insights resource: azd creates its own Private Link Scope, private endpoint and DNS zones and links the component and its workspace to the scope without modifying the component (local authentication must stay enabled). Because azd does not own the component, it cannot re-update it; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. -Managed egress (no `agentSubnet`), shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). +Shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). + +With managed egress (no `agentSubnet`), azd also adds an `ampls-monitor-rule` private-endpoint outbound rule (subresource `azuremonitor`) to the managed network so the agent runtime reaches the Private Link Scope, and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoint is approved. If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Managed egress is verified by a live run only after the outbound rule is confirmed to reach the scope. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 0bd9e959f9b..22a3ab2a0ea 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -207,11 +207,16 @@ modifying the component. The component must keep local authentication enabled. azd does not re-update a component it does not own; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. -Unsupported private-network cases never block provisioning: managed egress, -shared private DNS zones (`network.dns`), a virtual network that already has an +Unsupported private-network cases never block provisioning: shared private DNS zones (`network.dns`), a virtual network that already has an Azure Monitor private endpoint (azd does not adopt an existing scope or endpoint), or a network azd cannot inspect skip automatic monitoring and azd -prints a warning (also on stderr). Ejected or hand-authored Bicep that bypasses +prints a warning (also on stderr). With managed egress (no `agentSubnet`), azd +also adds an `ampls-monitor-rule` outbound rule to the managed network so the +agent runtime reaches the Private Link Scope through a managed private endpoint, +and grants the account identity the Azure AI Enterprise Network Connection +Approver role on the resource group so the endpoint is approved. If you set no +`isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. +Ejected or hand-authored Bicep that bypasses azd's checks never creates the private path unless `monitoringPrivateLinkApproved` is set. Set `monitoring.enabled: false` to opt out silently. diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index 948c7cdb621..cea8b9e9e51 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -91,9 +91,6 @@ func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( resolver *synthesis.MonitoringResolver, previous *monitoringTargetBinding, ) (string, error) { - if managed, _ := templateParameter(source, "useManagedEgress"); managed == true { - return "managed egress cannot reach a private Azure Monitor endpoint in your virtual network", nil - } ownedScopeID := p.ownedPrivateLinkScope(source, previous) owned := ownedScopeID != "" || (previous != nil && previous.owned) // Skipping would record monitoringMode none and drop the binding of an owned graph. @@ -174,12 +171,6 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if mode != "create" && mode != "reuse-connect" && mode != "already-connected" { return fmt.Errorf("monitoringMode must be none, create, reuse-connect, or already-connected") } - if managed, _ := templateParameter(source, "useManagedEgress"); managed == true && monitoringPrivateNetwork(source) && - (mode == "create" || mode == "reuse-connect") { - skipPrivateMonitoring(source, progress, - "managed egress cannot reach a private Azure Monitor endpoint in your virtual network") - return nil - } componentID, err := monitoringParameter(source, "applicationInsightsResourceId") if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index 0587bb5450a..6f3730ae697 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -550,7 +550,7 @@ func TestMonitoringPreflightPrivateNetworkGates(t *testing.T) { wantMode string approved bool }{ - {name: "managed egress is skipped", extra: map[string]any{"useManagedEgress": true}, wantMode: "none"}, + {name: "managed egress is verified and approved", extra: map[string]any{"useManagedEgress": true}, declared: true, wantMode: "reuse-connect", approved: true}, {name: "shared DNS zones are skipped", extra: map[string]any{"dnsZonesResourceGroup": "dns-rg"}, wantMode: "none"}, {name: "verified network is approved", declared: true, wantMode: "reuse-connect", approved: true}, {name: "template without the gate is left alone", wantMode: "reuse-connect"}, diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go index 6e5de45949f..65b43d8cc8d 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go @@ -383,7 +383,8 @@ func TestHostingGraphUsesOneNativeRegistry(t *testing.T) { assert.Equal(t, 2, identities) assert.Equal(t, 1, storages) assert.Equal(t, 1, connections) - assert.Equal(t, 5, roleTemplates, "the cluster role template is instantiated for both AKS roles") + // Includes the monitoring module's managed-egress network approver assignment. + assert.Equal(t, 6, roleTemplates, "the cluster role template is instantiated for both AKS roles") assert.Equal(t, 1, clusterGrantModules) text := string(raw) for _, required := range []string{ diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go index 5576a3e2c12..c8d261ba6ec 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -96,13 +96,16 @@ func TestPrivateMonitoringTemplateGates(t *testing.T) { return string(raw) } main := read("templates/main.bicep") - // Without azd's verified-network approval, or on managed egress, the private path is never created. + // Without azd's verified-network approval the private path is never created; managed egress adds an outbound rule. assert.Contains(t, main, "param monitoringPrivateLinkApproved bool = false") - assert.Contains(t, main, "enableNetworkIsolation && (useManagedEgress || !monitoringPrivateLinkApproved)") + assert.Contains(t, main, "enableNetworkIsolation && !monitoringPrivateLinkApproved") + assert.Contains(t, main, "managedEgressRule: managedMonitoringRule") assert.Contains(t, main, "module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'none')") assert.Contains(t, main, "output AZD_MONITORING_PRIVATE_LINK_SCOPE_ID") // The workspace closes public ingestion like the component does. monitoring := read("templates/modules/monitoring.bicep") + assert.Contains(t, monitoring, "ampls-monitor-rule") + assert.Contains(t, monitoring, "subresourceTarget: 'azuremonitor'") workspace := monitoring[strings.Index(monitoring, "resource workspace"):strings.Index(monitoring, "resource component")] assert.Contains(t, workspace, "publicNetworkAccessForIngestion: publicNetworkAccess") assert.Contains(t, workspace, "publicNetworkAccessForQuery: 'Enabled'") diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index 44085ced9eb..6eab676a19d 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -166,9 +166,6 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { }) require.NoError(t, err) wantMode, wantWarning := tt.wantMode, tt.wantWarning - if networkName == "managed egress" && (wantMode == "create" || wantMode == "reuse-connect") { - wantMode, wantWarning = "none", true - } assert.Equal(t, wantMode, result.Parameters["monitoringMode"]) if wantMode == "reuse-connect" { assert.Equal(t, component, result.Parameters["applicationInsightsResourceId"]) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 230b0853e06..09a8374774f 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -345,7 +345,8 @@ func Synthesize(in Input) (*Result, error) { var warnings []string monitoringMode, _ := monitoring["monitoringMode"].(string) if netMode != NetworkModeNone && monitoringMode != "" && monitoringMode != "none" && - !(netMode == NetworkModeByo && (monitoringMode == "create" || monitoringMode == "reuse-connect")) { + !((netMode == NetworkModeByo || netMode == NetworkModeManaged) && + (monitoringMode == "create" || monitoringMode == "reuse-connect")) { // This private-network setup is not supported yet; skip rather than fail provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} warnings = append(warnings, PrivateNetworkMonitoringWarning) @@ -374,7 +375,7 @@ func Synthesize(in Input) (*Result, error) { // PrivateNetworkMonitoringWarning is shown when automatic monitoring is skipped for a private network. const PrivateNetworkMonitoringWarning = "Automatic Application Insights monitoring does not support this " + - "private-network configuration (managed egress), so monitoring was not " + + "private-network configuration, so monitoring was not " + "configured. Agent traces will not be collected automatically." // GreenfieldACRMode distinguishes selectors from deployed outputs. diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 7dad5ae32ea..96fa44b3a3d 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "6131771428442677627" + "templateHash": "13623801948864660547" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "4682671837567762900" + "templateHash": "9994627183557504386" } }, "parameters": { @@ -871,9 +871,17 @@ "dnsZonesSubscription": { "type": "string", "defaultValue": "" + }, + "managedEgressRule": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope." + } } }, "variables": { + "networkConnectionApproverRoleId": "b556d68e-0be0-4f35-a333-ad7ee1ce17ea", "createResources": "[equals(parameters('mode'), 'create')]", "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", @@ -917,6 +925,35 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('networkConnectionApproverRoleId'))]", + "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/ampls-monitor-rule', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]", + "subresourceTarget": "azuremonitor" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" + ] + }, { "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index 14d7520781c..03d8c936e6a 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "16542737369431655029" + "templateHash": "3163459157451933935" } }, "definitions": { @@ -336,8 +336,10 @@ } }, "variables": { - "privateMonitoringBlocked": "[and(and(parameters('enableNetworkIsolation'), or(parameters('useManagedEgress'), not(parameters('monitoringPrivateLinkApproved')))), or(equals(parameters('monitoringMode'), 'create'), equals(parameters('monitoringMode'), 'reuse-connect')))]", - "effectiveMonitoringMode": "[if(variables('privateMonitoringBlocked'), 'none', parameters('monitoringMode'))]" + "privateMonitoringBlocked": "[and(and(parameters('enableNetworkIsolation'), not(parameters('monitoringPrivateLinkApproved'))), or(equals(parameters('monitoringMode'), 'create'), equals(parameters('monitoringMode'), 'reuse-connect')))]", + "effectiveMonitoringMode": "[if(variables('privateMonitoringBlocked'), 'none', parameters('monitoringMode'))]", + "managedMonitoringRule": "[and(and(parameters('enableNetworkIsolation'), parameters('useManagedEgress')), or(equals(variables('effectiveMonitoringMode'), 'create'), equals(variables('effectiveMonitoringMode'), 'reuse-connect')))]", + "effectiveManagedIsolationMode": "[if(and(variables('managedMonitoringRule'), empty(parameters('managedIsolationMode'))), 'AllowInternetOutbound', parameters('managedIsolationMode'))]" }, "resources": { "resourceGroup": { @@ -431,7 +433,7 @@ "value": "[parameters('createPESubnet')]" }, "managedIsolationMode": { - "value": "[parameters('managedIsolationMode')]" + "value": "[variables('effectiveManagedIsolationMode')]" }, "dnsZonesResourceGroup": { "value": "[parameters('dnsZonesResourceGroup')]" @@ -2519,6 +2521,9 @@ "peSubnetId": { "value": "[reference('resources').outputs.NETWORK_PE_SUBNET_ID.value]" }, + "managedEgressRule": { + "value": "[variables('managedMonitoringRule')]" + }, "dnsZonesResourceGroup": { "value": "[parameters('dnsZonesResourceGroup')]" }, @@ -2533,7 +2538,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "4682671837567762900" + "templateHash": "9994627183557504386" } }, "parameters": { @@ -2601,9 +2606,17 @@ "dnsZonesSubscription": { "type": "string", "defaultValue": "" + }, + "managedEgressRule": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope." + } } }, "variables": { + "networkConnectionApproverRoleId": "b556d68e-0be0-4f35-a333-ad7ee1ce17ea", "createResources": "[equals(parameters('mode'), 'create')]", "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", @@ -2647,6 +2660,35 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('networkConnectionApproverRoleId'))]", + "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/ampls-monitor-rule', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]", + "subresourceTarget": "azuremonitor" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" + ] + }, { "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 1a3629f8736..74e617547ac 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -160,9 +160,12 @@ param dnsZonesSubscription string = '' @description('Set by azd after it verified the VNet has no conflicting Azure Monitor private endpoint. A network-isolated deployment without it never creates the private monitoring path.') param monitoringPrivateLinkApproved bool = false -// Private monitoring needs a customer VNet (not managed egress) and a verified network. -var privateMonitoringBlocked = enableNetworkIsolation && (useManagedEgress || !monitoringPrivateLinkApproved) && (monitoringMode == 'create' || monitoringMode == 'reuse-connect') +// Private monitoring needs a verified network. Managed egress reaches the private scope through a managed-network outbound rule. +var privateMonitoringBlocked = enableNetworkIsolation && !monitoringPrivateLinkApproved && (monitoringMode == 'create' || monitoringMode == 'reuse-connect') var effectiveMonitoringMode = privateMonitoringBlocked ? 'none' : monitoringMode +var managedMonitoringRule = enableNetworkIsolation && useManagedEgress && (effectiveMonitoringMode == 'create' || effectiveMonitoringMode == 'reuse-connect') +// The outbound rule needs the managed network resource, so an unset mode defaults to AllowInternetOutbound. +var effectiveManagedIsolationMode = managedMonitoringRule && empty(managedIsolationMode) ? 'AllowInternetOutbound' : managedIsolationMode // Resources @@ -200,7 +203,7 @@ module resources 'modules/resources.bicep' = { peSubnetName: peSubnetName peSubnetPrefix: peSubnetPrefix createPESubnet: createPESubnet - managedIsolationMode: managedIsolationMode + managedIsolationMode: effectiveManagedIsolationMode dnsZonesResourceGroup: dnsZonesResourceGroup dnsZonesSubscription: dnsZonesSubscription } @@ -223,6 +226,7 @@ module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'n vnetId: resources.outputs.NETWORK_VNET_ID vnetLocation: resources.outputs.NETWORK_VNET_LOCATION peSubnetId: resources.outputs.NETWORK_PE_SUBNET_ID + managedEgressRule: managedMonitoringRule dnsZonesResourceGroup: dnsZonesResourceGroup dnsZonesSubscription: dnsZonesSubscription } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 3853400c8b2..75513a69555 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -24,6 +24,12 @@ param peSubnetId string = '' param dnsZonesResourceGroup string = '' param dnsZonesSubscription string = '' +@description('When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope.') +param managedEgressRule bool = false + +// Azure AI Enterprise Network Connection Approver: lets the account approve the managed private endpoint. +var networkConnectionApproverRoleId = 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea' + var createResources = mode == 'create' var publicNetworkAccess = privateNetwork ? 'Disabled' : 'Enabled' var token = uniqueString(projectResourceId, resourceGroup().id, resourceTokenSalt) @@ -78,6 +84,33 @@ module privateLink 'monitor-private-link.bicep' = if ((createResources || mode = } } +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (managedEgressRule) { + name: projectParts[8] +} + +resource networkApproverAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedEgressRule) { + name: guid(resourceGroup().id, projectResourceId, networkConnectionApproverRoleId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', networkConnectionApproverRoleId) + principalId: foundryAccount!.identity.principalId + principalType: 'ServicePrincipal' + } +} + +#disable-next-line BCP081 +resource managedNetworkRule 'Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview' = if (managedEgressRule) { + name: '${projectParts[8]}/default/ampls-monitor-rule' + properties: { + type: 'PrivateEndpoint' + destination: { + serviceResourceId: privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId + subresourceTarget: 'azuremonitor' + } + category: 'UserDefined' + } + dependsOn: [networkApproverAssignment] +} + module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork) { name: 'foundry-monitoring-resync' params: { From 1ae333f94776829fea42bf04562fb8679826d91d Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 16:34:28 -0800 Subject: [PATCH 11/23] Fix spelling in managed egress monitoring docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.agents/docs/private-networking.md | 2 +- cli/azd/extensions/azure.ai.projects/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 68190a5f040..596947a36ce 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -14,7 +14,7 @@ Provisioning is never blocked by monitoring in unsupported cases. With your own Shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). -With managed egress (no `agentSubnet`), azd also adds an `ampls-monitor-rule` private-endpoint outbound rule (subresource `azuremonitor`) to the managed network so the agent runtime reaches the Private Link Scope, and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoint is approved. If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Managed egress is verified by a live run only after the outbound rule is confirmed to reach the scope. +With managed egress (no `agentSubnet`), azd also adds a private-endpoint outbound rule (target `azuremonitor`) to the managed network so the agent runtime reaches the Private Link Scope, and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoint is approved. If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Managed egress is verified by a live run only after the outbound rule is confirmed to reach the scope. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 22a3ab2a0ea..d1eec716ccc 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -211,7 +211,7 @@ Unsupported private-network cases never block provisioning: shared private DNS z Azure Monitor private endpoint (azd does not adopt an existing scope or endpoint), or a network azd cannot inspect skip automatic monitoring and azd prints a warning (also on stderr). With managed egress (no `agentSubnet`), azd -also adds an `ampls-monitor-rule` outbound rule to the managed network so the +also adds an outbound rule to the managed network so the agent runtime reaches the Private Link Scope through a managed private endpoint, and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the endpoint is approved. If you set no From 5d0689ff3f072d9b9b09a23a3bf26eab5f23c195 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 17:40:15 -0800 Subject: [PATCH 12/23] Include principal in managed-egress network approver role assignment name Deploy the assignment through a module so the GUID is derived from scope, principal and role, avoiding RoleAssignmentUpdateNotPermitted when the account is recreated with the same project ID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../templates/existing-project.arm.json | 68 +++++++++++++++---- .../synthesis/templates/main.arm.json | 68 +++++++++++++++---- .../templates/modules/monitoring.bicep | 9 ++- .../network-approver-role-assignment.bicep | 13 ++++ 4 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 96fa44b3a3d..4896fdeab17 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13623801948864660547" + "templateHash": "7215862744136170496" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "9994627183557504386" + "templateHash": "5920771964239170690" } }, "parameters": { @@ -925,17 +925,6 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, - { - "condition": "[parameters('managedEgressRule')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId'))]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('networkConnectionApproverRoleId'))]", - "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, { "condition": "[parameters('managedEgressRule')]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", @@ -950,7 +939,7 @@ "category": "UserDefined" }, "dependsOn": [ - "[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-network-approver')]", "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" ] }, @@ -1197,6 +1186,57 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-network-approver", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]" + }, + "roleDefinitionId": { + "value": "[variables('networkConnectionApproverRoleId')]" + } + }, + "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": "785343973509457050" + } + }, + "parameters": { + "principalId": { + "type": "string" + }, + "roleDefinitionId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('principalId'), parameters('roleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]" + } + } + ] + } + } + }, { "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index 03d8c936e6a..66c3583a4f2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3163459157451933935" + "templateHash": "6420297895380766764" } }, "definitions": { @@ -2538,7 +2538,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "9994627183557504386" + "templateHash": "5920771964239170690" } }, "parameters": { @@ -2660,17 +2660,6 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, - { - "condition": "[parameters('managedEgressRule')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId'))]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('networkConnectionApproverRoleId'))]", - "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, { "condition": "[parameters('managedEgressRule')]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", @@ -2685,7 +2674,7 @@ "category": "UserDefined" }, "dependsOn": [ - "[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, parameters('projectResourceId'), variables('networkConnectionApproverRoleId')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-network-approver')]", "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" ] }, @@ -2932,6 +2921,57 @@ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-network-approver", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]" + }, + "roleDefinitionId": { + "value": "[variables('networkConnectionApproverRoleId')]" + } + }, + "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": "785343973509457050" + } + }, + "parameters": { + "principalId": { + "type": "string" + }, + "roleDefinitionId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('principalId'), parameters('roleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]" + } + } + ] + } + } + }, { "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 75513a69555..3bb2ddbd138 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -88,12 +88,11 @@ resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existi name: projectParts[8] } -resource networkApproverAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedEgressRule) { - name: guid(resourceGroup().id, projectResourceId, networkConnectionApproverRoleId) - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', networkConnectionApproverRoleId) +module networkApproverAssignment 'network-approver-role-assignment.bicep' = if (managedEgressRule) { + name: 'foundry-monitoring-network-approver' + params: { principalId: foundryAccount!.identity.principalId - principalType: 'ServicePrincipal' + roleDefinitionId: networkConnectionApproverRoleId } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep new file mode 100644 index 00000000000..6acfc8d4ab0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep @@ -0,0 +1,13 @@ +targetScope = 'resourceGroup' + +param principalId string +param roleDefinitionId string + +resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} From ed321206f1087b0182b878d870377ea09fb39e03 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 19:19:59 -0800 Subject: [PATCH 13/23] Include network-approver-role-assignment module in Bicep ejects monitoring.bicep references the module, so both eject paths must copy it or compilation fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go | 4 ++++ .../azure.ai.projects/internal/cmd/project_existing_infra.go | 1 + 2 files changed, 5 insertions(+) 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 b3266209e5c..591ca2093c4 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 @@ -1305,6 +1305,10 @@ func writeExistingProjectBicepTemplates( {"templates/modules/monitoring-connection.bicep", "modules/monitoring-connection.bicep"}, {"templates/modules/monitor-private-link.bicep", "modules/monitor-private-link.bicep"}, {"templates/modules/monitoring-resync.bicep", "modules/monitoring-resync.bicep"}, + { + "templates/modules/network-approver-role-assignment.bicep", + "modules/network-approver-role-assignment.bicep", + }, } if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) { registrySource, err := fs.ReadFile( 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 33cc2a11534..4c52f48dcea 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 @@ -973,6 +973,7 @@ func writeExistingProjectBicep( } for _, name := range []string{ "monitoring.bicep", "monitoring-connection.bicep", "monitor-private-link.bicep", "monitoring-resync.bicep", + "network-approver-role-assignment.bicep", } { data, err := fs.ReadFile(synthesis.TemplatesFS(), "templates/modules/"+name) if err != nil { From 4238d9989470b9b57b396b1d97da4b41da4fc23b Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Fri, 6 Nov 2026 19:36:14 -0800 Subject: [PATCH 14/23] Add account self private endpoint rule for managed-egress hosted agents Hosted agents fail with 'Public access is disabled' under a managed network unless the account has an outbound private endpoint rule to itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../synthesis/monitoring_private_link_test.go | 2 ++ .../templates/existing-project.arm.json | 21 +++++++++++++++++-- .../synthesis/templates/main.arm.json | 21 +++++++++++++++++-- .../templates/modules/monitoring.bicep | 16 ++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go index c8d261ba6ec..a7f15b71373 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -105,6 +105,8 @@ func TestPrivateMonitoringTemplateGates(t *testing.T) { // The workspace closes public ingestion like the component does. monitoring := read("templates/modules/monitoring.bicep") assert.Contains(t, monitoring, "ampls-monitor-rule") + assert.Contains(t, monitoring, "foundry-account-pe") + assert.Contains(t, monitoring, "subresourceTarget: 'account'") assert.Contains(t, monitoring, "subresourceTarget: 'azuremonitor'") workspace := monitoring[strings.Index(monitoring, "resource workspace"):strings.Index(monitoring, "resource component")] assert.Contains(t, workspace, "publicNetworkAccessForIngestion: publicNetworkAccess") diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 4896fdeab17..d17302c2955 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "7215862744136170496" + "templateHash": "19133302460120456" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "5920771964239170690" + "templateHash": "11506587318951603642" } }, "parameters": { @@ -943,6 +943,23 @@ "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/foundry-account-pe', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8])]", + "subresourceTarget": "account" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules', split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[0], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[1], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[2])]" + ] + }, { "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index 66c3583a4f2..2d1d4757c0f 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "6420297895380766764" + "templateHash": "842033600340647321" } }, "definitions": { @@ -2538,7 +2538,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "5920771964239170690" + "templateHash": "11506587318951603642" } }, "parameters": { @@ -2678,6 +2678,23 @@ "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/foundry-account-pe', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8])]", + "subresourceTarget": "account" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules', split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[0], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[1], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[2])]" + ] + }, { "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", "type": "Microsoft.Resources/deployments", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 3bb2ddbd138..2e3c507f9f2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -110,6 +110,22 @@ resource managedNetworkRule 'Microsoft.CognitiveServices/accounts/managedNetwork dependsOn: [networkApproverAssignment] } +// Hosted agents call back into the account from the managed network; without a +// private endpoint rule to the account itself they fail with "Public access is disabled". +#disable-next-line BCP081 +resource accountSelfRule 'Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview' = if (managedEgressRule) { + name: '${projectParts[8]}/default/foundry-account-pe' + properties: { + type: 'PrivateEndpoint' + destination: { + serviceResourceId: foundryAccount.id + subresourceTarget: 'account' + } + category: 'UserDefined' + } + dependsOn: [managedNetworkRule] +} + module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork) { name: 'foundry-monitoring-resync' params: { From f75cce29f44f4f166fe156e28fba0717b33dbfc7 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Sat, 7 Nov 2026 08:08:12 -0800 Subject: [PATCH 15/23] Document managed-egress self private endpoint rule and validation status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.agents/docs/private-networking.md | 7 ++++++- cli/azd/extensions/azure.ai.projects/README.md | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 596947a36ce..fc54a25c8df 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -14,7 +14,12 @@ Provisioning is never blocked by monitoring in unsupported cases. With your own Shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). -With managed egress (no `agentSubnet`), azd also adds a private-endpoint outbound rule (target `azuremonitor`) to the managed network so the agent runtime reaches the Private Link Scope, and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoint is approved. If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Managed egress is verified by a live run only after the outbound rule is confirmed to reach the scope. +With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbound rules to the managed network and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoints are approved: + +- `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. +- `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. + +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network, but only after both rules existed and the hosted agent's identity was granted `Monitoring Metrics Publisher` on the Application Insights component by hand. azd does not grant that role yet, so hosted-agent telemetry export can fail with 403 until you grant it. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index d1eec716ccc..f571881ed0c 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -211,10 +211,15 @@ Unsupported private-network cases never block provisioning: shared private DNS z Azure Monitor private endpoint (azd does not adopt an existing scope or endpoint), or a network azd cannot inspect skip automatic monitoring and azd prints a warning (also on stderr). With managed egress (no `agentSubnet`), azd -also adds an outbound rule to the managed network so the -agent runtime reaches the Private Link Scope through a managed private endpoint, -and grants the account identity the Azure AI Enterprise Network Connection -Approver role on the resource group so the endpoint is approved. If you set no +also adds two outbound rules to the managed network and grants the account +identity the Azure AI Enterprise Network Connection Approver role on the +resource group so the managed private endpoints are approved: +`ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the +Private Link Scope, and `foundry-account-pe` (target `account`, to the account +itself) is required for hosted agents, which otherwise fail with +`Public access is disabled`. The hosted agent's own identity also needs +`Monitoring Metrics Publisher` on the Application Insights component to export +telemetry; azd does not grant that role yet. If you set no `isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. Ejected or hand-authored Bicep that bypasses azd's checks never creates the private path unless From 6eb19dd8eff48a123185f4dfb260e992828d3bc1 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Sat, 7 Nov 2026 08:12:39 -0800 Subject: [PATCH 16/23] Add ampls to agents extension cspell words Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 6109ff2806d..67641454f0b 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -74,6 +74,7 @@ words: - abbrs - acrpush - alnum + - ampls - azuremonitor - foundryproject - agentignore From 208fee0197fbd500f2db7a5e28d9848f8032027f Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 08:30:28 -0800 Subject: [PATCH 17/23] Canonicalize and clean up the owned Private Link Scope output; block owned envs on unresolved vnetId Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../provisioning/foundry_provisioning_provider.go | 2 ++ .../provisioning/foundry_provisioning_provider_test.go | 9 +++++++++ .../internal/provisioning/monitoring_preflight.go | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) 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 6c8db57c14e..2e0aee8f75f 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 @@ -2467,6 +2467,7 @@ var canonicalOutputNames = []string{ "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", } var greenfieldOutputNames = []string{ @@ -2487,6 +2488,7 @@ var greenfieldOutputNames = []string{ "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", } var existingProjectOutputNames = []string{ 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 a6287b4ae53..eba38a858f8 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 @@ -621,6 +621,15 @@ func TestCanonicalizeOutputName(t *testing.T) { "unknown name must pass through verbatim, not silently dropped") } +func TestOwnedPrivateLinkScopeOutputIsCanonicalAndCleaned(t *testing.T) { + t.Parallel() + + name := synthesis.OwnedPrivateLinkScopeOutput + assert.Equal(t, name, canonicalizeOutputName("azD_MONITORING_PRIVATE_LINK_SCOPE_ID")) + assert.Contains(t, greenfieldOutputNames, name) + assert.Contains(t, monitoringOutputKeys(), name) +} + // mapKeys returns a sorted list of keys for stable assert messages. func mapKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go index cea8b9e9e51..9c69081d5db 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -26,6 +26,7 @@ func monitoringOutputKeys() []string { "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + synthesis.OwnedPrivateLinkScopeOutput, } } @@ -106,7 +107,7 @@ func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( } vnetID, err := monitoringParameter(source, "vnetId") if err != nil { - return err.Error(), nil + return ownedBlock(err.Error()) } if vnetID == "" { return "", nil From 5ecc51434939eb6f2e096f947d869aacd34e1ed8 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 11:03:43 -0800 Subject: [PATCH 18/23] Grant hosted agent identity Monitoring Metrics Publisher on deploy Hosted agents export telemetry with their instance identity, which only exists after the agent version is created. Grant it Monitoring Metrics Publisher on the azd-managed App Insights component at deploy time (best effort, with a manual command on failure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 +- .../project/monitoring_publisher_rbac.go | 120 ++++++++++++++++++ .../project/monitoring_publisher_rbac_test.go | 114 +++++++++++++++++ .../internal/project/service_target_agent.go | 5 + .../extensions/azure.ai.projects/README.md | 4 +- 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go 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 fc54a25c8df..081697d6220 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -19,7 +19,7 @@ With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbo - `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. - `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. -If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network, but only after both rules existed and the hosted agent's identity was granted `Monitoring Metrics Publisher` on the Application Insights component by hand. azd does not grant that role yet, so hosted-agent telemetry export can fail with 403 until you grant it. +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component. This is best effort: if the grant fails, azd prints the `az role assignment create` command, and hosted-agent export returns 403 until the role exists. Role propagation can take several minutes. In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network once both rules existed and the identity had this role. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go new file mode 100644 index 00000000000..bf11a92fe27 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" +) + +const roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" + +const monitoringGrantAttempts = 4 + +// monitoringGrantRetryDelay is a variable so tests can avoid waiting. A new instance identity +// can take a short while to replicate before ARM accepts it as a role assignment principal. +var monitoringGrantRetryDelay = 15 * time.Second + +type monitoringRoleAssigner func( + ctx context.Context, + principalID, roleID, roleName, scope string, + principalType armauthorization.PrincipalType, +) (bool, error) + +// azdManagedMonitoringComponentID returns the App Insights component azd wired to the project, +// or "" when azd did not provision or connect monitoring for this environment. +func azdManagedMonitoringComponentID(azdEnv map[string]string) string { + if strings.TrimSpace(azdEnv["AZD_FOUNDRY_MONITORING_MODE"]) == "" { + return "" + } + return strings.TrimSpace(azdEnv["AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"]) +} + +// grantHostedAgentMonitoringPublisher lets a hosted agent's instance identity publish telemetry to the +// project's App Insights component. The identity only exists after the agent version is created, so +// this cannot be done in the provisioning template. It is best effort: a failure prints a manual +// command and does not fail the deploy. +func grantHostedAgentMonitoringPublisher( + ctx context.Context, + assign monitoringRoleAssigner, + azdEnv map[string]string, + principalID string, +) { + componentID := azdManagedMonitoringComponentID(azdEnv) + principalID = strings.TrimSpace(principalID) + if componentID == "" || principalID == "" { + return + } + + var lastErr error + for attempt := 1; attempt <= monitoringGrantAttempts; attempt++ { + _, err := assign( + ctx, principalID, roleMonitoringMetricsPublisher, "Monitoring Metrics Publisher", + componentID, armauthorization.PrincipalTypeServicePrincipal, + ) + if err == nil { + return + } + lastErr = err + if !strings.Contains(err.Error(), "PrincipalNotFound") || attempt == monitoringGrantAttempts { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(monitoringGrantRetryDelay): + } + } + + _, _ = fmt.Fprintf( + os.Stderr, + "Warning: could not grant the agent identity Monitoring Metrics Publisher on %s: %v\n"+ + "Traces from the hosted agent are rejected with 403 until it has the role. Grant it with:\n"+ + " az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+ + "--role \"Monitoring Metrics Publisher\" --scope %s\n", + componentID, lastErr, principalID, componentID, + ) +} + +func newMonitoringRoleAssigner(credential azcore.TokenCredential, componentID string) (monitoringRoleAssigner, error) { + client, err := armauthorization.NewRoleAssignmentsClient( + extractSubscriptionID(componentID), credential, azure.NewArmClientOptions(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create role assignments client: %w", err) + } + return func( + ctx context.Context, + principalID, roleID, roleName, scope string, + principalType armauthorization.PrincipalType, + ) (bool, error) { + return assignRoleToIdentityWithClient(ctx, client, principalID, roleID, roleName, scope, principalType) + }, nil +} + +// ensureHostedAgentMonitoringPublisher wires the real ARM client into grantHostedAgentMonitoringPublisher. +func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( + ctx context.Context, + azdEnv map[string]string, + principalID string, +) { + componentID := azdManagedMonitoringComponentID(azdEnv) + if componentID == "" || strings.TrimSpace(principalID) == "" { + return + } + assign, err := newMonitoringRoleAssigner(p.credential, componentID) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: could not grant Monitoring Metrics Publisher: %v\n", err) + return + } + grantHostedAgentMonitoringPublisher(ctx, assign, azdEnv, principalID) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go new file mode 100644 index 00000000000..b07135bbc2d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + publisherTestComponent = "/subscriptions/sub-123/resourceGroups/rg/providers/microsoft.insights/components/appi" + publisherTestPrincipal = "11111111-2222-3333-4444-555555555555" +) + +func publisherTestEnv() map[string]string { + return map[string]string{ + "AZD_FOUNDRY_MONITORING_MODE": "create", + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": publisherTestComponent, + } +} + +func TestMonitoringPublisherRoleID(t *testing.T) { + assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) +} + +func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { + calls := 0 + assign := func( + _ context.Context, principalID, roleID, _, scope string, principalType armauthorization.PrincipalType, + ) (bool, error) { + calls++ + assert.Equal(t, publisherTestPrincipal, principalID) + assert.Equal(t, roleMonitoringMetricsPublisher, roleID) + assert.Equal(t, publisherTestComponent, scope) + assert.Equal(t, armauthorization.PrincipalTypeServicePrincipal, principalType) + return true, nil + } + + grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), " "+publisherTestPrincipal+" ") + + assert.Equal(t, 1, calls) +} + +func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { + tests := map[string]struct { + env map[string]string + principal string + }{ + "no monitoring mode": { + env: map[string]string{"AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": publisherTestComponent}, + principal: publisherTestPrincipal, + }, + "no component": { + env: map[string]string{"AZD_FOUNDRY_MONITORING_MODE": "create"}, + principal: publisherTestPrincipal, + }, + "no principal": {env: publisherTestEnv(), principal: " "}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + assign := func( + context.Context, string, string, string, string, armauthorization.PrincipalType, + ) (bool, error) { + t.Fatal("role must not be assigned") + return false, nil + } + grantHostedAgentMonitoringPublisher(t.Context(), assign, tt.env, tt.principal) + }) + } +} + +func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing.T) { + previous := monitoringGrantRetryDelay + monitoringGrantRetryDelay = time.Millisecond + t.Cleanup(func() { monitoringGrantRetryDelay = previous }) + + calls := 0 + assign := func( + context.Context, string, string, string, string, armauthorization.PrincipalType, + ) (bool, error) { + calls++ + if calls < 3 { + return false, errors.New("PrincipalNotFound: principal does not exist") + } + return true, nil + } + + grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), publisherTestPrincipal) + + assert.Equal(t, 3, calls) +} + +func TestGrantHostedAgentMonitoringPublisherDoesNotRetryOtherErrors(t *testing.T) { + calls := 0 + assign := func( + context.Context, string, string, string, string, armauthorization.PrincipalType, + ) (bool, error) { + calls++ + return false, errors.New("AuthorizationFailed") + } + + require.NotPanics(t, func() { + grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), publisherTestPrincipal) + }) + + assert.Equal(t, 1, calls) +} 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 de94f846fd3..8e6c45c1153 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 @@ -1947,6 +1947,11 @@ func (p *AgentServiceTargetProvider) Deploy( } } + if identity := result.agentVersion.InstanceIdentity; identity != nil && azdManagedMonitoringComponentID(azdEnv) != "" { + progress("Granting agent identity access to Application Insights") + p.ensureHostedAgentMonitoringPublisher(ctx, azdEnv, identity.PrincipalID) + } + return p.finalizeDeploy( ctx, progress, diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index f571881ed0c..2285797165e 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -219,7 +219,9 @@ Private Link Scope, and `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents, which otherwise fail with `Public access is disabled`. The hosted agent's own identity also needs `Monitoring Metrics Publisher` on the Application Insights component to export -telemetry; azd does not grant that role yet. If you set no +telemetry. That identity exists only after the agent is deployed, so +`azd deploy` in the agents extension grants it (best effort; on failure it +prints the `az role assignment create` command). If you set no `isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. Ejected or hand-authored Bicep that bypasses azd's checks never creates the private path unless From bc46375901bf93e6b4721ee69bc365a6696d0433 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 12:41:36 -0800 Subject: [PATCH 19/23] Refresh App Insights component after granting hosted agent publisher role Application Insights caches Entra ingestion authorization, so a new Monitoring Metrics Publisher assignment returned 403 for over 20 minutes until the component was updated. Merge a tag on the component after a new assignment to force a refresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 +- .../project/monitoring_publisher_rbac.go | 37 +++++++++++++++- .../project/monitoring_publisher_rbac_test.go | 43 +++++++++++++++++-- .../extensions/azure.ai.projects/README.md | 5 ++- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 081697d6220..b8c264ec5c9 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -19,7 +19,7 @@ With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbo - `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. - `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. -If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component. This is best effort: if the grant fails, azd prints the `az role assignment create` command, and hosted-agent export returns 403 until the role exists. Role propagation can take several minutes. In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network once both rules existed and the identity had this role. +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant is best effort: if it fails, azd prints the `az role assignment create` command, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go index bf11a92fe27..f173d812f0e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -14,6 +14,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" ) const roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" @@ -30,6 +31,11 @@ type monitoringRoleAssigner func( principalType armauthorization.PrincipalType, ) (bool, error) +// monitoringComponentToucher refreshes the component after a role assignment. Application Insights +// caches Entra authorization for ingestion, and without a component update a new Monitoring Metrics +// Publisher assignment kept returning 403 for over 20 minutes in live runs. +type monitoringComponentToucher func(ctx context.Context, componentID string) error + // azdManagedMonitoringComponentID returns the App Insights component azd wired to the project, // or "" when azd did not provision or connect monitoring for this environment. func azdManagedMonitoringComponentID(azdEnv map[string]string) string { @@ -46,6 +52,7 @@ func azdManagedMonitoringComponentID(azdEnv map[string]string) string { func grantHostedAgentMonitoringPublisher( ctx context.Context, assign monitoringRoleAssigner, + touch monitoringComponentToucher, azdEnv map[string]string, principalID string, ) { @@ -57,11 +64,22 @@ func grantHostedAgentMonitoringPublisher( var lastErr error for attempt := 1; attempt <= monitoringGrantAttempts; attempt++ { - _, err := assign( + created, err := assign( ctx, principalID, roleMonitoringMetricsPublisher, "Monitoring Metrics Publisher", componentID, armauthorization.PrincipalTypeServicePrincipal, ) if err == nil { + if created && touch != nil { + if touchErr := touch(ctx, componentID); touchErr != nil { + _, _ = fmt.Fprintf( + os.Stderr, + "Warning: granted Monitoring Metrics Publisher but could not refresh %s: %v\n"+ + "Hosted-agent telemetry can return 403 for a long time until the component is updated; "+ + "changing any tag on it forces a refresh.\n", + componentID, touchErr, + ) + } + } return } lastErr = err @@ -116,5 +134,20 @@ func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( _, _ = fmt.Fprintf(os.Stderr, "Warning: could not grant Monitoring Metrics Publisher: %v\n", err) return } - grantHostedAgentMonitoringPublisher(ctx, assign, azdEnv, principalID) + touch := func(ctx context.Context, componentID string) error { + tags, err := armresources.NewTagsClient( + extractSubscriptionID(componentID), p.credential, azure.NewArmClientOptions(), + ) + if err != nil { + return err + } + _, err = tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ + Operation: new(armresources.TagsPatchOperationMerge), + Properties: &armresources.Tags{Tags: map[string]*string{ + "azd-monitoring-role-sync": new(time.Now().UTC().Format(time.RFC3339)), + }}, + }, nil) + return err + } + grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go index b07135bbc2d..046aa3f0946 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -43,9 +43,44 @@ func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { return true, nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), " "+publisherTestPrincipal+" ") + touches := 0 + touch := func(_ context.Context, componentID string) error { + touches++ + assert.Equal(t, publisherTestComponent, componentID) + return nil + } + + grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), " "+publisherTestPrincipal+" ") assert.Equal(t, 1, calls) + assert.Equal(t, 1, touches) +} + +func TestGrantHostedAgentMonitoringPublisherTouchesOnlyNewAssignments(t *testing.T) { + assign := func( + context.Context, string, string, string, string, armauthorization.PrincipalType, + ) (bool, error) { + return false, nil + } + touch := func(context.Context, string) error { + t.Fatal("component must not be touched when the role already existed") + return nil + } + + grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) +} + +func TestGrantHostedAgentMonitoringPublisherTouchFailureIsNonFatal(t *testing.T) { + assign := func( + context.Context, string, string, string, string, armauthorization.PrincipalType, + ) (bool, error) { + return true, nil + } + touch := func(context.Context, string) error { return errors.New("denied") } + + require.NotPanics(t, func() { + grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) + }) } func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { @@ -71,7 +106,7 @@ func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { t.Fatal("role must not be assigned") return false, nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, tt.env, tt.principal) + grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, tt.env, tt.principal) }) } } @@ -92,7 +127,7 @@ func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing. return true, nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), publisherTestPrincipal) + grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) assert.Equal(t, 3, calls) } @@ -107,7 +142,7 @@ func TestGrantHostedAgentMonitoringPublisherDoesNotRetryOtherErrors(t *testing.T } require.NotPanics(t, func() { - grantHostedAgentMonitoringPublisher(t.Context(), assign, publisherTestEnv(), publisherTestPrincipal) + grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) }) assert.Equal(t, 1, calls) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 2285797165e..03f9133c7c7 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -220,8 +220,9 @@ itself) is required for hosted agents, which otherwise fail with `Public access is disabled`. The hosted agent's own identity also needs `Monitoring Metrics Publisher` on the Application Insights component to export telemetry. That identity exists only after the agent is deployed, so -`azd deploy` in the agents extension grants it (best effort; on failure it -prints the `az role assignment create` command). If you set no +`azd deploy` in the agents extension grants it, then updates a tag on the +component so Application Insights refreshes its cached ingestion authorization +(best effort; on failure it prints the `az role assignment create` command). If you set no `isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. Ejected or hand-authored Bicep that bypasses azd's checks never creates the private path unless From fcde87864932f4f7f8abc1265bd58d8c88af4421 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 14:45:23 -0800 Subject: [PATCH 20/23] Gate hosted-agent monitoring grant to private azd-created components, honor skip opt-out, surface warnings in deploy note Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 +- .../project/monitoring_publisher_rbac.go | 115 ++++++++--- .../project/monitoring_publisher_rbac_test.go | 178 +++++++++++++----- .../internal/project/service_target_agent.go | 12 +- .../extensions/azure.ai.projects/README.md | 4 +- 5 files changed, 225 insertions(+), 86 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index b8c264ec5c9..1910fb9494f 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -19,7 +19,7 @@ With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbo - `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. - `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. -If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant is best effort: if it fails, azd prints the `az role assignment create` command, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant applies only to azd-created components on the private path (not public-network projects or components you supply), and `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` skips it. It is best effort: if it is skipped or fails, azd adds a warning with the `az role assignment create` command to the deploy note, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go index f173d812f0e..a33b822242b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) const roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" @@ -36,30 +38,60 @@ type monitoringRoleAssigner func( // Publisher assignment kept returning 403 for over 20 minutes in live runs. type monitoringComponentToucher func(ctx context.Context, componentID string) error -// azdManagedMonitoringComponentID returns the App Insights component azd wired to the project, -// or "" when azd did not provision or connect monitoring for this environment. -func azdManagedMonitoringComponentID(azdEnv map[string]string) string { - if strings.TrimSpace(azdEnv["AZD_FOUNDRY_MONITORING_MODE"]) == "" { +// privateMonitoringComponentID returns the App Insights component only when azd created it and +// connected it through the private path (Private Link Scope). Public-network projects and +// user-supplied components return "" so azd never changes their role assignments or tags. +func privateMonitoringComponentID(azdEnv map[string]string) string { + if strings.TrimSpace(azdEnv["AZD_FOUNDRY_MONITORING_MODE"]) != "create" || + strings.TrimSpace(azdEnv["AZD_MONITORING_PRIVATE_LINK_SCOPE_ID"]) == "" { return "" } return strings.TrimSpace(azdEnv["AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"]) } +// roleAssignmentsSkipped reports whether the user opted out of azd writing role assignments, using +// the same precedence as the developer RBAC check: environment value first, then the process. +func roleAssignmentsSkipped(azdEnv map[string]string) bool { + value := strings.TrimSpace(azdEnv["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"]) + if value == "" { + value = os.Getenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + } + skip, err := strconv.ParseBool(value) + return err == nil && skip +} + +func monitoringGrantCommand(principalID, componentID string) string { + return fmt.Sprintf( + "az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+ + "--role \"Monitoring Metrics Publisher\" --scope %s", + principalID, componentID, + ) +} + // grantHostedAgentMonitoringPublisher lets a hosted agent's instance identity publish telemetry to the -// project's App Insights component. The identity only exists after the agent version is created, so -// this cannot be done in the provisioning template. It is best effort: a failure prints a manual -// command and does not fail the deploy. +// project's private App Insights component. The identity only exists after the agent version is +// created, so this cannot be done in the provisioning template. +// +// It is best effort and never fails the deploy. It returns a warning for the deploy note when +// telemetry may still be rejected, or "" when nothing needs the user's attention. func grantHostedAgentMonitoringPublisher( ctx context.Context, assign monitoringRoleAssigner, touch monitoringComponentToucher, azdEnv map[string]string, principalID string, -) { - componentID := azdManagedMonitoringComponentID(azdEnv) +) string { + componentID := privateMonitoringComponentID(azdEnv) principalID = strings.TrimSpace(principalID) if componentID == "" || principalID == "" { - return + return "" + } + if roleAssignmentsSkipped(azdEnv) { + return fmt.Sprintf( + "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS is set, so azd did not grant the hosted agent identity access to "+ + "Application Insights. Hosted-agent telemetry is rejected with 403 until it has the role:\n %s", + monitoringGrantCommand(principalID, componentID), + ) } var lastErr error @@ -71,16 +103,15 @@ func grantHostedAgentMonitoringPublisher( if err == nil { if created && touch != nil { if touchErr := touch(ctx, componentID); touchErr != nil { - _, _ = fmt.Fprintf( - os.Stderr, - "Warning: granted Monitoring Metrics Publisher but could not refresh %s: %v\n"+ + return fmt.Sprintf( + "Granted the hosted agent identity Monitoring Metrics Publisher, but could not refresh %s: %v. "+ "Hosted-agent telemetry can return 403 for a long time until the component is updated; "+ - "changing any tag on it forces a refresh.\n", + "changing any tag on it forces a refresh.", componentID, touchErr, ) } } - return + return "" } lastErr = err if !strings.Contains(err.Error(), "PrincipalNotFound") || attempt == monitoringGrantAttempts { @@ -88,18 +119,15 @@ func grantHostedAgentMonitoringPublisher( } select { case <-ctx.Done(): - return + return "" case <-time.After(monitoringGrantRetryDelay): } } - _, _ = fmt.Fprintf( - os.Stderr, - "Warning: could not grant the agent identity Monitoring Metrics Publisher on %s: %v\n"+ - "Traces from the hosted agent are rejected with 403 until it has the role. Grant it with:\n"+ - " az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+ - "--role \"Monitoring Metrics Publisher\" --scope %s\n", - componentID, lastErr, principalID, componentID, + return fmt.Sprintf( + "Could not grant the hosted agent identity Monitoring Metrics Publisher on %s: %v\n"+ + "Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s", + componentID, lastErr, monitoringGrantCommand(principalID, componentID), ) } @@ -119,20 +147,24 @@ func newMonitoringRoleAssigner(credential azcore.TokenCredential, componentID st }, nil } -// ensureHostedAgentMonitoringPublisher wires the real ARM client into grantHostedAgentMonitoringPublisher. +// ensureHostedAgentMonitoringPublisher wires the real ARM clients into grantHostedAgentMonitoringPublisher +// and returns its warning for the deploy note. func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( ctx context.Context, azdEnv map[string]string, principalID string, -) { - componentID := azdManagedMonitoringComponentID(azdEnv) +) string { + componentID := privateMonitoringComponentID(azdEnv) if componentID == "" || strings.TrimSpace(principalID) == "" { - return + return "" } assign, err := newMonitoringRoleAssigner(p.credential, componentID) if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Warning: could not grant Monitoring Metrics Publisher: %v\n", err) - return + return fmt.Sprintf( + "Could not grant the hosted agent identity Monitoring Metrics Publisher: %v\n"+ + "Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s", + err, monitoringGrantCommand(strings.TrimSpace(principalID), componentID), + ) } touch := func(ctx context.Context, componentID string) error { tags, err := armresources.NewTagsClient( @@ -149,5 +181,28 @@ func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( }, nil) return err } - grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) + return grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) +} + +// appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output +// instead of subprocess stderr, which the host discards. +func appendDeployWarning(result *azdext.ServiceDeployResult, warning string) { + if result == nil || strings.TrimSpace(warning) == "" { + return + } + for i := len(result.Artifacts) - 1; i >= 0; i-- { + artifact := result.Artifacts[i] + if artifact == nil { + continue + } + if artifact.Metadata == nil { + artifact.Metadata = map[string]string{} + } + if existing := artifact.Metadata["note"]; existing != "" { + artifact.Metadata["note"] = existing + "\n\nWarning: " + warning + } else { + artifact.Metadata["note"] = "Warning: " + warning + } + return + } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go index 046aa3f0946..32d1f9c0fc0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,10 +23,18 @@ const ( func publisherTestEnv() map[string]string { return map[string]string{ "AZD_FOUNDRY_MONITORING_MODE": "create", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID": "/subscriptions/sub-123/resourceGroups/rg/providers/microsoft.insights/privateLinkScopes/ampls", "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": publisherTestComponent, } } +func neverAssign(t *testing.T) monitoringRoleAssigner { + return func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + t.Fatal("role must not be assigned") + return false, nil + } +} + func TestMonitoringPublisherRoleID(t *testing.T) { assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) } @@ -42,7 +51,6 @@ func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { assert.Equal(t, armauthorization.PrincipalTypeServicePrincipal, principalType) return true, nil } - touches := 0 touch := func(_ context.Context, componentID string) error { touches++ @@ -50,16 +58,85 @@ func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { return nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), " "+publisherTestPrincipal+" ") + warning := grantHostedAgentMonitoringPublisher( + t.Context(), assign, touch, publisherTestEnv(), " "+publisherTestPrincipal+" ", + ) + assert.Empty(t, warning) assert.Equal(t, 1, calls) assert.Equal(t, 1, touches) } +func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { + without := func(key string) map[string]string { + env := publisherTestEnv() + delete(env, key) + return env + } + tests := map[string]struct { + env map[string]string + principal string + }{ + "no monitoring mode": {env: without("AZD_FOUNDRY_MONITORING_MODE"), principal: publisherTestPrincipal}, + "no component": {env: without("AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"), principal: publisherTestPrincipal}, + "public network (no scope)": {env: without("AZD_MONITORING_PRIVATE_LINK_SCOPE_ID"), principal: publisherTestPrincipal}, + "no principal": {env: publisherTestEnv(), principal: " "}, + "user supplied component": { + env: func() map[string]string { + env := publisherTestEnv() + env["AZD_FOUNDRY_MONITORING_MODE"] = "reuse-connect" + return env + }(), + principal: publisherTestPrincipal, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + warning := grantHostedAgentMonitoringPublisher(t.Context(), neverAssign(t), nil, tt.env, tt.principal) + assert.Empty(t, warning) + }) + } +} + +func TestGrantHostedAgentMonitoringPublisherHonorsSkipRoleAssignments(t *testing.T) { + t.Run("environment value", func(t *testing.T) { + env := publisherTestEnv() + env["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"] = "true" + + warning := grantHostedAgentMonitoringPublisher(t.Context(), neverAssign(t), nil, env, publisherTestPrincipal) + + assert.Contains(t, warning, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + assert.Contains(t, warning, "az role assignment create") + assert.Contains(t, warning, publisherTestPrincipal) + }) + t.Run("process fallback", func(t *testing.T) { + t.Setenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", "1") + + warning := grantHostedAgentMonitoringPublisher( + t.Context(), neverAssign(t), nil, publisherTestEnv(), publisherTestPrincipal, + ) + + assert.Contains(t, warning, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + }) + t.Run("environment value wins over process", func(t *testing.T) { + t.Setenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", "true") + env := publisherTestEnv() + env["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"] = "false" + calls := 0 + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + calls++ + return false, nil + } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, env, publisherTestPrincipal) + + assert.Empty(t, warning) + assert.Equal(t, 1, calls) + }) +} + func TestGrantHostedAgentMonitoringPublisherTouchesOnlyNewAssignments(t *testing.T) { - assign := func( - context.Context, string, string, string, string, armauthorization.PrincipalType, - ) (bool, error) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { return false, nil } touch := func(context.Context, string) error { @@ -67,48 +144,21 @@ func TestGrantHostedAgentMonitoringPublisherTouchesOnlyNewAssignments(t *testing return nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) + + assert.Empty(t, warning) } -func TestGrantHostedAgentMonitoringPublisherTouchFailureIsNonFatal(t *testing.T) { - assign := func( - context.Context, string, string, string, string, armauthorization.PrincipalType, - ) (bool, error) { +func TestGrantHostedAgentMonitoringPublisherTouchFailureReturnsWarning(t *testing.T) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { return true, nil } touch := func(context.Context, string) error { return errors.New("denied") } - require.NotPanics(t, func() { - grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) - }) -} + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) -func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { - tests := map[string]struct { - env map[string]string - principal string - }{ - "no monitoring mode": { - env: map[string]string{"AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": publisherTestComponent}, - principal: publisherTestPrincipal, - }, - "no component": { - env: map[string]string{"AZD_FOUNDRY_MONITORING_MODE": "create"}, - principal: publisherTestPrincipal, - }, - "no principal": {env: publisherTestEnv(), principal: " "}, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - assign := func( - context.Context, string, string, string, string, armauthorization.PrincipalType, - ) (bool, error) { - t.Fatal("role must not be assigned") - return false, nil - } - grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, tt.env, tt.principal) - }) - } + assert.Contains(t, warning, "could not refresh") + assert.Contains(t, warning, "denied") } func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing.T) { @@ -117,9 +167,7 @@ func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing. t.Cleanup(func() { monitoringGrantRetryDelay = previous }) calls := 0 - assign := func( - context.Context, string, string, string, string, armauthorization.PrincipalType, - ) (bool, error) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { calls++ if calls < 3 { return false, errors.New("PrincipalNotFound: principal does not exist") @@ -127,23 +175,51 @@ func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing. return true, nil } - grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) + assert.Empty(t, warning) assert.Equal(t, 3, calls) } -func TestGrantHostedAgentMonitoringPublisherDoesNotRetryOtherErrors(t *testing.T) { +func TestGrantHostedAgentMonitoringPublisherFailureReturnsCommand(t *testing.T) { calls := 0 - assign := func( - context.Context, string, string, string, string, armauthorization.PrincipalType, - ) (bool, error) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { calls++ return false, errors.New("AuthorizationFailed") } - require.NotPanics(t, func() { - grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) - }) + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) assert.Equal(t, 1, calls) + assert.Contains(t, warning, "AuthorizationFailed") + assert.Contains(t, warning, "az role assignment create --assignee-object-id "+publisherTestPrincipal) + assert.Contains(t, warning, "--scope "+publisherTestComponent) +} + +func TestAppendDeployWarning(t *testing.T) { + t.Run("appends to last artifact note", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{ + {Metadata: map[string]string{"note": "first"}}, + {Metadata: map[string]string{"note": "invoke"}}, + }} + + appendDeployWarning(result, "role missing") + + assert.Equal(t, "first", result.Artifacts[0].Metadata["note"]) + assert.Equal(t, "invoke\n\nWarning: role missing", result.Artifacts[1].Metadata["note"]) + }) + t.Run("creates note and metadata", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} + + appendDeployWarning(result, "role missing") + + require.Equal(t, "Warning: role missing", result.Artifacts[0].Metadata["note"]) + }) + t.Run("empty warning or no artifacts is a no-op", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} + appendDeployWarning(result, " ") + assert.Nil(t, result.Artifacts[0].Metadata) + assert.NotPanics(t, func() { appendDeployWarning(&azdext.ServiceDeployResult{}, "x") }) + assert.NotPanics(t, func() { appendDeployWarning(nil, "x") }) + }) } 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 8e6c45c1153..4259296c962 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 @@ -1947,12 +1947,13 @@ func (p *AgentServiceTargetProvider) Deploy( } } - if identity := result.agentVersion.InstanceIdentity; identity != nil && azdManagedMonitoringComponentID(azdEnv) != "" { + monitoringWarning := "" + if identity := result.agentVersion.InstanceIdentity; identity != nil && privateMonitoringComponentID(azdEnv) != "" { progress("Granting agent identity access to Application Insights") - p.ensureHostedAgentMonitoringPublisher(ctx, azdEnv, identity.PrincipalID) + monitoringWarning = p.ensureHostedAgentMonitoringPublisher(ctx, azdEnv, identity.PrincipalID) } - return p.finalizeDeploy( + deployResult, err := p.finalizeDeploy( ctx, progress, serviceConfig, @@ -1965,6 +1966,11 @@ func (p *AgentServiceTargetProvider) Deploy( activityProfile, serviceTargetConfig.Activity, ) + if err != nil { + return nil, err + } + appendDeployWarning(deployResult, monitoringWarning) + return deployResult, nil } func activityBotOwnership( diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 03f9133c7c7..bd8fc5ba6b7 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -222,7 +222,9 @@ itself) is required for hosted agents, which otherwise fail with telemetry. That identity exists only after the agent is deployed, so `azd deploy` in the agents extension grants it, then updates a tag on the component so Application Insights refreshes its cached ingestion authorization -(best effort; on failure it prints the `az role assignment create` command). If you set no +(best effort, only for azd-created components on the private path; set +`AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` to skip it; on skip or failure the +deploy note carries a warning with the `az role assignment create` command). If you set no `isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. Ejected or hand-authored Bicep that bypasses azd's checks never creates the private path unless From ebd6c9dbf509985d1bc96f835e57bc3086f07d4c Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 16:07:10 -0800 Subject: [PATCH 21/23] Re-apply monitoring sync tag on deploy when it is missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../docs/private-networking.md | 2 +- .../project/monitoring_publisher_rbac.go | 25 +++++++++++++++---- .../project/monitoring_publisher_rbac_test.go | 13 ++++++---- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 1910fb9494f..3472666b299 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -19,7 +19,7 @@ With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbo - `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. - `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. -If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant applies only to azd-created components on the private path (not public-network projects or components you supply), and `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` skips it. It is best effort: if it is skipped or fails, azd adds a warning with the `az role assignment create` command to the deploy note, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant applies only to azd-created components on the private path (not public-network projects or components you supply), and `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` skips it. On every deploy azd also re-applies the component tag if it is missing, which recovers from an earlier failed refresh. It is best effort: if it is skipped or fails, azd adds a warning with the `az role assignment create` command to the deploy note, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. **Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go index a33b822242b..d533b5d1494 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -36,7 +36,12 @@ type monitoringRoleAssigner func( // monitoringComponentToucher refreshes the component after a role assignment. Application Insights // caches Entra authorization for ingestion, and without a component update a new Monitoring Metrics // Publisher assignment kept returning 403 for over 20 minutes in live runs. -type monitoringComponentToucher func(ctx context.Context, componentID string) error +// +// created reports whether this deploy created the assignment. When it did not, the toucher only +// updates the component if the sync tag is missing, which recovers from an earlier failed touch. +type monitoringComponentToucher func(ctx context.Context, componentID string, created bool) error + +const monitoringRoleSyncTag = "azd-monitoring-role-sync" // privateMonitoringComponentID returns the App Insights component only when azd created it and // connected it through the private path (Private Link Scope). Public-network projects and @@ -101,8 +106,8 @@ func grantHostedAgentMonitoringPublisher( componentID, armauthorization.PrincipalTypeServicePrincipal, ) if err == nil { - if created && touch != nil { - if touchErr := touch(ctx, componentID); touchErr != nil { + if touch != nil { + if touchErr := touch(ctx, componentID, created); touchErr != nil { return fmt.Sprintf( "Granted the hosted agent identity Monitoring Metrics Publisher, but could not refresh %s: %v. "+ "Hosted-agent telemetry can return 403 for a long time until the component is updated; "+ @@ -166,17 +171,27 @@ func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( err, monitoringGrantCommand(strings.TrimSpace(principalID), componentID), ) } - touch := func(ctx context.Context, componentID string) error { + touch := func(ctx context.Context, componentID string, created bool) error { tags, err := armresources.NewTagsClient( extractSubscriptionID(componentID), p.credential, azure.NewArmClientOptions(), ) if err != nil { return err } + if !created { + current, err := tags.GetAtScope(ctx, componentID, nil) + if err != nil { + return err + } + if current.Properties != nil && current.Properties.Tags != nil && + current.Properties.Tags[monitoringRoleSyncTag] != nil { + return nil + } + } _, err = tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ Operation: new(armresources.TagsPatchOperationMerge), Properties: &armresources.Tags{Tags: map[string]*string{ - "azd-monitoring-role-sync": new(time.Now().UTC().Format(time.RFC3339)), + monitoringRoleSyncTag: new(time.Now().UTC().Format(time.RFC3339)), }}, }, nil) return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go index 32d1f9c0fc0..f235cb5a7e3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -52,8 +52,9 @@ func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { return true, nil } touches := 0 - touch := func(_ context.Context, componentID string) error { + touch := func(_ context.Context, componentID string, created bool) error { touches++ + assert.True(t, created) assert.Equal(t, publisherTestComponent, componentID) return nil } @@ -135,25 +136,27 @@ func TestGrantHostedAgentMonitoringPublisherHonorsSkipRoleAssignments(t *testing }) } -func TestGrantHostedAgentMonitoringPublisherTouchesOnlyNewAssignments(t *testing.T) { +func TestGrantHostedAgentMonitoringPublisherPassesCreatedFlagToToucher(t *testing.T) { assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { return false, nil } - touch := func(context.Context, string) error { - t.Fatal("component must not be touched when the role already existed") + var got []bool + touch := func(_ context.Context, _ string, created bool) error { + got = append(got, created) return nil } warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) assert.Empty(t, warning) + assert.Equal(t, []bool{false}, got) } func TestGrantHostedAgentMonitoringPublisherTouchFailureReturnsWarning(t *testing.T) { assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { return true, nil } - touch := func(context.Context, string) error { return errors.New("denied") } + touch := func(context.Context, string, bool) error { return errors.New("denied") } warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) From cb5e71b22831049aee0bc8b4f0dd4122046c52e7 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 21:36:23 -0800 Subject: [PATCH 22/23] Surface monitoring grant warning for Activity-only agents and test tag refresh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../project/monitoring_publisher_rbac.go | 74 ++++++++++---- .../project/monitoring_publisher_rbac_test.go | 96 +++++++++++++++++-- .../internal/project/service_target_agent.go | 2 +- 3 files changed, 146 insertions(+), 26 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go index d533b5d1494..19adca19012 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -178,33 +178,69 @@ func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( if err != nil { return err } - if !created { - current, err := tags.GetAtScope(ctx, componentID, nil) - if err != nil { - return err - } - if current.Properties != nil && current.Properties.Tags != nil && - current.Properties.Tags[monitoringRoleSyncTag] != nil { - return nil - } - } - _, err = tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ - Operation: new(armresources.TagsPatchOperationMerge), - Properties: &armresources.Tags{Tags: map[string]*string{ - monitoringRoleSyncTag: new(time.Now().UTC().Format(time.RFC3339)), - }}, - }, nil) - return err + return refreshMonitoringRoleSyncTag(ctx, tags, componentID, created, time.Now()) } return grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) } +// monitoringTagsClient is the subset of armresources.TagsClient used to refresh the component. +type monitoringTagsClient interface { + GetAtScope( + ctx context.Context, scope string, options *armresources.TagsClientGetAtScopeOptions, + ) (armresources.TagsClientGetAtScopeResponse, error) + UpdateAtScope( + ctx context.Context, scope string, parameters armresources.TagsPatchResource, + options *armresources.TagsClientUpdateAtScopeOptions, + ) (armresources.TagsClientUpdateAtScopeResponse, error) +} + +// refreshMonitoringRoleSyncTag merges the sync tag onto the component. When the assignment already +// existed (created is false) it only writes the tag if it is missing, so repeat deploys are no-ops +// but an earlier failed refresh is retried. +func refreshMonitoringRoleSyncTag( + ctx context.Context, + tags monitoringTagsClient, + componentID string, + created bool, + now time.Time, +) error { + if !created { + current, err := tags.GetAtScope(ctx, componentID, nil) + if err != nil { + return err + } + if current.Properties != nil && current.Properties.Tags != nil && + current.Properties.Tags[monitoringRoleSyncTag] != nil { + return nil + } + } + _, err := tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ + Operation: new(armresources.TagsPatchOperationMerge), + Properties: &armresources.Tags{Tags: map[string]*string{ + monitoringRoleSyncTag: new(now.UTC().Format(time.RFC3339)), + }}, + }, nil) + return err +} + // appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output -// instead of subprocess stderr, which the host discards. -func appendDeployWarning(result *azdext.ServiceDeployResult, warning string) { +// instead of subprocess stderr, which the host discards. Activity-only agents produce no artifacts, +// so a resource artifact for the component carries the warning instead. +func appendDeployWarning(result *azdext.ServiceDeployResult, componentID, warning string) { if result == nil || strings.TrimSpace(warning) == "" { return } + if len(result.Artifacts) == 0 && componentID != "" { + result.Artifacts = append(result.Artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_RESOURCE, + Location: componentID, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "label": "Application Insights", + "clickable": "false", + }, + }) + } for i := len(result.Artifacts) - 1; i >= 0; i-- { artifact := result.Artifacts[i] if artifact == nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go index f235cb5a7e3..6091b7b98fd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -206,7 +207,7 @@ func TestAppendDeployWarning(t *testing.T) { {Metadata: map[string]string{"note": "invoke"}}, }} - appendDeployWarning(result, "role missing") + appendDeployWarning(result, publisherTestComponent, "role missing") assert.Equal(t, "first", result.Artifacts[0].Metadata["note"]) assert.Equal(t, "invoke\n\nWarning: role missing", result.Artifacts[1].Metadata["note"]) @@ -214,15 +215,98 @@ func TestAppendDeployWarning(t *testing.T) { t.Run("creates note and metadata", func(t *testing.T) { result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} - appendDeployWarning(result, "role missing") + appendDeployWarning(result, publisherTestComponent, "role missing") require.Equal(t, "Warning: role missing", result.Artifacts[0].Metadata["note"]) }) - t.Run("empty warning or no artifacts is a no-op", func(t *testing.T) { + t.Run("empty warning is a no-op", func(t *testing.T) { result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} - appendDeployWarning(result, " ") + appendDeployWarning(result, publisherTestComponent, " ") assert.Nil(t, result.Artifacts[0].Metadata) - assert.NotPanics(t, func() { appendDeployWarning(&azdext.ServiceDeployResult{}, "x") }) - assert.NotPanics(t, func() { appendDeployWarning(nil, "x") }) + assert.NotPanics(t, func() { appendDeployWarning(nil, publisherTestComponent, "x") }) + }) + t.Run("no artifacts gets a fallback artifact carrying the warning", func(t *testing.T) { + result := &azdext.ServiceDeployResult{} + + appendDeployWarning(result, publisherTestComponent, "role missing") + + require.Len(t, result.Artifacts, 1) + assert.Equal(t, publisherTestComponent, result.Artifacts[0].Location) + assert.Equal(t, "Warning: role missing", result.Artifacts[0].Metadata["note"]) + }) + t.Run("no artifacts and no component is a no-op", func(t *testing.T) { + result := &azdext.ServiceDeployResult{} + appendDeployWarning(result, "", "role missing") + assert.Empty(t, result.Artifacts) + }) +} + +type fakeTagsClient struct { + existing map[string]*string + getErr error + updateErr error + gets int + updates []armresources.TagsPatchResource +} + +func (f *fakeTagsClient) GetAtScope( + _ context.Context, _ string, _ *armresources.TagsClientGetAtScopeOptions, +) (armresources.TagsClientGetAtScopeResponse, error) { + f.gets++ + if f.getErr != nil { + return armresources.TagsClientGetAtScopeResponse{}, f.getErr + } + return armresources.TagsClientGetAtScopeResponse{ + TagsResource: armresources.TagsResource{Properties: &armresources.Tags{Tags: f.existing}}, + }, nil +} + +func (f *fakeTagsClient) UpdateAtScope( + _ context.Context, _ string, p armresources.TagsPatchResource, _ *armresources.TagsClientUpdateAtScopeOptions, +) (armresources.TagsClientUpdateAtScopeResponse, error) { + if f.updateErr != nil { + return armresources.TagsClientUpdateAtScopeResponse{}, f.updateErr + } + f.updates = append(f.updates, p) + return armresources.TagsClientUpdateAtScopeResponse{}, nil +} + +func TestRefreshMonitoringRoleSyncTag(t *testing.T) { + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + synced := map[string]*string{monitoringRoleSyncTag: new("earlier")} + + t.Run("created assignment writes tag without reading", func(t *testing.T) { + c := &fakeTagsClient{existing: synced} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, true, now)) + assert.Equal(t, 0, c.gets) + require.Len(t, c.updates, 1) + assert.Equal(t, "2026-01-02T03:04:05Z", *c.updates[0].Properties.Tags[monitoringRoleSyncTag]) + assert.Equal(t, armresources.TagsPatchOperationMerge, *c.updates[0].Operation) + }) + t.Run("existing assignment with tag present is a no-op", func(t *testing.T) { + c := &fakeTagsClient{existing: synced} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Empty(t, c.updates) + }) + t.Run("existing assignment with tag missing re-applies it", func(t *testing.T) { + c := &fakeTagsClient{existing: map[string]*string{"other": new("x")}} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Len(t, c.updates, 1) + }) + t.Run("existing assignment with no tags re-applies it", func(t *testing.T) { + c := &fakeTagsClient{} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Len(t, c.updates, 1) + }) + t.Run("read failure is returned without writing", func(t *testing.T) { + c := &fakeTagsClient{getErr: errors.New("read denied")} + err := refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now) + require.ErrorContains(t, err, "read denied") + assert.Empty(t, c.updates) + }) + t.Run("update failure is returned", func(t *testing.T) { + c := &fakeTagsClient{updateErr: errors.New("write denied")} + err := refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, true, now) + require.ErrorContains(t, err, "write denied") }) } 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 4259296c962..a1de615a37c 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 @@ -1969,7 +1969,7 @@ func (p *AgentServiceTargetProvider) Deploy( if err != nil { return nil, err } - appendDeployWarning(deployResult, monitoringWarning) + appendDeployWarning(deployResult, privateMonitoringComponentID(azdEnv), monitoringWarning) return deployResult, nil } From 39a3d5eb8413c6ecf708e141f4c54fe58f8fa807 Mon Sep 17 00:00:00 2001 From: Kayla Porter Date: Mon, 9 Nov 2026 22:01:20 -0800 Subject: [PATCH 23/23] Split monitoring publisher grant into focused files and centralize constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../internal/project/deploy_warning.go | 45 ++++++++ .../internal/project/monitoring_constants.go | 33 ++++++ .../project/monitoring_publisher_rbac.go | 103 ++---------------- .../project/monitoring_publisher_rbac_test.go | 23 ++++ .../internal/project/monitoring_tags.go | 51 +++++++++ 5 files changed, 162 insertions(+), 93 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go b/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go new file mode 100644 index 00000000000..f911ca3880d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output +// instead of subprocess stderr, which the host discards. Activity-only agents produce no artifacts, +// so a resource artifact for the component carries the warning instead. +func appendDeployWarning(result *azdext.ServiceDeployResult, componentID, warning string) { + if result == nil || strings.TrimSpace(warning) == "" { + return + } + if len(result.Artifacts) == 0 && componentID != "" { + result.Artifacts = append(result.Artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_RESOURCE, + Location: componentID, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "label": "Application Insights", + "clickable": "false", + }, + }) + } + for i := len(result.Artifacts) - 1; i >= 0; i-- { + artifact := result.Artifacts[i] + if artifact == nil { + continue + } + if artifact.Metadata == nil { + artifact.Metadata = map[string]string{} + } + if existing := artifact.Metadata["note"]; existing != "" { + artifact.Metadata["note"] = existing + "\n\nWarning: " + warning + } else { + artifact.Metadata["note"] = "Warning: " + warning + } + return + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go new file mode 100644 index 00000000000..18378a16c4c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "time" + +// Environment keys written by the azure.ai.projects provisioning extension. They are defined +// separately in that Go module, so these values must stay identical to its outputs. +const ( + envFoundryMonitoringMode = "AZD_FOUNDRY_MONITORING_MODE" + envMonitoringPrivateLinkScope = "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID" + envApplicationInsightsResource = "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID" + envSkipRoleAssignments = "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS" + + // monitoringModeCreate means azd created the Application Insights component. + monitoringModeCreate = "create" +) + +const ( + roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" + roleMonitoringMetricsPublisherName = "Monitoring Metrics Publisher" + + // monitoringRoleSyncTag is merged onto the component after a grant so Application Insights + // refreshes its cached ingestion authorization. + monitoringRoleSyncTag = "azd-monitoring-role-sync" + + monitoringGrantAttempts = 4 +) + +// defaultMonitoringGrantRetryDelay is how long to wait between grant attempts. A new instance +// identity can take a short while to replicate before ARM accepts it as a role assignment principal. +const defaultMonitoringGrantRetryDelay = 15 * time.Second diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go index 19adca19012..48772af89f5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -16,16 +16,10 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -const roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" - -const monitoringGrantAttempts = 4 - -// monitoringGrantRetryDelay is a variable so tests can avoid waiting. A new instance identity -// can take a short while to replicate before ARM accepts it as a role assignment principal. -var monitoringGrantRetryDelay = 15 * time.Second +// monitoringGrantRetryDelay is a variable so tests can avoid waiting. +var monitoringGrantRetryDelay = defaultMonitoringGrantRetryDelay type monitoringRoleAssigner func( ctx context.Context, @@ -41,25 +35,23 @@ type monitoringRoleAssigner func( // updates the component if the sync tag is missing, which recovers from an earlier failed touch. type monitoringComponentToucher func(ctx context.Context, componentID string, created bool) error -const monitoringRoleSyncTag = "azd-monitoring-role-sync" - // privateMonitoringComponentID returns the App Insights component only when azd created it and // connected it through the private path (Private Link Scope). Public-network projects and // user-supplied components return "" so azd never changes their role assignments or tags. func privateMonitoringComponentID(azdEnv map[string]string) string { - if strings.TrimSpace(azdEnv["AZD_FOUNDRY_MONITORING_MODE"]) != "create" || - strings.TrimSpace(azdEnv["AZD_MONITORING_PRIVATE_LINK_SCOPE_ID"]) == "" { + if strings.TrimSpace(azdEnv[envFoundryMonitoringMode]) != monitoringModeCreate || + strings.TrimSpace(azdEnv[envMonitoringPrivateLinkScope]) == "" { return "" } - return strings.TrimSpace(azdEnv["AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"]) + return strings.TrimSpace(azdEnv[envApplicationInsightsResource]) } // roleAssignmentsSkipped reports whether the user opted out of azd writing role assignments, using // the same precedence as the developer RBAC check: environment value first, then the process. func roleAssignmentsSkipped(azdEnv map[string]string) bool { - value := strings.TrimSpace(azdEnv["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"]) + value := strings.TrimSpace(azdEnv[envSkipRoleAssignments]) if value == "" { - value = os.Getenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + value = os.Getenv(envSkipRoleAssignments) } skip, err := strconv.ParseBool(value) return err == nil && skip @@ -68,8 +60,8 @@ func roleAssignmentsSkipped(azdEnv map[string]string) bool { func monitoringGrantCommand(principalID, componentID string) string { return fmt.Sprintf( "az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+ - "--role \"Monitoring Metrics Publisher\" --scope %s", - principalID, componentID, + "--role \"%s\" --scope %s", + principalID, roleMonitoringMetricsPublisherName, componentID, ) } @@ -102,7 +94,7 @@ func grantHostedAgentMonitoringPublisher( var lastErr error for attempt := 1; attempt <= monitoringGrantAttempts; attempt++ { created, err := assign( - ctx, principalID, roleMonitoringMetricsPublisher, "Monitoring Metrics Publisher", + ctx, principalID, roleMonitoringMetricsPublisher, roleMonitoringMetricsPublisherName, componentID, armauthorization.PrincipalTypeServicePrincipal, ) if err == nil { @@ -182,78 +174,3 @@ func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( } return grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) } - -// monitoringTagsClient is the subset of armresources.TagsClient used to refresh the component. -type monitoringTagsClient interface { - GetAtScope( - ctx context.Context, scope string, options *armresources.TagsClientGetAtScopeOptions, - ) (armresources.TagsClientGetAtScopeResponse, error) - UpdateAtScope( - ctx context.Context, scope string, parameters armresources.TagsPatchResource, - options *armresources.TagsClientUpdateAtScopeOptions, - ) (armresources.TagsClientUpdateAtScopeResponse, error) -} - -// refreshMonitoringRoleSyncTag merges the sync tag onto the component. When the assignment already -// existed (created is false) it only writes the tag if it is missing, so repeat deploys are no-ops -// but an earlier failed refresh is retried. -func refreshMonitoringRoleSyncTag( - ctx context.Context, - tags monitoringTagsClient, - componentID string, - created bool, - now time.Time, -) error { - if !created { - current, err := tags.GetAtScope(ctx, componentID, nil) - if err != nil { - return err - } - if current.Properties != nil && current.Properties.Tags != nil && - current.Properties.Tags[monitoringRoleSyncTag] != nil { - return nil - } - } - _, err := tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ - Operation: new(armresources.TagsPatchOperationMerge), - Properties: &armresources.Tags{Tags: map[string]*string{ - monitoringRoleSyncTag: new(now.UTC().Format(time.RFC3339)), - }}, - }, nil) - return err -} - -// appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output -// instead of subprocess stderr, which the host discards. Activity-only agents produce no artifacts, -// so a resource artifact for the component carries the warning instead. -func appendDeployWarning(result *azdext.ServiceDeployResult, componentID, warning string) { - if result == nil || strings.TrimSpace(warning) == "" { - return - } - if len(result.Artifacts) == 0 && componentID != "" { - result.Artifacts = append(result.Artifacts, &azdext.Artifact{ - Kind: azdext.ArtifactKind_ARTIFACT_KIND_RESOURCE, - Location: componentID, - LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, - Metadata: map[string]string{ - "label": "Application Insights", - "clickable": "false", - }, - }) - } - for i := len(result.Artifacts) - 1; i >= 0; i-- { - artifact := result.Artifacts[i] - if artifact == nil { - continue - } - if artifact.Metadata == nil { - artifact.Metadata = map[string]string{} - } - if existing := artifact.Metadata["note"]; existing != "" { - artifact.Metadata["note"] = existing + "\n\nWarning: " + warning - } else { - artifact.Metadata["note"] = "Warning: " + warning - } - return - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go index 6091b7b98fd..e717d9fe1cd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -200,6 +200,29 @@ func TestGrantHostedAgentMonitoringPublisherFailureReturnsCommand(t *testing.T) assert.Contains(t, warning, "--scope "+publisherTestComponent) } +func TestMonitoringConstantsArePinned(t *testing.T) { + // These must match Azure's role definition and the azure.ai.projects provisioning outputs. + assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) + assert.Equal(t, "Monitoring Metrics Publisher", roleMonitoringMetricsPublisherName) + assert.Equal(t, "azd-monitoring-role-sync", monitoringRoleSyncTag) + assert.Equal(t, "AZD_FOUNDRY_MONITORING_MODE", envFoundryMonitoringMode) + assert.Equal(t, "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", envMonitoringPrivateLinkScope) + assert.Equal(t, "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", envApplicationInsightsResource) + assert.Equal(t, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", envSkipRoleAssignments) + assert.Equal(t, "create", monitoringModeCreate) + assert.Equal(t, 4, monitoringGrantAttempts) + assert.Equal(t, 15*time.Second, defaultMonitoringGrantRetryDelay) +} + +func TestMonitoringGrantCommand(t *testing.T) { + cmd := monitoringGrantCommand(publisherTestPrincipal, publisherTestComponent) + assert.Equal(t, + "az role assignment create --assignee-object-id "+publisherTestPrincipal+ + " --assignee-principal-type ServicePrincipal --role \"Monitoring Metrics Publisher\" --scope "+ + publisherTestComponent, + cmd) +} + func TestAppendDeployWarning(t *testing.T) { t.Run("appends to last artifact note", func(t *testing.T) { result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go new file mode 100644 index 00000000000..6d7e64e20b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// monitoringTagsClient is the subset of armresources.TagsClient used to refresh the component. +type monitoringTagsClient interface { + GetAtScope( + ctx context.Context, scope string, options *armresources.TagsClientGetAtScopeOptions, + ) (armresources.TagsClientGetAtScopeResponse, error) + UpdateAtScope( + ctx context.Context, scope string, parameters armresources.TagsPatchResource, + options *armresources.TagsClientUpdateAtScopeOptions, + ) (armresources.TagsClientUpdateAtScopeResponse, error) +} + +// refreshMonitoringRoleSyncTag merges the sync tag onto the component. When the assignment already +// existed (created is false) it only writes the tag if it is missing, so repeat deploys are no-ops +// but an earlier failed refresh is retried. +func refreshMonitoringRoleSyncTag( + ctx context.Context, + tags monitoringTagsClient, + componentID string, + created bool, + now time.Time, +) error { + if !created { + current, err := tags.GetAtScope(ctx, componentID, nil) + if err != nil { + return err + } + if current.Properties != nil && current.Properties.Tags != nil && + current.Properties.Tags[monitoringRoleSyncTag] != nil { + return nil + } + } + _, err := tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ + Operation: new(armresources.TagsPatchOperationMerge), + Properties: &armresources.Tags{Tags: map[string]*string{ + monitoringRoleSyncTag: new(now.UTC().Format(time.RFC3339)), + }}, + }, nil) + return err +}