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
35 changes: 18 additions & 17 deletions .github/workflows/deploy-orchestrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,25 @@ jobs:
TEST_SUITE: ${{ inputs.trigger_type == 'workflow_dispatch' && inputs.run_e2e_tests || 'GoldenPath-Testing' }}
secrets: inherit

cleanup-deployment:
if: "!cancelled() && needs.deploy.outputs.RESOURCE_GROUP_NAME != '' && inputs.existing_webapp_url == '' && (inputs.trigger_type != 'workflow_dispatch' || inputs.cleanup_resources)"
needs: [docker-build, deploy, e2e-test]
uses: ./.github/workflows/job-cleanup-deployment.yml
with:
runner_os: ${{ inputs.runner_os }}
trigger_type: ${{ inputs.trigger_type }}
cleanup_resources: ${{ inputs.cleanup_resources }}
existing_webapp_url: ${{ inputs.existing_webapp_url }}
RESOURCE_GROUP_NAME: ${{ needs.deploy.outputs.RESOURCE_GROUP_NAME }}
AZURE_LOCATION: ${{ needs.deploy.outputs.AZURE_LOCATION }}
AZURE_ENV_OPENAI_LOCATION: ${{ needs.deploy.outputs.AZURE_ENV_OPENAI_LOCATION }}
ENV_NAME: ${{ needs.deploy.outputs.ENV_NAME }}
IMAGE_TAG: ${{ needs.deploy.outputs.IMAGE_TAG }}
secrets: inherit

send-notification:
if: "!cancelled()"
needs: [docker-build, deploy, e2e-test]
needs: [docker-build, deploy, e2e-test, cleanup-deployment]
uses: ./.github/workflows/job-send-notification.yml
with:
trigger_type: ${{ inputs.trigger_type }}
Expand All @@ -121,20 +137,5 @@ jobs:
QUOTA_FAILED: ${{ needs.deploy.outputs.QUOTA_FAILED }}
TEST_SUCCESS: ${{ needs.e2e-test.outputs.TEST_SUCCESS }}
TEST_REPORT_URL: ${{ needs.e2e-test.outputs.TEST_REPORT_URL }}
secrets: inherit

