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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ services:
# Use resourceName
resourceName: ${SERVICE_FUNCTION_RESOURCE_NAME}
hooks:
predeploy:
windows:
shell: pwsh
run: ../../../scripts/function_network_toggle.ps1 -Action enable
continueOnError: false
posix:
shell: sh
run: bash ../../../scripts/function_network_toggle.sh enable
continueOnError: false
prepackage:
windows:
shell: pwsh
Expand All @@ -106,3 +115,12 @@ services:
shell: sh
run: poetry export -o requirements.txt --without-hashes; pip install -r requirements.txt
continueOnError: false
postdeploy:
windows:
shell: pwsh
run: ../../../scripts/function_network_toggle.ps1 -Action disable
continueOnError: false
posix:
shell: sh
run: bash ../../../scripts/function_network_toggle.sh disable
continueOnError: false
27 changes: 26 additions & 1 deletion infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,8 @@ var privateDnsZones = [
'privatelink.cognitiveservices.azure.com'
'privatelink.openai.azure.com'
'privatelink.vaultcore.azure.net'
'privatelink.api.azureml.ms'
'privatelink.azurewebsites.net'
]

// DNS Zone Index Constants
Expand All @@ -637,8 +639,14 @@ var dnsZoneIndex = {
cognitiveServices: 6
openAI: 7
keyVault: 8
machinelearning: 9
appService: 10
}

// Function App private networking is enabled for both container and code hosting.
// The predeploy hook temporarily enables public access for zip deploy during code deployments.
var enablePrivateNetworkingFunction = enablePrivateNetworking

