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..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,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 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. 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 cbda5b0e99b..17184e7ee0c 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 @@ -1263,6 +1263,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 fd551c1e775..ded1447a71d 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -188,13 +188,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: the component and workspace disable public ingestion and +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 +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..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 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..70f2bae538b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -0,0 +1,103 @@ +// 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() + // 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" + 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 3fb5165ddec..e9d86c56081 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -128,13 +128,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 { @@ -145,13 +151,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 47be8a55072..b9674711700 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -281,8 +281,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. + mode, _ := monitoring["monitoringMode"].(string) + if netMode != NetworkModeNone && mode != "" && mode != "none" && + !(netMode == NetworkModeByo && mode == "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) } @@ -308,8 +310,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 380a1bbcea4..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": "11625777883654081427" + "templateHash": "362655465246923852" } }, "definitions": { @@ -795,7 +795,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "15773462916423683174" } }, "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": "5272832185235486205" + } + }, + "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": "Open", + "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 faf0857d1a5..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": "5696676974240649629" + "templateHash": "5878864343609131917" } }, "definitions": { @@ -420,7 +420,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "12875653734830163232" + "templateHash": "8877667611998771297" } }, "definitions": { @@ -832,6 +832,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", @@ -2398,6 +2411,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, '')]" } } } @@ -2442,6 +2467,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": { @@ -2451,7 +2494,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "13442955573686093057" + "templateHash": "15773462916423683174" } }, "parameters": { @@ -2492,10 +2535,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'), '/')]", @@ -2515,7 +2586,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "[variables('publicNetworkAccess')]" } }, { @@ -2528,9 +2601,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": "5272832185235486205" + } + }, + "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": "Open", + "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 d151e42c322..63ffafdfc46 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -204,6 +204,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..1e3d046ccc9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep @@ -0,0 +1,152 @@ +// 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. +// +// 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. +// +// 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: 'Open' + 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 6d38b8bebd2..928d9fffca2 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 @@ -341,6 +341,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 @@ -449,3 +465,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") +}