cleanup-deployment:
if: "!cancelled() && needs.deploy.outputs.RESOURCE_GROUP_NAME != '' && inputs.existing_webapp_url == '' && (inputs.trigger_type != 'workflow_dispatch' || inputs.cleanup_resources)"
needs: [docker-build, deploy, e2e-test]
uses: ./.github/workflows/job-cleanup-deployment.yml
with:
runner_os: ${{ inputs.runner_os }}
trigger_type: ${{ inputs.trigger_type }}
cleanup_resources: ${{ inputs.cleanup_resources }}
existing_webapp_url: ${{ inputs.existing_webapp_url }}
RESOURCE_GROUP_NAME: ${{ needs.deploy.outputs.RESOURCE_GROUP_NAME }}
AZURE_LOCATION: ${{ needs.deploy.outputs.AZURE_LOCATION }}
AZURE_ENV_OPENAI_LOCATION: ${{ needs.deploy.outputs.AZURE_ENV_OPENAI_LOCATION }}
ENV_NAME: ${{ needs.deploy.outputs.ENV_NAME }}
IMAGE_TAG: ${{ needs.deploy.outputs.IMAGE_TAG }}
cleanup_result: ${{ needs.cleanup-deployment.result }}
secrets: inherit
289 changes: 106 additions & 183 deletions .github/workflows/job-send-notification.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/DeploymentGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Ensure you have access to an [Azure subscription](https://azure.microsoft.com/fr
|------------------------------|-----------|-------------|
| **Contributor** | Subscription level | Create and manage Azure resources |
| **User Access Administrator** | Subscription level | Manage user access and role assignments |
| **Role Based Access Control** | Subscription/Resource Group level | Configure RBAC permissions |
| **Role Based Access Control Admin** | Subscription/Resource Group level | Configure RBAC permissions |
| **App Registration Creation** | Azure Active Directory | Create and configure authentication |

**🔍 How to Check Your Permissions:**
Expand Down
Binary file added docs/images/readme/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/readme/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,7 @@ module webSite 'modules/web-sites.bicep' = {
WEBSITES_PORT: '3000'
WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
AUTH_ENABLED: 'false'
}
// WAF aligned configuration for Monitoring
Expand Down
43 changes: 40 additions & 3 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,35 @@
"value": "${AZURE_ENV_REASONING_MODEL_CAPACITY}"
},
"backendContainerImageTag": {
"value": "${AZURE_ENV_IMAGE_TAG}"
"value": "${AZURE_ENV_IMAGE_TAG=latest_v4}"
},
"frontendContainerImageTag": {
"value": "${AZURE_ENV_IMAGE_TAG}"
"value": "${AZURE_ENV_IMAGE_TAG=latest_v4}"
},
"MCPContainerImageTag": {
"value": "${AZURE_ENV_IMAGE_TAG}"
"value": "${AZURE_ENV_IMAGE_TAG=latest_v4}"
},
"enableTelemetry": {
"value": "${AZURE_ENV_ENABLE_TELEMETRY}"
},
"enableMonitoring": {
"value": true
},
"enablePrivateNetworking": {
"value": true
},
"enableScalability": {
"value": false
},
"virtualMachineAdminUsername": {
"value": "${AZURE_ENV_VM_ADMIN_USERNAME}"
},
"virtualMachineAdminPassword": {
"value": "${AZURE_ENV_VM_ADMIN_PASSWORD}"
},
"virtualMachineSize": {
"value": "${AZURE_ENV_VM_SIZE}"
},
"existingLogAnalyticsWorkspaceId": {
"value": "${AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID}"
},
Expand All @@ -73,6 +91,25 @@
},
"MCPContainerRegistryHostname": {
"value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT}"
},
"allowedFqdnList": {
"value": [
"mcr.microsoft.com",
"openai.azure.com",
"cognitiveservices.azure.com",
"login.microsoftonline.com",
"management.azure.com",
"aiinfra.azure.com",
"aiinfra.azure.net",
"aiinfra.azureedge.net",
"blob.core.windows.net",
"database.windows.net",
"vault.azure.net",
"monitoring.azure.com",
"dc.services.visualstudio.com",
"azconfig.io",
"azconfig.azure.net"
]
}
}
}
1 change: 1 addition & 0 deletions infra/main_custom.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,7 @@ module webSite 'modules/web-sites.bicep' = {
WEBSITES_PORT: '8000'
//WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
AUTH_ENABLED: 'false'
ENABLE_ORYX_BUILD: 'True'
}
Expand Down
19 changes: 19 additions & 0 deletions infra/scripts/Selecting-Team-Config-And-Data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,25 @@ do {
}
} while (-not $useCaseValid)

# WAF/Private Networking: If the Container App has IP restrictions or internal ingress,
# the backendUrl is not reachable from the developer's machine. Route through the frontend
# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet.
$solutionSuffix = az group show --name $ResourceGroup --query "tags.SolutionSuffix" -o tsv 2>$null
if ($solutionSuffix) {
$containerAppName = "ca-$solutionSuffix"
$isExternal = az containerapp show --name $containerAppName --resource-group $ResourceGroup `
--query "properties.configuration.ingress.external" -o tsv 2>$null
$hasIpRestrictions = az containerapp show --name $containerAppName --resource-group $ResourceGroup `
--query "length(properties.configuration.ingress.ipSecurityRestrictions || ``[]``)" -o tsv 2>$null
if ($isExternal -eq "false" -or [int]$hasIpRestrictions -gt 0) {
$frontendHostname = "app-$solutionSuffix"
$frontendUrl = "https://${frontendHostname}.azurewebsites.net"
Write-Host "Private networking detected: Container App has restricted access."
Write-Host "Routing API calls through frontend App Service: $frontendUrl"
$script:backendUrl = $frontendUrl
}
}

