diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index b097a8be4a9..67641454f0b 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -74,6 +74,8 @@ words: - abbrs - acrpush - alnum + - ampls + - azuremonitor - foundryproject - agentignore - ADLS 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..3472666b299 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,20 @@ 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 for a private network with your own agent subnet (BYO egress, `agentSubnet` set) and for managed egress (see below). For a component azd creates, azd creates the Application Insights resource and Log Analytics workspace with public ingestion disabled and public query enabled, an Azure Monitor Private Link Scope (ingestion Private Only, query Open, matching the Foundry samples), an `azuremonitor` private endpoint in the `peSubnet`, and the four Azure Monitor private DNS zones, then connects the project with an ApiKey connection. Ingestion is private only. Right after a fresh deployment, private ingestion can return 403 for a while (observed up to about 20 minutes) before traces arrive. Live testing confirmed that Foundry service-side traces from a deployed prompt agent arrive and can be read from inside the network with this configuration. + +Provisioning is never blocked by monitoring in unsupported cases. With your own agent subnet you can also connect an existing Application Insights resource: azd creates its own Private Link Scope, private endpoint and DNS zones and links the component and its workspace to the scope without modifying the component (local authentication must stay enabled). Because azd does not own the component, it cannot re-update it; if ingestion returns 403 after provisioning, touching the component (for example, updating a tag) can clear it. + +Shared private DNS zones (`network.dns`), a virtual network that already has an `azuremonitor` private endpoint (a virtual network can use only one Azure Monitor Private Link Scope; azd does not adopt an existing one), or a network azd cannot inspect skip automatic monitoring, and azd prints a warning (also on stderr). + +With managed egress (no `agentSubnet`), azd also adds two private-endpoint outbound rules to the managed network and grants the account identity the Azure AI Enterprise Network Connection Approver role on the resource group so the managed private endpoints are approved: + +- `ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the Private Link Scope. +- `foundry-account-pe` (target `account`, to the account itself) is required for hosted agents. Without it, hosted-agent calls fail with `Public access is disabled`; prompt agents are not affected. + +If `isolationMode` is not set, azd uses `AllowInternetOutbound` so the managed network resource exists. Hosted agents export telemetry with their own instance identity, which only exists after the agent is deployed. On `azd deploy`, azd grants that identity `Monitoring Metrics Publisher` on the Application Insights component, then updates a tag on the component (`azd-monitoring-role-sync`). The tag update is needed because Application Insights caches ingestion authorization: in live runs, hosted-agent export kept returning 403 for over 20 minutes after the grant until the component was updated. The grant applies only to azd-created components on the private path (not public-network projects or components you supply), and `AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` skips it. On every deploy azd also re-applies the component tag if it is missing, which recovers from an earlier failed refresh. It is best effort: if it is skipped or fails, azd adds a warning with the `az role assignment create` command to the deploy note, and hosted-agent export returns 403 until the role exists and the component has been updated (changing any tag does it). The deploying principal needs permission to create role assignments on the component (for example Owner or User Access Administrator). In a live run with `AllowOnlyApprovedOutbound`, hosted-agent traces reached the workspace and were read from inside the network with only this grant in place. + +**Impact on a shared virtual network.** The Azure Monitor private DNS zones and Private Link Scope change Azure Monitor routing for the whole virtual network, not only this project. With ingestion Private Only, any workload in the network (or in a network sharing its DNS zones) can send telemetry only to Application Insights or Log Analytics resources that are in the scope; telemetry to resources outside it stops arriving. A virtual network can use only one Private Link Scope, so to keep other workloads' telemetry, add their Application Insights components or Log Analytics workspaces as scoped resources of the Private Link Scope that azd created. To avoid any change to Azure Monitor routing, set `monitoring: {enabled: false}`. Check for existing workloads before you provision. For **existing AKS hosting**, set `agentHosting.clusterResourceId` on the project service. Without `network:`, the Foundry account uses public networking; with it, the account uses the private-networking configuration below. Hosting creates a Storage account and two separate managed identities. The native azd registry, its Project connection and image-pull grants are created only when an Agent requires building a container image, including with private account networking. Storage and any automatically created ACR expose public authenticated endpoints. See [Existing AKS hosting](../../azure.ai.projects/README.md#existing-aks-hosting) for cluster prerequisites and standard Foundry teardown behavior. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index fef2f1788a2..591ca2093c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -1303,6 +1303,12 @@ func writeExistingProjectBicepTemplates( {"templates/modules/foundry-project.bicep", "modules/foundry-project.bicep"}, {"templates/modules/monitoring.bicep", "modules/monitoring.bicep"}, {"templates/modules/monitoring-connection.bicep", "modules/monitoring-connection.bicep"}, + {"templates/modules/monitor-private-link.bicep", "modules/monitor-private-link.bicep"}, + {"templates/modules/monitoring-resync.bicep", "modules/monitoring-resync.bicep"}, + { + "templates/modules/network-approver-role-assignment.bicep", + "modules/network-approver-role-assignment.bicep", + }, } if acrMode == infraEjectAcrCreate || (acrMode == infraEjectAcrReuseConnect && !acrPullAssigned) { registrySource, err := fs.ReadFile( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go b/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go new file mode 100644 index 00000000000..f911ca3880d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/deploy_warning.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// appendDeployWarning adds text to the note of the last artifact, so it shows in the deploy output +// instead of subprocess stderr, which the host discards. Activity-only agents produce no artifacts, +// so a resource artifact for the component carries the warning instead. +func appendDeployWarning(result *azdext.ServiceDeployResult, componentID, warning string) { + if result == nil || strings.TrimSpace(warning) == "" { + return + } + if len(result.Artifacts) == 0 && componentID != "" { + result.Artifacts = append(result.Artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_RESOURCE, + Location: componentID, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "label": "Application Insights", + "clickable": "false", + }, + }) + } + for i := len(result.Artifacts) - 1; i >= 0; i-- { + artifact := result.Artifacts[i] + if artifact == nil { + continue + } + if artifact.Metadata == nil { + artifact.Metadata = map[string]string{} + } + if existing := artifact.Metadata["note"]; existing != "" { + artifact.Metadata["note"] = existing + "\n\nWarning: " + warning + } else { + artifact.Metadata["note"] = "Warning: " + warning + } + return + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go new file mode 100644 index 00000000000..18378a16c4c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_constants.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "time" + +// Environment keys written by the azure.ai.projects provisioning extension. They are defined +// separately in that Go module, so these values must stay identical to its outputs. +const ( + envFoundryMonitoringMode = "AZD_FOUNDRY_MONITORING_MODE" + envMonitoringPrivateLinkScope = "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID" + envApplicationInsightsResource = "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID" + envSkipRoleAssignments = "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS" + + // monitoringModeCreate means azd created the Application Insights component. + monitoringModeCreate = "create" +) + +const ( + roleMonitoringMetricsPublisher = "3913510d-42f4-4e42-8a64-420c390055eb" + roleMonitoringMetricsPublisherName = "Monitoring Metrics Publisher" + + // monitoringRoleSyncTag is merged onto the component after a grant so Application Insights + // refreshes its cached ingestion authorization. + monitoringRoleSyncTag = "azd-monitoring-role-sync" + + monitoringGrantAttempts = 4 +) + +// defaultMonitoringGrantRetryDelay is how long to wait between grant attempts. A new instance +// identity can take a short while to replicate before ARM accepts it as a role assignment principal. +const defaultMonitoringGrantRetryDelay = 15 * time.Second diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go new file mode 100644 index 00000000000..48772af89f5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac.go @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// monitoringGrantRetryDelay is a variable so tests can avoid waiting. +var monitoringGrantRetryDelay = defaultMonitoringGrantRetryDelay + +type monitoringRoleAssigner func( + ctx context.Context, + principalID, roleID, roleName, scope string, + principalType armauthorization.PrincipalType, +) (bool, error) + +// monitoringComponentToucher refreshes the component after a role assignment. Application Insights +// caches Entra authorization for ingestion, and without a component update a new Monitoring Metrics +// Publisher assignment kept returning 403 for over 20 minutes in live runs. +// +// created reports whether this deploy created the assignment. When it did not, the toucher only +// updates the component if the sync tag is missing, which recovers from an earlier failed touch. +type monitoringComponentToucher func(ctx context.Context, componentID string, created bool) error + +// privateMonitoringComponentID returns the App Insights component only when azd created it and +// connected it through the private path (Private Link Scope). Public-network projects and +// user-supplied components return "" so azd never changes their role assignments or tags. +func privateMonitoringComponentID(azdEnv map[string]string) string { + if strings.TrimSpace(azdEnv[envFoundryMonitoringMode]) != monitoringModeCreate || + strings.TrimSpace(azdEnv[envMonitoringPrivateLinkScope]) == "" { + return "" + } + return strings.TrimSpace(azdEnv[envApplicationInsightsResource]) +} + +// roleAssignmentsSkipped reports whether the user opted out of azd writing role assignments, using +// the same precedence as the developer RBAC check: environment value first, then the process. +func roleAssignmentsSkipped(azdEnv map[string]string) bool { + value := strings.TrimSpace(azdEnv[envSkipRoleAssignments]) + if value == "" { + value = os.Getenv(envSkipRoleAssignments) + } + skip, err := strconv.ParseBool(value) + return err == nil && skip +} + +func monitoringGrantCommand(principalID, componentID string) string { + return fmt.Sprintf( + "az role assignment create --assignee-object-id %s --assignee-principal-type ServicePrincipal "+ + "--role \"%s\" --scope %s", + principalID, roleMonitoringMetricsPublisherName, componentID, + ) +} + +// grantHostedAgentMonitoringPublisher lets a hosted agent's instance identity publish telemetry to the +// project's private App Insights component. The identity only exists after the agent version is +// created, so this cannot be done in the provisioning template. +// +// It is best effort and never fails the deploy. It returns a warning for the deploy note when +// telemetry may still be rejected, or "" when nothing needs the user's attention. +func grantHostedAgentMonitoringPublisher( + ctx context.Context, + assign monitoringRoleAssigner, + touch monitoringComponentToucher, + azdEnv map[string]string, + principalID string, +) string { + componentID := privateMonitoringComponentID(azdEnv) + principalID = strings.TrimSpace(principalID) + if componentID == "" || principalID == "" { + return "" + } + if roleAssignmentsSkipped(azdEnv) { + return fmt.Sprintf( + "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS is set, so azd did not grant the hosted agent identity access to "+ + "Application Insights. Hosted-agent telemetry is rejected with 403 until it has the role:\n %s", + monitoringGrantCommand(principalID, componentID), + ) + } + + var lastErr error + for attempt := 1; attempt <= monitoringGrantAttempts; attempt++ { + created, err := assign( + ctx, principalID, roleMonitoringMetricsPublisher, roleMonitoringMetricsPublisherName, + componentID, armauthorization.PrincipalTypeServicePrincipal, + ) + if err == nil { + if touch != nil { + if touchErr := touch(ctx, componentID, created); touchErr != nil { + return fmt.Sprintf( + "Granted the hosted agent identity Monitoring Metrics Publisher, but could not refresh %s: %v. "+ + "Hosted-agent telemetry can return 403 for a long time until the component is updated; "+ + "changing any tag on it forces a refresh.", + componentID, touchErr, + ) + } + } + return "" + } + lastErr = err + if !strings.Contains(err.Error(), "PrincipalNotFound") || attempt == monitoringGrantAttempts { + break + } + select { + case <-ctx.Done(): + return "" + case <-time.After(monitoringGrantRetryDelay): + } + } + + return fmt.Sprintf( + "Could not grant the hosted agent identity Monitoring Metrics Publisher on %s: %v\n"+ + "Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s", + componentID, lastErr, monitoringGrantCommand(principalID, componentID), + ) +} + +func newMonitoringRoleAssigner(credential azcore.TokenCredential, componentID string) (monitoringRoleAssigner, error) { + client, err := armauthorization.NewRoleAssignmentsClient( + extractSubscriptionID(componentID), credential, azure.NewArmClientOptions(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create role assignments client: %w", err) + } + return func( + ctx context.Context, + principalID, roleID, roleName, scope string, + principalType armauthorization.PrincipalType, + ) (bool, error) { + return assignRoleToIdentityWithClient(ctx, client, principalID, roleID, roleName, scope, principalType) + }, nil +} + +// ensureHostedAgentMonitoringPublisher wires the real ARM clients into grantHostedAgentMonitoringPublisher +// and returns its warning for the deploy note. +func (p *AgentServiceTargetProvider) ensureHostedAgentMonitoringPublisher( + ctx context.Context, + azdEnv map[string]string, + principalID string, +) string { + componentID := privateMonitoringComponentID(azdEnv) + if componentID == "" || strings.TrimSpace(principalID) == "" { + return "" + } + assign, err := newMonitoringRoleAssigner(p.credential, componentID) + if err != nil { + return fmt.Sprintf( + "Could not grant the hosted agent identity Monitoring Metrics Publisher: %v\n"+ + "Hosted-agent telemetry is rejected with 403 until it has the role. Grant it with:\n %s", + err, monitoringGrantCommand(strings.TrimSpace(principalID), componentID), + ) + } + touch := func(ctx context.Context, componentID string, created bool) error { + tags, err := armresources.NewTagsClient( + extractSubscriptionID(componentID), p.credential, azure.NewArmClientOptions(), + ) + if err != nil { + return err + } + return refreshMonitoringRoleSyncTag(ctx, tags, componentID, created, time.Now()) + } + return grantHostedAgentMonitoringPublisher(ctx, assign, touch, azdEnv, principalID) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go new file mode 100644 index 00000000000..e717d9fe1cd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_publisher_rbac_test.go @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + publisherTestComponent = "/subscriptions/sub-123/resourceGroups/rg/providers/microsoft.insights/components/appi" + publisherTestPrincipal = "11111111-2222-3333-4444-555555555555" +) + +func publisherTestEnv() map[string]string { + return map[string]string{ + "AZD_FOUNDRY_MONITORING_MODE": "create", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID": "/subscriptions/sub-123/resourceGroups/rg/providers/microsoft.insights/privateLinkScopes/ampls", + "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": publisherTestComponent, + } +} + +func neverAssign(t *testing.T) monitoringRoleAssigner { + return func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + t.Fatal("role must not be assigned") + return false, nil + } +} + +func TestMonitoringPublisherRoleID(t *testing.T) { + assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) +} + +func TestGrantHostedAgentMonitoringPublisherAssignsRole(t *testing.T) { + calls := 0 + assign := func( + _ context.Context, principalID, roleID, _, scope string, principalType armauthorization.PrincipalType, + ) (bool, error) { + calls++ + assert.Equal(t, publisherTestPrincipal, principalID) + assert.Equal(t, roleMonitoringMetricsPublisher, roleID) + assert.Equal(t, publisherTestComponent, scope) + assert.Equal(t, armauthorization.PrincipalTypeServicePrincipal, principalType) + return true, nil + } + touches := 0 + touch := func(_ context.Context, componentID string, created bool) error { + touches++ + assert.True(t, created) + assert.Equal(t, publisherTestComponent, componentID) + return nil + } + + warning := grantHostedAgentMonitoringPublisher( + t.Context(), assign, touch, publisherTestEnv(), " "+publisherTestPrincipal+" ", + ) + + assert.Empty(t, warning) + assert.Equal(t, 1, calls) + assert.Equal(t, 1, touches) +} + +func TestGrantHostedAgentMonitoringPublisherSkips(t *testing.T) { + without := func(key string) map[string]string { + env := publisherTestEnv() + delete(env, key) + return env + } + tests := map[string]struct { + env map[string]string + principal string + }{ + "no monitoring mode": {env: without("AZD_FOUNDRY_MONITORING_MODE"), principal: publisherTestPrincipal}, + "no component": {env: without("AZURE_APPLICATION_INSIGHTS_RESOURCE_ID"), principal: publisherTestPrincipal}, + "public network (no scope)": {env: without("AZD_MONITORING_PRIVATE_LINK_SCOPE_ID"), principal: publisherTestPrincipal}, + "no principal": {env: publisherTestEnv(), principal: " "}, + "user supplied component": { + env: func() map[string]string { + env := publisherTestEnv() + env["AZD_FOUNDRY_MONITORING_MODE"] = "reuse-connect" + return env + }(), + principal: publisherTestPrincipal, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + warning := grantHostedAgentMonitoringPublisher(t.Context(), neverAssign(t), nil, tt.env, tt.principal) + assert.Empty(t, warning) + }) + } +} + +func TestGrantHostedAgentMonitoringPublisherHonorsSkipRoleAssignments(t *testing.T) { + t.Run("environment value", func(t *testing.T) { + env := publisherTestEnv() + env["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"] = "true" + + warning := grantHostedAgentMonitoringPublisher(t.Context(), neverAssign(t), nil, env, publisherTestPrincipal) + + assert.Contains(t, warning, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + assert.Contains(t, warning, "az role assignment create") + assert.Contains(t, warning, publisherTestPrincipal) + }) + t.Run("process fallback", func(t *testing.T) { + t.Setenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", "1") + + warning := grantHostedAgentMonitoringPublisher( + t.Context(), neverAssign(t), nil, publisherTestEnv(), publisherTestPrincipal, + ) + + assert.Contains(t, warning, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS") + }) + t.Run("environment value wins over process", func(t *testing.T) { + t.Setenv("AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", "true") + env := publisherTestEnv() + env["AZD_AGENT_SKIP_ROLE_ASSIGNMENTS"] = "false" + calls := 0 + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + calls++ + return false, nil + } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, env, publisherTestPrincipal) + + assert.Empty(t, warning) + assert.Equal(t, 1, calls) + }) +} + +func TestGrantHostedAgentMonitoringPublisherPassesCreatedFlagToToucher(t *testing.T) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + return false, nil + } + var got []bool + touch := func(_ context.Context, _ string, created bool) error { + got = append(got, created) + return nil + } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) + + assert.Empty(t, warning) + assert.Equal(t, []bool{false}, got) +} + +func TestGrantHostedAgentMonitoringPublisherTouchFailureReturnsWarning(t *testing.T) { + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + return true, nil + } + touch := func(context.Context, string, bool) error { return errors.New("denied") } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, touch, publisherTestEnv(), publisherTestPrincipal) + + assert.Contains(t, warning, "could not refresh") + assert.Contains(t, warning, "denied") +} + +func TestGrantHostedAgentMonitoringPublisherRetriesPrincipalNotFound(t *testing.T) { + previous := monitoringGrantRetryDelay + monitoringGrantRetryDelay = time.Millisecond + t.Cleanup(func() { monitoringGrantRetryDelay = previous }) + + calls := 0 + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + calls++ + if calls < 3 { + return false, errors.New("PrincipalNotFound: principal does not exist") + } + return true, nil + } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) + + assert.Empty(t, warning) + assert.Equal(t, 3, calls) +} + +func TestGrantHostedAgentMonitoringPublisherFailureReturnsCommand(t *testing.T) { + calls := 0 + assign := func(context.Context, string, string, string, string, armauthorization.PrincipalType) (bool, error) { + calls++ + return false, errors.New("AuthorizationFailed") + } + + warning := grantHostedAgentMonitoringPublisher(t.Context(), assign, nil, publisherTestEnv(), publisherTestPrincipal) + + assert.Equal(t, 1, calls) + assert.Contains(t, warning, "AuthorizationFailed") + assert.Contains(t, warning, "az role assignment create --assignee-object-id "+publisherTestPrincipal) + assert.Contains(t, warning, "--scope "+publisherTestComponent) +} + +func TestMonitoringConstantsArePinned(t *testing.T) { + // These must match Azure's role definition and the azure.ai.projects provisioning outputs. + assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) + assert.Equal(t, "Monitoring Metrics Publisher", roleMonitoringMetricsPublisherName) + assert.Equal(t, "azd-monitoring-role-sync", monitoringRoleSyncTag) + assert.Equal(t, "AZD_FOUNDRY_MONITORING_MODE", envFoundryMonitoringMode) + assert.Equal(t, "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", envMonitoringPrivateLinkScope) + assert.Equal(t, "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID", envApplicationInsightsResource) + assert.Equal(t, "AZD_AGENT_SKIP_ROLE_ASSIGNMENTS", envSkipRoleAssignments) + assert.Equal(t, "create", monitoringModeCreate) + assert.Equal(t, 4, monitoringGrantAttempts) + assert.Equal(t, 15*time.Second, defaultMonitoringGrantRetryDelay) +} + +func TestMonitoringGrantCommand(t *testing.T) { + cmd := monitoringGrantCommand(publisherTestPrincipal, publisherTestComponent) + assert.Equal(t, + "az role assignment create --assignee-object-id "+publisherTestPrincipal+ + " --assignee-principal-type ServicePrincipal --role \"Monitoring Metrics Publisher\" --scope "+ + publisherTestComponent, + cmd) +} + +func TestAppendDeployWarning(t *testing.T) { + t.Run("appends to last artifact note", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{ + {Metadata: map[string]string{"note": "first"}}, + {Metadata: map[string]string{"note": "invoke"}}, + }} + + appendDeployWarning(result, publisherTestComponent, "role missing") + + assert.Equal(t, "first", result.Artifacts[0].Metadata["note"]) + assert.Equal(t, "invoke\n\nWarning: role missing", result.Artifacts[1].Metadata["note"]) + }) + t.Run("creates note and metadata", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} + + appendDeployWarning(result, publisherTestComponent, "role missing") + + require.Equal(t, "Warning: role missing", result.Artifacts[0].Metadata["note"]) + }) + t.Run("empty warning is a no-op", func(t *testing.T) { + result := &azdext.ServiceDeployResult{Artifacts: []*azdext.Artifact{{}}} + appendDeployWarning(result, publisherTestComponent, " ") + assert.Nil(t, result.Artifacts[0].Metadata) + assert.NotPanics(t, func() { appendDeployWarning(nil, publisherTestComponent, "x") }) + }) + t.Run("no artifacts gets a fallback artifact carrying the warning", func(t *testing.T) { + result := &azdext.ServiceDeployResult{} + + appendDeployWarning(result, publisherTestComponent, "role missing") + + require.Len(t, result.Artifacts, 1) + assert.Equal(t, publisherTestComponent, result.Artifacts[0].Location) + assert.Equal(t, "Warning: role missing", result.Artifacts[0].Metadata["note"]) + }) + t.Run("no artifacts and no component is a no-op", func(t *testing.T) { + result := &azdext.ServiceDeployResult{} + appendDeployWarning(result, "", "role missing") + assert.Empty(t, result.Artifacts) + }) +} + +type fakeTagsClient struct { + existing map[string]*string + getErr error + updateErr error + gets int + updates []armresources.TagsPatchResource +} + +func (f *fakeTagsClient) GetAtScope( + _ context.Context, _ string, _ *armresources.TagsClientGetAtScopeOptions, +) (armresources.TagsClientGetAtScopeResponse, error) { + f.gets++ + if f.getErr != nil { + return armresources.TagsClientGetAtScopeResponse{}, f.getErr + } + return armresources.TagsClientGetAtScopeResponse{ + TagsResource: armresources.TagsResource{Properties: &armresources.Tags{Tags: f.existing}}, + }, nil +} + +func (f *fakeTagsClient) UpdateAtScope( + _ context.Context, _ string, p armresources.TagsPatchResource, _ *armresources.TagsClientUpdateAtScopeOptions, +) (armresources.TagsClientUpdateAtScopeResponse, error) { + if f.updateErr != nil { + return armresources.TagsClientUpdateAtScopeResponse{}, f.updateErr + } + f.updates = append(f.updates, p) + return armresources.TagsClientUpdateAtScopeResponse{}, nil +} + +func TestRefreshMonitoringRoleSyncTag(t *testing.T) { + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + synced := map[string]*string{monitoringRoleSyncTag: new("earlier")} + + t.Run("created assignment writes tag without reading", func(t *testing.T) { + c := &fakeTagsClient{existing: synced} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, true, now)) + assert.Equal(t, 0, c.gets) + require.Len(t, c.updates, 1) + assert.Equal(t, "2026-01-02T03:04:05Z", *c.updates[0].Properties.Tags[monitoringRoleSyncTag]) + assert.Equal(t, armresources.TagsPatchOperationMerge, *c.updates[0].Operation) + }) + t.Run("existing assignment with tag present is a no-op", func(t *testing.T) { + c := &fakeTagsClient{existing: synced} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Empty(t, c.updates) + }) + t.Run("existing assignment with tag missing re-applies it", func(t *testing.T) { + c := &fakeTagsClient{existing: map[string]*string{"other": new("x")}} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Len(t, c.updates, 1) + }) + t.Run("existing assignment with no tags re-applies it", func(t *testing.T) { + c := &fakeTagsClient{} + require.NoError(t, refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now)) + assert.Len(t, c.updates, 1) + }) + t.Run("read failure is returned without writing", func(t *testing.T) { + c := &fakeTagsClient{getErr: errors.New("read denied")} + err := refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, false, now) + require.ErrorContains(t, err, "read denied") + assert.Empty(t, c.updates) + }) + t.Run("update failure is returned", func(t *testing.T) { + c := &fakeTagsClient{updateErr: errors.New("write denied")} + err := refreshMonitoringRoleSyncTag(t.Context(), c, publisherTestComponent, true, now) + require.ErrorContains(t, err, "write denied") + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go new file mode 100644 index 00000000000..6d7e64e20b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/monitoring_tags.go @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +// monitoringTagsClient is the subset of armresources.TagsClient used to refresh the component. +type monitoringTagsClient interface { + GetAtScope( + ctx context.Context, scope string, options *armresources.TagsClientGetAtScopeOptions, + ) (armresources.TagsClientGetAtScopeResponse, error) + UpdateAtScope( + ctx context.Context, scope string, parameters armresources.TagsPatchResource, + options *armresources.TagsClientUpdateAtScopeOptions, + ) (armresources.TagsClientUpdateAtScopeResponse, error) +} + +// refreshMonitoringRoleSyncTag merges the sync tag onto the component. When the assignment already +// existed (created is false) it only writes the tag if it is missing, so repeat deploys are no-ops +// but an earlier failed refresh is retried. +func refreshMonitoringRoleSyncTag( + ctx context.Context, + tags monitoringTagsClient, + componentID string, + created bool, + now time.Time, +) error { + if !created { + current, err := tags.GetAtScope(ctx, componentID, nil) + if err != nil { + return err + } + if current.Properties != nil && current.Properties.Tags != nil && + current.Properties.Tags[monitoringRoleSyncTag] != nil { + return nil + } + } + _, err := tags.UpdateAtScope(ctx, componentID, armresources.TagsPatchResource{ + Operation: new(armresources.TagsPatchOperationMerge), + Properties: &armresources.Tags{Tags: map[string]*string{ + monitoringRoleSyncTag: new(now.UTC().Format(time.RFC3339)), + }}, + }, nil) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index de94f846fd3..a1de615a37c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1947,7 +1947,13 @@ func (p *AgentServiceTargetProvider) Deploy( } } - return p.finalizeDeploy( + monitoringWarning := "" + if identity := result.agentVersion.InstanceIdentity; identity != nil && privateMonitoringComponentID(azdEnv) != "" { + progress("Granting agent identity access to Application Insights") + monitoringWarning = p.ensureHostedAgentMonitoringPublisher(ctx, azdEnv, identity.PrincipalID) + } + + deployResult, err := p.finalizeDeploy( ctx, progress, serviceConfig, @@ -1960,6 +1966,11 @@ func (p *AgentServiceTargetProvider) Deploy( activityProfile, serviceTargetConfig.Activity, ) + if err != nil { + return nil, err + } + appendDeployWarning(deployResult, privateMonitoringComponentID(azdEnv), monitoringWarning) + return deployResult, nil } func activityBotOwnership( diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 9562dbbe1fc..bd8fc5ba6b7 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -189,13 +189,60 @@ Log Analytics Reader role on the Application Insights resource; azd does not grant it. Discovery rejects conflicting destinations, protected names, ambiguous sharing, unreadable resources, and authentication or ingestion policies that would need -to be weakened. ApiKey setup does not enable local authentication or configure -private-network ingestion. A project with a `network:` block always skips -automatic monitoring, whether `monitoring:` is omitted, empty, or names a -component, so provisioning is never blocked; azd prints a warning when it -skips monitoring. Set up monitoring for such -projects outside azd. Other monitoring failures can fail normal provisioning; there -is no second best-effort deployment or agent deployment repair hook. +to be weakened. ApiKey setup does not enable local authentication. A new +project with a `network:` block that uses your own agent subnet gets a private +monitoring path that matches the Foundry samples: the component disables public +ingestion and keeps public query enabled, and azd adds an Azure Monitor Private +Link Scope (ingestion Private Only, query Open), an `azuremonitor` private +endpoint, and the four Azure Monitor private DNS zones. Ingestion is private +only; trace reads are not restricted to the private network. Right after a +fresh deployment, private ingestion can return 403 for a while (observed up to +about 20 minutes) before traces start to arrive. Live testing confirmed that +Foundry service-side traces from a deployed prompt agent were read from inside +the network without any post-deployment change. +With your own agent subnet you can also connect an existing component +(`reuse-connect`): azd builds its own Private Link Scope, private endpoint and +DNS zones and links the component and its workspace to the scope, without +modifying the component. The component must keep local authentication enabled. +azd does not re-update a component it does not own; if ingestion returns 403 +after provisioning, touching the component (for example, updating a tag) can +clear it. +Unsupported private-network cases never block provisioning: shared private DNS zones (`network.dns`), a virtual network that already has an +Azure Monitor private endpoint (azd does not adopt an existing scope or +endpoint), or a network azd cannot inspect skip automatic monitoring and azd +prints a warning (also on stderr). With managed egress (no `agentSubnet`), azd +also adds two outbound rules to the managed network and grants the account +identity the Azure AI Enterprise Network Connection Approver role on the +resource group so the managed private endpoints are approved: +`ampls-monitor-rule` (target `azuremonitor`) lets the agent runtime reach the +Private Link Scope, and `foundry-account-pe` (target `account`, to the account +itself) is required for hosted agents, which otherwise fail with +`Public access is disabled`. The hosted agent's own identity also needs +`Monitoring Metrics Publisher` on the Application Insights component to export +telemetry. That identity exists only after the agent is deployed, so +`azd deploy` in the agents extension grants it, then updates a tag on the +component so Application Insights refreshes its cached ingestion authorization +(best effort, only for azd-created components on the private path; set +`AZD_AGENT_SKIP_ROLE_ASSIGNMENTS=true` to skip it; on skip or failure the +deploy note carries a warning with the `az role assignment create` command). If you set no +`isolationMode`, azd uses `AllowInternetOutbound` so the managed network exists. +Ejected or hand-authored Bicep that bypasses +azd's checks never creates the private path unless +`monitoringPrivateLinkApproved` is set. Set `monitoring.enabled: false` to opt +out silently. + +**Impact on a shared virtual network.** The Azure Monitor private DNS zones and +Private Link Scope change Azure Monitor routing for the whole virtual network, +not only this project. With ingestion Private Only, any workload in the network +(or in a network sharing its DNS zones) can send telemetry only to Application +Insights or Log Analytics resources that are in the scope; telemetry to +resources outside it stops arriving. A virtual network can use only one Private +Link Scope, so to keep other workloads' telemetry, add their Application +Insights components or Log Analytics workspaces as scoped resources of the +Private Link Scope that azd created. To avoid any change to Azure Monitor +routing, set `monitoring.enabled: false`. Check for existing workloads before +you provision. Other monitoring failures can fail normal provisioning; there is +no second best-effort deployment or agent deployment repair hook. Monitoring creates billable Azure resources and can store agent telemetry, including application-supplied content. Review your application's telemetry, diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 9362e773e82..6985be18418 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -6,6 +6,8 @@ overrides: words: - ABAC - alnum + - ampls + - azuremonitor - exterrors - foundryproject - idempotently 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..4c52f48dcea 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_existing_infra.go @@ -971,7 +971,10 @@ func writeExistingProjectBicep( } } } - for _, name := range []string{"monitoring.bicep", "monitoring-connection.bicep"} { + for _, name := range []string{ + "monitoring.bicep", "monitoring-connection.bicep", "monitor-private-link.bicep", "monitoring-resync.bicep", + "network-approver-role-assignment.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/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index 6c8db57c14e..2e0aee8f75f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -2467,6 +2467,7 @@ var canonicalOutputNames = []string{ "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", } var greenfieldOutputNames = []string{ @@ -2487,6 +2488,7 @@ var greenfieldOutputNames = []string{ "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID", } var existingProjectOutputNames = []string{ diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index a6287b4ae53..eba38a858f8 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -621,6 +621,15 @@ func TestCanonicalizeOutputName(t *testing.T) { "unknown name must pass through verbatim, not silently dropped") } +func TestOwnedPrivateLinkScopeOutputIsCanonicalAndCleaned(t *testing.T) { + t.Parallel() + + name := synthesis.OwnedPrivateLinkScopeOutput + assert.Equal(t, name, canonicalizeOutputName("azD_MONITORING_PRIVATE_LINK_SCOPE_ID")) + assert.Contains(t, greenfieldOutputNames, name) + assert.Contains(t, monitoringOutputKeys(), name) +} + // mapKeys returns a sorted list of keys for stable assert messages. func mapKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go index 65e38a80862..90d904e1bcd 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_graph.go @@ -14,8 +14,33 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" ) +// monitoringPlannedPrivateLinkScope returns the Azure Monitor Private Link Scope +// ARM plans in the owned resource group, or "" unless there is exactly one. +func monitoringPlannedPrivateLinkScope(result *armresources.WhatIfOperationResult, subscriptionID, resourceGroup string) string { + if result == nil || result.Properties == nil { + return "" + } + selected := "" + for _, change := range result.Properties.Changes { + if change == nil || change.ResourceID == nil || change.ChangeType == nil || + *change.ChangeType == armresources.ChangeTypeIgnore { + continue + } + id, err := arm.ParseResourceID(*change.ResourceID) + if err != nil || !strings.EqualFold(id.ResourceType.String(), "Microsoft.Insights/privateLinkScopes") || + !strings.EqualFold(id.SubscriptionID, subscriptionID) || !strings.EqualFold(id.ResourceGroupName, resourceGroup) { + continue + } + if selected != "" && !strings.EqualFold(selected, id.String()) { + return "" + } + selected = id.String() + } + return selected +} + func monitoringPlannedResource( - result *armresources.WhatIfOperationResult, resourceType, subscriptionID, resourceGroup string, + 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 +69,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 +117,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 +129,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 +158,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..9c69081d5db 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight.go @@ -7,7 +7,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "log" + "os" "strings" "azure.ai.projects/synthesis" @@ -23,6 +26,7 @@ func monitoringOutputKeys() []string { "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID", "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME", "AZD_FOUNDRY_MONITORING_MODE", + synthesis.OwnedPrivateLinkScopeOutput, } } @@ -49,6 +53,102 @@ 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 +} + +// ownedPrivateLinkScope is the exact Azure Monitor Private Link Scope this +// environment already created. A succeeded deployment records it as an output; +// a verified failed retry has no outputs, so the scope comes from the What-If plan. +func (p *FoundryProvisioningProvider) ownedPrivateLinkScope( + source *templateSource, previous *monitoringTargetBinding, +) string { + if previous == nil { + return "" + } + for name, output := range armOutputsToProto(previous.outputs) { + if output != nil && output.Value != "" && strings.EqualFold(name, synthesis.OwnedPrivateLinkScopeOutput) { + return output.Value + } + } + if previous.retry { + group, err := monitoringParameter(source, "resourceGroupName") + if err == nil { + return monitoringPlannedPrivateLinkScope(previous.plan, p.subID, group) + } + } + return "" +} + +// A private-link graph is only deployed after the VNet is proven free of other +// Azure Monitor private endpoints. For a new environment anything inconclusive +// skips monitoring, because ARM would accept a second scope and rewrite the +// shared Azure Monitor DNS. An environment that already owns monitoring gets a +// retryable error instead, so a transient read cannot drop its binding. +func (p *FoundryProvisioningProvider) privateMonitoringSkipReason( + ctx context.Context, + source *templateSource, + resolver *synthesis.MonitoringResolver, + previous *monitoringTargetBinding, +) (string, error) { + ownedScopeID := p.ownedPrivateLinkScope(source, previous) + owned := ownedScopeID != "" || (previous != nil && previous.owned) + // Skipping would record monitoringMode none and drop the binding of an owned graph. + ownedBlock := func(reason string) (string, error) { + if owned { + return "", fmt.Errorf("automatic monitoring cannot continue for this environment: %s; "+ + "resolve this or set monitoring.enabled: false", reason) + } + return reason, nil + } + if zones, _ := templateParameter(source, "dnsZonesResourceGroup"); zones != nil && zones != "" { + return ownedBlock("shared private DNS zones are configured and azd cannot verify they are safe to bind to a new Azure Monitor scope") + } + vnetID, err := monitoringParameter(source, "vnetId") + if err != nil { + return ownedBlock(err.Error()) + } + if vnetID == "" { + return "", nil + } + err = resolver.CheckMonitorPrivateLinkConflict(ctx, vnetID, ownedScopeID) + if err == nil { + return "", nil + } + if _, conflict := errors.AsType[*synthesis.MonitorPrivateLinkConflictError](err); conflict { + return ownedBlock(err.Error()) + } + log.Printf("[warn] foundry provider: could not check for an existing Azure Monitor private endpoint: %v", err) + if owned { + return "", fmt.Errorf("could not verify the Azure Monitor private endpoint this environment already uses; "+ + "retry provisioning: %w", err) + } + return "the virtual network could not be checked for an existing Azure Monitor private endpoint: " + err.Error(), nil +} + +func skipPrivateMonitoring(source *templateSource, progress grpcbroker.ProgressFunc, reason string) { + source.parameters["monitoringMode"] = map[string]any{"value": "none"} + if _, approved := source.parameters["monitoringPrivateLinkApproved"]; approved { + source.parameters["monitoringPrivateLinkApproved"] = map[string]any{"value": false} + } + monitoringWarning(progress, "automatic monitoring was skipped. "+reason) +} + +// Progress is a replaceable spinner message, so a skip is also written to stderr. +func monitoringWarning(progress grpcbroker.ProgressFunc, message string) { + progress("Warning: " + message) + fmt.Fprintln(os.Stderr, "WARNING: "+message) +} + +// Templates without the gate parameter (older or hand-authored) are left untouched. +func approvePrivateMonitoring(source *templateSource) { + parameters, _ := source.armTemplate["parameters"].(map[string]any) + if _, declared := parameters["monitoringPrivateLinkApproved"]; declared { + source.parameters["monitoringPrivateLinkApproved"] = map[string]any{"value": true} + } +} + // Both Preview and Deploy resolve the same graph. This is read-only and never // runs during State or agent deployment. func (p *FoundryProvisioningProvider) prepareMonitoring( @@ -59,7 +159,7 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( ) error { if p.synthResult != nil { for _, warning := range p.synthResult.Warnings { - progress("Warning: " + warning) + monitoringWarning(progress, warning) } } mode, err := monitoringParameter(source, "monitoringMode") @@ -79,6 +179,10 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if mode != "create" && componentID == "" { return fmt.Errorf("monitoring mode %s requires applicationInsightsResourceId", mode) } + if monitoringPrivateNetwork(source) && (mode == "create" || mode == "reuse-connect") { + // The planning What-If must include the monitoring graph. A skip below withdraws this approval. + approvePrivateMonitoring(source) + } projectID, previous, err := p.resolveMonitoringTarget(ctx, source, client, progress) if err != nil { return err @@ -87,7 +191,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 @@ -119,6 +225,27 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( if err := p.validateMonitoringCreateGraph(ctx, source, previous, connection); err != nil { return err } + if monitoringPrivateNetwork(source) { + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) + if err != nil { + return err + } + if reason != "" { + skipPrivateMonitoring(source, progress, reason) + return nil + } + } + } else if mode == "reuse-connect" && monitoringPrivateNetwork(source) { + // Keep the private-link graph reconciled on every provision, not only the first, + // so a retry after a partial failure still completes it. + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) + if err != nil { + return err + } + if reason != "" { + skipPrivateMonitoring(source, progress, reason) + return nil + } } else if mode == "create" && source.mode != templateModeEmbedded { return fmt.Errorf("a compatible AppInsights connection already exists; preserve it with " + "monitoringMode already-connected and its applicationInsightsResourceId in the authored infrastructure") @@ -126,6 +253,7 @@ func (p *FoundryProvisioningProvider) prepareMonitoring( mode = "already-connected" componentID = connection.Target } + // A preserved connection is reused as is, so only a new one needs key authentication. if err := resolver.ValidateComponent(ctx, connection.Target, mode == "create"); err != nil { return err } @@ -138,22 +266,28 @@ 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 - } - properties, err := synthesis.MonitoringProperties(resource) + if monitoringPrivateNetwork(source) { + reason, err := p.privateMonitoringSkipReason(ctx, source, &resolver, previous) if err != nil { return err } - if err := synthesis.MonitoringNetworkPolicy(properties); err != nil { - return err + if reason != "" { + // Skip rather than fail so an existing private network keeps provisioning. + skipPrivateMonitoring(source, progress, reason) + return nil + } + } else { + for _, resource := range []*armresources.GenericResource{account, projectResource} { + 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" { @@ -247,7 +381,8 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( previous = &monitoringTargetBinding{projectID: id, legacy: true} } previous.plan = &result.WhatIfOperationResult - if !previous.owned && mode == "create" && monitoringFailedCreateAttempt(bindingParameters, response.Properties) { + if !previous.owned && (mode == "create" || (mode == "reuse-connect" && monitoringPrivateNetwork(source))) && + monitoringFailedCreateAttempt(bindingParameters, response.Properties, mode) { exported, err := client.ExportTemplateAtSubscriptionScope(ctx, p.deploymentName(), nil) if err != nil { return "", nil, synthesis.MonitoringReadError("verify the previous failed monitoring graph", err) @@ -255,6 +390,11 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( before, beforeErr := json.Marshal(exported.Template) after, afterErr := json.Marshal(source.armTemplate) if beforeErr != nil || afterErr != nil || !bytes.Equal(before, after) { + if mode == "reuse-connect" { + return "", nil, fmt.Errorf("the failed deployment used a different monitoring graph; " + + "existing-component reuse is already selected, so restore the previous infrastructure " + + "template or disable monitoring (monitoring.enabled: false) before retrying") + } return "", nil, fmt.Errorf("the failed deployment used a different monitoring graph; " + "restore its infrastructure or explicitly configure existing-component reuse before retrying") } @@ -266,7 +406,7 @@ func (p *FoundryProvisioningProvider) resolveMonitoringTarget( // A failed deployment's outputs are not an identity binding. Its exact graph // and identity inputs can still authorize retry after What-If resolves targets. func monitoringFailedCreateAttempt( - parameters map[string]any, previous *armresources.DeploymentPropertiesExtended, + parameters map[string]any, previous *armresources.DeploymentPropertiesExtended, mode string, ) bool { if previous == nil || previous.ProvisioningState == nil || (*previous.ProvisioningState != armresources.ProvisioningStateFailed && @@ -275,7 +415,7 @@ func monitoringFailedCreateAttempt( return false } old, _ := previous.Parameters.(map[string]any) - if mode, _ := armStringParameter(old, "monitoringMode"); mode != "create" { + if oldMode, _ := armStringParameter(old, "monitoringMode"); oldMode != mode { return false } return true diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go index 8b1ac2a1ccb..6f3730ae697 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/monitoring_preflight_test.go @@ -21,6 +21,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,8 +72,15 @@ type monitoringCloudFixture struct { deploymentStatus int deploymentOutputs map[string]any deploymentResources []any + vnetStatus int + foreignEndpointScope string } +const ( + monitoringTestVNet = "/subscriptions/sub/resourceGroups/net-rg/providers/Microsoft.Network/virtualNetworks/vnet" + monitoringTestEndpoint = "/subscriptions/sub/resourceGroups/net-rg/providers/Microsoft.Network/privateEndpoints/pe" +) + func newMonitoringCloudFixture(t *testing.T, fixture *monitoringCloudFixture) *FoundryProvisioningProvider { t.Helper() server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -90,6 +98,24 @@ func newMonitoringCloudFixture(t *testing.T, fixture *monitoringCloudFixture) *F }}) } switch { + case strings.Contains(request.URL.Path, "/providers/Microsoft.Network/virtualNetworks/"): + if fixture.vnetStatus != 0 { + failure(fixture.vnetStatus) + return + } + endpoints := []any{} + if fixture.foreignEndpointScope != "" { + endpoints = append(endpoints, map[string]any{"id": monitoringTestEndpoint}) + } + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "subnets": []any{map[string]any{"properties": map[string]any{"privateEndpoints": endpoints}}}, + }}) + case strings.EqualFold(request.URL.Path, monitoringTestEndpoint): + reply(http.StatusOK, map[string]any{"id": request.URL.Path, "properties": map[string]any{ + "privateLinkServiceConnections": []any{map[string]any{"properties": map[string]any{ + "groupIds": []any{"azuremonitor"}, "privateLinkServiceId": fixture.foreignEndpointScope, + }}}, + }}) case strings.EqualFold(request.URL.Path, "/subscriptions/sub/resourceGroups/adjunct-rg"): if request.Method == http.MethodDelete { fixture.deletions = append(fixture.deletions, request.URL.Path) @@ -473,20 +499,84 @@ func TestMonitoringPreflightImplicitExistingProjectIsNotConsent(t *testing.T) { assert.Len(t, fixture.requests, 3, "only deployment binding, What-If and project identity are read; no setup or discovery") } -func TestMonitoringPreflightPrivateNetworkSkipsMonitoring(t *testing.T) { +func TestMonitoringPreflightPrivateNetworkConnectsExistingComponent(t *testing.T) { t.Parallel() - fixture := monitoringCloudFixture{} - p := newMonitoringCloudFixture(t, &fixture) - p.synthResult = &synthesis.Result{MonitoringExplicit: true} - source := monitoringTestSource("create") - source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} - client, err := p.deploymentsClient(t.Context()) - require.NoError(t, err) - require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) - assert.Equal(t, "none", source.parameters["monitoringMode"].(map[string]any)["value"]) - fixture.mu.Lock() - defer fixture.mu.Unlock() - assert.Empty(t, fixture.writes) + tests := []struct { + name string + component map[string]any + wantErr string + }{ + { + name: "component with public ingestion disabled", + component: map[string]any{"publicNetworkAccessForIngestion": "Disabled"}, + }, + {name: "component with public ingestion enabled"}, + { + name: "local authentication disabled", + component: map[string]any{"DisableLocalAuth": true}, + wantErr: "disabled local authentication", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{componentPolicy: tt.component} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + err = p.prepareMonitoring(t.Context(), source, client, func(string) {}) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, "reuse-connect", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + assert.Empty(t, fixture.writes) + }) + } +} + +func TestMonitoringPreflightPrivateNetworkGates(t *testing.T) { + t.Parallel() + tests := []struct { + name string + extra map[string]any + declared bool + wantMode string + approved bool + }{ + {name: "managed egress is verified and approved", extra: map[string]any{"useManagedEgress": true}, declared: true, wantMode: "reuse-connect", approved: true}, + {name: "shared DNS zones are skipped", extra: map[string]any{"dnsZonesResourceGroup": "dns-rg"}, wantMode: "none"}, + {name: "verified network is approved", declared: true, wantMode: "reuse-connect", approved: true}, + {name: "template without the gate is left alone", wantMode: "reuse-connect"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{} + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + for name, value := range tt.extra { + source.parameters[name] = map[string]any{"value": value} + } + if tt.declared { + source.armTemplate["parameters"] = map[string]any{"monitoringPrivateLinkApproved": map[string]any{}} + } + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, tt.wantMode, source.parameters["monitoringMode"].(map[string]any)["value"]) + approved, present := source.parameters["monitoringPrivateLinkApproved"].(map[string]any) + assert.Equal(t, tt.approved, present && approved["value"] == true) + }) + } } func TestMonitoringPreflightOwnedExistingProjectRetainsCreateGraph(t *testing.T) { @@ -577,6 +667,181 @@ func TestMonitoringPreflightFreshNativeTargetComesFromARM(t *testing.T) { } } +func TestMonitoringPreflightPrivateCreatePlansWithApproval(t *testing.T) { + t.Parallel() + component := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.Insights/components/new" + workspace := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.OperationalInsights/workspaces/new" + fixture := monitoringCloudFixture{ + projectStatus: http.StatusNotFound, accountStatus: http.StatusNotFound, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestProject, "changeType": "Create"}, + map[string]any{"resourceId": component, "changeType": "Create"}, + map[string]any{"resourceId": workspace, "changeType": "Create"}, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{} + source := monitoringTestSource("create") + delete(source.parameters, "projectResourceId") + source.parameters["applicationInsightsResourceId"] = map[string]any{"value": ""} + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + source.armTemplate["parameters"] = map[string]any{"monitoringPrivateLinkApproved": map[string]any{}} + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "project-rg", "foundryProjectName": "project", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "create", source.parameters["monitoringMode"].(map[string]any)["value"]) + fixture.mu.Lock() + defer fixture.mu.Unlock() + require.Len(t, fixture.plans, 1) + planned := fixture.plans[0]["properties"].(map[string]any)["parameters"].(map[string]any) + assert.Equal(t, true, planned["monitoringPrivateLinkApproved"].(map[string]any)["value"], + "the planning What-If must include the monitoring graph") +} + +func TestMonitoringPrivateMonitoringSkipReasonDecisions(t *testing.T) { + t.Parallel() + owned := "/subscriptions/sub/resourceGroups/project-rg/providers/Microsoft.Insights/privateLinkScopes/owned" + foreign := "/subscriptions/sub/resourceGroups/other/providers/Microsoft.Insights/privateLinkScopes/foreign" + ownedOutputs := map[string]any{"azd_monitoring_private_link_scope_id": map[string]any{"value": owned}} + planWith := func(id string) *armresources.WhatIfOperationResult { + kind := armresources.ChangeTypeCreate + return &armresources.WhatIfOperationResult{Properties: &armresources.WhatIfOperationProperties{ + Changes: []*armresources.WhatIfChange{{ResourceID: &id, ChangeType: &kind}}, + }} + } + tests := []struct { + name string + foreign string + vnetStatus int + previous *monitoringTargetBinding + extra map[string]any + wantSkip bool + wantErr bool + }{ + {name: "new environment with a free network proceeds"}, + {name: "new environment conflict skips", foreign: foreign, wantSkip: true}, + {name: "new environment read failure skips", vnetStatus: http.StatusInternalServerError, wantSkip: true}, + { + name: "owned environment conflict errors", foreign: foreign, + previous: &monitoringTargetBinding{owned: true}, wantErr: true, + }, + { + name: "owned environment read failure errors", vnetStatus: http.StatusInternalServerError, + previous: &monitoringTargetBinding{owned: true}, wantErr: true, + }, + { + name: "owned environment shared DNS errors", previous: &monitoringTargetBinding{owned: true}, + extra: map[string]any{"dnsZonesResourceGroup": "dns-rg"}, wantErr: true, + }, + { + name: "own scope from mixed-case outputs is exempt", foreign: owned, + previous: &monitoringTargetBinding{outputs: ownedOutputs}, + }, + { + name: "own scope from a failed retry plan is exempt", foreign: owned, + previous: &monitoringTargetBinding{retry: true, plan: planWith(owned)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{foreignEndpointScope: tt.foreign, vnetStatus: tt.vnetStatus} + p := newMonitoringCloudFixture(t, &fixture) + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + source.parameters["vnetId"] = map[string]any{"value": monitoringTestVNet} + source.parameters["resourceGroupName"] = map[string]any{"value": "project-rg"} + for name, value := range tt.extra { + source.parameters[name] = map[string]any{"value": value} + } + resolver := synthesis.MonitoringResolver{ + Credential: p.credential, ClientOptions: p.armClientOptions, PrivateNetwork: true, + } + reason, err := p.privateMonitoringSkipReason(t.Context(), source, &resolver, tt.previous) + if tt.wantErr { + require.Error(t, err) + assert.Empty(t, reason) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantSkip, reason != "", reason) + }) + } +} + +func TestMonitoringPreflightPrivateNetworkPreservesKeylessConnection(t *testing.T) { + t.Parallel() + fixture := monitoringCloudFixture{ + componentPolicy: map[string]any{"DisableLocalAuth": true}, + accountConnections: []any{monitoringTestConnection("shared-monitoring", monitoringTestComponent, "AAD", true)}, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{MonitoringExplicit: true} + source := monitoringTestSource("reuse-connect") + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + require.NoError(t, p.prepareMonitoring(t.Context(), source, client, func(string) {})) + assert.Equal(t, "reuse-connect", source.parameters["monitoringMode"].(map[string]any)["value"]) + assert.Equal(t, false, source.parameters["monitoringCreateConnection"].(map[string]any)["value"]) +} + +func TestMonitoringPlannedPrivateLinkScope(t *testing.T) { + t.Parallel() + prefix := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/" + result := func(changes ...*armresources.WhatIfChange) *armresources.WhatIfOperationResult { + return &armresources.WhatIfOperationResult{Properties: &armresources.WhatIfOperationProperties{Changes: changes}} + } + change := func(id string, kind armresources.ChangeType) *armresources.WhatIfChange { + return &armresources.WhatIfChange{ResourceID: &id, ChangeType: &kind} + } + assert.Equal(t, prefix+"a", monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate)), "SUB", "RG")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate), change(prefix+"b", armresources.ChangeTypeCreate)), "sub", "rg")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeIgnore)), "sub", "rg")) + assert.Empty(t, monitoringPlannedPrivateLinkScope( + result(change(prefix+"a", armresources.ChangeTypeCreate)), "sub", "other")) + assert.Empty(t, monitoringPlannedPrivateLinkScope(nil, "sub", "rg")) +} + +func TestMonitoringPreflightPrivateReuseConnectRetryMismatchSuggestsRecovery(t *testing.T) { + t.Parallel() + source := monitoringTestSource("reuse-connect") + delete(source.parameters, "projectResourceId") + for name, value := range map[string]string{ + "location": "westus3", "resourceGroupName": "project-rg", "foundryProjectName": "project", "resourceTokenSalt": "test", + } { + source.parameters[name] = map[string]any{"value": value} + } + source.parameters["enableNetworkIsolation"] = map[string]any{"value": true} + fixture := monitoringCloudFixture{ + plannedStatus: http.StatusOK, + plannedChanges: []any{ + map[string]any{"resourceId": monitoringTestProject, "changeType": "NoChange"}, + }, + previous: map[string]any{ + "provisioningState": "Failed", "parameters": source.parameters, + "template": map[string]any{"resources": []any{"different"}}, + }, + } + p := newMonitoringCloudFixture(t, &fixture) + p.synthResult = &synthesis.Result{} + client, err := p.deploymentsClient(t.Context()) + require.NoError(t, err) + err = p.prepareMonitoring(t.Context(), source, client, func(string) {}) + require.Error(t, err) + assert.Contains(t, err.Error(), "different monitoring graph") + assert.Contains(t, err.Error(), "monitoring.enabled: false") + assert.NotContains(t, err.Error(), "explicitly configure existing-component reuse") +} + func TestMonitoringPreflightFailedNativeAttemptCanRetryWithoutTrustingOutputs(t *testing.T) { t.Parallel() source := monitoringTestSource("create") diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go index 6e5de45949f..65b43d8cc8d 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/hosting_test.go @@ -383,7 +383,8 @@ func TestHostingGraphUsesOneNativeRegistry(t *testing.T) { assert.Equal(t, 2, identities) assert.Equal(t, 1, storages) assert.Equal(t, 1, connections) - assert.Equal(t, 5, roleTemplates, "the cluster role template is instantiated for both AKS roles") + // Includes the monitoring module's managed-egress network approver assignment. + assert.Equal(t, 6, roleTemplates, "the cluster role template is instantiated for both AKS roles") assert.Equal(t, 1, clusterGrantModules) text := string(raw) for _, required := range []string{ diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_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..09144e487b8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link.go @@ -0,0 +1,107 @@ +// 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/resourcemanager/resources/armresources" +) + +// OwnedPrivateLinkScopeOutput is the deployment output carrying the exact +// Azure Monitor Private Link Scope ID a previous deployment of this project created. +const OwnedPrivateLinkScopeOutput = "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID" + +// MonitorPrivateLinkConflictError reports an Azure Monitor private endpoint that +// already serves the VNet. A VNet can use only one Azure Monitor Private Link +// 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 other than the exact scope (ownedScopeID) a previous +// deployment of this project created. It is read-only. A +// *MonitorPrivateLinkConflictError means a conflict; any other error means the +// check could not complete. +func (p *MonitoringResolver) CheckMonitorPrivateLinkConflict( + ctx context.Context, vnetID, ownedScopeID 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 ownedScopeID != "" && strings.EqualFold(scopeID, ownedScopeID) { + 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..a7f15b71373 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_private_link_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "errors" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testVNetID = "/subscriptions/" + testSub + "/resourceGroups/net/providers/Microsoft.Network/virtualNetworks/vnet" + testPEID = "/subscriptions/" + testSub + "/resourceGroups/net/providers/Microsoft.Network/privateEndpoints/pe" +) + +func privateLinkReader(group, scopeID string) *fakeReader { + connection := func(groupID string) []any { + return []any{map[string]any{"properties": map[string]any{ + "groupIds": []any{groupID}, "privateLinkServiceId": scopeID, + }}} + } + return &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testVNetID): armResource(testVNetID, map[string]any{"subnets": []any{ + map[string]any{"properties": map[string]any{"privateEndpoints": []any{map[string]any{"id": testPEID}}}}, + }}), + strings.ToLower(testPEID): armResource(testPEID, map[string]any{ + "privateLinkServiceConnections": connection(group), + }), + }} +} + +func TestMonitorPrivateLinkAccessModes(t *testing.T) { + t.Parallel() + // Matches the Foundry samples: ingestion PrivateOnly, query Open. + for _, name := range []string{"templates/main.arm.json", "templates/existing-project.arm.json"} { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + text := string(raw) + assert.Contains(t, text, `"ingestionAccessMode": "PrivateOnly"`, name) + assert.Contains(t, text, `"queryAccessMode": "Open"`, name) + assert.NotContains(t, text, `"ingestionAccessMode": "Open"`, name) + assert.NotContains(t, text, "azd-ampls-linked", name) + } + bicep, err := templatesFS.ReadFile("templates/modules/monitor-private-link.bicep") + require.NoError(t, err) + assert.Contains(t, string(bicep), "ingestionAccessMode: 'PrivateOnly'") + assert.Contains(t, string(bicep), "queryAccessMode: 'Open'") + assert.NotContains(t, string(bicep), "touchApplicationInsights") +} + +func TestMonitoringResyncMatchesComponent(t *testing.T) { + t.Parallel() + read := func(name string) string { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + return string(raw) + } + monitoring := read("templates/modules/monitoring.bicep") + resync := read("templates/modules/monitoring-resync.bicep") + // The second component update must carry the original properties, run only on + // private networks, and be ordered after the scope through its output. + assert.Contains(t, monitoring, "module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork)") + assert.Contains(t, monitoring, "privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId") + assert.Contains(t, monitoring, "resync!.outputs.id") + assert.Contains(t, resync, "publicNetworkAccessForQuery: publicNetworkAccessForQuery") + assert.Contains(t, resync, "uniqueString(privateLinkScopeResourceId, name)") + assert.NotContains(t, resync, "utcNow") + assert.Contains(t, monitoring, "publicNetworkAccessForQuery: 'Enabled'") +} + +func TestMonitoringPrivateLinkScopesExistingComponent(t *testing.T) { + t.Parallel() + raw, err := templatesFS.ReadFile("templates/modules/monitoring.bicep") + require.NoError(t, err) + monitoring := string(raw) + // A connected existing component is scoped to the new AMPLS, but never rewritten. + assert.Contains(t, monitoring, + "module privateLink 'monitor-private-link.bicep' = if ((createResources || mode == 'reuse-connect') && privateNetwork)") + assert.Contains(t, monitoring, "createResources ? component!.id : existingComponent!.id") + assert.Contains(t, monitoring, + "createResources ? workspace!.id : existingComponent!.properties.WorkspaceResourceId") + assert.Contains(t, monitoring, "module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork)") + assert.NotContains(t, monitoring, "resource existingComponent 'Microsoft.Insights/components@2020-02-02' = ") +} + +func TestPrivateMonitoringTemplateGates(t *testing.T) { + t.Parallel() + read := func(name string) string { + raw, err := templatesFS.ReadFile(name) + require.NoError(t, err) + return string(raw) + } + main := read("templates/main.bicep") + // Without azd's verified-network approval the private path is never created; managed egress adds an outbound rule. + assert.Contains(t, main, "param monitoringPrivateLinkApproved bool = false") + assert.Contains(t, main, "enableNetworkIsolation && !monitoringPrivateLinkApproved") + assert.Contains(t, main, "managedEgressRule: managedMonitoringRule") + assert.Contains(t, main, "module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'none')") + assert.Contains(t, main, "output AZD_MONITORING_PRIVATE_LINK_SCOPE_ID") + // The workspace closes public ingestion like the component does. + monitoring := read("templates/modules/monitoring.bicep") + assert.Contains(t, monitoring, "ampls-monitor-rule") + assert.Contains(t, monitoring, "foundry-account-pe") + assert.Contains(t, monitoring, "subresourceTarget: 'account'") + assert.Contains(t, monitoring, "subresourceTarget: 'azuremonitor'") + workspace := monitoring[strings.Index(monitoring, "resource workspace"):strings.Index(monitoring, "resource component")] + assert.Contains(t, workspace, "publicNetworkAccessForIngestion: publicNetworkAccess") + assert.Contains(t, workspace, "publicNetworkAccessForQuery: 'Enabled'") +} + +func TestCheckMonitorPrivateLinkConflict(t *testing.T) { + t.Parallel() + const ownedScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" + const foreignScope = "/subscriptions/" + testSub + "/resourceGroups/shared/providers/Microsoft.Insights/privateLinkScopes/corp" + const sameGroupForeign = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/corp" + + const lookalikeScope = "/subscriptions/" + testSub + "/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-other" + const otherSubScope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Insights/privateLinkScopes/ampls-abc" + + tests := []struct { + name string + reader *fakeReader + owned string + conflict bool + err bool + }{ + {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), owned: ownedScope}, + {name: "owned scope matched case-insensitively", reader: privateLinkReader("azuremonitor", ownedScope), + owned: strings.ToUpper(ownedScope)}, + {name: "scope not yet created by this project", reader: privateLinkReader("azuremonitor", ownedScope), conflict: true}, + {name: "ampls-prefixed scope of another project", reader: privateLinkReader("azuremonitor", lookalikeScope), + owned: ownedScope, conflict: true}, + {name: "same scope name in another subscription", reader: privateLinkReader("azuremonitor", otherSubScope), + owned: ownedScope, conflict: true}, + {name: "foreign scope", reader: privateLinkReader("azuremonitor", foreignScope), owned: ownedScope, conflict: true}, + {name: "foreign scope in the deployment group", reader: privateLinkReader("azuremonitor", sameGroupForeign), + owned: ownedScope, conflict: true}, + {name: "read failure", reader: &fakeReader{resourceErr: errors.New("boom")}, err: true}, + {name: "private endpoint deleted since listing", reader: func() *fakeReader { + r := privateLinkReader("azuremonitor", foreignScope) + delete(r.resources, strings.ToLower(testPEID)) + return r + }()}, + {name: "malformed private endpoint id", reader: &fakeReader{resources: map[string]*armresources.GenericResource{ + strings.ToLower(testVNetID): armResource(testVNetID, map[string]any{"subnets": []any{ + map[string]any{"properties": map[string]any{"privateEndpoints": []any{map[string]any{"id": "not-an-id"}}}}, + }}), + }}, err: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := (&MonitoringResolver{Reader: tt.reader}).CheckMonitorPrivateLinkConflict(t.Context(), testVNetID, tt.owned) + var conflict *MonitorPrivateLinkConflictError + switch { + case tt.conflict: + require.ErrorAs(t, err, &conflict) + assert.Equal(t, testPEID, conflict.PrivateEndpointID) + assert.Contains(t, err.Error(), "monitoring.enabled: false") + case tt.err: + require.Error(t, err) + assert.False(t, errors.As(err, &conflict)) + default: + require.NoError(t, err) + } + }) + } +} + +func TestMonitoringKeyPolicyForPrivateNetwork(t *testing.T) { + t.Parallel() + disabled := map[string]any{"publicNetworkAccessForIngestion": "Disabled"} + assert.Error(t, MonitoringKeyPolicyFor(disabled, false)) + assert.NoError(t, MonitoringKeyPolicyFor(disabled, true)) + assert.Error(t, MonitoringKeyPolicyFor(map[string]any{"DisableLocalAuth": true}, true)) +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go index cf8422fc792..e88361d6313 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_resolver.go @@ -29,6 +29,9 @@ type MonitoringResolver struct { Credential azcore.TokenCredential ClientOptions *arm.ClientOptions Reader MonitoringReader + // PrivateNetwork accepts components whose public ingestion is disabled + // because the deployment configures the private path itself. + PrivateNetwork bool } func (p *MonitoringResolver) reader() MonitoringReader { @@ -84,7 +87,7 @@ func (p *MonitoringResolver) ValidateComponent( return fmt.Errorf("the linked Log Analytics workspace does not exist") } if needsKey { - if err := MonitoringKeyPolicy(properties); err != nil { + if err := MonitoringKeyPolicyFor(properties, p.PrivateNetwork); err != nil { return err } if key, _ := properties["ConnectionString"].(string); strings.TrimSpace(key) == "" { @@ -94,7 +97,7 @@ func (p *MonitoringResolver) ValidateComponent( if err != nil { return err } - return MonitoringKeyPolicy(workspaceProperties) + return MonitoringKeyPolicyFor(workspaceProperties, p.PrivateNetwork) } return nil } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go index 3b77b187d60..6eab676a19d 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/monitoring_test.go @@ -142,13 +142,19 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { " peSubnet: {vnet: " + vnet + ", name: pe-subnet, prefix: 192.168.1.0/24}\n", } monitoring := []struct { - name string - settings string + name string + settings string + wantMode string + wantWarning bool }{ - {name: "default"}, - {name: "empty block", settings: " monitoring: {}\n"}, - {name: "BYO component", settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n"}, - {name: "explicit opt out", settings: " monitoring:\n enabled: false\n"}, + {name: "default", wantMode: "create"}, + {name: "empty block", settings: " monitoring: {}\n", wantMode: "create"}, + { + name: "BYO component", + settings: " monitoring:\n applicationInsightsResourceId: " + component + "\n", + wantMode: "reuse-connect", + }, + {name: "explicit opt out", settings: " monitoring:\n enabled: false\n", wantMode: "none"}, } for networkName, network := range networks { for _, tt := range monitoring { @@ -159,13 +165,18 @@ func TestSynthesizeMonitoringPrivateNetwork(t *testing.T) { ServiceName: "foundry", }) require.NoError(t, err) - assert.Equal(t, "none", result.Parameters["monitoringMode"]) - assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) - if tt.name == "explicit opt out" { - assert.Empty(t, result.Warnings) - } else { + wantMode, wantWarning := tt.wantMode, tt.wantWarning + assert.Equal(t, wantMode, result.Parameters["monitoringMode"]) + if wantMode == "reuse-connect" { + assert.Equal(t, component, result.Parameters["applicationInsightsResourceId"]) + } + if wantWarning { assert.Equal(t, []string{PrivateNetworkMonitoringWarning}, result.Warnings) + assert.Equal(t, "", result.Parameters["applicationInsightsResourceId"]) + } else { + assert.Empty(t, result.Warnings) } + assert.Equal(t, true, result.Parameters["enableNetworkIsolation"]) }) } } diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go index 137e15b4b80..09a8374774f 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/synthesis/synthesizer.go @@ -343,8 +343,11 @@ func Synthesize(in Input) (*Result, error) { return nil, err } var warnings []string - if mode, _ := monitoring["monitoringMode"].(string); netMode != NetworkModeNone && mode != "" && mode != "none" { - // Private-network ingestion is not supported yet; skip rather than fail provisioning. + monitoringMode, _ := monitoring["monitoringMode"].(string) + if netMode != NetworkModeNone && monitoringMode != "" && monitoringMode != "none" && + !((netMode == NetworkModeByo || netMode == NetworkModeManaged) && + (monitoringMode == "create" || monitoringMode == "reuse-connect")) { + // This private-network setup is not supported yet; skip rather than fail provisioning. monitoring = map[string]any{"monitoringMode": "none", "applicationInsightsResourceId": ""} warnings = append(warnings, PrivateNetworkMonitoringWarning) } @@ -371,8 +374,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, so monitoring was not " + + "configured. Agent traces will not be collected automatically." // GreenfieldACRMode distinguishes selectors from deployed outputs. func GreenfieldACRMode(in Input) (string, error) { diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json index 121e3a9f355..d17302c2955 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/existing-project.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "17391728415161597698" + "templateHash": "19133302460120456" } }, "definitions": { @@ -803,7 +803,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3471042481280362115" + "templateHash": "11506587318951603642" } }, "parameters": { @@ -844,16 +844,51 @@ "resourceTokenSalt": { "type": "string", "defaultValue": "" + }, + "privateNetwork": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "" + }, + "vnetLocation": { + "type": "string", + "defaultValue": "" + }, + "peSubnetId": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "" + }, + "managedEgressRule": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope." + } } }, "variables": { + "networkConnectionApproverRoleId": "b556d68e-0be0-4f35-a333-ad7ee1ce17ea", "createResources": "[equals(parameters('mode'), 'create')]", + "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", "projectParts": "[split(parameters('projectResourceId'), '/')]", "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", - "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]", - "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]" }, "resources": [ { @@ -867,7 +902,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" } }, { @@ -880,12 +917,441 @@ "kind": "web", "properties": { "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]", + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" }, "dependsOn": [ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/ampls-monitor-rule', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]", + "subresourceTarget": "azuremonitor" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-network-approver')]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" + ] + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/foundry-account-pe', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8])]", + "subresourceTarget": "account" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules', split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[0], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[1], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[2])]" + ] + }, + { + "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "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": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]", + "logAnalyticsWorkspaceResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]", + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "peSubnetId": { + "value": "[parameters('peSubnetId')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "10577939142835646204" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet; the private endpoint must be in the same region." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource and link names." + } + }, + "applicationInsightsResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Application Insights component to scope." + } + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Log Analytics workspace to scope." + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the customer VNet." + } + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the private-endpoint subnet." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "variables": { + "copy": [ + { + "name": "zoneIds", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]), resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]))]" + } + ], + "monitorDnsZoneNames": [ + "privatelink.monitor.azure.com", + "privatelink.oms.opinsights.azure.com", + "privatelink.ods.opinsights.azure.com", + "privatelink.agentsvc.azure-automation.net" + ], + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "scopeName": "[format('ampls-{0}', parameters('suffix'))]" + }, + "resources": [ + { + "type": "microsoft.insights/privateLinkScopes", + "apiVersion": "2021-07-01-preview", + "name": "[variables('scopeName')]", + "location": "global", + "properties": { + "accessModeSettings": { + "ingestionAccessMode": "PrivateOnly", + "queryAccessMode": "Open" + } + } + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'appinsights-scoped')]", + "properties": { + "linkedResourceId": "[parameters('applicationInsightsResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + ] + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'workspace-scoped')]", + "properties": { + "linkedResourceId": "[parameters('logAnalyticsWorkspaceResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]" + ] + }, + { + "copy": { + "name": "zones", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('monitorDnsZoneNames')[copyIndex()]]", + "location": "global" + }, + { + "copy": { + "name": "zoneLinks", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('monitorDnsZoneNames')[copyIndex()], format('{0}-{1}-link', replace(variables('monitorDnsZoneNames')[copyIndex()], '.', '-'), parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex()])]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', variables('scopeName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', variables('scopeName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "groupIds": [ + "azuremonitor" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'workspace-scoped')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', variables('scopeName')), format('{0}-dns-group', variables('scopeName')))]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": { + "name": "[replace(variables('monitorDnsZoneNames')[copyIndex('privateDnsZoneConfigs')], '.', '-')]", + "properties": { + "privateDnsZoneId": "[variables('zoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', variables('scopeName')))]", + "zoneLinks" + ] + } + ], + "outputs": { + "azureMonitorPrivateLinkScopeResourceId": { + "type": "string", + "value": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-network-approver", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]" + }, + "roleDefinitionId": { + "value": "[variables('networkConnectionApproverRoleId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "785343973509457050" + } + }, + "parameters": { + "principalId": { + "type": "string" + }, + "roleDefinitionId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('principalId'), parameters('roleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]" + } + } + ] + } + } + }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-resync", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('appi-{0}', variables('token'))]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('monitoringTags')]" + }, + "workspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "publicNetworkAccessForIngestion": { + "value": "[variables('publicNetworkAccess')]" + }, + "publicNetworkAccessForQuery": { + "value": "Enabled" + }, + "privateLinkScopeResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "10336562090588378730" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "workspaceResourceId": { + "type": "string" + }, + "publicNetworkAccessForIngestion": { + "type": "string" + }, + "publicNetworkAccessForQuery": { + "type": "string" + }, + "privateLinkScopeResourceId": { + "type": "string", + "metadata": { + "description": "The private link scope resource ID. Hashing it into a tag orders this deployment after the scope." + } + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-ampls-link', uniqueString(parameters('privateLinkScopeResourceId'), parameters('name'))))]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[parameters('workspaceResourceId')]", + "publicNetworkAccessForIngestion": "[parameters('publicNetworkAccessForIngestion')]", + "publicNetworkAccessForQuery": "[parameters('publicNetworkAccessForQuery')]" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, { "condition": "[and(parameters('createConnection'), or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')))]", "type": "Microsoft.Resources/deployments", @@ -905,9 +1371,7 @@ "projectName": { "value": "[variables('projectParts')[10]]" }, - "applicationInsightsResourceId": { - "value": "[variables('effectiveComponentId')]" - }, + "applicationInsightsResourceId": "[if(equals(parameters('mode'), 'none'), createObject('value', ''), if(variables('createResources'), if(parameters('privateNetwork'), createObject('value', reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]))))]", "environmentName": { "value": "[parameters('environmentName')]" } @@ -991,14 +1455,19 @@ } }, "dependsOn": [ - "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync')]" ] } ], "outputs": { + "privateLinkScopeResourceId": { + "type": "string", + "value": "[if(and(or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork')), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value, '')]" + }, "applicationInsightsResourceId": { "type": "string", - "value": "[variables('effectiveComponentId')]" + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" }, "applicationInsightsAppId": { "type": "string", diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json index f2306051c37..2d1d4757c0f 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.arm.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "8956748682766475904" + "templateHash": "842033600340647321" } }, "definitions": { @@ -326,8 +326,21 @@ "metadata": { "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." } + }, + "monitoringPrivateLinkApproved": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set by azd after it verified the VNet has no conflicting Azure Monitor private endpoint. A network-isolated deployment without it never creates the private monitoring path." + } } }, + "variables": { + "privateMonitoringBlocked": "[and(and(parameters('enableNetworkIsolation'), not(parameters('monitoringPrivateLinkApproved'))), or(equals(parameters('monitoringMode'), 'create'), equals(parameters('monitoringMode'), 'reuse-connect')))]", + "effectiveMonitoringMode": "[if(variables('privateMonitoringBlocked'), 'none', parameters('monitoringMode'))]", + "managedMonitoringRule": "[and(and(parameters('enableNetworkIsolation'), parameters('useManagedEgress')), or(equals(variables('effectiveMonitoringMode'), 'create'), equals(variables('effectiveMonitoringMode'), 'reuse-connect')))]", + "effectiveManagedIsolationMode": "[if(and(variables('managedMonitoringRule'), empty(parameters('managedIsolationMode'))), 'AllowInternetOutbound', parameters('managedIsolationMode'))]" + }, "resources": { "resourceGroup": { "type": "Microsoft.Resources/resourceGroups", @@ -420,7 +433,7 @@ "value": "[parameters('createPESubnet')]" }, "managedIsolationMode": { - "value": "[parameters('managedIsolationMode')]" + "value": "[variables('effectiveManagedIsolationMode')]" }, "dnsZonesResourceGroup": { "value": "[parameters('dnsZonesResourceGroup')]" @@ -437,7 +450,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "9059128905136687232" + "templateHash": "7637784606052942916" } }, "definitions": { @@ -853,6 +866,19 @@ "prerequisites" ] }, + "byoProjectCapabilityHost": { + "condition": "[variables('useByoNetwork')]", + "type": "Microsoft.CognitiveServices/accounts/projects/capabilityHosts", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/agents', variables('activeFoundryAccountName'), parameters('foundryProjectName'))]", + "properties": { + "capabilityHostKind": "Agents" + }, + "dependsOn": [ + "byoFoundryAccount::project", + "foundryAccount::project" + ] + }, "foundryManagedNetwork": { "condition": "[and(variables('useManagedNetwork'), not(empty(parameters('managedIsolationMode'))))]", "type": "Microsoft.CognitiveServices/accounts/managedNetworks", @@ -2426,6 +2452,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, '')]" } } } @@ -2435,7 +2473,7 @@ ] }, "monitoring": { - "condition": "[not(equals(parameters('monitoringMode'), 'none'))]", + "condition": "[not(equals(variables('effectiveMonitoringMode'), 'none'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "foundry-monitoring", @@ -2447,7 +2485,7 @@ "mode": "Incremental", "parameters": { "mode": { - "value": "[parameters('monitoringMode')]" + "value": "[variables('effectiveMonitoringMode')]" }, "location": { "value": "[parameters('location')]" @@ -2470,6 +2508,27 @@ "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]" + }, + "managedEgressRule": { + "value": "[variables('managedMonitoringRule')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" } }, "template": { @@ -2479,7 +2538,7 @@ "_generator": { "name": "bicep", "version": "0.48.1.52935", - "templateHash": "3471042481280362115" + "templateHash": "11506587318951603642" } }, "parameters": { @@ -2520,16 +2579,51 @@ "resourceTokenSalt": { "type": "string", "defaultValue": "" + }, + "privateNetwork": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public." + } + }, + "vnetId": { + "type": "string", + "defaultValue": "" + }, + "vnetLocation": { + "type": "string", + "defaultValue": "" + }, + "peSubnetId": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "" + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "" + }, + "managedEgressRule": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope." + } } }, "variables": { + "networkConnectionApproverRoleId": "b556d68e-0be0-4f35-a333-ad7ee1ce17ea", "createResources": "[equals(parameters('mode'), 'create')]", + "publicNetworkAccess": "[if(parameters('privateNetwork'), 'Disabled', 'Enabled')]", "token": "[uniqueString(parameters('projectResourceId'), resourceGroup().id, parameters('resourceTokenSalt'))]", "projectParts": "[split(parameters('projectResourceId'), '/')]", "componentParts": "[split(parameters('applicationInsightsResourceId'), '/')]", "connectionName": "[format('azd-appinsights-{0}', take(replace(guid(toLower(variables('projectParts')[10])), '-', ''), 13))]", - "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]", - "effectiveComponentId": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" + "monitoringTags": "[union(parameters('tags'), createObject('azd-created-by', 'azure.ai.projects', 'azd-environment', parameters('environmentName'), 'azd-monitoring-project', guid(toLower(parameters('projectResourceId')))))]" }, "resources": [ { @@ -2543,7 +2637,9 @@ "sku": { "name": "PerGB2018" }, - "retentionInDays": 30 + "retentionInDays": 30, + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" } }, { @@ -2556,12 +2652,441 @@ "kind": "web", "properties": { "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]", + "publicNetworkAccessForIngestion": "[variables('publicNetworkAccess')]", + "publicNetworkAccessForQuery": "Enabled" }, "dependsOn": [ "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" ] }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/ampls-monitor-rule', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]", + "subresourceTarget": "azuremonitor" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-network-approver')]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]" + ] + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "apiVersion": "2025-10-01-preview", + "name": "[format('{0}/default/foundry-account-pe', variables('projectParts')[8])]", + "properties": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "[resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8])]", + "subresourceTarget": "account" + }, + "category": "UserDefined" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules', split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[0], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[1], split(format('{0}/default/ampls-monitor-rule', variables('projectParts')[8]), '/')[2])]" + ] + }, + { + "condition": "[and(or(variables('createResources'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "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": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]", + "logAnalyticsWorkspaceResourceId": "[if(variables('createResources'), createObject('value', resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]), '2020-02-02').WorkspaceResourceId))]", + "vnetId": { + "value": "[parameters('vnetId')]" + }, + "peSubnetId": { + "value": "[parameters('peSubnetId')]" + }, + "dnsZonesResourceGroup": { + "value": "[parameters('dnsZonesResourceGroup')]" + }, + "dnsZonesSubscription": { + "value": "[parameters('dnsZonesSubscription')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "10577939142835646204" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region of the customer VNet; the private endpoint must be in the same region." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource and link names." + } + }, + "applicationInsightsResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Application Insights component to scope." + } + }, + "logAnalyticsWorkspaceResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the Log Analytics workspace to scope." + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the customer VNet." + } + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "ARM resource id of the private-endpoint subnet." + } + }, + "dnsZonesResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group holding existing private DNS zones. Empty creates and links new zones." + } + }, + "dnsZonesSubscription": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription holding existing private DNS zones. Empty defaults to this subscription." + } + } + }, + "variables": { + "copy": [ + { + "name": "zoneIds", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": "[if(variables('useExistingZones'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingZonesSubscription'), parameters('dnsZonesResourceGroup')), 'Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]), resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex('zoneIds')]))]" + } + ], + "monitorDnsZoneNames": [ + "privatelink.monitor.azure.com", + "privatelink.oms.opinsights.azure.com", + "privatelink.ods.opinsights.azure.com", + "privatelink.agentsvc.azure-automation.net" + ], + "useExistingZones": "[not(empty(parameters('dnsZonesResourceGroup')))]", + "existingZonesSubscription": "[if(empty(parameters('dnsZonesSubscription')), subscription().subscriptionId, parameters('dnsZonesSubscription'))]", + "scopeName": "[format('ampls-{0}', parameters('suffix'))]" + }, + "resources": [ + { + "type": "microsoft.insights/privateLinkScopes", + "apiVersion": "2021-07-01-preview", + "name": "[variables('scopeName')]", + "location": "global", + "properties": { + "accessModeSettings": { + "ingestionAccessMode": "PrivateOnly", + "queryAccessMode": "Open" + } + } + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'appinsights-scoped')]", + "properties": { + "linkedResourceId": "[parameters('applicationInsightsResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + ] + }, + { + "type": "Microsoft.Insights/privateLinkScopes/scopedResources", + "apiVersion": "2021-07-01-preview", + "name": "[format('{0}/{1}', variables('scopeName'), 'workspace-scoped')]", + "properties": { + "linkedResourceId": "[parameters('logAnalyticsWorkspaceResourceId')]" + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]" + ] + }, + { + "copy": { + "name": "zones", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('monitorDnsZoneNames')[copyIndex()]]", + "location": "global" + }, + { + "copy": { + "name": "zoneLinks", + "count": "[length(variables('monitorDnsZoneNames'))]" + }, + "condition": "[not(variables('useExistingZones'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('monitorDnsZoneNames')[copyIndex()], format('{0}-{1}-link', replace(variables('monitorDnsZoneNames')[copyIndex()], '.', '-'), parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('monitorDnsZoneNames')[copyIndex()])]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', variables('scopeName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', variables('scopeName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "groupIds": [ + "azuremonitor" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'appinsights-scoped')]", + "[resourceId('Microsoft.Insights/privateLinkScopes/scopedResources', variables('scopeName'), 'workspace-scoped')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', variables('scopeName')), format('{0}-dns-group', variables('scopeName')))]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(variables('monitorDnsZoneNames'))]", + "input": { + "name": "[replace(variables('monitorDnsZoneNames')[copyIndex('privateDnsZoneConfigs')], '.', '-')]", + "properties": { + "privateDnsZoneId": "[variables('zoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', variables('scopeName')))]", + "zoneLinks" + ] + } + ], + "outputs": { + "azureMonitorPrivateLinkScopeResourceId": { + "type": "string", + "value": "[resourceId('microsoft.insights/privateLinkScopes', variables('scopeName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, + { + "condition": "[parameters('managedEgressRule')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-network-approver", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('projectParts')[8]), '2025-06-01', 'full').identity.principalId]" + }, + "roleDefinitionId": { + "value": "[variables('networkConnectionApproverRoleId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "785343973509457050" + } + }, + "parameters": { + "principalId": { + "type": "string" + }, + "roleDefinitionId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('principalId'), parameters('roleDefinitionId'))]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]" + } + } + ] + } + } + }, + { + "condition": "[and(variables('createResources'), parameters('privateNetwork'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "foundry-monitoring-resync", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('appi-{0}', variables('token'))]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('monitoringTags')]" + }, + "workspaceResourceId": { + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + }, + "publicNetworkAccessForIngestion": { + "value": "[variables('publicNetworkAccess')]" + }, + "publicNetworkAccessForQuery": { + "value": "Enabled" + }, + "privateLinkScopeResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "10336562090588378730" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "workspaceResourceId": { + "type": "string" + }, + "publicNetworkAccessForIngestion": { + "type": "string" + }, + "publicNetworkAccessForQuery": { + "type": "string" + }, + "privateLinkScopeResourceId": { + "type": "string", + "metadata": { + "description": "The private link scope resource ID. Hashing it into a tag orders this deployment after the scope." + } + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-ampls-link', uniqueString(parameters('privateLinkScopeResourceId'), parameters('name'))))]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[parameters('workspaceResourceId')]", + "publicNetworkAccessForIngestion": "[parameters('publicNetworkAccessForIngestion')]", + "publicNetworkAccessForQuery": "[parameters('publicNetworkAccessForQuery')]" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link')]", + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', variables('token')))]" + ] + }, { "condition": "[and(parameters('createConnection'), or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')))]", "type": "Microsoft.Resources/deployments", @@ -2581,9 +3106,7 @@ "projectName": { "value": "[variables('projectParts')[10]]" }, - "applicationInsightsResourceId": { - "value": "[variables('effectiveComponentId')]" - }, + "applicationInsightsResourceId": "[if(equals(parameters('mode'), 'none'), createObject('value', ''), if(variables('createResources'), if(parameters('privateNetwork'), createObject('value', reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value), createObject('value', resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token'))))), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8]))))]", "environmentName": { "value": "[parameters('environmentName')]" } @@ -2667,14 +3190,19 @@ } }, "dependsOn": [ - "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]" + "[resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))]", + "[resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync')]" ] } ], "outputs": { + "privateLinkScopeResourceId": { + "type": "string", + "value": "[if(and(or(equals(parameters('mode'), 'create'), equals(parameters('mode'), 'reuse-connect')), parameters('privateNetwork')), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-private-link'), '2025-04-01').outputs.azureMonitorPrivateLinkScopeResourceId.value, '')]" + }, "applicationInsightsResourceId": { "type": "string", - "value": "[variables('effectiveComponentId')]" + "value": "[if(equals(parameters('mode'), 'none'), '', if(variables('createResources'), if(parameters('privateNetwork'), reference(resourceId('Microsoft.Resources/deployments', 'foundry-monitoring-resync'), '2025-04-01').outputs.id.value, resourceId('Microsoft.Insights/components', format('appi-{0}', variables('token')))), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('componentParts')[2], variables('componentParts')[4]), 'Microsoft.Insights/components', variables('componentParts')[8])))]" }, "applicationInsightsAppId": { "type": "string", @@ -2748,23 +3276,27 @@ }, "AZURE_APPLICATION_INSIGHTS_RESOURCE_ID": { "type": "string", - "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsResourceId.value, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsResourceId.value, '')]" }, "AZURE_APPLICATION_INSIGHTS_APP_ID": { "type": "string", - "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsAppId.value, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.applicationInsightsAppId.value, '')]" }, "AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID": { "type": "string", - "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.logAnalyticsWorkspaceResourceId.value, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.logAnalyticsWorkspaceResourceId.value, '')]" }, "AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME": { "type": "string", - "value": "[if(not(equals(parameters('monitoringMode'), 'none')), reference('monitoring').outputs.projectConnectionName.value, '')]" + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.projectConnectionName.value, '')]" }, "AZD_FOUNDRY_MONITORING_MODE": { "type": "string", - "value": "[parameters('monitoringMode')]" + "value": "[variables('effectiveMonitoringMode')]" + }, + "AZD_MONITORING_PRIVATE_LINK_SCOPE_ID": { + "type": "string", + "value": "[if(not(equals(variables('effectiveMonitoringMode'), 'none')), reference('monitoring').outputs.privateLinkScopeResourceId.value, '')]" } } } \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep index 69e789b863b..74e617547ac 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/main.bicep @@ -157,6 +157,16 @@ param dnsZonesResourceGroup string = '' @description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') param dnsZonesSubscription string = '' +@description('Set by azd after it verified the VNet has no conflicting Azure Monitor private endpoint. A network-isolated deployment without it never creates the private monitoring path.') +param monitoringPrivateLinkApproved bool = false + +// Private monitoring needs a verified network. Managed egress reaches the private scope through a managed-network outbound rule. +var privateMonitoringBlocked = enableNetworkIsolation && !monitoringPrivateLinkApproved && (monitoringMode == 'create' || monitoringMode == 'reuse-connect') +var effectiveMonitoringMode = privateMonitoringBlocked ? 'none' : monitoringMode +var managedMonitoringRule = enableNetworkIsolation && useManagedEgress && (effectiveMonitoringMode == 'create' || effectiveMonitoringMode == 'reuse-connect') +// The outbound rule needs the managed network resource, so an unset mode defaults to AllowInternetOutbound. +var effectiveManagedIsolationMode = managedMonitoringRule && empty(managedIsolationMode) ? 'AllowInternetOutbound' : managedIsolationMode + // Resources resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { @@ -193,17 +203,17 @@ module resources 'modules/resources.bicep' = { peSubnetName: peSubnetName peSubnetPrefix: peSubnetPrefix createPESubnet: createPESubnet - managedIsolationMode: managedIsolationMode + managedIsolationMode: effectiveManagedIsolationMode dnsZonesResourceGroup: dnsZonesResourceGroup dnsZonesSubscription: dnsZonesSubscription } } -module monitoring 'modules/monitoring.bicep' = if (monitoringMode != 'none') { +module monitoring 'modules/monitoring.bicep' = if (effectiveMonitoringMode != 'none') { name: 'foundry-monitoring' scope: resourceGroup params: { - mode: monitoringMode + mode: effectiveMonitoringMode location: location tags: tags projectResourceId: resources.outputs.AZURE_AI_PROJECT_ID @@ -212,6 +222,13 @@ 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 + managedEgressRule: managedMonitoringRule + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription } } @@ -229,8 +246,9 @@ output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CON output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME output AZURE_FOUNDRY_NETWORK_MODE string = resources.outputs.AZURE_FOUNDRY_NETWORK_MODE output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = resources.outputs.AZURE_FOUNDRY_MANAGED_ISOLATION_MODE -output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' -output AZURE_APPLICATION_INSIGHTS_APP_ID string = monitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' -output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = monitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' -output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = monitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' -output AZD_FOUNDRY_MONITORING_MODE string = monitoringMode +output AZURE_APPLICATION_INSIGHTS_RESOURCE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.applicationInsightsResourceId : '' +output AZURE_APPLICATION_INSIGHTS_APP_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.applicationInsightsAppId : '' +output AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.logAnalyticsWorkspaceResourceId : '' +output AZURE_AI_PROJECT_APPINSIGHTS_CONNECTION_NAME string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.projectConnectionName : '' +output AZD_FOUNDRY_MONITORING_MODE string = effectiveMonitoringMode +output AZD_MONITORING_PRIVATE_LINK_SCOPE_ID string = effectiveMonitoringMode != 'none' ? monitoring!.outputs.privateLinkScopeResourceId : '' diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep new file mode 100644 index 00000000000..6ffbb538043 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitor-private-link.bicep @@ -0,0 +1,149 @@ +// Private Azure Monitor path for a network-secured Foundry project: an Azure +// Monitor Private Link Scope (AMPLS) holding the App Insights component and its +// Log Analytics workspace, an 'azuremonitor' private endpoint in the PE subnet, +// and the four Azure Monitor private DNS zones. +// +// Matches the Foundry samples (15/17/19): ingestion is PrivateOnly, query access +// is Open, and the component's own public ingestion is disabled while its public +// query stays enabled. Like private-endpoint-dns.bicep, zones +// are created and linked to the VNet by default; when dnsZonesResourceGroup is +// set they are referenced from that resource group (in dnsZonesSubscription, +// defaulting to this subscription) instead of being created. +// +// A VNet can connect to only one AMPLS. The caller checks that the VNet has no +// 'azuremonitor' private endpoint before deploying this module. + +targetScope = 'resourceGroup' + +@description('Azure region of the customer VNet; the private endpoint must be in the same region.') +param location string + +@description('Suffix for unique resource and link names.') +param suffix string + +@description('ARM resource id of the Application Insights component to scope.') +param applicationInsightsResourceId string + +@description('ARM resource id of the Log Analytics workspace to scope.') +param logAnalyticsWorkspaceResourceId string + +@description('ARM resource id of the customer VNet.') +param vnetId string + +@description('ARM resource id of the private-endpoint subnet.') +param peSubnetId string + +@description('Resource group holding existing private DNS zones. Empty creates and links new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +var monitorDnsZoneNames = [ + 'privatelink.monitor.azure.com' + 'privatelink.oms.opinsights.azure.com' + 'privatelink.ods.opinsights.azure.com' + 'privatelink.agentsvc.azure-automation.net' +] + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription +var scopeName = 'ampls-${suffix}' + +resource ampls 'Microsoft.Insights/privateLinkScopes@2021-07-01-preview' = { + name: scopeName + location: 'global' + properties: { + accessModeSettings: { + ingestionAccessMode: 'PrivateOnly' + queryAccessMode: 'Open' + } + } +} + +resource scopedApplicationInsights 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = { + parent: ampls + name: 'appinsights-scoped' + properties: { + linkedResourceId: applicationInsightsResourceId + } +} + +resource scopedWorkspace 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = { + parent: ampls + name: 'workspace-scoped' + properties: { + linkedResourceId: logAnalyticsWorkspaceResourceId + } + dependsOn: [ + scopedApplicationInsights + ] +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource zones 'Microsoft.Network/privateDnsZones@2020-06-01' = [for zone in monitorDnsZoneNames: if (!useExistingZones) { + name: zone + location: 'global' +}] + +resource zoneLinks 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = [for (zone, i) in monitorDnsZoneNames: if (!useExistingZones) { + parent: zones[i] + name: '${replace(zone, '.', '-')}-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +}] + +var zoneIds = [for zone in monitorDnsZoneNames: useExistingZones + ? extensionResourceId('/subscriptions/${existingZonesSubscription}/resourceGroups/${dnsZonesResourceGroup}', 'Microsoft.Network/privateDnsZones', zone) + : resourceId('Microsoft.Network/privateDnsZones', zone)] + +// ---- Private endpoint to the AMPLS, group 'azuremonitor' ---- + +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${scopeName}-private-endpoint' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${scopeName}-private-link-service-connection' + properties: { + privateLinkServiceId: ampls.id + groupIds: [ + 'azuremonitor' + ] + } + } + ] + } + dependsOn: [ + scopedApplicationInsights + scopedWorkspace + ] +} + +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: privateEndpoint + name: '${scopeName}-dns-group' + properties: { + privateDnsZoneConfigs: [for (zone, i) in monitorDnsZoneNames: { + name: replace(zone, '.', '-') + properties: { + privateDnsZoneId: zoneIds[i] + } + }] + } + dependsOn: [ + zoneLinks + ] +} + +output azureMonitorPrivateLinkScopeResourceId string = ampls.id diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep new file mode 100644 index 00000000000..427144977c0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring-resync.bicep @@ -0,0 +1,33 @@ +targetScope = 'resourceGroup' + +// Re-applies the Application Insights component after its Azure Monitor private link +// scope association exists. A fresh ingestion path can keep rejecting traffic with +// "Component private link id does not match incoming link" until the component is +// updated again, and ARM reports the scoped resource as Succeeded before then. +// Every property must match the original component declaration in monitoring.bicep. +param name string +param location string +param tags object +param workspaceResourceId string +param publicNetworkAccessForIngestion string +param publicNetworkAccessForQuery string + +@description('The private link scope resource ID. Hashing it into a tag orders this deployment after the scope.') +param privateLinkScopeResourceId string + +resource component 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: union(tags, { + 'azd-ampls-link': uniqueString(privateLinkScopeResourceId, name) + }) + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspaceResourceId + publicNetworkAccessForIngestion: publicNetworkAccessForIngestion + publicNetworkAccessForQuery: publicNetworkAccessForQuery + } +} + +output id string = component.id diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep index 7557b7a2e01..2e3c507f9f2 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/monitoring.bicep @@ -16,7 +16,22 @@ param existingConnectionName string = '' param environmentName string param resourceTokenSalt string = '' +@description('When true, new monitoring resources accept ingestion only through an Azure Monitor private link. Queries stay public.') +param privateNetwork bool = false +param vnetId string = '' +param vnetLocation string = '' +param peSubnetId string = '' +param dnsZonesResourceGroup string = '' +param dnsZonesSubscription string = '' + +@description('When true, the project uses managed egress and needs an outbound rule so the managed network can reach the private link scope.') +param managedEgressRule bool = false + +// Azure AI Enterprise Network Connection Approver: lets the account approve the managed private endpoint. +var networkConnectionApproverRoleId = 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea' + var createResources = mode == 'create' +var publicNetworkAccess = privateNetwork ? 'Disabled' : 'Enabled' var token = uniqueString(projectResourceId, resourceGroup().id, resourceTokenSalt) var projectParts = split(projectResourceId, '/') var componentParts = split(applicationInsightsResourceId, '/') @@ -37,6 +52,8 @@ resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (c name: 'PerGB2018' } retentionInDays: 30 + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' } } @@ -48,6 +65,77 @@ resource component 'Microsoft.Insights/components@2020-02-02' = if (createResour properties: { Application_Type: 'web' WorkspaceResourceId: workspace!.id + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' + } +} + +module privateLink 'monitor-private-link.bicep' = if ((createResources || mode == 'reuse-connect') && privateNetwork) { + name: 'foundry-monitoring-private-link' + params: { + location: vnetLocation + suffix: token + applicationInsightsResourceId: createResources ? component!.id : existingComponent!.id + logAnalyticsWorkspaceResourceId: createResources ? workspace!.id : existingComponent!.properties.WorkspaceResourceId + vnetId: vnetId + peSubnetId: peSubnetId + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (managedEgressRule) { + name: projectParts[8] +} + +module networkApproverAssignment 'network-approver-role-assignment.bicep' = if (managedEgressRule) { + name: 'foundry-monitoring-network-approver' + params: { + principalId: foundryAccount!.identity.principalId + roleDefinitionId: networkConnectionApproverRoleId + } +} + +#disable-next-line BCP081 +resource managedNetworkRule 'Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview' = if (managedEgressRule) { + name: '${projectParts[8]}/default/ampls-monitor-rule' + properties: { + type: 'PrivateEndpoint' + destination: { + serviceResourceId: privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId + subresourceTarget: 'azuremonitor' + } + category: 'UserDefined' + } + dependsOn: [networkApproverAssignment] +} + +// Hosted agents call back into the account from the managed network; without a +// private endpoint rule to the account itself they fail with "Public access is disabled". +#disable-next-line BCP081 +resource accountSelfRule 'Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview' = if (managedEgressRule) { + name: '${projectParts[8]}/default/foundry-account-pe' + properties: { + type: 'PrivateEndpoint' + destination: { + serviceResourceId: foundryAccount.id + subresourceTarget: 'account' + } + category: 'UserDefined' + } + dependsOn: [managedNetworkRule] +} + +module resync 'monitoring-resync.bicep' = if (createResources && privateNetwork) { + name: 'foundry-monitoring-resync' + params: { + name: 'appi-${token}' + location: location + tags: monitoringTags + workspaceResourceId: workspace!.id + publicNetworkAccessForIngestion: publicNetworkAccess + publicNetworkAccessForQuery: 'Enabled' + privateLinkScopeResourceId: privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId } } @@ -56,7 +144,8 @@ resource existingComponent 'Microsoft.Insights/components@2020-02-02' existing = scope: resourceGroup(componentParts[2], componentParts[4]) } -var effectiveComponentId = mode == 'none' ? '' : (createResources ? component!.id : existingComponent!.id) +// Routing through the resync output makes the connection wait for the second component update. +var effectiveComponentId = mode == 'none' ? '' : (createResources ? (privateNetwork ? resync!.outputs.id : component!.id) : existingComponent!.id) module connection 'monitoring-connection.bicep' = if (createConnection && (mode == 'create' || mode == 'reuse-connect')) { name: 'foundry-monitoring-connection' @@ -69,6 +158,7 @@ module connection 'monitoring-connection.bicep' = if (createConnection && (mode } } +output privateLinkScopeResourceId string = (mode == 'create' || mode == 'reuse-connect') && privateNetwork ? privateLink!.outputs.azureMonitorPrivateLinkScopeResourceId : '' output applicationInsightsResourceId string = effectiveComponentId output applicationInsightsAppId string = mode == 'none' ? '' : (createResources ? component!.properties.AppId : existingComponent!.properties.AppId) output logAnalyticsWorkspaceResourceId string = mode == 'none' ? '' : (createResources ? component!.properties.WorkspaceResourceId : existingComponent!.properties.WorkspaceResourceId) diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep new file mode 100644 index 00000000000..6acfc8d4ab0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/network-approver-role-assignment.bicep @@ -0,0 +1,13 @@ +targetScope = 'resourceGroup' + +param principalId string +param roleDefinitionId string + +resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep index b8395961c66..2cb38d4c331 100644 --- a/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep +++ b/cli/azd/extensions/azure.ai.projects/synthesis/templates/modules/resources.bicep @@ -342,6 +342,22 @@ resource byoFoundryAccount 'Microsoft.CognitiveServices/accounts@2026-07-15-prev } } +// VNet-injected (BYO egress) projects need a project-level Agents capability +// host so agent traffic and traces route through the private network. The +// platform creates only the account-level host. +#disable-next-line BCP081 +resource byoProjectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = if (useByoNetwork) { + name: '${activeFoundryAccountName}/${foundryProjectName}/agents' + properties: { + #disable-next-line BCP037 + capabilityHostKind: 'Agents' + } + dependsOn: [ + foundryAccount::project + byoFoundryAccount::project + ] +} + // Managed-network isolation (managed egress only). Applies the chosen outbound // isolation mode to the Microsoft-managed VNet that hosts the agent runtime. // Only deployed when an explicit isolationMode is requested; otherwise the @@ -451,3 +467,6 @@ output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = !includeAcr ? '' : (empty(e output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = !includeAcr ? '' : (empty(existingAcrResourceId) ? acr!.outputs.connectionName : existingAcrConnection!.outputs.acrConnectionName) output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' +output NETWORK_VNET_ID string = enableNetworkIsolation ? network!.outputs.vnetId : '' +output NETWORK_VNET_LOCATION string = enableNetworkIsolation ? network!.outputs.vnetLocation : '' +output NETWORK_PE_SUBNET_ID string = enableNetworkIsolation ? network!.outputs.peSubnetId : '' diff --git a/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go b/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go new file mode 100644 index 00000000000..846a28de636 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/synthesis/vnet_requirements_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package synthesis + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// VNet-injected Foundry projects need a project-level Agents capability host +// for private tracing to work; the platform only creates the account host. +func TestResourcesBicep_BYOProjectCapabilityHost(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("templates", "modules", "resources.bicep")) + require.NoError(t, err) + text := string(raw) + + require.Contains(t, text, "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@") + require.Contains(t, text, "= if (useByoNetwork) {", "host must be BYO-egress only") + require.Contains(t, text, "capabilityHostKind: 'Agents'") + require.Contains(t, text, "foundryAccount::project", "host must wait for the project") + require.Contains(t, text, "byoFoundryAccount::project", "host must wait for the project") +}