// ===================================================
// DEPLOY PRIVATE DNS ZONES
// - Deploys all zones if no existing Foundry project is used
Expand Down Expand Up @@ -1434,7 +1442,24 @@ module function 'modules/app/function.bicep' = {
virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : ''
vnetRouteAllEnabled: enablePrivateNetworking ? true : false
vnetImagePullEnabled: enablePrivateNetworking ? true : false
publicNetworkAccess: 'Enabled' // Always enabling public network access
publicNetworkAccess: enablePrivateNetworkingFunction ? 'Disabled' : 'Enabled'
privateEndpoints: enablePrivateNetworkingFunction
? [
{
name: 'pep-${hostingModel == 'container' ? '${functionName}-docker' : functionName}'
customNetworkInterfaceName: 'nic-${hostingModel == 'container' ? '${functionName}-docker' : functionName}'
privateDnsZoneGroup: {
privateDnsZoneGroupConfigs: [
{
privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.appService]!.outputs.resourceId
}
]
}
service: 'sites'
subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId
}
]
: []
appSettings: union(
{
AZURE_BLOB_ACCOUNT_NAME: storageAccountName
Expand Down
19 changes: 12 additions & 7 deletions infra/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"_generator": {
"name": "bicep",
"version": "0.43.8.12551",
"templateHash": "6545323487376399203"
"templateHash": "9784573585359353721"
}
},
"parameters": {
Expand Down Expand Up @@ -634,7 +634,9 @@
"privatelink.search.windows.net",
"privatelink.cognitiveservices.azure.com",
"privatelink.openai.azure.com",
"privatelink.vaultcore.azure.net"
"privatelink.vaultcore.azure.net",
"privatelink.api.azureml.ms",
"privatelink.azurewebsites.net"
],
"dnsZoneIndex": {
"cosmosDB": 0,
Expand All @@ -645,8 +647,11 @@
"searchService": 5,
"cognitiveServices": 6,
"openAI": 7,
"keyVault": 8
"keyVault": 8,
"machinelearning": 9,
"appService": 10
},
"enablePrivateNetworkingFunction": "[parameters('enablePrivateNetworking')]",
"cosmosDbName": "db_conversation_history",
"cosmosDbContainerName": "conversations",
"allowAllIPsFirewall": false,
Expand Down Expand Up @@ -37756,9 +37761,8 @@
"virtualNetworkSubnetId": "[if(parameters('enablePrivateNetworking'), createObject('value', reference('virtualNetwork').outputs.webSubnetResourceId.value), createObject('value', ''))]",
"vnetRouteAllEnabled": "[if(parameters('enablePrivateNetworking'), createObject('value', true()), createObject('value', false()))]",
"vnetImagePullEnabled": "[if(parameters('enablePrivateNetworking'), createObject('value', true()), createObject('value', false()))]",
"publicNetworkAccess": {
"value": "Enabled"
},
"publicNetworkAccess": "[if(variables('enablePrivateNetworkingFunction'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]",
"privateEndpoints": "[if(variables('enablePrivateNetworkingFunction'), createObject('value', createArray(createObject('name', format('pep-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'customNetworkInterfaceName', format('nic-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').appService)).outputs.resourceId.value))), 'service', 'sites', 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value))), createObject('value', createArray()))]",
"appSettings": {
"value": "[union(createObject('AZURE_BLOB_ACCOUNT_NAME', variables('storageAccountName'), 'AZURE_BLOB_CONTAINER_NAME', variables('blobContainerName'), 'AZURE_FORM_RECOGNIZER_ENDPOINT', reference('formrecognizer').outputs.endpoint.value, 'AZURE_COMPUTER_VISION_ENDPOINT', if(parameters('useAdvancedImageProcessing'), reference('computerVision').outputs.endpoint.value, ''), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_API_VERSION', parameters('computerVisionVectorizeImageApiVersion'), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_MODEL_VERSION', parameters('computerVisionVectorizeImageModelVersion'), 'AZURE_CONTENT_SAFETY_ENDPOINT', reference('contentsafety').outputs.endpoint.value, 'AZURE_KEY_VAULT_ENDPOINT', reference('keyvault').outputs.uri.value, 'AZURE_OPENAI_MODEL', parameters('azureOpenAIModel'), 'AZURE_OPENAI_MODEL_NAME', parameters('azureOpenAIModelName'), 'AZURE_OPENAI_MODEL_VERSION', parameters('azureOpenAIModelVersion'), 'AZURE_OPENAI_EMBEDDING_MODEL', parameters('azureOpenAIEmbeddingModel'), 'AZURE_OPENAI_EMBEDDING_MODEL_NAME', parameters('azureOpenAIEmbeddingModelName'), 'AZURE_OPENAI_EMBEDDING_MODEL_VERSION', parameters('azureOpenAIEmbeddingModelVersion'), 'AZURE_OPENAI_RESOURCE', variables('azureOpenAIResourceName'), 'AZURE_OPENAI_API_VERSION', parameters('azureOpenAIApiVersion'), 'USE_ADVANCED_IMAGE_PROCESSING', if(parameters('useAdvancedImageProcessing'), 'true', 'false'), 'DOCUMENT_PROCESSING_QUEUE_NAME', variables('queueName'), 'ORCHESTRATION_STRATEGY', parameters('orchestrationStrategy'), 'LOGLEVEL', parameters('logLevel'), 'PACKAGE_LOGGING_LEVEL', 'WARNING', 'AZURE_LOGGING_PACKAGES', '', 'AZURE_OPENAI_SYSTEM_MESSAGE', parameters('azureOpenAISystemMessage'), 'OPEN_AI_FUNCTIONS_SYSTEM_PROMPT', variables('openAIFunctionsSystemPrompt'), 'SEMANTIC_KERNEL_SYSTEM_PROMPT', variables('semanticKernelSystemPrompt'), 'DATABASE_TYPE', parameters('databaseType'), 'MANAGED_IDENTITY_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'MANAGED_IDENTITY_RESOURCE_ID', reference('managedIdentityModule').outputs.resourceId.value, 'AZURE_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'APP_ENV', parameters('appEnvironment'), 'BACKEND_URL', variables('backendUrl'), 'AZURE_SEARCH_DIMENSIONS', parameters('azureSearchDimensions'), 'APPLICATIONINSIGHTS_ENABLED', if(parameters('enableMonitoring'), 'true', 'false')), if(equals(parameters('databaseType'), 'CosmosDB'), createObject('AZURE_SEARCH_INDEX', variables('azureSearchIndex'), 'AZURE_SEARCH_SERVICE', format('https://{0}.search.windows.net', variables('azureAISearchName')), 'AZURE_SEARCH_DATASOURCE_NAME', variables('azureSearchDatasource'), 'AZURE_SEARCH_INDEXER_NAME', variables('azureSearchIndexer'), 'AZURE_SEARCH_USE_INTEGRATED_VECTORIZATION', if(parameters('azureSearchUseIntegratedVectorization'), 'true', 'false'), 'AZURE_SEARCH_FIELDS_ID', parameters('azureSearchFieldId'), 'AZURE_SEARCH_CONTENT_COLUMN', parameters('azureSearchContentColumn'), 'AZURE_SEARCH_CONTENT_VECTOR_COLUMN', parameters('azureSearchVectorColumn'), 'AZURE_SEARCH_TITLE_COLUMN', parameters('azureSearchTitleColumn'), 'AZURE_SEARCH_FIELDS_METADATA', parameters('azureSearchFieldsMetadata'), 'AZURE_SEARCH_SOURCE_COLUMN', parameters('azureSearchSourceColumn'), 'AZURE_SEARCH_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchTextColumn'), ''), 'AZURE_SEARCH_LAYOUT_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchLayoutTextColumn'), ''), 'AZURE_SEARCH_CHUNK_COLUMN', parameters('azureSearchChunkColumn'), 'AZURE_SEARCH_OFFSET_COLUMN', parameters('azureSearchOffsetColumn'), 'AZURE_SEARCH_TOP_K', parameters('azureSearchTopK')), if(equals(parameters('databaseType'), 'PostgreSQL'), createObject('AZURE_POSTGRESQL_HOST_NAME', variables('postgresDBFqdn'), 'AZURE_POSTGRESQL_DATABASE_NAME', variables('postgresDBName'), 'AZURE_POSTGRESQL_USER', reference('managedIdentityModule').outputs.name.value), createObject())))]"
}
Expand Down Expand Up @@ -40392,6 +40396,7 @@
}
},
"dependsOn": [
"[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').appService)]",
"computerVision",
"contentsafety",
"formrecognizer",
Expand Down Expand Up @@ -54522,8 +54527,8 @@
},
"dependsOn": [
"[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)]",
"[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageFile)]",
"[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)]",
"[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageFile)]",
"managedIdentityModule",
"virtualNetwork"
]
Expand Down
109 changes: 109 additions & 0 deletions scripts/function_network_toggle.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet("enable", "disable")]
[string]$Action
)