Write-Host ""
Write-Host "==============================================="
Write-Host "Values to be used:"
Expand Down
19 changes: 19 additions & 0 deletions infra/scripts/selecting_team_config_and_data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,25 @@ while [[ "$useCaseValid" != true ]]; do
fi
done

# WAF/Private Networking: If the Container App has IP restrictions or internal ingress,
# the backendUrl is not reachable from the developer's machine. Route through the frontend
# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet.
solutionSuffix=$(az group show --name "$ResourceGroup" --query "tags.SolutionSuffix" -o tsv 2>/dev/null)
if [[ -n "$solutionSuffix" ]]; then
containerAppName="ca-${solutionSuffix}"
isExternal=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \
--query "properties.configuration.ingress.external" -o tsv 2>/dev/null)
hasIpRestrictions=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \
--query "length(properties.configuration.ingress.ipSecurityRestrictions || \`[]\`)" -o tsv 2>/dev/null)
if [[ "$isExternal" == "false" ]] || [[ "$hasIpRestrictions" -gt 0 ]]; then
frontendHostname="app-${solutionSuffix}"
frontendUrl="https://${frontendHostname}.azurewebsites.net"
echo "Private networking detected: Container App has restricted access."
echo "Routing API calls through frontend App Service: $frontendUrl"
backendUrl="$frontendUrl"
fi
fi

echo ""
echo "==============================================="
echo "Values to be used:"
Expand Down
3 changes: 1 addition & 2 deletions src/frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ WORKDIR /app
COPY pyproject.toml requirements.txt* uv.lock* ./

# Install Python dependencies using UV
RUN --mount=type=cache,target=/root/.cache/uv \
if [ -f "requirements.txt" ]; then \
RUN if [ -f "requirements.txt" ]; then \
uv pip install --system -r requirements.txt && uv pip install --system "uvicorn[standard]"; \
else \
uv pip install --system pyproject.toml && uv pip install --system "uvicorn[standard]"; \
Expand Down
57 changes: 52 additions & 5 deletions src/frontend/frontend_server.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import os

import httpx
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles

# Load environment variables from .env file
Expand All @@ -23,6 +24,10 @@
BUILD_DIR = os.path.join(os.path.dirname(__file__), "build")
INDEX_HTML = os.path.join(BUILD_DIR, "index.html")

# Proxy configuration for WAF/private networking deployments
PROXY_API_REQUESTS = os.getenv("PROXY_API_REQUESTS", "false").lower() == "true"
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")

# Serve static files from build directory
app.mount(
"/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets"
Expand All @@ -36,17 +41,59 @@ async def serve_index():

@app.get("/config")
async def get_config():
backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000")
auth_enabled = os.getenv("AUTH_ENABLED", "false")
backend_url = backend_url + "/api"

if PROXY_API_REQUESTS:
# WAF mode: frontend proxies API calls, so tell browser to use same origin
api_url = "/api"
else:
# Non-WAF mode: browser calls backend directly
backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000")
api_url = backend_url + "/api"

config = {
"API_URL": backend_url,
"API_URL": api_url,
"ENABLE_AUTH": auth_enabled,
}
return config


@app.get("/health")
async def health():
return {"status": "healthy"}


# API proxy routes for WAF/private networking deployments
if PROXY_API_REQUESTS:

@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_api(request: Request, path: str):
"""Proxy API requests to the private backend over VNet."""
target_url = f"{BACKEND_API_URL}/api/{path}"
query_string = str(request.query_params)
if query_string:
target_url = f"{target_url}?{query_string}"

headers = dict(request.headers)
headers.pop("host", None)

body = await request.body()

async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
)

return StreamingResponse(
iter([response.content]),
status_code=response.status_code,
headers=dict(response.headers),
)


@app.get("/{full_path:path}")
async def serve_app(full_path: str):
# Remediation: normalize and check containment before serving
Expand Down
1 change: 1 addition & 0 deletions src/frontend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
fastapi
uvicorn[standard]
# uvicorn removed and added above to allow websocket support
httpx
jinja2
azure-identity
python-dotenv
Expand Down
Loading