From ce4ec225cb299d3807c2f59afc7c5e8344469fa7 Mon Sep 17 00:00:00 2001 From: Jesse Liberty Date: Wed, 9 Sep 2026 14:36:14 -0400 Subject: [PATCH] Add infrastructure modules for Foundry project and Azure Container Registry - Introduced main.parameters.json for parameter management in HostedAgents/Researcher. - Created acr-pull-role-assignment.bicep to assign AcrPull role to service principals. - Developed acr.bicep for provisioning Azure Container Registry with managed identity. - Implemented connections.bicep to manage project connections for Foundry. - Added network.bicep for configuring virtual network and subnets for network isolation. - Created private-endpoint-dns.bicep to manage private endpoints and DNS zones. - Developed resources.bicep for managing Foundry account, project, and model deployments. - Added subnet.bicep for creating subnets within existing virtual networks. - Introduced main.bicep and main.parameters.json for HostedAgents/Reviewer to manage existing Foundry projects. - Created foundry-project.bicep to manage deployments and connections for Foundry projects. --- HostedAgents/Author/azure.yaml | 76 ++-- HostedAgents/Author/infra/abbreviations.json | 4 + HostedAgents/Author/infra/main.bicep | 176 ++++++++++ .../Author/infra/main.parameters.json | 54 +++ .../modules/acr-pull-role-assignment.bicep | 24 ++ HostedAgents/Author/infra/modules/acr.bicep | 115 ++++++ .../Author/infra/modules/connections.bicep | 85 +++++ .../Author/infra/modules/network.bicep | 95 +++++ .../infra/modules/private-endpoint-dns.bicep | 168 +++++++++ .../Author/infra/modules/resources.bicep | 331 ++++++++++++++++++ .../Author/infra/modules/subnet.bicep | 28 ++ HostedAgents/Blogger/azure.yaml | 69 ++-- HostedAgents/Blogger/infra/main.bicep | 73 ++++ .../Blogger/infra/main.parameters.json | 21 ++ .../infra/modules/foundry-project.bicep | 95 +++++ HostedAgents/README.md | 1 + HostedAgents/Researcher/azure.yaml | 71 ++-- .../Researcher/infra/abbreviations.json | 4 + HostedAgents/Researcher/infra/main.bicep | 176 ++++++++++ .../Researcher/infra/main.parameters.json | 54 +++ .../modules/acr-pull-role-assignment.bicep | 24 ++ .../Researcher/infra/modules/acr.bicep | 115 ++++++ .../infra/modules/connections.bicep | 85 +++++ .../Researcher/infra/modules/network.bicep | 95 +++++ .../infra/modules/private-endpoint-dns.bicep | 168 +++++++++ .../Researcher/infra/modules/resources.bicep | 331 ++++++++++++++++++ .../Researcher/infra/modules/subnet.bicep | 28 ++ HostedAgents/Reviewer/azure.yaml | 71 ++-- HostedAgents/Reviewer/infra/main.bicep | 73 ++++ .../Reviewer/infra/main.parameters.json | 21 ++ .../infra/modules/foundry-project.bicep | 95 +++++ docs/deployment.md | 1 + 32 files changed, 2724 insertions(+), 103 deletions(-) create mode 100644 HostedAgents/Author/infra/abbreviations.json create mode 100644 HostedAgents/Author/infra/main.bicep create mode 100644 HostedAgents/Author/infra/main.parameters.json create mode 100644 HostedAgents/Author/infra/modules/acr-pull-role-assignment.bicep create mode 100644 HostedAgents/Author/infra/modules/acr.bicep create mode 100644 HostedAgents/Author/infra/modules/connections.bicep create mode 100644 HostedAgents/Author/infra/modules/network.bicep create mode 100644 HostedAgents/Author/infra/modules/private-endpoint-dns.bicep create mode 100644 HostedAgents/Author/infra/modules/resources.bicep create mode 100644 HostedAgents/Author/infra/modules/subnet.bicep create mode 100644 HostedAgents/Blogger/infra/main.bicep create mode 100644 HostedAgents/Blogger/infra/main.parameters.json create mode 100644 HostedAgents/Blogger/infra/modules/foundry-project.bicep create mode 100644 HostedAgents/Researcher/infra/abbreviations.json create mode 100644 HostedAgents/Researcher/infra/main.bicep create mode 100644 HostedAgents/Researcher/infra/main.parameters.json create mode 100644 HostedAgents/Researcher/infra/modules/acr-pull-role-assignment.bicep create mode 100644 HostedAgents/Researcher/infra/modules/acr.bicep create mode 100644 HostedAgents/Researcher/infra/modules/connections.bicep create mode 100644 HostedAgents/Researcher/infra/modules/network.bicep create mode 100644 HostedAgents/Researcher/infra/modules/private-endpoint-dns.bicep create mode 100644 HostedAgents/Researcher/infra/modules/resources.bicep create mode 100644 HostedAgents/Researcher/infra/modules/subnet.bicep create mode 100644 HostedAgents/Reviewer/infra/main.bicep create mode 100644 HostedAgents/Reviewer/infra/main.parameters.json create mode 100644 HostedAgents/Reviewer/infra/modules/foundry-project.bicep diff --git a/HostedAgents/Author/azure.yaml b/HostedAgents/Author/azure.yaml index 9eb2ef6..5b0e7e5 100644 --- a/HostedAgents/Author/azure.yaml +++ b/HostedAgents/Author/azure.yaml @@ -2,30 +2,54 @@ name: blogwriter-hosted-agent-author metadata: - template: blogwriter-hosted-agent + template: blogwriter-hosted-agent services: - ai-project: - host: azure.ai.project - blogwriter-author: - project: . - host: azure.ai.agent - language: csharp - uses: - - ai-project - description: Drafts and revises blog posts. - codeConfiguration: - dependencyResolution: remote_build - entryPoint: BlogWriter.HostedAgents.Author.dll - runtime: dotnet_10 - container: - resources: - cpu: "0.5" - memory: 1Gi - kind: hosted - name: blogwriter-author - environmentVariables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - protocols: - - protocol: responses - version: 2.0.0 + ai-project: + host: azure.ai.project + endpoint: https://AgentFrameworkJesseLiberty.services.ai.azure.com/api/projects/AgentFramework + author: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Author.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: author + protocols: + - protocol: responses + version: 2.0.0 + blogwriter-author: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Author.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + description: Drafts and revises blog posts. + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + kind: hosted + name: blogwriter-author + protocols: + - protocol: responses + version: 2.0.0 +infra: + provider: microsoft.foundry diff --git a/HostedAgents/Author/infra/abbreviations.json b/HostedAgents/Author/infra/abbreviations.json new file mode 100644 index 0000000..8e6ed84 --- /dev/null +++ b/HostedAgents/Author/infra/abbreviations.json @@ -0,0 +1,4 @@ +{ + "cognitiveServicesAccounts": "cog-", + "containerRegistryRegistries": "cr" +} diff --git a/HostedAgents/Author/infra/main.bicep b/HostedAgents/Author/infra/main.bicep new file mode 100644 index 0000000..7d61760 --- /dev/null +++ b/HostedAgents/Author/infra/main.bicep @@ -0,0 +1,176 @@ +// Provisioning template for a Foundry project service. +// +// Inputs are derived from the host: azure.ai.project service body in +// azure.yaml by internal/synthesis. This entry point creates a new Foundry +// account and project; existing projects use the separate editable entry point. +// +// Subscription-scoped so the resource group is part of the deployment. This +// keeps `azd provision --preview` side-effect free: the resource group shows +// up as a previewed Create instead of being created up front to satisfy a +// resource-group-scoped what-if. + +targetScope = 'subscription' + +// User-defined types + +@description('Shape of one model deployment entry in azure.yaml.') +type deploymentsType = deploymentType[] + +@description('Shape of a single model deployment.') +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +@description('Shape of a list of Foundry project connections.') +type connectionsType = connectionType[] + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string + +@description('Name of the resource group to create and deploy resources into.') +@minLength(1) +@maxLength(90) +param resourceGroupName string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters (see modules/resources.bicep for semantics). +// All default off so an absent network: block yields a public account. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode).') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +// Resources + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module resources 'modules/resources.bicep' = { + name: 'foundry-resources' + scope: resourceGroup + params: { + location: location + tags: tags + resourceTokenSalt: resourceTokenSalt + foundryProjectName: foundryProjectName + deployments: deployments + includeAcr: includeAcr + connections: connections + connectionCredentials: connectionCredentials + principalId: principalId + principalType: principalType + enableNetworkIsolation: enableNetworkIsolation + useManagedEgress: useManagedEgress + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + managedIsolationMode: managedIsolationMode + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Outputs + +output AZURE_RESOURCE_GROUP string = resourceGroupName +output AZURE_FOUNDRY_RESOURCE_GROUP string = resourceGroupName +output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID +output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME +output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME +output AZURE_OPENAI_ENDPOINT string = resources.outputs.AZURE_OPENAI_ENDPOINT +output FOUNDRY_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME +output AZURE_AI_PROJECT_CONNECTION_NAMES string = resources.outputs.AZURE_AI_PROJECT_CONNECTION_NAMES +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT +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 diff --git a/HostedAgents/Author/infra/main.parameters.json b/HostedAgents/Author/infra/main.parameters.json new file mode 100644 index 0000000..3b92a83 --- /dev/null +++ b/HostedAgents/Author/infra/main.parameters.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "agentSubnetName": { + "value": "agent-subnet" + }, + "agentSubnetPrefix": { + "value": "" + }, + "connectionCredentials": { + "value": {} + }, + "connections": { + "value": [] + }, + "createAgentSubnet": { + "value": false + }, + "createPESubnet": { + "value": false + }, + "deployments": { + "value": [] + }, + "dnsZonesResourceGroup": { + "value": "" + }, + "dnsZonesSubscription": { + "value": "" + }, + "enableNetworkIsolation": { + "value": false + }, + "includeAcr": { + "value": false + }, + "managedIsolationMode": { + "value": "" + }, + "peSubnetName": { + "value": "pe-subnet" + }, + "peSubnetPrefix": { + "value": "" + }, + "useManagedEgress": { + "value": false + }, + "vnetId": { + "value": "" + } + } +} diff --git a/HostedAgents/Author/infra/modules/acr-pull-role-assignment.bicep b/HostedAgents/Author/infra/modules/acr-pull-role-assignment.bicep new file mode 100644 index 0000000..f54a2d5 --- /dev/null +++ b/HostedAgents/Author/infra/modules/acr-pull-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@description('Name of the Azure Container Registry.') +param registryName string + +@description('Principal receiving AcrPull on the registry.') +param principalId string + +@description('AcrPull role definition resource ID.') +param roleDefinitionId string + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} + +resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, principalId, roleDefinitionId) + scope: registry + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleDefinitionId + } +} diff --git a/HostedAgents/Author/infra/modules/acr.bicep b/HostedAgents/Author/infra/modules/acr.bicep new file mode 100644 index 0000000..cf9c125 --- /dev/null +++ b/HostedAgents/Author/infra/modules/acr.bicep @@ -0,0 +1,115 @@ +// Azure Container Registry for hosted agents that use docker:. +// Wires the registry as a connection on the Foundry project so the +// project's managed identity can pull images. +// +// Premium SKU is intentional: Foundry recommends Premium so the registry +// can support content trust and geo-replication if the user enables them +// post-provision. + +// Parameters + +@description('Azure region.') +param location string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Registry name. 5-50 alphanumeric chars.') +@minLength(5) +@maxLength(50) +param name string + +@description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') +param foundryAccountName string + +@description('Name of the existing Foundry project receiving the ACR connection.') +param foundryProjectName string + +@description('Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity.') +param foundryProjectPrincipalId string + +@description('When true, the registry disables public network access to stay inside the isolation boundary.') +param enableNetworkIsolation bool = false + +// Variables + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + +// Resources + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: name + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + // Disable public access when network isolation is enabled so the registry + // stays inside the VNet boundary. Docker-backed agents in isolated projects + // must pull via the private endpoint; public access would leave a dependency + // outside the isolation perimeter and can break pulls in locked-down egress. + publicNetworkAccess: enableNetworkIsolation ? 'Disabled' : 'Enabled' + zoneRedundancy: 'Disabled' + } +} + +// Grant the Foundry project's managed identity AcrPull on this registry so the +// hosted agent can pull images using the project identity. +resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) + scope: registry + properties: { + principalId: foundryProjectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +// Existing parent references so the connection can be nested under the +// project. Pinned to 2025-04-01-preview: GA 2025-06-01 fails to resolve the +// projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + + // Project-scoped connection so Foundry can resolve the registry by name. + resource acrConnection 'connections' = { + name: '${name}-conn' + properties: { + category: 'ContainerRegistry' + target: registry.properties.loginServer + authType: 'ManagedIdentity' + // RegistryIdentity auth requires both the identity client id (the + // project principal) and the registry resource id. + credentials: { + clientId: foundryProjectPrincipalId + resourceId: registry.id + } + isSharedToAll: true + metadata: { + ResourceId: registry.id + } + } + dependsOn: [ + foundryAcrPull + ] + } + } +} + +// Outputs + +output loginServer string = registry.properties.loginServer +output resourceId string = registry.id +output connectionName string = foundryAccount::project::acrConnection.name diff --git a/HostedAgents/Author/infra/modules/connections.bicep b/HostedAgents/Author/infra/modules/connections.bicep new file mode 100644 index 0000000..08fe0a1 --- /dev/null +++ b/HostedAgents/Author/infra/modules/connections.bicep @@ -0,0 +1,85 @@ +// Foundry project connections declared as host: azure.ai.connection services +// in azure.yaml. Creates one Microsoft.CognitiveServices/accounts/projects/connections +// resource per entry. +// +// Provision-time equivalent of the deploy-time connection target. +// Supports every auth type. Metadata passes through. +// Credentials arrive in a separate secure parameter. +// +// Pinned to 2025-04-01-preview via a separate existing account reference: GA +// 2025-06-01 fails to resolve the projects/connections sub-resource +// (MissingApiVersionParameter), the same reason acr.bicep does this. + +// User-defined types + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + @description('Connection name. The resource name and the key a toolbox tool references via connection: .') + name: string + + @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') + category: string + + @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') + target: string + + @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') + authType: string + + @description('Optional metadata key-value pairs.') + metadata: object? +} + +@description('Shape of a list of connections.') +type connectionsType = connectionType[] + +// Parameters + +@description('Name of the existing Foundry CognitiveServices account that hosts the project.') +param foundryAccountName string + +@description('Name of the existing Foundry project the connections are created on.') +param foundryProjectName string + +@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +// Resources + +// Existing parent references so each connection nests under the project. +// Pinned to 2025-04-01-preview (see file header). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + } +} + +// Optional credentials and metadata are emitted only when supplied. +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccount::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +// Outputs + +@description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/HostedAgents/Author/infra/modules/network.bicep b/HostedAgents/Author/infra/modules/network.bicep new file mode 100644 index 0000000..e9ec3e7 --- /dev/null +++ b/HostedAgents/Author/infra/modules/network.bicep @@ -0,0 +1,95 @@ +// Virtual network wiring for a network-secured (VNet-injected) Foundry account. +// +// Bring-your-own VNet only (network.mode: byo). The VNet must already exist; +// v1 references it by the ARM id supplied in azure.yaml. Each subnet follows +// the tri-state rule from the synthesizer: +// +// create=true, prefix set -> create the subnet with that prefix +// create=true, prefix empty -> create the subnet with a derived /24 prefix +// create=false -> reference an existing subnet as-is +// +// All subnet ids are deterministic ('/subnets/'), so outputs are +// valid whether the subnet was created here or already existed. + +targetScope = 'resourceGroup' + +@description('ARM resource id of the existing customer VNet.') +param vnetId string + +@description('Name of the agent (delegated) subnet.') +param agentSubnetName string + +@description('CIDR for the agent subnet. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool + +@description('Name of the private-endpoint subnet.') +param peSubnetName string + +@description('CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool + +// The VNet may live in a different resource group than the deployment RG. +var vnetParts = split(vnetId, '/') +var vnetSubscriptionId = vnetParts[2] +var vnetResourceGroupName = vnetParts[4] +var vnetName = last(vnetParts) + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) +} + +var vnetAddressSpace = vnet.properties.addressSpace.addressPrefixes[0] +var agentPrefix = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix +var pePrefix = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix + +// Create the agent subnet, delegated to Microsoft.App/environments so the +// hosted agent's container app environment can be injected into it. +module agentSubnet 'subnet.bicep' = if (createAgentSubnet) { + name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: agentSubnetName + addressPrefix: agentPrefix + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } +} + +// Create the private-endpoint subnet. Depends on the agent subnet so the two +// subnet PUTs against the same VNet do not race (ARM serializes subnet writes). +module peSubnet 'subnet.bicep' = if (createPESubnet) { + name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: peSubnetName + addressPrefix: pePrefix + delegations: [] + } + dependsOn: [ + agentSubnet + ] +} + +output vnetId string = vnet.id +output vnetName string = vnetName +output vnetLocation string = vnet.location +output vnetSubscriptionId string = vnetSubscriptionId +output vnetResourceGroupName string = vnetResourceGroupName +output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' +output peSubnetId string = '${vnet.id}/subnets/${peSubnetName}' +output peSubnetName string = peSubnetName diff --git a/HostedAgents/Author/infra/modules/private-endpoint-dns.bicep b/HostedAgents/Author/infra/modules/private-endpoint-dns.bicep new file mode 100644 index 0000000..1167d0a --- /dev/null +++ b/HostedAgents/Author/infra/modules/private-endpoint-dns.bicep @@ -0,0 +1,168 @@ +// Account private endpoint + the three AI private DNS zones for a +// network-secured Foundry account. Dependent stores stay platform-managed, so +// only the account itself gets a private endpoint here (no Search / Storage / +// Cosmos endpoints). +// +// DNS zones are created and linked to the VNet by default. When +// dnsZonesResourceGroup is set, the zones are referenced from that resource +// group (in dnsZonesSubscription, defaulting to this subscription) instead of +// being created. + +targetScope = 'resourceGroup' + +@description('Name of the Foundry (AIServices) account to bind the private endpoint to.') +param aiAccountName string + +@description('Azure region of the customer VNet.') +param location 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('Suffix for unique resource/link names.') +param suffix 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 aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' +var openAiDnsZoneName = 'privatelink.openai.azure.com' +var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription + +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiAccountName + scope: resourceGroup() +} + +// Account private endpoint in the PE subnet, targeting the 'account' group. +resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${aiAccountName}-private-endpoint' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${aiAccountName}-private-link-service-connection' + properties: { + privateLinkServiceId: aiAccount.id + groupIds: [ + 'account' + ] + } + } + ] + } +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource aiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: aiServicesDnsZoneName + location: 'global' +} +resource existingAiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: aiServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var aiServicesZoneId = useExistingZones ? existingAiServicesZone.id : aiServicesZone.id + +resource openAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: openAiDnsZoneName + location: 'global' +} +resource existingOpenAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: openAiDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var openAiZoneId = useExistingZones ? existingOpenAiZone.id : openAiZone.id + +resource cognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: cognitiveServicesDnsZoneName + location: 'global' +} +resource existingCognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: cognitiveServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var cognitiveServicesZoneId = useExistingZones ? existingCognitiveServicesZone.id : cognitiveServicesZone.id + +// ---- VNet links (only when we create the zones) ---- + +resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: aiServicesZone + name: 'aiServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: openAiZone + name: 'aiServicesOpenAI-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: cognitiveServicesZone + name: 'aiServicesCognitiveServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} + +// ---- DNS zone group binds the three zones to the account endpoint ---- + +resource aiAccountDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: aiAccountPrivateEndpoint + name: '${aiAccountName}-dns-group' + properties: { + privateDnsZoneConfigs: [ + { + name: '${aiAccountName}-dns-aiserv-config' + properties: { + privateDnsZoneId: aiServicesZoneId + } + } + { + name: '${aiAccountName}-dns-openai-config' + properties: { + privateDnsZoneId: openAiZoneId + } + } + { + name: '${aiAccountName}-dns-cogserv-config' + properties: { + privateDnsZoneId: cognitiveServicesZoneId + } + } + ] + } + dependsOn: [ + aiServicesLink + openAiLink + cognitiveServicesLink + ] +} diff --git a/HostedAgents/Author/infra/modules/resources.bicep b/HostedAgents/Author/infra/modules/resources.bicep new file mode 100644 index 0000000..efc8671 --- /dev/null +++ b/HostedAgents/Author/infra/modules/resources.bicep @@ -0,0 +1,331 @@ +// Resource-group-scoped resources for a microsoft.foundry service: the +// Foundry (AIServices) account, its project, model deployments, the optional +// container registry, and the developer role assignment. +// +// Deployed by main.bicep into a resource group it creates at subscription +// scope. Kept as a separate module so main.bicep can target the subscription +// (and thus create the resource group) while these resources stay RG-scoped. + +targetScope = 'resourceGroup' + +// User-defined types + +@description('Shape of one model deployment entry in azure.yaml.') +type deploymentsType = deploymentType[] + +@description('Shape of a single model deployment.') +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +@description('Shape of a list of Foundry project connections.') +type connectionsType = connectionType[] + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters. All default off so an absent network: block in +// azure.yaml yields a public account identical to the pre-network template. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound.') +param managedIsolationMode 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 = '' + +// Variables + +var resourceToken = empty(resourceTokenSalt) + ? uniqueString(subscription().id, resourceGroup().id, location) + : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) + +var abbrs = loadJsonContent('../abbreviations.json') + +var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' + +// Egress: byo injects the agent into a customer subnet; managed uses the +// Microsoft-managed network. Ingress: an account private endpoint is always +// provisioned when isolation is on, so the data plane is never left public. +var useByoNetwork = enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = enableNetworkIsolation + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var cognitiveServicesUserRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'a97b65f3-24c7-4388-baec-2e87135dc908' +) + +// Resources + +// Customer VNet wiring: reference the VNet and create or reference the agent +// (byo egress only) + private-endpoint subnets. Runs whenever isolation is on +// because the account private endpoint is always provisioned. +module network 'network.bicep' = if (enableNetworkIsolation) { + name: 'foundry-network' + params: { + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + } +} + +// networkInjections wires the account into the agent subnet (byo) or the +// Microsoft-managed network (managed). Null when isolation is off. +// +// subnetArmId is built as a concrete string from the (concrete) vnetId param +// rather than network!.outputs.agentSubnetId. The account and the network +// module deploy in the same template, so an inter-module reference() here is +// unresolved at the CognitiveServices RP preflight, which then fails to convert +// networkInjections to its typed contract (ARM what-if does not catch this). +// The deterministic id avoids the unresolved reference; an explicit dependsOn +// on the network module preserves ordering (the subnet must exist first). +var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' +var agentNetworkInjections = useByoNetwork + ? [ + { + scenario: 'agent' + subnetArmId: agentSubnetArmId + useMicrosoftManagedNetwork: false + } + ] + : (useManagedNetwork + ? [ + { + scenario: 'agent' + useMicrosoftManagedNetwork: true + } + ] + : null) + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: foundryAccountName + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: foundryAccountName + publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' + disableLocalAuth: true + networkAcls: { + defaultAction: disablePublicDataPlaneAccess ? 'Deny' : 'Allow' + bypass: disablePublicDataPlaneAccess ? 'AzureServices' : null + virtualNetworkRules: [] + ipRules: [] + } + networkInjections: agentNetworkInjections + } + + // The account injects into the agent subnet via a deterministic id (above), + // so Bicep cannot infer the dependency on the network module that creates + // that subnet. Declare it explicitly so the subnet exists before injection. + dependsOn: useByoNetwork ? [network] : [] + + // Sequential model deployment creation; ARM throttles concurrent + // deployments on the same account. + @batchSize(1) + resource modelDeployments 'deployments' = [ + for d in deployments: { + name: d.name + properties: { + model: d.model + } + sku: d.sku + } + ] + + resource project 'projects' = { + name: foundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${foundryProjectName} Project' + displayName: foundryProjectName + } + // Explicit dependsOn ensures all model deployments complete before + // the project is created; the project does not reference them so + // there is no implicit dependency Bicep can infer. + dependsOn: [ + modelDeployments + ] + } +} + +// 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 +// platform default applies. Note: AllowOnlyApprovedOutbound additionally +// requires approved outbound rules for the agent to reach dependent resources; +// for the platform-managed stores used here those are managed by the platform. +resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = + if (useManagedNetwork && !empty(managedIsolationMode)) { + parent: foundryAccount + name: 'default' + properties: { + managedNetwork: { + isolationMode: managedIsolationMode + } + } + } + +module acr 'acr.bicep' = if (includeAcr) { + name: 'acr' + params: { + location: location + tags: tags + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + foundryProjectPrincipalId: foundryAccount::project.identity.principalId + enableNetworkIsolation: enableNetworkIsolation + } +} + +// Account private endpoint + AI private DNS zones. The account is always given a +// private endpoint when isolation is on (byo or managed egress); dependent +// stores stay platform-managed, so only the account gets an endpoint. +module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { + name: 'foundry-private-endpoint-dns' + params: { + aiAccountName: foundryAccount.name + location: network!.outputs.vnetLocation + vnetId: network!.outputs.vnetId + peSubnetId: network!.outputs.peSubnetId + suffix: resourceToken + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as +// host: azure.ai.connection services. Created at provision time so a toolbox +// that references a connection by name resolves it at deploy. Depends on the +// project via foundryAccount.name / project.name so ordering is correct. +module projectConnections 'connections.bicep' = if (!empty(connections)) { + name: 'foundry-connections' + params: { + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + connections: connections + connectionCredentials: connectionCredentials + } +} + +// Grant the developer Cognitive Services User on the project so they can call +// the Foundry data-plane (chat/completions, agents API) from their machine. +resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { + name: guid(foundryAccount::project.id, principalId, cognitiveServicesUserRoleId) + scope: foundryAccount::project + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: cognitiveServicesUserRoleId + } +} + +// Outputs + +output AZURE_AI_PROJECT_ID string = foundryAccount::project.id +output AZURE_AI_ACCOUNT_NAME string = foundryAccount.name +output AZURE_AI_PROJECT_NAME string = foundryAccount::project.name +output AZURE_OPENAI_ENDPOINT string = 'https://${foundryAccount.name}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.services.ai.azure.com/api/projects/${foundryAccount::project.name}' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames +output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/HostedAgents/Author/infra/modules/subnet.bicep b/HostedAgents/Author/infra/modules/subnet.bicep new file mode 100644 index 0000000..27abadc --- /dev/null +++ b/HostedAgents/Author/infra/modules/subnet.bicep @@ -0,0 +1,28 @@ +// Single subnet on an existing VNet. Kept as its own module so the parent can +// place subnets in the VNet's resource group (which may differ from the +// deployment RG) and serialize subnet writes via module dependsOn. + +targetScope = 'resourceGroup' + +@description('Name of the virtual network the subnet belongs to.') +param vnetName string + +@description('Name of the subnet to create.') +param subnetName string + +@description('CIDR for the subnet.') +param addressPrefix string + +@description('Subnet delegations (e.g. Microsoft.App/environments for the agent subnet).') +param delegations array = [] + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + name: '${vnetName}/${subnetName}' + properties: { + addressPrefix: addressPrefix + delegations: delegations + } +} + +output subnetId string = subnet.id +output subnetName string = subnetName diff --git a/HostedAgents/Blogger/azure.yaml b/HostedAgents/Blogger/azure.yaml index c8b0d13..eef3620 100644 --- a/HostedAgents/Blogger/azure.yaml +++ b/HostedAgents/Blogger/azure.yaml @@ -2,28 +2,47 @@ name: blogger services: - ai-project: - host: azure.ai.project - blogwriter-blogger: - project: . - host: azure.ai.agent - language: csharp - uses: - - ai-project - description: Routes the BlogWriter workflow to the next best step. - codeConfiguration: - dependencyResolution: remote_build - entryPoint: BlogWriter.HostedAgents.Blogger.dll - runtime: dotnet_10 - container: - resources: - cpu: "0.5" - memory: 1Gi - kind: hosted - name: blogwriter-blogger - environmentVariables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - protocols: - - protocol: responses - version: 2.0.0 + ai-project: + host: azure.ai.project + endpoint: https://AgentFrameworkJesseLiberty.services.ai.azure.com/api/projects/AgentFramework + blogger: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Blogger.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + blogwriter-blogger: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Blogger.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + description: Routes the BlogWriter workflow to the next best step. + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + kind: hosted + name: blogwriter-blogger + protocols: + - protocol: responses + version: 2.0.0 +infra: + provider: microsoft.foundry diff --git a/HostedAgents/Blogger/infra/main.bicep b/HostedAgents/Blogger/infra/main.bicep new file mode 100644 index 0000000..c72178f --- /dev/null +++ b/HostedAgents/Blogger/infra/main.bicep @@ -0,0 +1,73 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only. ACR behavior was selected when this file was +// generated, so the graph contains no runtime mode switch. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param deployments deploymentType[] = [] +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = '' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = 'none' diff --git a/HostedAgents/Blogger/infra/main.parameters.json b/HostedAgents/Blogger/infra/main.parameters.json new file mode 100644 index 0000000..6346bcf --- /dev/null +++ b/HostedAgents/Blogger/infra/main.parameters.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "connectionCredentials": { + "value": {} + }, + "connections": { + "value": [] + }, + "deployments": { + "value": [] + }, + "projectEndpoint": { + "value": "${FOUNDRY_PROJECT_ENDPOINT}" + }, + "projectResourceId": { + "value": "${AZURE_AI_PROJECT_ID}" + } + } +} diff --git a/HostedAgents/Blogger/infra/modules/foundry-project.bicep b/HostedAgents/Blogger/infra/modules/foundry-project.bicep new file mode 100644 index 0000000..0331938 --- /dev/null +++ b/HostedAgents/Blogger/infra/modules/foundry-project.bicep @@ -0,0 +1,95 @@ +targetScope = 'resourceGroup' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param accountName string +param projectName string +param deployments deploymentType[] = [] +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +param acrName string = '' +param acrEndpoint string = '' +param acrResourceId string = '' +param createAcrConnection bool = false +param existingAcrConnectionName string = '' + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: accountName +} + +@batchSize(1) +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + parent: foundryAccount + name: d.name + properties: { + model: d.model + } + sku: d.sku + } +] + +resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (createAcrConnection) { + parent: foundryAccountPreview::project + name: '${acrName}-conn' + properties: { + category: 'ContainerRegistry' + target: acrEndpoint + authType: 'ManagedIdentity' + credentials: { + clientId: foundryAccountPreview::project.identity.principalId + resourceId: acrResourceId + } + isSharedToAll: true + metadata: { + ResourceId: acrResourceId + } + } +} + +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccountPreview::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) ? { credentials: connectionCredentials[c.name] } : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +output acrConnectionName string = createAcrConnection ? acrConnection!.name : existingAcrConnectionName +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/HostedAgents/README.md b/HostedAgents/README.md index cd482dc..eb82a5b 100644 --- a/HostedAgents/README.md +++ b/HostedAgents/README.md @@ -29,6 +29,7 @@ From inside each `HostedAgents/` folder: ```powershell # First time only: scaffold azd wiring for this folder (or hand-author azure.yaml — see below) azd ai agent init --deploy-mode code +azd ai agent init --infra=bicep # Provision Foundry project/model/ACR resources (skip if reusing an existing project) azd provision diff --git a/HostedAgents/Researcher/azure.yaml b/HostedAgents/Researcher/azure.yaml index 055ee13..75f2612 100644 --- a/HostedAgents/Researcher/azure.yaml +++ b/HostedAgents/Researcher/azure.yaml @@ -2,30 +2,49 @@ name: blogwriter-hosted-agent-researcher metadata: - template: blogwriter-hosted-agent + template: blogwriter-hosted-agent services: - ai-project: - host: azure.ai.project - blogwriter-researcher: - project: . - host: azure.ai.agent - language: csharp - uses: - - ai-project - description: Performs web research for blog posts. - codeConfiguration: - dependencyResolution: remote_build - entryPoint: BlogWriter.HostedAgents.Researcher.dll - runtime: dotnet_10 - container: - resources: - cpu: "0.5" - memory: 1Gi - kind: hosted - name: blogwriter-researcher - environmentVariables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - protocols: - - protocol: responses - version: 2.0.0 + ai-project: + host: azure.ai.project + endpoint: https://AgentFrameworkJesseLiberty.services.ai.azure.com/api/projects/AgentFramework + blogwriter-researcher: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Researcher.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + description: Performs web research for blog posts. + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + kind: hosted + name: blogwriter-researcher + protocols: + - protocol: responses + version: 2.0.0 + researcher: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Researcher.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi +infra: + provider: microsoft.foundry diff --git a/HostedAgents/Researcher/infra/abbreviations.json b/HostedAgents/Researcher/infra/abbreviations.json new file mode 100644 index 0000000..8e6ed84 --- /dev/null +++ b/HostedAgents/Researcher/infra/abbreviations.json @@ -0,0 +1,4 @@ +{ + "cognitiveServicesAccounts": "cog-", + "containerRegistryRegistries": "cr" +} diff --git a/HostedAgents/Researcher/infra/main.bicep b/HostedAgents/Researcher/infra/main.bicep new file mode 100644 index 0000000..7d61760 --- /dev/null +++ b/HostedAgents/Researcher/infra/main.bicep @@ -0,0 +1,176 @@ +// Provisioning template for a Foundry project service. +// +// Inputs are derived from the host: azure.ai.project service body in +// azure.yaml by internal/synthesis. This entry point creates a new Foundry +// account and project; existing projects use the separate editable entry point. +// +// Subscription-scoped so the resource group is part of the deployment. This +// keeps `azd provision --preview` side-effect free: the resource group shows +// up as a previewed Create instead of being created up front to satisfy a +// resource-group-scoped what-if. + +targetScope = 'subscription' + +// User-defined types + +@description('Shape of one model deployment entry in azure.yaml.') +type deploymentsType = deploymentType[] + +@description('Shape of a single model deployment.') +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +@description('Shape of a list of Foundry project connections.') +type connectionsType = connectionType[] + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string + +@description('Name of the resource group to create and deploy resources into.') +@minLength(1) +@maxLength(90) +param resourceGroupName string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters (see modules/resources.bicep for semantics). +// All default off so an absent network: block yields a public account. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode).') +param managedIsolationMode string = '' + +@description('Resource group holding existing private DNS zones. Empty creates new zones.') +param dnsZonesResourceGroup string = '' + +@description('Subscription holding existing private DNS zones. Empty defaults to this subscription.') +param dnsZonesSubscription string = '' + +// Resources + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module resources 'modules/resources.bicep' = { + name: 'foundry-resources' + scope: resourceGroup + params: { + location: location + tags: tags + resourceTokenSalt: resourceTokenSalt + foundryProjectName: foundryProjectName + deployments: deployments + includeAcr: includeAcr + connections: connections + connectionCredentials: connectionCredentials + principalId: principalId + principalType: principalType + enableNetworkIsolation: enableNetworkIsolation + useManagedEgress: useManagedEgress + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + managedIsolationMode: managedIsolationMode + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Outputs + +output AZURE_RESOURCE_GROUP string = resourceGroupName +output AZURE_FOUNDRY_RESOURCE_GROUP string = resourceGroupName +output AZURE_AI_PROJECT_ID string = resources.outputs.AZURE_AI_PROJECT_ID +output AZURE_AI_ACCOUNT_NAME string = resources.outputs.AZURE_AI_ACCOUNT_NAME +output AZURE_AI_PROJECT_NAME string = resources.outputs.AZURE_AI_PROJECT_NAME +output AZURE_OPENAI_ENDPOINT string = resources.outputs.AZURE_OPENAI_ENDPOINT +output FOUNDRY_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_RESOURCE_ID +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = resources.outputs.AZURE_AI_PROJECT_ACR_CONNECTION_NAME +output AZURE_AI_PROJECT_CONNECTION_NAMES string = resources.outputs.AZURE_AI_PROJECT_CONNECTION_NAMES +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = resources.outputs.FOUNDRY_PROJECT_ENDPOINT +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 diff --git a/HostedAgents/Researcher/infra/main.parameters.json b/HostedAgents/Researcher/infra/main.parameters.json new file mode 100644 index 0000000..3b92a83 --- /dev/null +++ b/HostedAgents/Researcher/infra/main.parameters.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "agentSubnetName": { + "value": "agent-subnet" + }, + "agentSubnetPrefix": { + "value": "" + }, + "connectionCredentials": { + "value": {} + }, + "connections": { + "value": [] + }, + "createAgentSubnet": { + "value": false + }, + "createPESubnet": { + "value": false + }, + "deployments": { + "value": [] + }, + "dnsZonesResourceGroup": { + "value": "" + }, + "dnsZonesSubscription": { + "value": "" + }, + "enableNetworkIsolation": { + "value": false + }, + "includeAcr": { + "value": false + }, + "managedIsolationMode": { + "value": "" + }, + "peSubnetName": { + "value": "pe-subnet" + }, + "peSubnetPrefix": { + "value": "" + }, + "useManagedEgress": { + "value": false + }, + "vnetId": { + "value": "" + } + } +} diff --git a/HostedAgents/Researcher/infra/modules/acr-pull-role-assignment.bicep b/HostedAgents/Researcher/infra/modules/acr-pull-role-assignment.bicep new file mode 100644 index 0000000..f54a2d5 --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/acr-pull-role-assignment.bicep @@ -0,0 +1,24 @@ +targetScope = 'resourceGroup' + +@description('Name of the Azure Container Registry.') +param registryName string + +@description('Principal receiving AcrPull on the registry.') +param principalId string + +@description('AcrPull role definition resource ID.') +param roleDefinitionId string + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: registryName +} + +resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, principalId, roleDefinitionId) + scope: registry + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleDefinitionId + } +} diff --git a/HostedAgents/Researcher/infra/modules/acr.bicep b/HostedAgents/Researcher/infra/modules/acr.bicep new file mode 100644 index 0000000..cf9c125 --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/acr.bicep @@ -0,0 +1,115 @@ +// Azure Container Registry for hosted agents that use docker:. +// Wires the registry as a connection on the Foundry project so the +// project's managed identity can pull images. +// +// Premium SKU is intentional: Foundry recommends Premium so the registry +// can support content trust and geo-replication if the user enables them +// post-provision. + +// Parameters + +@description('Azure region.') +param location string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Registry name. 5-50 alphanumeric chars.') +@minLength(5) +@maxLength(50) +param name string + +@description('Name of the existing Foundry CognitiveServices account that hosts the project receiving the ACR connection.') +param foundryAccountName string + +@description('Name of the existing Foundry project receiving the ACR connection.') +param foundryProjectName string + +@description('Principal id of the Foundry project managed identity; receives AcrPull and is the connection credential identity.') +param foundryProjectPrincipalId string + +@description('When true, the registry disables public network access to stay inside the isolation boundary.') +param enableNetworkIsolation bool = false + +// Variables + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var acrPullRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + +// Resources + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: name + location: location + tags: tags + sku: { + name: 'Premium' + } + identity: { + type: 'SystemAssigned' + } + properties: { + adminUserEnabled: false + // Disable public access when network isolation is enabled so the registry + // stays inside the VNet boundary. Docker-backed agents in isolated projects + // must pull via the private endpoint; public access would leave a dependency + // outside the isolation perimeter and can break pulls in locked-down egress. + publicNetworkAccess: enableNetworkIsolation ? 'Disabled' : 'Enabled' + zoneRedundancy: 'Disabled' + } +} + +// Grant the Foundry project's managed identity AcrPull on this registry so the +// hosted agent can pull images using the project identity. +resource foundryAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, foundryProjectPrincipalId, acrPullRoleId) + scope: registry + properties: { + principalId: foundryProjectPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleId + } +} + +// Existing parent references so the connection can be nested under the +// project. Pinned to 2025-04-01-preview: GA 2025-06-01 fails to resolve the +// projects/connections ContainerRegistry sub-resource (MissingApiVersionParameter). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + + // Project-scoped connection so Foundry can resolve the registry by name. + resource acrConnection 'connections' = { + name: '${name}-conn' + properties: { + category: 'ContainerRegistry' + target: registry.properties.loginServer + authType: 'ManagedIdentity' + // RegistryIdentity auth requires both the identity client id (the + // project principal) and the registry resource id. + credentials: { + clientId: foundryProjectPrincipalId + resourceId: registry.id + } + isSharedToAll: true + metadata: { + ResourceId: registry.id + } + } + dependsOn: [ + foundryAcrPull + ] + } + } +} + +// Outputs + +output loginServer string = registry.properties.loginServer +output resourceId string = registry.id +output connectionName string = foundryAccount::project::acrConnection.name diff --git a/HostedAgents/Researcher/infra/modules/connections.bicep b/HostedAgents/Researcher/infra/modules/connections.bicep new file mode 100644 index 0000000..08fe0a1 --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/connections.bicep @@ -0,0 +1,85 @@ +// Foundry project connections declared as host: azure.ai.connection services +// in azure.yaml. Creates one Microsoft.CognitiveServices/accounts/projects/connections +// resource per entry. +// +// Provision-time equivalent of the deploy-time connection target. +// Supports every auth type. Metadata passes through. +// Credentials arrive in a separate secure parameter. +// +// Pinned to 2025-04-01-preview via a separate existing account reference: GA +// 2025-06-01 fails to resolve the projects/connections sub-resource +// (MissingApiVersionParameter), the same reason acr.bicep does this. + +// User-defined types + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + @description('Connection name. The resource name and the key a toolbox tool references via connection: .') + name: string + + @description('Connection category, e.g. RemoteTool (MCP), CognitiveSearch, AzureOpenAI, ApiKey, CustomKeys.') + category: string + + @description('Target endpoint URL or ARM resource id. For a RemoteTool/MCP connection this is the MCP server URL.') + target: string + + @description('Auth type: None | ApiKey | CustomKeys | OAuth2 | UserEntraToken | ProjectManagedIdentity | AgenticIdentityToken | ManagedIdentity | ...') + authType: string + + @description('Optional metadata key-value pairs.') + metadata: object? +} + +@description('Shape of a list of connections.') +type connectionsType = connectionType[] + +// Parameters + +@description('Name of the existing Foundry CognitiveServices account that hosts the project.') +param foundryAccountName string + +@description('Name of the existing Foundry project the connections are created on.') +param foundryProjectName string + +@description('Connections to create on the Foundry project. Each entry maps to one host: azure.ai.connection service.') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +// Resources + +// Existing parent references so each connection nests under the project. +// Pinned to 2025-04-01-preview (see file header). +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: foundryAccountName + + resource project 'projects' existing = { + name: foundryProjectName + } +} + +// Optional credentials and metadata are emitted only when supplied. +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccount::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) + ? { credentials: connectionCredentials[c.name] } + : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +// Outputs + +@description('Comma-joined names of the connections created, in input order. Reference these from toolbox tools via connection: . A string (not an array) so it round-trips through the azd .env without JSON double-encoding.') +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/HostedAgents/Researcher/infra/modules/network.bicep b/HostedAgents/Researcher/infra/modules/network.bicep new file mode 100644 index 0000000..e9ec3e7 --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/network.bicep @@ -0,0 +1,95 @@ +// Virtual network wiring for a network-secured (VNet-injected) Foundry account. +// +// Bring-your-own VNet only (network.mode: byo). The VNet must already exist; +// v1 references it by the ARM id supplied in azure.yaml. Each subnet follows +// the tri-state rule from the synthesizer: +// +// create=true, prefix set -> create the subnet with that prefix +// create=true, prefix empty -> create the subnet with a derived /24 prefix +// create=false -> reference an existing subnet as-is +// +// All subnet ids are deterministic ('/subnets/'), so outputs are +// valid whether the subnet was created here or already existed. + +targetScope = 'resourceGroup' + +@description('ARM resource id of the existing customer VNet.') +param vnetId string + +@description('Name of the agent (delegated) subnet.') +param agentSubnetName string + +@description('CIDR for the agent subnet. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool + +@description('Name of the private-endpoint subnet.') +param peSubnetName string + +@description('CIDR for the private-endpoint subnet. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool + +// The VNet may live in a different resource group than the deployment RG. +var vnetParts = split(vnetId, '/') +var vnetSubscriptionId = vnetParts[2] +var vnetResourceGroupName = vnetParts[4] +var vnetName = last(vnetParts) + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) +} + +var vnetAddressSpace = vnet.properties.addressSpace.addressPrefixes[0] +var agentPrefix = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix +var pePrefix = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix + +// Create the agent subnet, delegated to Microsoft.App/environments so the +// hosted agent's container app environment can be injected into it. +module agentSubnet 'subnet.bicep' = if (createAgentSubnet) { + name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: agentSubnetName + addressPrefix: agentPrefix + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } +} + +// Create the private-endpoint subnet. Depends on the agent subnet so the two +// subnet PUTs against the same VNet do not race (ARM serializes subnet writes). +module peSubnet 'subnet.bicep' = if (createPESubnet) { + name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: peSubnetName + addressPrefix: pePrefix + delegations: [] + } + dependsOn: [ + agentSubnet + ] +} + +output vnetId string = vnet.id +output vnetName string = vnetName +output vnetLocation string = vnet.location +output vnetSubscriptionId string = vnetSubscriptionId +output vnetResourceGroupName string = vnetResourceGroupName +output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' +output peSubnetId string = '${vnet.id}/subnets/${peSubnetName}' +output peSubnetName string = peSubnetName diff --git a/HostedAgents/Researcher/infra/modules/private-endpoint-dns.bicep b/HostedAgents/Researcher/infra/modules/private-endpoint-dns.bicep new file mode 100644 index 0000000..1167d0a --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/private-endpoint-dns.bicep @@ -0,0 +1,168 @@ +// Account private endpoint + the three AI private DNS zones for a +// network-secured Foundry account. Dependent stores stay platform-managed, so +// only the account itself gets a private endpoint here (no Search / Storage / +// Cosmos endpoints). +// +// DNS zones are created and linked to the VNet by default. When +// dnsZonesResourceGroup is set, the zones are referenced from that resource +// group (in dnsZonesSubscription, defaulting to this subscription) instead of +// being created. + +targetScope = 'resourceGroup' + +@description('Name of the Foundry (AIServices) account to bind the private endpoint to.') +param aiAccountName string + +@description('Azure region of the customer VNet.') +param location 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('Suffix for unique resource/link names.') +param suffix 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 aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' +var openAiDnsZoneName = 'privatelink.openai.azure.com' +var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' + +var useExistingZones = !empty(dnsZonesResourceGroup) +var existingZonesSubscription = empty(dnsZonesSubscription) ? subscription().subscriptionId : dnsZonesSubscription + +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiAccountName + scope: resourceGroup() +} + +// Account private endpoint in the PE subnet, targeting the 'account' group. +resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${aiAccountName}-private-endpoint' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: '${aiAccountName}-private-link-service-connection' + properties: { + privateLinkServiceId: aiAccount.id + groupIds: [ + 'account' + ] + } + } + ] + } +} + +// ---- Private DNS zones: create-and-link, or reference existing ---- + +resource aiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: aiServicesDnsZoneName + location: 'global' +} +resource existingAiServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: aiServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var aiServicesZoneId = useExistingZones ? existingAiServicesZone.id : aiServicesZone.id + +resource openAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: openAiDnsZoneName + location: 'global' +} +resource existingOpenAiZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: openAiDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var openAiZoneId = useExistingZones ? existingOpenAiZone.id : openAiZone.id + +resource cognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (!useExistingZones) { + name: cognitiveServicesDnsZoneName + location: 'global' +} +resource existingCognitiveServicesZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (useExistingZones) { + name: cognitiveServicesDnsZoneName + scope: resourceGroup(existingZonesSubscription, dnsZonesResourceGroup) +} +var cognitiveServicesZoneId = useExistingZones ? existingCognitiveServicesZone.id : cognitiveServicesZone.id + +// ---- VNet links (only when we create the zones) ---- + +resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: aiServicesZone + name: 'aiServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: openAiZone + name: 'aiServicesOpenAI-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} +resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (!useExistingZones) { + parent: cognitiveServicesZone + name: 'aiServicesCognitiveServices-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { + id: vnetId + } + registrationEnabled: false + } +} + +// ---- DNS zone group binds the three zones to the account endpoint ---- + +resource aiAccountDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: aiAccountPrivateEndpoint + name: '${aiAccountName}-dns-group' + properties: { + privateDnsZoneConfigs: [ + { + name: '${aiAccountName}-dns-aiserv-config' + properties: { + privateDnsZoneId: aiServicesZoneId + } + } + { + name: '${aiAccountName}-dns-openai-config' + properties: { + privateDnsZoneId: openAiZoneId + } + } + { + name: '${aiAccountName}-dns-cogserv-config' + properties: { + privateDnsZoneId: cognitiveServicesZoneId + } + } + ] + } + dependsOn: [ + aiServicesLink + openAiLink + cognitiveServicesLink + ] +} diff --git a/HostedAgents/Researcher/infra/modules/resources.bicep b/HostedAgents/Researcher/infra/modules/resources.bicep new file mode 100644 index 0000000..efc8671 --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/resources.bicep @@ -0,0 +1,331 @@ +// Resource-group-scoped resources for a microsoft.foundry service: the +// Foundry (AIServices) account, its project, model deployments, the optional +// container registry, and the developer role assignment. +// +// Deployed by main.bicep into a resource group it creates at subscription +// scope. Kept as a separate module so main.bicep can target the subscription +// (and thus create the resource group) while these resources stay RG-scoped. + +targetScope = 'resourceGroup' + +// User-defined types + +@description('Shape of one model deployment entry in azure.yaml.') +type deploymentsType = deploymentType[] + +@description('Shape of a single model deployment.') +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +@description('Shape of a list of Foundry project connections.') +type connectionsType = connectionType[] + +@description('Shape of one Foundry project connection (a host: azure.ai.connection service).') +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +// Parameters + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Tags applied to all resources.') +param tags object = {} + +@description('Optional salt to vary resource names across re-provisions.') +param resourceTokenSalt string = '' + +@description('Foundry project name. 3-32 alphanumeric/hyphen chars.') +@minLength(3) +@maxLength(32) +param foundryProjectName string + +@description('Model deployments to provision on the Foundry account.') +param deployments deploymentsType = [] + +@description('Include an Azure Container Registry. Set true when any agent uses docker:.') +param includeAcr bool = false + +@description('Foundry project connections to create (host: azure.ai.connection services).') +param connections connectionsType = [] + +@description('Credentials keyed by Foundry project connection name.') +@secure() +param connectionCredentials object = {} + +@description('Object id of the developer running azd. When set, grants Cognitive Services User on the project. Empty disables the role assignment so headless / CI runs do not fail.') +param principalId string = '' + +@description('Principal type used in the developer role assignment.') +param principalType string = 'User' + +// Network isolation parameters. All default off so an absent network: block in +// azure.yaml yields a public account identical to the pre-network template. + +@description('Master switch: when true the account is VNet-bound (private).') +param enableNetworkIsolation bool = false + +@description('When true (and isolation on), the agent runtime uses the Microsoft-managed network instead of injecting into a customer subnet.') +param useManagedEgress bool = false + +@description('ARM id of the existing customer VNet (byo mode).') +param vnetId string = '' + +@description('Agent (delegated) subnet name.') +param agentSubnetName string = 'agent-subnet' + +@description('Agent subnet CIDR. Empty derives a /24 from the VNet space.') +param agentSubnetPrefix string = '' + +@description('When true, create the agent subnet; when false, reference it.') +param createAgentSubnet bool = false + +@description('Private-endpoint subnet name.') +param peSubnetName string = 'pe-subnet' + +@description('Private-endpoint subnet CIDR. Empty derives a /24 from the VNet space.') +param peSubnetPrefix string = '' + +@description('When true, create the PE subnet; when false, reference it.') +param createPESubnet bool = false + +@description('Managed-network isolation mode (managed mode). AllowInternetOutbound | AllowOnlyApprovedOutbound.') +param managedIsolationMode 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 = '' + +// Variables + +var resourceToken = empty(resourceTokenSalt) + ? uniqueString(subscription().id, resourceGroup().id, location) + : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) + +var abbrs = loadJsonContent('../abbreviations.json') + +var foundryAccountName = '${abbrs.cognitiveServicesAccounts}${resourceToken}' + +// Egress: byo injects the agent into a customer subnet; managed uses the +// Microsoft-managed network. Ingress: an account private endpoint is always +// provisioned when isolation is on, so the data plane is never left public. +var useByoNetwork = enableNetworkIsolation && !useManagedEgress +var useManagedNetwork = enableNetworkIsolation && useManagedEgress +var disablePublicDataPlaneAccess = enableNetworkIsolation + +// Built-in role definition ids. See: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles +var cognitiveServicesUserRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'a97b65f3-24c7-4388-baec-2e87135dc908' +) + +// Resources + +// Customer VNet wiring: reference the VNet and create or reference the agent +// (byo egress only) + private-endpoint subnets. Runs whenever isolation is on +// because the account private endpoint is always provisioned. +module network 'network.bicep' = if (enableNetworkIsolation) { + name: 'foundry-network' + params: { + vnetId: vnetId + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + createAgentSubnet: createAgentSubnet + peSubnetName: peSubnetName + peSubnetPrefix: peSubnetPrefix + createPESubnet: createPESubnet + } +} + +// networkInjections wires the account into the agent subnet (byo) or the +// Microsoft-managed network (managed). Null when isolation is off. +// +// subnetArmId is built as a concrete string from the (concrete) vnetId param +// rather than network!.outputs.agentSubnetId. The account and the network +// module deploy in the same template, so an inter-module reference() here is +// unresolved at the CognitiveServices RP preflight, which then fails to convert +// networkInjections to its typed contract (ARM what-if does not catch this). +// The deterministic id avoids the unresolved reference; an explicit dependsOn +// on the network module preserves ordering (the subnet must exist first). +var agentSubnetArmId = '${vnetId}/subnets/${agentSubnetName}' +var agentNetworkInjections = useByoNetwork + ? [ + { + scenario: 'agent' + subnetArmId: agentSubnetArmId + useMicrosoftManagedNetwork: false + } + ] + : (useManagedNetwork + ? [ + { + scenario: 'agent' + useMicrosoftManagedNetwork: true + } + ] + : null) + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: foundryAccountName + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: foundryAccountName + publicNetworkAccess: disablePublicDataPlaneAccess ? 'Disabled' : 'Enabled' + disableLocalAuth: true + networkAcls: { + defaultAction: disablePublicDataPlaneAccess ? 'Deny' : 'Allow' + bypass: disablePublicDataPlaneAccess ? 'AzureServices' : null + virtualNetworkRules: [] + ipRules: [] + } + networkInjections: agentNetworkInjections + } + + // The account injects into the agent subnet via a deterministic id (above), + // so Bicep cannot infer the dependency on the network module that creates + // that subnet. Declare it explicitly so the subnet exists before injection. + dependsOn: useByoNetwork ? [network] : [] + + // Sequential model deployment creation; ARM throttles concurrent + // deployments on the same account. + @batchSize(1) + resource modelDeployments 'deployments' = [ + for d in deployments: { + name: d.name + properties: { + model: d.model + } + sku: d.sku + } + ] + + resource project 'projects' = { + name: foundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${foundryProjectName} Project' + displayName: foundryProjectName + } + // Explicit dependsOn ensures all model deployments complete before + // the project is created; the project does not reference them so + // there is no implicit dependency Bicep can infer. + dependsOn: [ + modelDeployments + ] + } +} + +// 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 +// platform default applies. Note: AllowOnlyApprovedOutbound additionally +// requires approved outbound rules for the agent to reach dependent resources; +// for the platform-managed stores used here those are managed by the platform. +resource foundryManagedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = + if (useManagedNetwork && !empty(managedIsolationMode)) { + parent: foundryAccount + name: 'default' + properties: { + managedNetwork: { + isolationMode: managedIsolationMode + } + } + } + +module acr 'acr.bicep' = if (includeAcr) { + name: 'acr' + params: { + location: location + tags: tags + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + foundryProjectPrincipalId: foundryAccount::project.identity.principalId + enableNetworkIsolation: enableNetworkIsolation + } +} + +// Account private endpoint + AI private DNS zones. The account is always given a +// private endpoint when isolation is on (byo or managed egress); dependent +// stores stay platform-managed, so only the account gets an endpoint. +module privateEndpointDns 'private-endpoint-dns.bicep' = if (enableNetworkIsolation) { + name: 'foundry-private-endpoint-dns' + params: { + aiAccountName: foundryAccount.name + location: network!.outputs.vnetLocation + vnetId: network!.outputs.vnetId + peSubnetId: network!.outputs.peSubnetId + suffix: resourceToken + dnsZonesResourceGroup: dnsZonesResourceGroup + dnsZonesSubscription: dnsZonesSubscription + } +} + +// Project connections (RemoteTool/MCP, CognitiveSearch, ...) declared as +// host: azure.ai.connection services. Created at provision time so a toolbox +// that references a connection by name resolves it at deploy. Depends on the +// project via foundryAccount.name / project.name so ordering is correct. +module projectConnections 'connections.bicep' = if (!empty(connections)) { + name: 'foundry-connections' + params: { + foundryAccountName: foundryAccount.name + foundryProjectName: foundryAccount::project.name + connections: connections + connectionCredentials: connectionCredentials + } +} + +// Grant the developer Cognitive Services User on the project so they can call +// the Foundry data-plane (chat/completions, agents API) from their machine. +resource developerCognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(principalId)) { + name: guid(foundryAccount::project.id, principalId, cognitiveServicesUserRoleId) + scope: foundryAccount::project + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: cognitiveServicesUserRoleId + } +} + +// Outputs + +output AZURE_AI_PROJECT_ID string = foundryAccount::project.id +output AZURE_AI_ACCOUNT_NAME string = foundryAccount.name +output AZURE_AI_PROJECT_NAME string = foundryAccount::project.name +output AZURE_OPENAI_ENDPOINT string = 'https://${foundryAccount.name}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = 'https://${foundryAccount.name}.services.ai.azure.com/api/projects/${foundryAccount::project.name}' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = includeAcr ? acr!.outputs.loginServer : '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = includeAcr ? acr!.outputs.resourceId : '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = includeAcr ? acr!.outputs.connectionName : '' +output AZURE_AI_PROJECT_CONNECTION_NAMES string = empty(connections) ? '' : projectConnections!.outputs.connectionNames +output AZURE_FOUNDRY_NETWORK_MODE string = !enableNetworkIsolation ? 'none' : (useManagedEgress ? 'managed' : 'byo') +output AZURE_FOUNDRY_MANAGED_ISOLATION_MODE string = useManagedNetwork ? managedIsolationMode : '' diff --git a/HostedAgents/Researcher/infra/modules/subnet.bicep b/HostedAgents/Researcher/infra/modules/subnet.bicep new file mode 100644 index 0000000..27abadc --- /dev/null +++ b/HostedAgents/Researcher/infra/modules/subnet.bicep @@ -0,0 +1,28 @@ +// Single subnet on an existing VNet. Kept as its own module so the parent can +// place subnets in the VNet's resource group (which may differ from the +// deployment RG) and serialize subnet writes via module dependsOn. + +targetScope = 'resourceGroup' + +@description('Name of the virtual network the subnet belongs to.') +param vnetName string + +@description('Name of the subnet to create.') +param subnetName string + +@description('CIDR for the subnet.') +param addressPrefix string + +@description('Subnet delegations (e.g. Microsoft.App/environments for the agent subnet).') +param delegations array = [] + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + name: '${vnetName}/${subnetName}' + properties: { + addressPrefix: addressPrefix + delegations: delegations + } +} + +output subnetId string = subnet.id +output subnetName string = subnetName diff --git a/HostedAgents/Reviewer/azure.yaml b/HostedAgents/Reviewer/azure.yaml index 8fab74c..a47a866 100644 --- a/HostedAgents/Reviewer/azure.yaml +++ b/HostedAgents/Reviewer/azure.yaml @@ -2,30 +2,49 @@ name: blogwriter-hosted-agent-reviewer metadata: - template: blogwriter-hosted-agent + template: blogwriter-hosted-agent services: - ai-project: - host: azure.ai.project - blogwriter-reviewer: - project: . - host: azure.ai.agent - language: csharp - uses: - - ai-project - description: Reviews blog drafts and approves or requests revisions. - codeConfiguration: - dependencyResolution: remote_build - entryPoint: BlogWriter.HostedAgents.Reviewer.dll - runtime: dotnet_10 - container: - resources: - cpu: "0.5" - memory: 1Gi - kind: hosted - name: blogwriter-reviewer - environmentVariables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - protocols: - - protocol: responses - version: 2.0.0 + ai-project: + host: azure.ai.project + endpoint: https://AgentFrameworkJesseLiberty.services.ai.azure.com/api/projects/AgentFramework + blogwriter-reviewer: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Reviewer.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + description: Reviews blog drafts and approves or requests revisions. + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + kind: hosted + name: blogwriter-reviewer + protocols: + - protocol: responses + version: 2.0.0 + reviewer: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Reviewer.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi +infra: + provider: microsoft.foundry diff --git a/HostedAgents/Reviewer/infra/main.bicep b/HostedAgents/Reviewer/infra/main.bicep new file mode 100644 index 0000000..c72178f --- /dev/null +++ b/HostedAgents/Reviewer/infra/main.bicep @@ -0,0 +1,73 @@ +// Editable infrastructure for an existing Foundry project. The account and +// project are referenced only. ACR behavior was selected when this file was +// generated, so the graph contains no runtime mode switch. + +targetScope = 'subscription' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param projectResourceId string +param deployments deploymentType[] = [] +param projectEndpoint string +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} + +var projectIdParts = split(projectResourceId, '/') +var projectSubscriptionId = projectIdParts[2] +var projectResourceGroupName = projectIdParts[4] +var accountName = projectIdParts[8] +var projectName = projectIdParts[10] + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +module projectResources 'modules/foundry-project.bicep' = { + name: 'foundry-project-resources' + scope: resourceGroup(projectSubscriptionId, projectResourceGroupName) + params: { + accountName: accountName + projectName: projectName + deployments: deployments + connections: connections + connectionCredentials: connectionCredentials + } +} + +output AZURE_AI_PROJECT_ID string = projectResourceId +output AZURE_AI_ACCOUNT_NAME string = accountName +output AZURE_AI_PROJECT_NAME string = projectName +output AZURE_OPENAI_ENDPOINT string = 'https://${accountName}.openai.azure.com/' +output FOUNDRY_PROJECT_ENDPOINT string = projectEndpoint +output AZURE_FOUNDRY_RESOURCE_GROUP string = '' +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = '' +output AZURE_CONTAINER_REGISTRY_RESOURCE_ID string = '' +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = projectResources.outputs.acrConnectionName +output AZURE_AI_PROJECT_CONNECTION_NAMES string = projectResources.outputs.connectionNames +output AZURE_AI_PROJECT_CONNECTIONS_PROJECT_ENDPOINT string = projectEndpoint +output AZD_FOUNDRY_ACR_MODE string = 'none' diff --git a/HostedAgents/Reviewer/infra/main.parameters.json b/HostedAgents/Reviewer/infra/main.parameters.json new file mode 100644 index 0000000..6346bcf --- /dev/null +++ b/HostedAgents/Reviewer/infra/main.parameters.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "connectionCredentials": { + "value": {} + }, + "connections": { + "value": [] + }, + "deployments": { + "value": [] + }, + "projectEndpoint": { + "value": "${FOUNDRY_PROJECT_ENDPOINT}" + }, + "projectResourceId": { + "value": "${AZURE_AI_PROJECT_ID}" + } + } +} diff --git a/HostedAgents/Reviewer/infra/modules/foundry-project.bicep b/HostedAgents/Reviewer/infra/modules/foundry-project.bicep new file mode 100644 index 0000000..0331938 --- /dev/null +++ b/HostedAgents/Reviewer/infra/modules/foundry-project.bicep @@ -0,0 +1,95 @@ +targetScope = 'resourceGroup' + +type deploymentType = { + name: string + model: { + name: string + format: string + version: string + } + sku: { + name: string + capacity: int + } +} + +type connectionType = { + name: string + category: string + target: string + authType: string + metadata: object? +} + +param accountName string +param projectName string +param deployments deploymentType[] = [] +param connections connectionType[] = [] +@secure() +param connectionCredentials object = {} +param acrName string = '' +param acrEndpoint string = '' +param acrResourceId string = '' +param createAcrConnection bool = false +param existingAcrConnectionName string = '' + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: accountName +} + +@batchSize(1) +resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for d in deployments: { + parent: foundryAccount + name: d.name + properties: { + model: d.model + } + sku: d.sku + } +] + +resource foundryAccountPreview 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + + resource project 'projects' existing = { + name: projectName + } +} + +resource acrConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (createAcrConnection) { + parent: foundryAccountPreview::project + name: '${acrName}-conn' + properties: { + category: 'ContainerRegistry' + target: acrEndpoint + authType: 'ManagedIdentity' + credentials: { + clientId: foundryAccountPreview::project.identity.principalId + resourceId: acrResourceId + } + isSharedToAll: true + metadata: { + ResourceId: acrResourceId + } + } +} + +resource projectConnections 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = [ + for c in connections: { + parent: foundryAccountPreview::project + name: c.name + properties: union( + { + category: c.category + target: c.target + authType: c.authType + }, + contains(connectionCredentials, c.name) ? { credentials: connectionCredentials[c.name] } : {}, + c.?metadata != null ? { metadata: c.?metadata } : {} + ) + } +] + +output acrConnectionName string = createAcrConnection ? acrConnection!.name : existingAcrConnectionName +output connectionNames string = join(map(connections, c => c.name), ',') diff --git a/docs/deployment.md b/docs/deployment.md index ec1dde0..c79a491 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,6 +21,7 @@ From inside each `HostedAgents/` folder (`Blogger`, `Researcher`, `Author` ```powershell # First time only per project — scaffold/replace azure.yaml against your real Foundry project azd ai agent init --deploy-mode code +azd ai agent init --infra=bicep # Provision Foundry project/model/ACR resources (skip if reusing an existing project) azd provision