$ErrorActionPreference = "Stop"

$functionAppName = $env:SERVICE_FUNCTION_RESOURCE_NAME
$resourceGroup = $env:AZURE_RESOURCE_GROUP

if (-not $functionAppName -or -not $resourceGroup) {
Write-Host "Skipping Function App network toggle: missing SERVICE_FUNCTION_RESOURCE_NAME or AZURE_RESOURCE_GROUP."
exit 0
}

az functionapp show --name $functionAppName --resource-group $resourceGroup 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "Skipping Function App network toggle: Function App '$functionAppName' not found in '$resourceGroup'."
exit 0
}

# Only toggle when private endpoint exists, which is the WAF/private-networking scenario for container hosting.
$functionAppId = az functionapp show `
--name $functionAppName `
--resource-group $resourceGroup `
--query "id" `
-o tsv 2>$null

$privateEndpointCount = az network private-endpoint list `
--resource-group $resourceGroup `
--query "length([?contains(privateLinkServiceConnections[].privateLinkServiceId, '$functionAppId')])" `
-o tsv 2>$null

if ($LASTEXITCODE -ne 0 -or -not $privateEndpointCount -or [int]$privateEndpointCount -eq 0) {
Write-Host "Skipping Function App network toggle: no private endpoint is configured on '$functionAppName'."
exit 0
}

$currentPublicAccess = az functionapp show `
--name $functionAppName `
--resource-group $resourceGroup `
--query "publicNetworkAccess" `
-o tsv

if ($Action -eq "enable") {
if ($currentPublicAccess -eq "Enabled") {
Write-Host "Function App public access already enabled; no change needed."
exit 0
}

Write-Host "Temporarily enabling Function App public access for deployment."
az functionapp update `
--name $functionAppName `
--resource-group $resourceGroup `
--set publicNetworkAccess=Enabled | Out-Null

if ($LASTEXITCODE -ne 0) {
throw "Failed to enable Function App public access."
}

Write-Host "Function App public access enabled. Waiting for SCM endpoint to become reachable..."
$scmUrl = "https://$functionAppName.scm.azurewebsites.net/"
$maxRetries = 12
$retryDelay = 10
for ($i = 1; $i -le $maxRetries; $i++) {
try {
$response = Invoke-WebRequest -Uri $scmUrl -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
if ($response.StatusCode -ne 403) {
Write-Host "SCM endpoint is reachable (HTTP $($response.StatusCode)) after $($i * $retryDelay)s."
break
}
} catch {
$statusCode = $null
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
if ($statusCode -and $statusCode -ne 403) {
Write-Host "SCM endpoint returned HTTP $statusCode (not 403) after $($i * $retryDelay)s — access is open."
break
}
}
if ($i -eq $maxRetries) {
Write-Host "WARNING: SCM endpoint still not reachable after $($maxRetries * $retryDelay)s. Proceeding anyway."
} else {
Write-Host " Retry $i/$maxRetries — SCM endpoint not yet reachable, waiting ${retryDelay}s..."
Start-Sleep -Seconds $retryDelay
}
}

