Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 17 additions & 7 deletions cli/azd/extensions/azure.ai.projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"

"azure.ai.projects/synthesis"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading