From c9cc76d767e5efb2f75956b539b0f2eab8beff0f Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 16:16:51 -0800 Subject: [PATCH 1/4] Add private Azure Monitor path for VNet-injected Foundry projects Create App Insights with ingestion disabled, an Azure Monitor Private Link Scope, the azuremonitor private endpoint and DNS zones when a project uses BYO-egress network isolation. Add a preflight check for an existing azuremonitor private endpoint on the VNet. Managed egress stays unsupported. 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 | 1 + .../extensions/azure.ai.projects/README.md | 17 +- .../internal/cmd/project_existing_infra.go | 2 +- .../internal/provisioning/monitoring_graph.go | 13 +- .../provisioning/monitoring_preflight.go | 64 +++- .../synthesis/monitoring_policy.go | 10 + .../synthesis/monitoring_private_link.go | 109 ++++++ .../synthesis/monitoring_private_link_test.go | 85 +++++ .../synthesis/monitoring_resolver.go | 7 +- .../synthesis/monitoring_test.go | 29 +- .../synthesis/synthesizer.go | 13 +- .../templates/existing-project.arm.json | 287 +++++++++++++++- .../synthesis/templates/main.arm.json | 319 +++++++++++++++++- .../synthesis/templates/main.bicep | 6 + .../modules/monitor-private-link.bicep | 148 ++++++++ .../templates/modules/monitoring.bicep | 27 ++ .../templates/modules/resources.bicep | 3 + 18 files changed, 1099 insertions(+), 45 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 diff --git a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md index 5c2ffa28084..934610756ad 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 fails synthesis unless you set `monitoring: {enabled: false}` on the project service. +Automatic Application Insights monitoring works with a private network that uses your own agent subnet (BYO egress, `agentSubnet` set). azd creates the Application Insights resource and Log Analytics workspace with public ingestion and query disabled, an Azure Monitor Private Link Scope (ingestion and query both Private Only), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Traces are therefore readable only from a network that can reach the private link scope (for example a jump host or VPN); a laptop on the public internet cannot query them. + +A virtual network can use only one Azure Monitor Private Link Scope. If the network already has an `azuremonitor` private endpoint, provisioning fails before any change and tells you to set `monitoring: {enabled: false}` on the project service. Managed egress (no `agentSubnet`) and connecting an existing Application Insights resource to a private project are not supported yet and also fail unless monitoring is disabled. For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, and cannot be combined with private networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 1742ea51d8c..8e7d5e6cded 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 @@ -1260,6 +1260,7 @@ 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"}, } 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 9f40f2c821b..a56c50876db 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -188,11 +188,18 @@ 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 new project with a `network:` block is rejected -unless you set `monitoring.enabled: false`, because automatic monitoring cannot -yet use private ingestion. Such failures can fail normal provisioning; there -is no second best-effort deployment or agent deployment repair hook. +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: the component and workspace disable public ingestion and +query, and azd adds an Azure Monitor Private Link Scope (Private Only), an +`azuremonitor` private endpoint, and the four Azure Monitor private DNS zones. +Traces are then readable only from a network that reaches the scope. If the +virtual network already has an Azure Monitor private endpoint, provisioning +fails before any change; set `monitoring.enabled: false` to opt out. Managed +egress and connecting an existing component to a private project are rejected +unless you set `monitoring.enabled: false`. Such failures can fail normal +provisioning; there is no second best-effort deployment or agent deployment +repair hook. Monitoring creates billable Azure resources and can store agent telemetry, including application-supplied content. Review your application's telemetry, 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..a9afa15d308 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,7 @@ func writeExistingProjectBicep( } } } - for _, name := range []string{"monitoring.bicep", "monitoring-connection.bicep"} { + for _, name := range []string{"monitoring.bicep", "monitoring-connection.bicep", "monitor-private-link.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 466cb8828e5..a37f8602597 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( @@ -82,7 +114,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 @@ -133,20 +167,26 @@ 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 { - return fmt.Errorf("automatic monitoring does not configure private-network ingestion; " + - "preserve the existing connection or disable monitoring setup") - } - for _, resource := range []*armresources.GenericResource{account, projectResource} { - if resource == nil { - continue + if monitoringPrivateNetwork(source) { + if mode != "create" { + return fmt.Errorf("connecting an existing Application Insights resource does not configure " + + "private-network ingestion; preserve the existing connection or disable monitoring setup") } - properties, err := synthesis.MonitoringProperties(resource) - if err != nil { + if err := p.checkMonitorPrivateLinkConflict(ctx, source, &resolver, progress); err != 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/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..7541c697fec --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -0,0 +1,85 @@ +// 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 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 a030280b3e6..0b3fb141980 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -130,12 +130,17 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { monitoring := []struct { name string settings string - wantErr bool + wantErr string + wantMode string }{ - {name: "default", wantErr: true}, - {name: "empty block", settings: " monitoring: {}\n", wantErr: true}, - {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", wantErr: true}, - {name: "explicit opt out", settings: " monitoring:\n enabled: false\n"}, + {name: "default", wantErr: "does not yet support a managed network", wantMode: "create"}, + {name: "empty block", settings: " monitoring: {}\n", wantErr: "does not yet support a managed network", wantMode: "create"}, + { + name: "BYO component", + settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", + wantErr: "does not yet support a managed network", wantMode: "reuse-connect", + }, + {name: "explicit opt out", settings: " monitoring:\n enabled: false\n", wantMode: "none"}, } for networkName, network := range networks { for _, tt := range monitoring { @@ -145,13 +150,21 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { RawAzureYAML: []byte("services:\n foundry:\n host: azure.ai.project\n" + network + tt.settings), ServiceName: "foundry", }) - if tt.wantErr { - require.ErrorContains(t, err, "does not configure private-network ingestion") + wantErr := tt.wantErr + switch { + case networkName == "BYO egress" && tt.wantMode == "create": + wantErr = "" + case networkName == "BYO egress" && tt.wantMode == "reuse-connect": + wantErr = "does not configure private-network ingestion" + } + if wantErr != "" { + require.ErrorContains(t, err, wantErr) require.ErrorContains(t, err, "monitoring.enabled: false") return } require.NoError(t, err) - assert.Equal(t, "none", result.Parameters["monitoringMode"]) + assert.Equal(t, tt.wantMode, result.Parameters["monitoringMode"]) + 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 81a06dc998b..79f032ac66b 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -277,11 +277,20 @@ func Synthesize(in Input) (*Result, error) { if err != nil { return nil, err } - if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { + mode, _ := monitoring["monitoringMode"].(string) + monitoringOn := mode != "" && mode != "none" + switch { + case netMode == NetworkModeManaged && monitoringOn: return nil, errors.New( - "synthesis: automatic monitoring does not configure private-network ingestion; " + + "synthesis: automatic monitoring does not yet support a managed network " + + "(it needs a managed private endpoint to the Azure Monitor private link scope); " + "set monitoring.enabled: false on the project service", ) + case netMode == NetworkModeByo && monitoringOn && mode != "create": + return nil, errors.New( + "synthesis: connecting an existing Application Insights resource does not configure " + + "private-network ingestion; set monitoring.enabled: false on the project service", + ) } maps.Copy(params, monitoring) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 380a1bbcea4..3ad2c0dfdb1 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "11625777883654081427" + "templateHash": "13249333888689485548" } }, "definitions": { @@ -795,7 +795,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "8552008835627421720" } }, "parameters": { @@ -836,10 +836,38 @@ "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'), '/')]", @@ -859,7 +887,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "[variables('publicNetworkAccess')]" } }, { @@ -872,9 +902,258 @@ "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": "[variables('publicNetworkAccess')]" + }, + "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": "5675483631496827259" + } + }, + "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": "PrivateOnly" + } + } + }, + { + "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')))]" ] }, 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 f99fe155865..9a86f3d0fa0 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": "1864592008397094471" + "templateHash": "15096591180338143477" } }, "definitions": { @@ -385,7 +385,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "11291957688034724305" + "templateHash": "3590758027701824840" } }, "definitions": { @@ -2293,6 +2293,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, '')]" } } } @@ -2337,6 +2349,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": { @@ -2346,7 +2376,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "8552008835627421720" } }, "parameters": { @@ -2387,10 +2417,38 @@ "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'), '/')]", @@ -2410,7 +2468,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "[variables('publicNetworkAccess')]" } }, { @@ -2423,9 +2483,258 @@ "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": "[variables('publicNetworkAccess')]" + }, + "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": "5675483631496827259" + } + }, + "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": "PrivateOnly" + } + } + }, + { + "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')))]" ] }, 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 6e4965ff4f2..a0ff0a46e08 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -190,6 +190,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..f02e6d09db6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep @@ -0,0 +1,148 @@ +// 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. +// +// Ingestion and query are both PrivateOnly, so traces are written and read only +// from networks connected to the scope. 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: 'PrivateOnly' + } + } +} + +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.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index af238cde858..8b46734af25 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, '/') @@ -36,6 +45,8 @@ resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (c name: 'PerGB2018' } retentionInDays: 30 + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: publicNetworkAccess } } @@ -47,6 +58,22 @@ resource component 'Microsoft.Insights/components@2020-02-02' = if (createResour properties: { Application_Type: 'web' WorkspaceResourceId: workspace!.id + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: publicNetworkAccess + } +} + +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 } } 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 e13a3a50a86..a655042ab82 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 @@ -424,3 +424,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 : '' From e5e4d2c6f7a60ca25a2126a74acaf162f0769384 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 18:24:45 -0800 Subject: [PATCH 2/4] Add project capability host for VNet-injected Foundry projects Bug bash S2 and the Foundry team require a project-level Agents capability host for private tracing. Add it for BYO egress, regenerate main.arm.json, and add a regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../synthesis/templates/main.arm.json | 17 +++++++++-- .../templates/modules/resources.bicep | 16 +++++++++++ .../synthesis/vnet_requirements_test.go | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go 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 9a86f3d0fa0..92a98c02e7b 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": "15096591180338143477" + "templateHash": "2432952303527346653" } }, "definitions": { @@ -385,7 +385,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3590758027701824840" + "templateHash": "13379278077384385671" } }, "definitions": { @@ -768,6 +768,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", 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 a655042ab82..0a11b79c93c 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 @@ -316,6 +316,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 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 6997df31a16044db640332369d814ad5f164fb09 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Mon, 2 Nov 2026 21:40:39 -0800 Subject: [PATCH 3/4] Document private monitoring trace-ingestion limitation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- cli/azd/extensions/azure.ai.projects/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index a56c50876db..716d92903bd 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -193,7 +193,11 @@ project with a `network:` block that uses your own agent subnet gets a private monitoring path: the component and workspace disable public ingestion and query, and azd adds an Azure Monitor Private Link Scope (Private Only), an `azuremonitor` private endpoint, and the four Azure Monitor private DNS zones. -Traces are then readable only from a network that reaches the scope. If the +Traces are then readable only from a network that reaches the scope. Live +testing found that Foundry's own service-side traces (prompt and hosted agents) +did not reach a component with public ingestion disabled, so expect no +service-side traces in this mode until the platform supports private ingestion; +the private path is provisioned and verified, but trace arrival is not. If the virtual network already has an Azure Monitor private endpoint, provisioning fails before any change; set `monitoring.enabled: false` to opt out. Managed egress and connecting an existing component to a private project are rejected From 26e85b06a233ba0d89b09d14982bb85eac850c90 Mon Sep 17 00:00:00 2001 From: Kayla Ames Date: Tue, 3 Nov 2026 13:41:42 -0800 Subject: [PATCH 4/4] Set AMPLS ingestion access mode to Open so private tracing works Live testing in a VNet showed PrivateOnly ingestion makes a scoped App Insights component reject private ingestion (403) and no Foundry traces arrive. Ingestion Open with query PrivateOnly delivered traces readable from inside the network. Component public ingestion and query stay disabled. Adds a regression test pinning the access modes in the Bicep and ARM templates and updates the README and private networking docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1d0003f-40c8-481d-92a5-ecb8d7afb491 --- .../azure.ai.agents/docs/private-networking.md | 2 +- cli/azd/extensions/azure.ai.projects/README.md | 15 ++++++++------- .../synthesis/monitoring_private_link_test.go | 18 ++++++++++++++++++ .../templates/existing-project.arm.json | 8 ++++---- .../synthesis/templates/main.arm.json | 8 ++++---- .../modules/monitor-private-link.bicep | 10 +++++++--- 6 files changed, 42 insertions(+), 19 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 0438a4a1db6..bbd092d823e 100644 --- a/cli/azd/extensions/azure.ai.agents/docs/private-networking.md +++ b/cli/azd/extensions/azure.ai.agents/docs/private-networking.md @@ -8,7 +8,7 @@ The `azure.ai.projects` extension owns the project service and the `microsoft.fo When `network:` is present, azd always provisions an **account private endpoint** and disables public data-plane access. Without `agentHosting:`, dependent stores (Cosmos DB, AI Search, Storage) stay platform-managed. -Automatic Application Insights monitoring 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 and query disabled, an Azure Monitor Private Link Scope (ingestion and query both Private Only), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Traces are readable only from a network that can reach the private link scope (for example a jump host or VPN); a laptop on the public internet cannot query them. Live testing found that Foundry's own service-side traces did not reach a component with public ingestion disabled, so the private path is provisioned but trace arrival is not guaranteed. +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 and query disabled, an Azure Monitor Private Link Scope (query Private Only, ingestion Open, because Private Only ingestion makes the component reject private ingestion and no traces arrive), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Traces are readable only from a network that can reach the private link scope (for example a jump host or VPN); a laptop on the public internet cannot query them. Live testing confirmed Foundry service-side and hosted-agent traces 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. diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index d11222f8d18..ded1447a71d 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -191,13 +191,14 @@ unreadable resources, and authentication or ingestion policies that would need 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: the component and workspace disable public ingestion and -query, and azd adds an Azure Monitor Private Link Scope (Private Only), an -`azuremonitor` private endpoint, and the four Azure Monitor private DNS zones. -Traces are then readable only from a network that reaches the scope. Live -testing found that Foundry's own service-side traces (prompt and hosted agents) -did not reach a component with public ingestion disabled, so expect no -service-side traces in this mode until the platform supports private ingestion; -the private path is provisioned and verified, but trace arrival is not. +query, and azd adds an Azure Monitor Private Link Scope (query Private Only, +ingestion Open), an `azuremonitor` private endpoint, and the four Azure Monitor +private DNS zones. Traces are then readable only from a network that reaches +the scope. Ingestion access mode is Open because live testing showed that a +Private Only ingestion mode makes the component reject private ingestion (403) +and no Foundry service-side traces (prompt and hosted agents) arrive; with +ingestion Open and the component's public ingestion disabled, those traces +arrived and were read privately from inside the network. 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 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 7541c697fec..70f2bae538b 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 @@ -34,6 +34,24 @@ func privateLinkReader(group, scopeID string) *fakeReader { }} } +func TestMonitorPrivateLinkAccessModes(t *testing.T) { + t.Parallel() + // Ingestion must stay Open: PrivateOnly ingestion makes a scoped component + // reject private ingestion (403) and no traces arrive. Query stays PrivateOnly, + // and the component's own public ingestion is disabled in monitoring.bicep. + 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": "Open"`, name) + assert.Contains(t, text, `"queryAccessMode": "PrivateOnly"`, name) + } + bicep, err := templatesFS.ReadFile("templates/modules/monitor-private-link.bicep") + require.NoError(t, err) + assert.Contains(t, string(bicep), "ingestionAccessMode: 'Open'") + assert.Contains(t, string(bicep), "queryAccessMode: 'PrivateOnly'") +} + 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/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 3ad2c0dfdb1..735263a49c7 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": "13249333888689485548" + "templateHash": "362655465246923852" } }, "definitions": { @@ -795,7 +795,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "8552008835627421720" + "templateHash": "15773462916423683174" } }, "parameters": { @@ -953,7 +953,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "5675483631496827259" + "templateHash": "5272832185235486205" } }, "parameters": { @@ -1034,7 +1034,7 @@ "location": "global", "properties": { "accessModeSettings": { - "ingestionAccessMode": "PrivateOnly", + "ingestionAccessMode": "Open", "queryAccessMode": "PrivateOnly" } } 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 1e657f31b61..531f9d5c353 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": "7837420760078715005" + "templateHash": "5878864343609131917" } }, "definitions": { @@ -2494,7 +2494,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "8552008835627421720" + "templateHash": "15773462916423683174" } }, "parameters": { @@ -2652,7 +2652,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "5675483631496827259" + "templateHash": "5272832185235486205" } }, "parameters": { @@ -2733,7 +2733,7 @@ "location": "global", "properties": { "accessModeSettings": { - "ingestionAccessMode": "PrivateOnly", + "ingestionAccessMode": "Open", "queryAccessMode": "PrivateOnly" } } 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 index f02e6d09db6..1e3d046ccc9 100644 --- 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 @@ -3,8 +3,12 @@ // Log Analytics workspace, an 'azuremonitor' private endpoint in the PE subnet, // and the four Azure Monitor private DNS zones. // -// Ingestion and query are both PrivateOnly, so traces are written and read only -// from networks connected to the scope. Like private-endpoint-dns.bicep, zones +// Query is PrivateOnly, so traces are read only from networks connected to the +// scope. Ingestion is Open: live testing showed that with ingestion PrivateOnly, +// the component rejects private ingestion (403 "Component private link id does +// not match incoming link") and no Foundry traces arrive. The component's own +// public ingestion stays disabled, so ingestion still arrives only through the +// private endpoint. 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. @@ -54,7 +58,7 @@ resource ampls 'Microsoft.Insights/privateLinkScopes@2021-07-01-preview' = { location: 'global' properties: { accessModeSettings: { - ingestionAccessMode: 'PrivateOnly' + ingestionAccessMode: 'Open' queryAccessMode: 'PrivateOnly' } }