exit 0
}

if ($currentPublicAccess -eq "Disabled") {
Write-Host "Function App public access already disabled; no change needed."
exit 0
}

Write-Host "Restoring Function App to private-only access."
az functionapp update `
--name $functionAppName `
--resource-group $resourceGroup `
--set publicNetworkAccess=Disabled | Out-Null

if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Function App public access."
}

Write-Host "Function App public access disabled."
89 changes: 89 additions & 0 deletions scripts/function_network_toggle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/bin/bash
set -euo pipefail

ACTION="${1:-}"
if [[ "$ACTION" != "enable" && "$ACTION" != "disable" ]]; then
echo "Usage: $0 <enable|disable>"
exit 1
fi

FUNCTION_APP_NAME="${SERVICE_FUNCTION_RESOURCE_NAME:-}"
RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-}"

if [[ -z "$FUNCTION_APP_NAME" || -z "$RESOURCE_GROUP" ]]; then
echo "Skipping Function App network toggle: missing SERVICE_FUNCTION_RESOURCE_NAME or AZURE_RESOURCE_GROUP."
exit 0
fi

if ! az functionapp show --name "$FUNCTION_APP_NAME" --resource-group "$RESOURCE_GROUP" >/dev/null 2>&1; then
echo "Skipping Function App network toggle: Function App '$FUNCTION_APP_NAME' not found in '$RESOURCE_GROUP'."
exit 0
fi

# Only toggle when private endpoint exists, which is the WAF/private-networking scenario for container hosting.
FUNCTION_APP_ID=$(az functionapp show \
--name "$FUNCTION_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query "id" \
-o tsv)

PRIVATE_ENDPOINT_COUNT=$(az network private-endpoint list \
--resource-group "$RESOURCE_GROUP" \
--query "length([?contains(privateLinkServiceConnections[].privateLinkServiceId, '${FUNCTION_APP_ID}')])" \
-o tsv 2>/dev/null || echo "0")

if [[ -z "$PRIVATE_ENDPOINT_COUNT" || "$PRIVATE_ENDPOINT_COUNT" -eq 0 ]]; then
echo "Skipping Function App network toggle: no private endpoint is configured on '$FUNCTION_APP_NAME'."
exit 0
fi

CURRENT_PUBLIC_ACCESS=$(az functionapp show \
--name "$FUNCTION_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query "publicNetworkAccess" \
-o tsv)

if [[ "$ACTION" == "enable" ]]; then
if [[ "$CURRENT_PUBLIC_ACCESS" == "Enabled" ]]; then
echo "Function App public access already enabled; no change needed."
exit 0
fi

echo "Temporarily enabling Function App public access for deployment."
az functionapp update \
--name "$FUNCTION_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--set publicNetworkAccess=Enabled >/dev/null

echo "Function App public access enabled. Waiting for SCM endpoint to become reachable..."
SCM_URL="https://${FUNCTION_APP_NAME}.scm.azurewebsites.net/"
MAX_RETRIES=12
RETRY_DELAY=10
for i in $(seq 1 $MAX_RETRIES); do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$SCM_URL" 2>/dev/null || echo "000")
if [[ "$HTTP_STATUS" != "403" && "$HTTP_STATUS" != "000" ]]; then
echo "SCM endpoint is reachable (HTTP $HTTP_STATUS) after $((i * RETRY_DELAY))s."
break
fi
if [[ "$i" -eq "$MAX_RETRIES" ]]; then
echo "WARNING: SCM endpoint still not reachable after $((MAX_RETRIES * RETRY_DELAY))s. Proceeding anyway."
else
echo " Retry $i/$MAX_RETRIES — SCM endpoint not yet reachable, waiting ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
fi
done

exit 0
fi

if [[ "$CURRENT_PUBLIC_ACCESS" == "Disabled" ]]; then
echo "Function App public access already disabled; no change needed."
exit 0
fi

echo "Restoring Function App to private-only access."
az functionapp update \
--name "$FUNCTION_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--set publicNetworkAccess=Disabled >/dev/null
echo "Function App public access disabled."
Loading