diff --git a/.github/workflows/deploy-orchestrator.yml b/.github/workflows/deploy-orchestrator.yml
index d61dcd6b6..4148def1a 100644
--- a/.github/workflows/deploy-orchestrator.yml
+++ b/.github/workflows/deploy-orchestrator.yml
@@ -8,7 +8,7 @@ on:
required: true
type: string
azure_location:
- description: 'Azure Location For Deployment'
+ description: 'Azure Region (Non-AI Services)'
required: false
default: 'australiaeast'
type: string
@@ -18,22 +18,27 @@ on:
default: ''
type: string
waf_enabled:
- description: 'Enable WAF'
+ description: 'Deploy WAF'
+ required: false
+ default: false
+ type: boolean
+ enable_scalability:
+ description: 'Enable Scalability features for WAF deployments (opt-in, defaults to false)'
required: false
default: false
type: boolean
EXP:
- description: 'Enable EXP'
+ description: 'Deploy EXP'
required: false
default: false
type: boolean
build_docker_image:
- description: 'Build And Push Docker Image (Optional)'
+ description: 'Build & Use Custom Images (Optional)'
required: false
default: false
type: boolean
cleanup_resources:
- description: 'Cleanup Deployed Resources'
+ description: 'Auto Delete RG'
required: false
default: false
type: boolean
@@ -43,17 +48,17 @@ on:
default: 'GoldenPath-Testing'
type: string
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID:
- description: 'Log Analytics Workspace ID (Optional)'
+ description: 'Existing Log Analytics Workspace Resource ID (Optional)'
required: false
default: ''
type: string
AZURE_EXISTING_AIPROJECT_RESOURCE_ID:
- description: 'AI Project Resource ID (Optional)'
+ description: 'Existing AI Project Resource ID (Optional)'
required: false
default: ''
type: string
existing_webapp_url:
- description: 'Existing Container WebApp URL (Skips Deployment)'
+ description: 'Run Tests Against Existing RG (Provide Web App URL)'
required: false
default: ''
type: string
@@ -83,6 +88,7 @@ jobs:
azure_location: ${{ inputs.azure_location }}
resource_group_name: ${{ inputs.resource_group_name }}
waf_enabled: ${{ inputs.waf_enabled }}
+ enable_scalability: ${{ inputs.enable_scalability }}
EXP: ${{ inputs.EXP }}
build_docker_image: ${{ inputs.build_docker_image }}
existing_webapp_url: ${{ inputs.existing_webapp_url }}
diff --git a/.github/workflows/deploy-v2.yml b/.github/workflows/deploy-v2.yml
index 897914b59..57253a073 100644
--- a/.github/workflows/deploy-v2.yml
+++ b/.github/workflows/deploy-v2.yml
@@ -24,7 +24,7 @@ on:
- 'Local'
default: 'codespace'
azure_location:
- description: 'Azure Location For Deployment'
+ description: 'Azure Region (Non-AI Services)'
required: false
default: 'australiaeast'
type: choice
@@ -43,24 +43,31 @@ on:
default: ''
type: string
+ build_docker_image:
+ description: 'Build & Use Custom Images (Optional)'
+ required: false
+ default: false
+ type: boolean
+
waf_enabled:
- description: 'Enable WAF'
+ description: 'Deploy WAF'
required: false
default: false
type: boolean
EXP:
- description: 'Enable EXP'
+ description: 'Deploy EXP'
required: false
default: false
type: boolean
- build_docker_image:
- description: 'Build & Push Docker Image (Optional)'
+
+ enable_scalability:
+ description: 'Enable Scalability (WAF only)'
required: false
default: false
type: boolean
cleanup_resources:
- description: 'Cleanup Deployed Resources'
+ description: 'Auto Delete RG'
required: false
default: false
type: boolean
@@ -76,17 +83,17 @@ on:
- 'None'
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID:
- description: 'Log Analytics Workspace ID (Optional)'
+ description: 'Existing Log Analytics Workspace Resource ID (Optional)'
required: false
default: ''
type: string
AZURE_EXISTING_AIPROJECT_RESOURCE_ID:
- description: 'AI Project Resource ID (Optional)'
+ description: 'Existing AI Project Resource ID (Optional)'
required: false
default: ''
type: string
existing_webapp_url:
- description: 'Existing WebApp URL (Skips Deployment)'
+ description: 'Run Tests Against Existing RG (Provide Web App URL)'
required: false
default: ''
type: string
@@ -103,6 +110,7 @@ jobs:
azure_location: ${{ steps.validate.outputs.azure_location }}
resource_group_name: ${{ steps.validate.outputs.resource_group_name }}
waf_enabled: ${{ steps.validate.outputs.waf_enabled }}
+ enable_scalability: ${{ steps.validate.outputs.enable_scalability }}
exp: ${{ steps.validate.outputs.exp }}
build_docker_image: ${{ steps.validate.outputs.build_docker_image }}
cleanup_resources: ${{ steps.validate.outputs.cleanup_resources }}
@@ -119,6 +127,7 @@ jobs:
INPUT_AZURE_LOCATION: ${{ github.event.inputs.azure_location }}
INPUT_RESOURCE_GROUP_NAME: ${{ github.event.inputs.resource_group_name }}
INPUT_WAF_ENABLED: ${{ github.event.inputs.waf_enabled }}
+ INPUT_ENABLE_SCALABILITY: ${{ github.event.inputs.enable_scalability }}
INPUT_EXP: ${{ github.event.inputs.EXP }}
INPUT_BUILD_DOCKER_IMAGE: ${{ github.event.inputs.build_docker_image }}
INPUT_CLEANUP_RESOURCES: ${{ github.event.inputs.cleanup_resources }}
@@ -178,6 +187,15 @@ jobs:
echo "✅ waf_enabled: '$WAF_ENABLED' is valid"
fi
+ # Validate enable_scalability (boolean, opt-in for WAF deployments)
+ ENABLE_SCALABILITY="${INPUT_ENABLE_SCALABILITY:-false}"
+ if [[ "$ENABLE_SCALABILITY" != "true" && "$ENABLE_SCALABILITY" != "false" ]]; then
+ echo "❌ ERROR: enable_scalability must be 'true' or 'false', got: '$ENABLE_SCALABILITY'"
+ VALIDATION_FAILED=true
+ else
+ echo "✅ enable_scalability: '$ENABLE_SCALABILITY' is valid"
+ fi
+
# Validate EXP (boolean)
EXP_ENABLED="${INPUT_EXP:-false}"
if [[ "$EXP_ENABLED" != "true" && "$EXP_ENABLED" != "false" ]]; then
@@ -270,6 +288,7 @@ jobs:
echo "azure_location=$LOCATION" >> $GITHUB_OUTPUT
echo "resource_group_name=$INPUT_RESOURCE_GROUP_NAME" >> $GITHUB_OUTPUT
echo "waf_enabled=$WAF_ENABLED" >> $GITHUB_OUTPUT
+ echo "enable_scalability=$ENABLE_SCALABILITY" >> $GITHUB_OUTPUT
echo "exp=$EXP_ENABLED" >> $GITHUB_OUTPUT
echo "build_docker_image=$BUILD_DOCKER" >> $GITHUB_OUTPUT
echo "cleanup_resources=$CLEANUP_RESOURCES" >> $GITHUB_OUTPUT
@@ -287,6 +306,7 @@ jobs:
azure_location: ${{ needs.validate-inputs.outputs.azure_location || 'australiaeast' }}
resource_group_name: ${{ needs.validate-inputs.outputs.resource_group_name || '' }}
waf_enabled: ${{ needs.validate-inputs.outputs.waf_enabled == 'true' }}
+ enable_scalability: ${{ needs.validate-inputs.outputs.enable_scalability == 'true' }}
EXP: ${{ needs.validate-inputs.outputs.exp == 'true' }}
build_docker_image: ${{ needs.validate-inputs.outputs.build_docker_image == 'true' }}
cleanup_resources: ${{ needs.validate-inputs.outputs.cleanup_resources == 'true' }}
diff --git a/.github/workflows/deploy-waf.yml b/.github/workflows/deploy-waf.yml
index 49729e484..ace2f4dc2 100644
--- a/.github/workflows/deploy-waf.yml
+++ b/.github/workflows/deploy-waf.yml
@@ -94,14 +94,19 @@ jobs:
- name: Check and Create Resource Group
id: check_create_rg
run: |
- set -e
- echo "Checking if resource group exists..."
+ set -e
+ OWNER_TAG_VALUE="${{ github.actor }}"
+ echo "🔍 Checking if resource group '${{ env.RESOURCE_GROUP_NAME }}' exists..."
rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }})
if [ "$rg_exists" = "false" ]; then
- echo "Resource group does not exist. Creating..."
- az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location ${{ env.AZURE_LOCATION }} || { echo "Error creating resource group"; exit 1; }
+ echo "📦 Resource group does not exist. Creating new resource group '${{ env.RESOURCE_GROUP_NAME }}' in location '${{ env.AZURE_LOCATION }}'..."
+ echo "🏷️ Adding Owner tag: Owner=${OWNER_TAG_VALUE}"
+ az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location ${{ env.AZURE_LOCATION }} --tags "Owner=${OWNER_TAG_VALUE}" || { echo "❌ Error creating resource group"; exit 1; }
+ echo "✅ Resource group '${{ env.RESOURCE_GROUP_NAME }}' created successfully."
else
- echo "Resource group already exists."
+ echo "✅ Resource group '${{ env.RESOURCE_GROUP_NAME }}' already exists."
+ echo "🏷️ Merging Owner tag on existing resource group: Owner=${OWNER_TAG_VALUE}"
+ az group update --name "${{ env.RESOURCE_GROUP_NAME }}" --set tags.Owner="${OWNER_TAG_VALUE}" --output none || echo "⚠️ Warning: failed to update Owner tag on existing resource group '${{ env.RESOURCE_GROUP_NAME }}'."
fi
- name: Generate Unique Solution Prefix
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index cd1ac2c2f..1e0bd7a2a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -101,9 +101,18 @@ jobs:
id: check_create_rg
run: |
set -e
+ OWNER_TAG_VALUE="${{ github.actor }}"
+ echo "🔍 Checking if resource group '${{ env.RESOURCE_GROUP_NAME }}' exists..."
rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }})
if [ "$rg_exists" = "false" ]; then
- az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location ${{ env.AZURE_LOCATION }}
+ echo "📦 Resource group does not exist. Creating new resource group '${{ env.RESOURCE_GROUP_NAME }}' in location '${{ env.AZURE_LOCATION }}'..."
+ echo "🏷️ Adding Owner tag: Owner=${OWNER_TAG_VALUE}"
+ az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location ${{ env.AZURE_LOCATION }} --tags "Owner=${OWNER_TAG_VALUE}" || { echo "❌ Error creating resource group"; exit 1; }
+ echo "✅ Resource group '${{ env.RESOURCE_GROUP_NAME }}' created successfully."
+ else
+ echo "✅ Resource group '${{ env.RESOURCE_GROUP_NAME }}' already exists. Deploying to existing resource group."
+ echo "🏷️ Merging Owner tag on existing resource group: Owner=${OWNER_TAG_VALUE}"
+ az group update --name "${{ env.RESOURCE_GROUP_NAME }}" --set tags.Owner="${OWNER_TAG_VALUE}" --output none || echo "⚠️ Warning: failed to update Owner tag on existing resource group '${{ env.RESOURCE_GROUP_NAME }}'."
fi
echo "RESOURCE_GROUP_NAME=${{ env.RESOURCE_GROUP_NAME }}" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/job-deploy-linux.yml b/.github/workflows/job-deploy-linux.yml
index e8c7c9d70..ad5cfb678 100644
--- a/.github/workflows/job-deploy-linux.yml
+++ b/.github/workflows/job-deploy-linux.yml
@@ -28,6 +28,11 @@ on:
required: false
type: string
default: 'false'
+ ENABLE_SCALABILITY:
+ description: 'Enable Scalability features for WAF deployments (opt-in)'
+ required: false
+ type: string
+ default: 'false'
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID:
required: false
type: string
@@ -192,10 +197,26 @@ jobs:
shell: bash
env:
INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }}
+ INPUT_ENABLE_SCALABILITY: ${{ inputs.ENABLE_SCALABILITY }}
run: |
+ set -euo pipefail
if [[ "$INPUT_WAF_ENABLED" == "true" ]]; then
cp infra/main.waf.parameters.json infra/main.parameters.json
echo "✅ Successfully copied WAF parameters to main parameters file"
+ SCALABILITY_VALUE="${INPUT_ENABLE_SCALABILITY:-false}"
+ if [[ "$SCALABILITY_VALUE" != "true" && "$SCALABILITY_VALUE" != "false" ]]; then
+ echo "❌ ERROR: ENABLE_SCALABILITY must be 'true' or 'false', got: '$SCALABILITY_VALUE'"
+ exit 1
+ fi
+ echo "🔧 Setting enableScalability=${SCALABILITY_VALUE}"
+ tmpfile=$(mktemp)
+ if ! jq --argjson v "$SCALABILITY_VALUE" '.parameters.enableScalability.value = $v' infra/main.parameters.json > "$tmpfile"; then
+ echo "❌ ERROR: jq failed to update enableScalability in infra/main.parameters.json"
+ rm -f "$tmpfile"
+ exit 1
+ fi
+ mv "$tmpfile" infra/main.parameters.json
+ echo "✅ enableScalability set to ${SCALABILITY_VALUE}"
else
echo "🔧 Configuring Non-WAF deployment - using default main.parameters.json..."
fi
diff --git a/.github/workflows/job-deploy-windows.yml b/.github/workflows/job-deploy-windows.yml
index 09120b68f..a6e9665c1 100644
--- a/.github/workflows/job-deploy-windows.yml
+++ b/.github/workflows/job-deploy-windows.yml
@@ -28,6 +28,11 @@ on:
required: false
type: string
default: 'false'
+ ENABLE_SCALABILITY:
+ description: 'Enable Scalability features for WAF deployments (opt-in)'
+ required: false
+ type: string
+ default: 'false'
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID:
required: false
type: string
@@ -191,10 +196,26 @@ jobs:
shell: bash
env:
INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }}
+ INPUT_ENABLE_SCALABILITY: ${{ inputs.ENABLE_SCALABILITY }}
run: |
+ set -euo pipefail
if [[ "$INPUT_WAF_ENABLED" == "true" ]]; then
cp infra/main.waf.parameters.json infra/main.parameters.json
echo "✅ Successfully copied WAF parameters to main parameters file"
+ SCALABILITY_VALUE="${INPUT_ENABLE_SCALABILITY:-false}"
+ if [[ "$SCALABILITY_VALUE" != "true" && "$SCALABILITY_VALUE" != "false" ]]; then
+ echo "❌ ERROR: ENABLE_SCALABILITY must be 'true' or 'false', got: '$SCALABILITY_VALUE'"
+ exit 1
+ fi
+ echo "🔧 Setting enableScalability=${SCALABILITY_VALUE}"
+ tmpfile=$(mktemp)
+ if ! jq --argjson v "$SCALABILITY_VALUE" '.parameters.enableScalability.value = $v' infra/main.parameters.json > "$tmpfile"; then
+ echo "❌ ERROR: jq failed to update enableScalability in infra/main.parameters.json"
+ rm -f "$tmpfile"
+ exit 1
+ fi
+ mv "$tmpfile" infra/main.parameters.json
+ echo "✅ enableScalability set to ${SCALABILITY_VALUE}"
else
echo "🔧 Configuring Non-WAF deployment - using default main.parameters.json..."
fi
diff --git a/.github/workflows/job-deploy.yml b/.github/workflows/job-deploy.yml
index 85c633009..a5af4831a 100644
--- a/.github/workflows/job-deploy.yml
+++ b/.github/workflows/job-deploy.yml
@@ -12,7 +12,7 @@ on:
required: true
type: string
azure_location:
- description: 'Azure Location For Deployment'
+ description: 'Azure Region (Non-AI Services)'
required: false
default: 'australiaeast'
type: string
@@ -22,22 +22,27 @@ on:
default: ''
type: string
waf_enabled:
- description: 'Enable WAF'
+ description: 'Deploy WAF'
+ required: false
+ default: false
+ type: boolean
+ enable_scalability:
+ description: 'Enable Scalability features for WAF deployments (opt-in, defaults to false)'
required: false
default: false
type: boolean
EXP:
- description: 'Enable EXP'
+ description: 'Deploy EXP'
required: false
default: false
type: boolean
build_docker_image:
- description: 'Build And Push Docker Image (Optional)'
+ description: 'Build & Use Custom Images (Optional)'
required: false
default: false
type: boolean
cleanup_resources:
- description: 'Cleanup Deployed Resources'
+ description: 'Auto Delete RG'
required: false
default: false
type: boolean
@@ -47,17 +52,17 @@ on:
default: 'GoldenPath-Testing'
type: string
existing_webapp_url:
- description: 'Existing Container WebApp URL (Skips Deployment)'
+ description: 'Run Tests Against Existing RG (Provide Web App URL)'
required: false
default: ''
type: string
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID:
- description: 'Log Analytics Workspace ID (Optional)'
+ description: 'Existing Log Analytics Workspace Resource ID (Optional)'
required: false
default: ''
type: string
AZURE_EXISTING_AIPROJECT_RESOURCE_ID:
- description: 'AI Project Resource ID (Optional)'
+ description: 'Existing AI Project Resource ID (Optional)'
required: false
default: ''
type: string
@@ -377,15 +382,19 @@ jobs:
id: check_create_rg
shell: bash
run: |
- set -e
+ set -e
+ OWNER_TAG_VALUE="${{ github.actor }}"
echo "🔍 Checking if resource group '$RESOURCE_GROUP_NAME' exists..."
rg_exists=$(az group exists --name $RESOURCE_GROUP_NAME)
if [ "$rg_exists" = "false" ]; then
echo "📦 Resource group does not exist. Creating new resource group '$RESOURCE_GROUP_NAME' in location '$AZURE_LOCATION'..."
- az group create --name $RESOURCE_GROUP_NAME --location $AZURE_LOCATION --tags ${{ env.RG_TAGS }} || { echo "❌ Error creating resource group"; exit 1; }
+ echo "🏷️ Adding Owner tag: Owner=${OWNER_TAG_VALUE}"
+ az group create --name $RESOURCE_GROUP_NAME --location $AZURE_LOCATION --tags ${{ env.RG_TAGS }} "Owner=${OWNER_TAG_VALUE}" || { echo "❌ Error creating resource group"; exit 1; }
echo "✅ Resource group '$RESOURCE_GROUP_NAME' created successfully."
else
echo "✅ Resource group '$RESOURCE_GROUP_NAME' already exists. Deploying to existing resource group."
+ echo "🏷️ Adding Owner tag on existing resource group: Owner=${OWNER_TAG_VALUE}"
+ az group update --name "$RESOURCE_GROUP_NAME" --set tags.Owner="${OWNER_TAG_VALUE}" --output none || echo "⚠️ Warning: failed to update Owner tag on existing resource group '$RESOURCE_GROUP_NAME'."
fi
echo "RESOURCE_GROUP_NAME=$RESOURCE_GROUP_NAME" >> $GITHUB_OUTPUT
echo "RESOURCE_GROUP_NAME=$RESOURCE_GROUP_NAME" >> $GITHUB_ENV
@@ -502,6 +511,7 @@ jobs:
BUILD_DOCKER_IMAGE: ${{ inputs.build_docker_image || 'false' }}
EXP: ${{ needs.azure-setup.outputs.EXP_ENABLED }}
WAF_ENABLED: ${{ inputs.waf_enabled == true && 'true' || 'false' }}
+ ENABLE_SCALABILITY: ${{ inputs.enable_scalability == true && 'true' || 'false' }}
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID: ${{ inputs.AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID }}
AZURE_EXISTING_AIPROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AIPROJECT_RESOURCE_ID }}
secrets: inherit
@@ -520,6 +530,7 @@ jobs:
BUILD_DOCKER_IMAGE: ${{ inputs.build_docker_image || 'false' }}
EXP: ${{ needs.azure-setup.outputs.EXP_ENABLED }}
WAF_ENABLED: ${{ inputs.waf_enabled == true && 'true' || 'false' }}
+ ENABLE_SCALABILITY: ${{ inputs.enable_scalability == true && 'true' || 'false' }}
AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID: ${{ inputs.AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID }}
AZURE_EXISTING_AIPROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AIPROJECT_RESOURCE_ID }}
secrets: inherit
diff --git a/.github/workflows/job-send-notification.yml b/.github/workflows/job-send-notification.yml
index 836639e25..2bef97813 100644
--- a/.github/workflows/job-send-notification.yml
+++ b/.github/workflows/job-send-notification.yml
@@ -99,17 +99,25 @@ jobs:
echo "TEST_SUITE_NAME=$TEST_SUITE_NAME" >> $GITHUB_OUTPUT
echo "Test Suite: $TEST_SUITE_NAME"
- - name: Determine Cleanup Status
+ - name: Determine Cleanup Pill
id: cleanup
shell: bash
env:
CLEANUP_RESULT: ${{ inputs.cleanup_result }}
run: |
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
case "$CLEANUP_RESULT" in
- success) echo "CLEANUP_STATUS=✅ SUCCESS" >> $GITHUB_OUTPUT ;;
- failure) echo "CLEANUP_STATUS=❌ FAILED (Needs Manual Cleanup)" >> $GITHUB_OUTPUT ;;
- *) echo "CLEANUP_STATUS=⏭️ SKIPPED (Needs Manual Cleanup)" >> $GITHUB_OUTPUT ;;
+ success)
+ CLEANUP_PILL="✅ SUCCESS"
+ ;;
+ failure)
+ CLEANUP_PILL="❌ FAILED"
+ ;;
+ *)
+ CLEANUP_PILL="⏭️ SKIPPED"
+ ;;
esac
+ echo "CLEANUP_PILL=$CLEANUP_PILL" >> $GITHUB_OUTPUT
- name: Determine Configuration Label
id: config
@@ -122,6 +130,9 @@ jobs:
EXP_LABEL=$( [ "$EXP" = "true" ] && echo "EXP" || echo "Non-EXP" )
echo "CONFIG_LABEL=${WAF_LABEL} + ${EXP_LABEL}" >> $GITHUB_OUTPUT
+ # ------------------------------------------------------------------
+ # Quota failure
+ # ------------------------------------------------------------------
- name: Send Quota Failure Notification
if: inputs.deploy_result == 'failure' && inputs.QUOTA_FAILED == 'true'
shell: bash
@@ -130,51 +141,156 @@ jobs:
GITHUB_RUN_ID: ${{ github.run_id }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
run: |
RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- EMAIL_BODY=$(cat <Dear Team,
We would like to inform you that the ${ACCELERATOR_NAME} deployment has failed due to insufficient quota.
Status Summary:
| Stage | Status |
|---|
| Deployment | ❌ FAILED (Insufficient Quota) |
| E2E Tests | ⏭️ SKIPPED |
| Cleanup | ${CLEANUP_STATUS} |
Configuration: ${CONFIG_LABEL}
Run URL: ${RUN_URL}
Please resolve the quota issue and retry the deployment.
Best regards,
Your Automation Team
",
- "subject": "❌[CI/CD-Automation] [${ACCELERATOR_NAME}] Insufficient Quota"
- }
- EOF
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="❌ FAILED"
+ E2E_PILL="⏭️ SKIPPED"
+
+ BODY_HTML=$(cat <
+
+
+
+
+ Pipeline Failed — Insufficient Quota
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, The ${ACCELERATOR_NAME} deployment has failed due to insufficient quota.
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+
+ Please resolve the quota issue and retry the deployment.
+
+ Deployment Details
+
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
)
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u274C[CI/CD-Automation] [${ACCELERATOR_NAME}] Insufficient Quota\",\"body\":${BODY_JSON}}"
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send quota failure notification"
+ -d "$PAYLOAD" || echo "Failed to send quota failure notification"
+ # ------------------------------------------------------------------
+ # Deployment failure (non-quota)
+ # ------------------------------------------------------------------
- name: Send Deployment Failure Notification
if: inputs.deploy_result == 'failure' && inputs.QUOTA_FAILED != 'true'
shell: bash
env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_RUN_ID: ${{ github.run_id }}
INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
run: |
- RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
RESOURCE_GROUP="$INPUT_RESOURCE_GROUP_NAME"
-
- EMAIL_BODY=$(cat <Dear Team,We would like to inform you that the ${ACCELERATOR_NAME} deployment has failed.
Status Summary:
| Stage | Status |
|---|
| Deployment | ❌ FAILED (Deployment Issue) |
| E2E Tests | ⏭️ SKIPPED |
| Cleanup | ${CLEANUP_STATUS} |
Deployment Details:
• Resource Group: ${RESOURCE_GROUP}
Configuration: ${CONFIG_LABEL}
Run URL: ${RUN_URL}
Please investigate the deployment failure at your earliest convenience.
Best regards,
Your Automation Team
",
- "subject": "❌[CI/CD-Automation] [${ACCELERATOR_NAME}] Deployment-Failed"
- }
- EOF
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="❌ FAILED"
+ E2E_PILL="⏭️ SKIPPED"
+
+ BODY_HTML=$(cat <
+
+
+
+
+ Pipeline Failed
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, The ${ACCELERATOR_NAME} deployment has failed. Please investigate at your earliest convenience.
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+
+ Please investigate the deployment failure at your earliest convenience.
+
+ Deployment Details
+
+ | Resource Group |
+ ${RESOURCE_GROUP} |
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
)
-
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u274C[CI/CD-Automation] [${ACCELERATOR_NAME}] Deployment-Failed\",\"body\":${BODY_JSON}}"
+
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send deployment failure notification"
+ -d "$PAYLOAD" || echo "Failed to send deployment failure notification"
+ # ------------------------------------------------------------------
+ # Success (deploy + optional E2E)
+ # ------------------------------------------------------------------
- name: Send Success Notification
if: inputs.deploy_result == 'success' && (inputs.e2e_test_result == 'skipped' || inputs.TEST_SUCCESS == 'true')
shell: bash
env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_RUN_ID: ${{ github.run_id }}
INPUT_CONTAINER_WEB_APPURL: ${{ inputs.CONTAINER_WEB_APPURL }}
INPUT_EXISTING_WEBAPP_URL: ${{ inputs.existing_webapp_url }}
INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }}
@@ -182,41 +298,91 @@ jobs:
INPUT_E2E_TEST_RESULT: ${{ inputs.e2e_test_result }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_RUN_ID: ${{ github.run_id }}
CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
- RUN_E2E_TESTS: ${{ env.RUN_E2E_TESTS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
TEST_SUITE_NAME: ${{ steps.test_suite.outputs.TEST_SUITE_NAME }}
-
run: |
+ # HTML-escape values that get embedded into the email template to avoid HTML/attribute injection from workflow inputs.
+ html_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
+ }
RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- WEBAPP_URL="${INPUT_CONTAINER_WEB_APPURL:-$INPUT_EXISTING_WEBAPP_URL}"
- RESOURCE_GROUP="$INPUT_RESOURCE_GROUP_NAME"
- TEST_REPORT_URL="$INPUT_TEST_REPORT_URL"
-
+ WEBAPP_URL="$(html_escape "${INPUT_CONTAINER_WEB_APPURL:-$INPUT_EXISTING_WEBAPP_URL}")"
+ RESOURCE_GROUP="$(html_escape "$INPUT_RESOURCE_GROUP_NAME")"
+ TEST_REPORT_URL="$(html_escape "$INPUT_TEST_REPORT_URL")"
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="✅ SUCCESS"
+
if [ "$INPUT_E2E_TEST_RESULT" = "skipped" ]; then
- EMAIL_BODY=$(cat <Dear Team,We would like to inform you that the ${ACCELERATOR_NAME} deployment has completed successfully.
Status Summary:
| Stage | Status |
|---|
| Deployment | ✅ SUCCESS |
| E2E Tests | ⏭️ SKIPPED |
| Cleanup | ${CLEANUP_STATUS} |
Deployment Details:
• Resource Group: ${RESOURCE_GROUP}
• Web App URL: ${WEBAPP_URL}
Configuration: ${CONFIG_LABEL}
Run URL: ${RUN_URL}
Best regards,
Your Automation Team
",
- "subject": "✅[CI/CD-Automation] [${ACCELERATOR_NAME}] Success"
- }
- EOF
- )
+ E2E_PILL="⏭️ SKIPPED"
+ INTRO="The ${ACCELERATOR_NAME} deployment has completed successfully."
+ TEST_DETAIL_ROWS=""
else
- EMAIL_BODY=$(cat <Dear Team,We would like to inform you that the ${ACCELERATOR_NAME} deployment and test automation has completed successfully.
Status Summary:
| Stage | Status |
|---|
| Deployment | ✅ SUCCESS |
| E2E Tests | ✅ SUCCESS |
| Cleanup | ${CLEANUP_STATUS} |
Deployment Details:
• Resource Group: ${RESOURCE_GROUP}
• Web App URL: ${WEBAPP_URL}
• Test Suite: ${TEST_SUITE_NAME}
• Test Report: View Report
Configuration: ${CONFIG_LABEL}
Run URL: ${RUN_URL}
Best regards,
Your Automation Team
",
- "subject": "✅[CI/CD-Automation] [${ACCELERATOR_NAME}] Success"
- }
- EOF
- )
+ E2E_PILL="✅ SUCCESS"
+ INTRO="The ${ACCELERATOR_NAME} deployment and test automation has completed successfully."
+ TEST_DETAIL_ROWS="| Test Suite | ${TEST_SUITE_NAME} |
| Test Report | 📄 View Report |
"
fi
-
+
+ BODY_HTML=$(cat <
+
+
+
+
+ Pipeline Succeeded
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, ${INTRO}
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+ Deployment Details
+
+ | Resource Group |
+ ${RESOURCE_GROUP} |
+ | Web App URL |
+ ${WEBAPP_URL} |
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+ ${TEST_DETAIL_ROWS}
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
+ )
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u2705[CI/CD-Automation] [${ACCELERATOR_NAME}] Success\",\"body\":${BODY_JSON}}"
+
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send success notification"
+ -d "$PAYLOAD" || echo "Failed to send success notification"
+ # ------------------------------------------------------------------
+ # E2E test failure (deploy succeeded)
+ # ------------------------------------------------------------------
- name: Send Test Failure Notification
if: inputs.deploy_result == 'success' && inputs.e2e_test_result != 'skipped' && inputs.TEST_SUCCESS != 'true'
shell: bash
@@ -226,31 +392,88 @@ jobs:
INPUT_CONTAINER_WEB_APPURL: ${{ inputs.CONTAINER_WEB_APPURL }}
INPUT_EXISTING_WEBAPP_URL: ${{ inputs.existing_webapp_url }}
INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }}
+ INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
- RUN_E2E_TESTS: ${{ env.RUN_E2E_TESTS }}
TEST_SUITE_NAME: ${{ steps.test_suite.outputs.TEST_SUITE_NAME }}
- INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }}
run: |
- RUN_URL="https://github.com/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}"
- TEST_REPORT_URL="$INPUT_TEST_REPORT_URL"
- WEBAPP_URL="${INPUT_CONTAINER_WEB_APPURL:-$INPUT_EXISTING_WEBAPP_URL}"
- RESOURCE_GROUP="$INPUT_RESOURCE_GROUP_NAME"
-
- EMAIL_BODY=$(cat <Dear Team,We would like to inform you that ${ACCELERATOR_NAME} test automation has failed.
Status Summary:
| Stage | Status |
|---|
| Deployment | ✅ SUCCESS |
| E2E Tests | ❌ FAILED |
| Cleanup | ${CLEANUP_STATUS} |
Deployment Details:
• Resource Group: ${RESOURCE_GROUP}
• Web App URL: ${WEBAPP_URL}
• Test Suite: ${TEST_SUITE_NAME}
• Test Report: View Report
Configuration: ${CONFIG_LABEL}
Run URL: ${RUN_URL}
Please investigate the matter at your earliest convenience.
Best regards,
Your Automation Team
",
- "subject": "❌[CI/CD-Automation] [${ACCELERATOR_NAME}] E2E Test-Failed"
+ # HTML-escape values that get embedded into the email template to avoid HTML/attribute injection from workflow inputs.
+ html_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
}
- EOF
+ RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ WEBAPP_URL="$(html_escape "${INPUT_CONTAINER_WEB_APPURL:-$INPUT_EXISTING_WEBAPP_URL}")"
+ RESOURCE_GROUP="$(html_escape "$INPUT_RESOURCE_GROUP_NAME")"
+ TEST_REPORT_URL="$(html_escape "$INPUT_TEST_REPORT_URL")"
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="✅ SUCCESS"
+ E2E_PILL="❌ FAILED"
+
+ BODY_HTML=$(cat <
+
+
+
+
+ E2E Tests Failed
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, The ${ACCELERATOR_NAME} test automation has failed. Please investigate at your earliest convenience.
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+ Deployment Details
+
+ | Resource Group |
+ ${RESOURCE_GROUP} |
+ | Web App URL |
+ ${WEBAPP_URL} |
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+ | Test Suite |
+ ${TEST_SUITE_NAME} |
+ | Test Report |
+ 📄 View Report |
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
)
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u274C[CI/CD-Automation] [${ACCELERATOR_NAME}] E2E Test-Failed\",\"body\":${BODY_JSON}}"
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send test failure notification"
+ -d "$PAYLOAD" || echo "Failed to send test failure notification"
+ # ------------------------------------------------------------------
+ # Existing URL: success
+ # ------------------------------------------------------------------
- name: Send Existing URL Success Notification
if: inputs.deploy_result == 'skipped' && inputs.existing_webapp_url != '' && inputs.e2e_test_result == 'success' && (inputs.TEST_SUCCESS == 'true' || inputs.TEST_SUCCESS == '')
shell: bash
@@ -261,26 +484,86 @@ jobs:
INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
- RUN_E2E_TESTS: ${{ env.RUN_E2E_TESTS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
+ CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
TEST_SUITE_NAME: ${{ steps.test_suite.outputs.TEST_SUITE_NAME }}
run: |
- RUN_URL="https://github.com/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}"
- EXISTING_URL="$INPUT_EXISTING_WEBAPP_URL"
- TEST_REPORT_URL="$INPUT_TEST_REPORT_URL"
-
- EMAIL_BODY=$(cat <Dear Team,The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and test automation has completed successfully.
Status Summary:
| Stage | Status |
|---|
| Deployment | ⏭️ SKIPPED (Tests executed on Pre-deployed RG) |
| E2E Tests | ✅ SUCCESS |
| Cleanup | ${CLEANUP_STATUS} |
Test Results:
• Test Suite: ${TEST_SUITE_NAME}
${TEST_REPORT_URL:+• Test Report: View Report}
• Target URL: ${EXISTING_URL}
Run URL: ${RUN_URL}
Best regards,
Your Automation Team
",
- "subject": "✅[CI/CD-Automation] [${ACCELERATOR_NAME}] Success"
+ # HTML-escape values that get embedded into the email template to avoid HTML/attribute injection from workflow inputs.
+ html_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
}
- EOF
+ RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ EXISTING_URL="$(html_escape "$INPUT_EXISTING_WEBAPP_URL")"
+ TEST_REPORT_URL="$(html_escape "$INPUT_TEST_REPORT_URL")"
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="⏭️ SKIPPED"
+ E2E_PILL="✅ SUCCESS"
+ if [ -n "$TEST_REPORT_URL" ]; then
+ REPORT_ROW="| Test Report | 📄 View Report |
"
+ else
+ REPORT_ROW=""
+ fi
+
+ BODY_HTML=$(cat <
+
+
+
+
+ Pipeline Succeeded
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and test automation has completed successfully.
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+ Test Results
+
+ | Target URL |
+ ${EXISTING_URL} |
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+ | Test Suite |
+ ${TEST_SUITE_NAME} |
+ ${REPORT_ROW}
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
)
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u2705[CI/CD-Automation] [${ACCELERATOR_NAME}] Success\",\"body\":${BODY_JSON}}"
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send existing URL success notification"
+ -d "$PAYLOAD" || echo "Failed to send existing URL success notification"
+ # ------------------------------------------------------------------
+ # Existing URL: test failure
+ # ------------------------------------------------------------------
- name: Send Existing URL Test Failure Notification
if: inputs.deploy_result == 'skipped' && inputs.existing_webapp_url != '' && inputs.e2e_test_result == 'failure'
shell: bash
@@ -291,22 +574,79 @@ jobs:
INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }}
ACCELERATOR_NAME: ${{ env.accelerator_name }}
LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}
- CLEANUP_STATUS: ${{ steps.cleanup.outputs.CLEANUP_STATUS }}
- RUN_E2E_TESTS: ${{ env.RUN_E2E_TESTS }}
+ CLEANUP_PILL: ${{ steps.cleanup.outputs.CLEANUP_PILL }}
+ CONFIG_LABEL: ${{ steps.config.outputs.CONFIG_LABEL }}
TEST_SUITE_NAME: ${{ steps.test_suite.outputs.TEST_SUITE_NAME }}
run: |
- RUN_URL="https://github.com/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}"
- EXISTING_URL="$INPUT_EXISTING_WEBAPP_URL"
- TEST_REPORT_URL="$INPUT_TEST_REPORT_URL"
-
- EMAIL_BODY=$(cat <Dear Team,The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and test automation has failed.
Status Summary:
| Stage | Status |
|---|
| Deployment | ⏭️ SKIPPED (Tests executed on Pre-deployed RG) |
| E2E Tests | ❌ FAILED |
| Cleanup | ${CLEANUP_STATUS} |
Failure Details:
• Target URL: ${EXISTING_URL}
${TEST_REPORT_URL:+• Test Report: View Report}
• Test Suite: ${TEST_SUITE_NAME}
Run URL: ${RUN_URL}
Best regards,
Your Automation Team
",
- "subject": "❌[CI/CD-Automation] [${ACCELERATOR_NAME}] E2E Test-Failed"
+ # HTML-escape values that get embedded into the email template to avoid HTML/attribute injection from workflow inputs.
+ html_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
}
- EOF
+ RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ EXISTING_URL="$(html_escape "$INPUT_EXISTING_WEBAPP_URL")"
+ TEST_REPORT_URL="$(html_escape "$INPUT_TEST_REPORT_URL")"
+ PILL_BASE="display:inline-block; min-width:70px; text-align:center; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; line-height:1.4;"
+ DEPLOY_PILL="⏭️ SKIPPED"
+ E2E_PILL="❌ FAILED"
+ if [ -n "$TEST_REPORT_URL" ]; then
+ REPORT_ROW="| Test Report | 📄 View Report |
"
+ else
+ REPORT_ROW=""
+ fi
+
+ BODY_HTML=$(cat <
+
+
+
+
+ E2E Tests Failed
+ ${ACCELERATOR_NAME} Accelerator • CI/CD Automation |
+
+ ${CONFIG_LABEL}
+ |
+
+ |
+ |
+ Dear Team, The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and test automation has failed.
+
+ Status Summary
+
+ | Deployment | ${DEPLOY_PILL} |
+ | E2E Tests | ${E2E_PILL} |
+ | Cleanup | ${CLEANUP_PILL} |
+
+
+ Failure Details
+
+ | Target URL |
+ ${EXISTING_URL} |
+ | Triggered By |
+ ${{ github.actor }} |
+ | Branch |
+ ${{ env.BRANCH_NAME }} |
+ | Test Suite |
+ ${TEST_SUITE_NAME} |
+ ${REPORT_ROW}
+
+
+ |
+
+
+ | CI/CD Automation Pipeline |
+ ${ACCELERATOR_NAME} Accelerator |
+
+ |
+
+ |
+
+ HTML
)
+ BODY_JSON=$(printf '%s' "$BODY_HTML" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
+ PAYLOAD="{\"subject\":\"\u274C[CI/CD-Automation] [${ACCELERATOR_NAME}] E2E Test-Failed\",\"body\":${BODY_JSON}}"
curl -X POST "${LOGICAPP_URL}" \
-H "Content-Type: application/json" \
- -d "$EMAIL_BODY" || echo "Failed to send existing URL test failure notification"
+ -d "$PAYLOAD" || echo "Failed to send existing URL test failure notification"
diff --git a/docs/LocalDevelopmentSetup.md b/docs/LocalDevelopmentSetup.md
index ee89d9ed8..815fabd89 100644
--- a/docs/LocalDevelopmentSetup.md
+++ b/docs/LocalDevelopmentSetup.md
@@ -273,8 +273,8 @@ az cosmosdb sql role assignment create --resource-group --role "Azure AI User" --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/
+# Foundry User role
+az role assignment create --assignee --role "Foundry User" --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/
```
```bash
diff --git a/docs/re-use-foundry-project.md b/docs/re-use-foundry-project.md
index 86b9823a9..1fdc0e927 100644
--- a/docs/re-use-foundry-project.md
+++ b/docs/re-use-foundry-project.md
@@ -44,9 +44,9 @@ Replace `` with the value obtained from St
Proceed with the next steps in the [deployment guide](DeploymentGuide.md#deployment-steps).
> **Note:**
-> After deployment, if you want to access agents created by the accelerator via the Azure AI Foundry Portal, or if you plan to debug or run the application locally, you must assign yourself either the **Azure AI User** or **Azure AI Developer** role for the Foundry resource.
+> After deployment, if you want to access agents created by the accelerator via the Azure AI Foundry Portal, or if you plan to debug or run the application locally, you must assign yourself either the **Foundry User** or **Azure AI Developer** role for the Foundry resource.
> You can do this in the Azure Portal under the Foundry resource's "Access control (IAM)" section,
> **or** run the following command in your terminal (replace `` with your Azure AD user principal name and `` with the Resource ID you copied in Step 5):
> ```bash
-> az role assignment create --assignee --role "Azure AI User" --scope
+> az role assignment create --assignee --role "Foundry User" --scope
> ```
diff --git a/infra/main.bicep b/infra/main.bicep
index 0f233ffda..54e23853e 100644
--- a/infra/main.bicep
+++ b/infra/main.bicep
@@ -2,10 +2,7 @@
targetScope = 'resourceGroup'
metadata name = 'Multi-Agent Custom Automation Engine'
-metadata description = '''This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.
-
-> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.
-'''
+metadata description = 'This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.\n\n> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.\n'
@description('Optional. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.')
@minLength(3)
@@ -479,6 +476,7 @@ var dataCollectionRulesResourceName = 'dcr-${solutionSuffix}'
var dataCollectionRulesLocation = useExistingLogAnalytics
? existingLogAnalyticsWorkspace!.location
: logAnalyticsWorkspace!.outputs.location
+var dcrLogAnalyticsDestinationName = 'la-${logAnalyticsWorkspaceResourceName}-destination'
module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if (enablePrivateNetworking && enableMonitoring) {
name: take('avm.res.insights.data-collection-rule.${dataCollectionRulesResourceName}', 64)
params: {
@@ -550,19 +548,10 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
{
name: 'SecurityAuditEvents'
streams: [
- 'Microsoft-WindowsEvent'
- ]
- eventLogName: 'Security'
- eventTypes: [
- {
- eventType: 'Audit Success'
- }
- {
- eventType: 'Audit Failure'
- }
+ 'Microsoft-Event'
]
xPathQueries: [
- 'Security!*[System[(EventID=4624 or EventID=4625)]]'
+ 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]'
]
}
]
@@ -571,7 +560,7 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
logAnalytics: [
{
workspaceResourceId: logAnalyticsWorkspaceResourceId
- name: 'la--1264800308'
+ name: dcrLogAnalyticsDestinationName
}
]
}
@@ -581,11 +570,21 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
'Microsoft-Perf'
]
destinations: [
- 'la--1264800308'
+ dcrLogAnalyticsDestinationName
]
transformKql: 'source'
outputStream: 'Microsoft-Perf'
}
+ {
+ streams: [
+ 'Microsoft-Event'
+ ]
+ destinations: [
+ dcrLogAnalyticsDestinationName
+ ]
+ transformKql: 'source'
+ outputStream: 'Microsoft-Event'
+ }
]
}
}
@@ -854,7 +853,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b
]
roleAssignments: [
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: userAssignedIdentity.outputs.principalId
principalType: 'ServicePrincipal'
}
@@ -935,7 +934,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service
managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource
roleAssignments: [
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: userAssignedIdentity.outputs.principalId
principalType: 'ServicePrincipal'
}
@@ -950,7 +949,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service
principalType: 'ServicePrincipal'
}
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: deployingUserPrincipalId
principalType: deployerPrincipalType
}
@@ -1213,6 +1212,8 @@ module containerApp 'br/public:avm/res/app/container-app:0.22.0' = {
ingressTargetPort: 8000
ingressExternal: true
activeRevisionsMode: 'Single'
+ // SFI: Enforce HTTPS-only ingress. When false, HTTP requests are automatically redirected to HTTPS.
+ ingressAllowInsecure: false
corsPolicy: {
allowedOrigins: [
'https://${webSiteResourceName}.azurewebsites.net'
@@ -1421,6 +1422,8 @@ module containerAppMcp 'br/public:avm/res/app/container-app:0.22.0' = {
ingressTargetPort: 9000
ingressExternal: true
activeRevisionsMode: 'Single'
+ // SFI: Enforce HTTPS-only ingress. When false, HTTP requests are automatically redirected to HTTPS.
+ ingressAllowInsecure: false
corsPolicy: {
allowedOrigins: [
'https://${webSiteResourceName}.azurewebsites.net'
@@ -1596,6 +1599,7 @@ module avmStorageAccount 'br/public:avm/res/storage/storage-account:0.32.0' = {
tags: tags
accessTier: 'Hot'
supportsHttpsTrafficOnly: true
+ requireInfrastructureEncryption: true
roleAssignments: [
{
diff --git a/infra/main.json b/infra/main.json
index 4085548d8..b65ae11b3 100644
--- a/infra/main.json
+++ b/infra/main.json
@@ -5,11 +5,11 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "8490920419623942773"
+ "version": "0.43.8.12551",
+ "templateHash": "6587818059632090787"
},
"name": "Multi-Agent Custom Automation Engine",
- "description": "This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.\r\n\r\n> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.\r\n"
+ "description": "This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.\n\n> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.\n"
},
"parameters": {
"solutionName": {
@@ -415,6 +415,7 @@
"bastionResourceName": "[format('bas-{0}', variables('solutionSuffix'))]",
"maintenanceConfigurationResourceName": "[format('mc-{0}', variables('solutionSuffix'))]",
"dataCollectionRulesResourceName": "[format('dcr-{0}', variables('solutionSuffix'))]",
+ "dcrLogAnalyticsDestinationName": "[format('la-{0}-destination', variables('logAnalyticsWorkspaceResourceName'))]",
"proximityPlacementGroupResourceName": "[format('ppg-{0}', variables('solutionSuffix'))]",
"virtualMachineResourceName": "[format('vm-{0}', variables('solutionSuffix'))]",
"virtualMachineAvailabilityZone": 1,
@@ -4991,8 +4992,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "4286500745908716598"
+ "version": "0.43.8.12551",
+ "templateHash": "9540091515555271756"
}
},
"definitions": {
@@ -10065,19 +10066,10 @@
{
"name": "SecurityAuditEvents",
"streams": [
- "Microsoft-WindowsEvent"
- ],
- "eventLogName": "Security",
- "eventTypes": [
- {
- "eventType": "Audit Success"
- },
- {
- "eventType": "Audit Failure"
- }
+ "Microsoft-Event"
],
"xPathQueries": [
- "Security!*[System[(EventID=4624 or EventID=4625)]]"
+ "Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]"
]
}
]
@@ -10086,7 +10078,7 @@
"logAnalytics": [
{
"workspaceResourceId": "[if(variables('useExistingLogAnalytics'), parameters('existingLogAnalyticsWorkspaceId'), reference('logAnalyticsWorkspace').outputs.resourceId.value)]",
- "name": "la--1264800308"
+ "name": "[variables('dcrLogAnalyticsDestinationName')]"
}
]
},
@@ -10096,10 +10088,20 @@
"Microsoft-Perf"
],
"destinations": [
- "la--1264800308"
+ "[variables('dcrLogAnalyticsDestinationName')]"
],
"transformKql": "source",
"outputStream": "Microsoft-Perf"
+ },
+ {
+ "streams": [
+ "Microsoft-Event"
+ ],
+ "destinations": [
+ "[variables('dcrLogAnalyticsDestinationName')]"
+ ],
+ "transformKql": "source",
+ "outputStream": "Microsoft-Event"
}
]
}
@@ -24308,8 +24310,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "6570260143045999127"
+ "version": "0.43.8.12551",
+ "templateHash": "7866379492866507946"
}
},
"definitions": {
@@ -28012,8 +28014,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "14513113443903512301"
+ "version": "0.43.8.12551",
+ "templateHash": "2868048678223903575"
}
},
"parameters": {
@@ -38445,6 +38447,9 @@
"activeRevisionsMode": {
"value": "Single"
},
+ "ingressAllowInsecure": {
+ "value": false
+ },
"corsPolicy": {
"value": {
"allowedOrigins": [
@@ -40187,6 +40192,9 @@
"activeRevisionsMode": {
"value": "Single"
},
+ "ingressAllowInsecure": {
+ "value": false
+ },
"corsPolicy": {
"value": {
"allowedOrigins": [
@@ -42561,8 +42569,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "15053339789155096730"
+ "version": "0.43.8.12551",
+ "templateHash": "18345308984648474640"
}
},
"definitions": {
@@ -43593,8 +43601,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "16493651611122310009"
+ "version": "0.43.8.12551",
+ "templateHash": "1009721598684973971"
},
"name": "Site App Settings",
"description": "This module deploys a Site App Setting."
@@ -44510,6 +44518,9 @@
"supportsHttpsTrafficOnly": {
"value": true
},
+ "requireInfrastructureEncryption": {
+ "value": true
+ },
"roleAssignments": {
"value": [
{
@@ -54840,8 +54851,8 @@
"metadata": {
"_generator": {
"name": "bicep",
- "version": "0.42.1.51946",
- "templateHash": "4859654437121510695"
+ "version": "0.43.8.12551",
+ "templateHash": "9739523049889844356"
}
},
"parameters": {
diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep
index fbbbcf280..f48b78ad4 100644
--- a/infra/main_custom.bicep
+++ b/infra/main_custom.bicep
@@ -2,10 +2,7 @@
targetScope = 'resourceGroup'
metadata name = 'Multi-Agent Custom Automation Engine'
-metadata description = '''This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.
-
-> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.
-'''
+metadata description = 'This module contains the resources required to deploy the [Multi-Agent Custom Automation Engine solution accelerator](https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator) for both Sandbox environments and WAF aligned environments.\n\n> **Note:** This module is not intended for broad, generic use, as it was designed by the Commercial Solution Areas CTO team, as a Microsoft Solution Accelerator. Feature requests and bug fix requests are welcome if they support the needs of this organization but may not be incorporated if they aim to make this module more generic than what it needs to be for its primary use case. This module will likely be updated to leverage AVM resource modules in the future. This may result in breaking changes in upcoming versions when these features are implemented.\n'
@description('Optional. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.')
@minLength(3)
@@ -478,6 +475,7 @@ var dataCollectionRulesResourceName = 'dcr-${solutionSuffix}'
var dataCollectionRulesLocation = useExistingLogAnalytics
? existingLogAnalyticsWorkspace!.location
: logAnalyticsWorkspace!.outputs.location
+var dcrLogAnalyticsDestinationName = 'la-${logAnalyticsWorkspaceResourceName}-destination'
module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if (enablePrivateNetworking && enableMonitoring) {
name: take('avm.res.insights.data-collection-rule.${dataCollectionRulesResourceName}', 64)
params: {
@@ -549,19 +547,10 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
{
name: 'SecurityAuditEvents'
streams: [
- 'Microsoft-WindowsEvent'
- ]
- eventLogName: 'Security'
- eventTypes: [
- {
- eventType: 'Audit Success'
- }
- {
- eventType: 'Audit Failure'
- }
+ 'Microsoft-Event'
]
xPathQueries: [
- 'Security!*[System[(EventID=4624 or EventID=4625)]]'
+ 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]'
]
}
]
@@ -570,7 +559,7 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
logAnalytics: [
{
workspaceResourceId: logAnalyticsWorkspaceResourceId
- name: 'la--1264800308'
+ name: dcrLogAnalyticsDestinationName
}
]
}
@@ -580,11 +569,21 @@ module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-
'Microsoft-Perf'
]
destinations: [
- 'la--1264800308'
+ dcrLogAnalyticsDestinationName
]
transformKql: 'source'
outputStream: 'Microsoft-Perf'
}
+ {
+ streams: [
+ 'Microsoft-Event'
+ ]
+ destinations: [
+ dcrLogAnalyticsDestinationName
+ ]
+ transformKql: 'source'
+ outputStream: 'Microsoft-Event'
+ }
]
}
}
@@ -853,7 +852,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b
]
roleAssignments: [
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: userAssignedIdentity.outputs.principalId
principalType: 'ServicePrincipal'
}
@@ -934,7 +933,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service
managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource
roleAssignments: [
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: userAssignedIdentity.outputs.principalId
principalType: 'ServicePrincipal'
}
@@ -949,7 +948,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service
principalType: 'ServicePrincipal'
}
{
- roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User
+ roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User
principalId: deployingUserPrincipalId
principalType: deployerPrincipalType
}
@@ -1240,6 +1239,8 @@ module containerApp 'br/public:avm/res/app/container-app:0.22.0' = {
ingressTargetPort: 8000
ingressExternal: true
activeRevisionsMode: 'Single'
+ // SFI: Enforce HTTPS-only ingress. When false, HTTP requests are automatically redirected to HTTPS.
+ ingressAllowInsecure: false
corsPolicy: {
allowedOrigins: [
'https://${webSiteResourceName}.azurewebsites.net'
@@ -1463,6 +1464,8 @@ module containerAppMcp 'br/public:avm/res/app/container-app:0.22.0' = {
ingressTargetPort: 9000
ingressExternal: true
activeRevisionsMode: 'Single'
+ // SFI: Enforce HTTPS-only ingress. When false, HTTP requests are automatically redirected to HTTPS.
+ ingressAllowInsecure: false
corsPolicy: {
allowedOrigins: [
'https://${webSiteResourceName}.azurewebsites.net'
@@ -1648,6 +1651,7 @@ module avmStorageAccount 'br/public:avm/res/storage/storage-account:0.32.0' = {
tags: tags
accessTier: 'Hot'
supportsHttpsTrafficOnly: true
+ requireInfrastructureEncryption: true
roleAssignments: [
{
diff --git a/infra/scripts/assign_azure_ai_user_role.sh b/infra/scripts/assign_azure_ai_user_role.sh
index e44dad6cb..12326e806 100644
--- a/infra/scripts/assign_azure_ai_user_role.sh
+++ b/infra/scripts/assign_azure_ai_user_role.sh
@@ -25,25 +25,25 @@ fi
IFS=',' read -r -a principal_ids_array <<< $principal_ids
-echo "Assigning Azure AI User role role to users"
+echo "Assigning Foundry User role to users"
-echo "Using provided Azure AI resource id: $aif_resource_id"
+echo "Using provided Foundry resource id: $aif_resource_id"
for principal_id in "${principal_ids_array[@]}"; do
- # Check if the user has the Azure AI User role
- echo "Checking if user - ${principal_id} has the Azure AI User role"
+ # Check if the user has the Foundry User role
+ echo "Checking if user - ${principal_id} has the Foundry User role"
role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --assignee $principal_id --query "[].roleDefinitionId" -o tsv)
if [ -z "$role_assignment" ]; then
- echo "User - ${principal_id} does not have the Azure AI User role. Assigning the role."
+ echo "User - ${principal_id} does not have the Foundry User role. Assigning the role."
MSYS_NO_PATHCONV=1 az role assignment create --assignee $principal_id --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --output none
if [ $? -eq 0 ]; then
- echo "Azure AI User role assigned successfully."
+ echo "Foundry User role assigned successfully."
else
- echo "Failed to assign Azure AI User role."
+ echo "Failed to assign Foundry User role."
exit 1
fi
else
- echo "User - ${principal_id} already has the Azure AI User role."
+ echo "User - ${principal_id} already has the Foundry User role."
fi
done
\ No newline at end of file
diff --git a/src/App/src/components/content/streaming/StreamingAgentMessage.tsx b/src/App/src/components/content/streaming/StreamingAgentMessage.tsx
index 37c604558..cccc8b7d7 100644
--- a/src/App/src/components/content/streaming/StreamingAgentMessage.tsx
+++ b/src/App/src/components/content/streaming/StreamingAgentMessage.tsx
@@ -176,11 +176,13 @@ const renderAgentMessages = (
{getAgentDisplayName(msg.agent)}
-
- AI Agent
-
+ {msg.agent_type !== AgentMessageType.SYSTEM_AGENT && msg.agent?.toLowerCase() !== 'system' && (
+
+ AI Agent
+
+ )}
)}
diff --git a/src/App/src/hooks/usePlanWebSocket.tsx b/src/App/src/hooks/usePlanWebSocket.tsx
index eb9faa1a3..af038e116 100644
--- a/src/App/src/hooks/usePlanWebSocket.tsx
+++ b/src/App/src/hooks/usePlanWebSocket.tsx
@@ -17,6 +17,7 @@ import {
selectPlanApproved,
approvalRequestReceived,
planCompletedFinal,
+ planFailedFinal,
} from '@/store/slices/planSlice';
import {
setSubmittingChatDisableInput,
@@ -178,16 +179,18 @@ export function usePlanWebSocket({
WebsocketMessageType.FINAL_RESULT_MESSAGE,
(finalMessage: any) => {
if (!finalMessage) return;
- const agentMessageData: AgentMessageData = {
- agent: AgentType.GROUP_CHAT_MANAGER,
- agent_type: AgentMessageType.AI_AGENT,
- timestamp: Date.now(),
- steps: [],
- next_steps: [],
- content: '\u{1F389}\u{1F389} ' + (finalMessage.data?.content || ''),
- raw_data: finalMessage,
- };
- if (finalMessage?.data?.status === PlanStatus.COMPLETED) {
+ const messageStatus = finalMessage?.data?.status;
+
+ if (messageStatus === PlanStatus.COMPLETED) {
+ const agentMessageData: AgentMessageData = {
+ agent: AgentType.GROUP_CHAT_MANAGER,
+ agent_type: AgentMessageType.AI_AGENT,
+ timestamp: Date.now(),
+ steps: [],
+ next_steps: [],
+ content: '\u{1F389}\u{1F389} ' + (finalMessage.data?.content || ''),
+ raw_data: finalMessage,
+ };
dispatch(setShowBufferingText(true));
dispatch(addAgentMessage(agentMessageData));
dispatch(setSelectedTeam(planData?.team || null));
@@ -196,11 +199,30 @@ export function usePlanWebSocket({
scrollToBottom();
webSocketService.disconnect();
persistAgentMessage(agentMessageData, planData, dispatch, true, streamingMessageBuffer);
+ } else if (messageStatus === 'error') {
+ // Safety net: handle error status sent as FINAL_RESULT_MESSAGE
+ const errorContent = finalMessage.data?.content || 'An unexpected error occurred. Please try again later.';
+ const errorAgent: AgentMessageData = {
+ agent: 'system',
+ agent_type: AgentMessageType.SYSTEM_AGENT,
+ timestamp: Date.now(),
+ steps: [],
+ next_steps: [],
+ content: formatErrorMessage(errorContent),
+ raw_data: finalMessage || '',
+ };
+ dispatch(addAgentMessage(errorAgent));
+ dispatch(planFailedFinal());
+ dispatch(setShowBufferingText(false));
+ dispatch(setSubmittingChatDisableInput(true));
+ scrollToBottom();
+ showToast(errorContent, 'error');
+ webSocketService.disconnect();
}
},
);
return unsub;
- }, [dispatch, scrollToBottom, planData, streamingMessageBuffer]);
+ }, [dispatch, scrollToBottom, planData, streamingMessageBuffer, formatErrorMessage, showToast]);
// ── ERROR_MESSAGE ─────────────────────────────────────────────
useEffect(() => {
@@ -231,11 +253,12 @@ export function usePlanWebSocket({
raw_data: errorMessage || '',
};
dispatch(addAgentMessage(errorAgent));
- dispatch(setShowProcessingPlanSpinner(false));
+ dispatch(planFailedFinal());
dispatch(setShowBufferingText(false));
- dispatch(setSubmittingChatDisableInput(false));
+ dispatch(setSubmittingChatDisableInput(true));
scrollToBottom();
showToast(errorContent, 'error');
+ webSocketService.disconnect();
},
);
return unsub;
diff --git a/src/App/src/store/slices/planSlice.ts b/src/App/src/store/slices/planSlice.ts
index 1d99169f7..1214d94a1 100644
--- a/src/App/src/store/slices/planSlice.ts
+++ b/src/App/src/store/slices/planSlice.ts
@@ -161,6 +161,14 @@ const planSlice = createSlice({
}
},
+ /** Single dispatch when an error occurs during plan execution */
+ planFailedFinal(state) {
+ state.showProcessingPlanSpinner = false;
+ if (state.planData?.plan) {
+ (state.planData as any).plan.overall_status = PlanStatus.FAILED;
+ }
+ },
+
/** Reset everything back to initial state (used when navigating to a new plan) */
resetPlan() {
return { ...initialState };
@@ -229,6 +237,7 @@ export const {
planApprovalRejected,
approvalRequestReceived,
planCompletedFinal,
+ planFailedFinal,
resetPlan,
} = planSlice.actions;
diff --git a/src/backend/v4/api/router.py b/src/backend/v4/api/router.py
index 2a3d5fd97..d14602452 100644
--- a/src/backend/v4/api/router.py
+++ b/src/backend/v4/api/router.py
@@ -371,7 +371,20 @@ async def process_request(
try:
async def run_orchestration_task():
- await OrchestrationManager().run_orchestration(user_id, input_task)
+ try:
+ await OrchestrationManager().run_orchestration(user_id, input_task, plan_id=plan_id)
+ except Exception as orch_error:
+ logger.error("Background orchestration failed for plan '%s': %s", plan_id, orch_error)
+ track_event_if_configured(
+ "Error_Orchestration_Failed",
+ {
+ "plan_id": plan_id,
+ "session_id": input_task.session_id,
+ "user_id": user_id,
+ "error": str(orch_error),
+ "error_type": type(orch_error).__name__,
+ },
+ )
background_tasks.add_task(run_orchestration_task)
diff --git a/src/backend/v4/orchestration/orchestration_manager.py b/src/backend/v4/orchestration/orchestration_manager.py
index 42fb43dc5..0f7df9776 100644
--- a/src/backend/v4/orchestration/orchestration_manager.py
+++ b/src/backend/v4/orchestration/orchestration_manager.py
@@ -25,7 +25,7 @@
)
from common.config.app_config import config
-from common.models.messages_af import TeamConfiguration
+from common.models.messages_af import TeamConfiguration, PlanStatus
from common.database.database_base import DatabaseBase
@@ -38,6 +38,7 @@
from v4.models.messages import WebsocketMessageType
from v4.orchestration.human_approval_manager import HumanApprovalMagenticManager
from v4.magentic_agents.magentic_agent_factory import MagenticAgentFactory
+from common.database.database_factory import DatabaseFactory
class OrchestrationManager:
@@ -47,6 +48,7 @@ class OrchestrationManager:
def __init__(self):
self.user_id: Optional[str] = None
+ self._plan_id: Optional[str] = None
self.logger = self.__class__.logger
def _extract_response_text(self, data) -> str:
@@ -293,10 +295,11 @@ async def get_current_or_new_orchestration(
# ---------------------------
# Execution
# ---------------------------
- async def run_orchestration(self, user_id: str, input_task) -> None:
+ async def run_orchestration(self, user_id: str, input_task, plan_id: Optional[str] = None) -> None:
"""
Execute the Magentic workflow for the provided user and task description.
"""
+ self._plan_id = plan_id
job_id = str(uuid.uuid4())
orchestration_config.set_approval_pending(job_id)
self.logger.info(
@@ -545,19 +548,50 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
self.logger.error("Error attributes: %s", e.__dict__)
self.logger.info("=" * 50)
- # Send error status to user
+ # Build a user-friendly error message
+ error_str = str(e)
+ if "Too Many Requests" in error_str or "429" in error_str:
+ user_error_message = (
+ "The service is currently experiencing high demand (rate limit exceeded). "
+ "Please wait a moment and try again."
+ )
+ elif "timeout" in error_str.lower():
+ user_error_message = (
+ "The request timed out while processing. Please try again."
+ )
+ elif "conflict" in error_str.lower() or "modified concurrently" in error_str.lower():
+ user_error_message = (
+ "A conflict occurred while processing your request. "
+ "The resource was modified by another operation. Please start a new task and try again."
+ )
+ else:
+ user_error_message = "An error occurred while processing your request. Please start a new task and try again."
+
+ # Update plan status to failed in the database
+ try:
+ if self._plan_id:
+ memory_store = await DatabaseFactory.get_database(user_id=user_id)
+ plan = await memory_store.get_plan_by_plan_id(plan_id=self._plan_id)
+ if plan:
+ plan.overall_status = PlanStatus.failed
+ await memory_store.update_plan(plan)
+ self.logger.info("Plan '%s' status updated to FAILED", self._plan_id)
+ except Exception as db_error:
+ self.logger.error("Failed to update plan status to FAILED: %s", db_error)
+
+ # Send error status to user via ERROR_MESSAGE type
try:
await connection_config.send_status_update_async(
{
- "type": WebsocketMessageType.FINAL_RESULT_MESSAGE,
+ "type": WebsocketMessageType.ERROR_MESSAGE,
"data": {
- "content": f"Error during orchestration: {str(e)}",
+ "content": user_error_message,
"status": "error",
"timestamp": asyncio.get_event_loop().time(),
},
},
user_id,
- message_type=WebsocketMessageType.FINAL_RESULT_MESSAGE,
+ message_type=WebsocketMessageType.ERROR_MESSAGE,
)
except Exception as send_error:
self.logger.error("Failed to send error status: %s", send_error)
diff --git a/src/mcp_server/pyproject.toml b/src/mcp_server/pyproject.toml
index af9e15e8e..03b06a8f9 100644
--- a/src/mcp_server/pyproject.toml
+++ b/src/mcp_server/pyproject.toml
@@ -27,7 +27,7 @@ dependencies = [
"urllib3==2.7.0",
"azure-core==1.38.0",
"cryptography==46.0.7",
- "authlib==1.6.11",
+ "authlib==1.6.12",
]
[project.optional-dependencies]
diff --git a/src/mcp_server/uv.lock b/src/mcp_server/uv.lock
index 5afbe8dc8..0756bc0b7 100644
--- a/src/mcp_server/uv.lock
+++ b/src/mcp_server/uv.lock
@@ -48,14 +48,14 @@ wheels = [
[[package]]
name = "authlib"
-version = "1.6.11"
+version = "1.6.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" },
]
[[package]]
@@ -774,7 +774,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "authlib", specifier = "==1.6.11" },
+ { name = "authlib", specifier = "==1.6.12" },
{ name = "azure-core", specifier = "==1.38.0" },
{ name = "azure-identity", specifier = "==1.19.0" },
{ name = "cryptography", specifier = "==46.0.7" },
diff --git a/src/tests/backend/v4/orchestration/test_orchestration_manager.py b/src/tests/backend/v4/orchestration/test_orchestration_manager.py
index 14f0fa748..d064a5095 100644
--- a/src/tests/backend/v4/orchestration/test_orchestration_manager.py
+++ b/src/tests/backend/v4/orchestration/test_orchestration_manager.py
@@ -277,6 +277,7 @@ class MockDatabaseBase:
sys.modules['common.database'] = Mock()
sys.modules['common.database.database_base'] = Mock(DatabaseBase=MockDatabaseBase)
+sys.modules['common.database.database_factory'] = Mock(DatabaseFactory=Mock())
# Mock v4 modules
class MockTeamService:
@@ -315,9 +316,25 @@ def __init__(self):
class MockWebsocketMessageType:
"""Mock WebsocketMessageType."""
FINAL_RESULT_MESSAGE = "final_result_message"
+ ERROR_MESSAGE = "error_message"
+ AGENT_MESSAGE = "agent_message"
+
+class MockPlanStatus:
+ """Mock PlanStatus."""
+ FAILED = "failed"
+ COMPLETED = "completed"
+ IN_PROGRESS = "in_progress"
+ # messages_af.PlanStatus uses lowercase member names
+ failed = "failed"
+ completed = "completed"
+ in_progress = "in_progress"
sys.modules['v4.models'] = Mock()
sys.modules['v4.models.messages'] = Mock(WebsocketMessageType=MockWebsocketMessageType)
+sys.modules['v4.models.models'] = Mock(PlanStatus=MockPlanStatus)
+# Attach PlanStatus to the already-mocked messages_af module (production code now imports
+# PlanStatus from common.models.messages_af, not v4.models.models).
+sys.modules['common.models.messages_af'].PlanStatus = MockPlanStatus
# Mock v4.orchestration.human_approval_manager
class MockHumanApprovalMagenticManager:
@@ -923,6 +940,82 @@ async def test_run_orchestration_all_event_types(self):
# Verify streaming callback was called (for output event with AgentResponseUpdate data)
streaming_agent_response_callback.assert_called()
+ async def test_run_orchestration_marks_plan_failed_on_exception(self):
+ """When orchestration raises and plan_id is set, plan.overall_status must be
+ updated to FAILED via DatabaseFactory/get_plan_by_plan_id/update_plan."""
+ mock_workflow = Mock()
+ mock_workflow.executors = {}
+ mock_workflow.run = Mock(side_effect=Exception("Workflow execution failed"))
+ orchestration_config.get_current_orchestration.return_value = mock_workflow
+
+ mock_plan = Mock()
+ mock_plan.overall_status = "in_progress"
+ mock_memory_store = Mock()
+ mock_memory_store.get_plan_by_plan_id = AsyncMock(return_value=mock_plan)
+ mock_memory_store.update_plan = AsyncMock()
+
+ db_factory_mock = sys.modules['common.database.database_factory'].DatabaseFactory
+ db_factory_mock.get_database = AsyncMock(return_value=mock_memory_store)
+
+ input_task = Mock()
+ input_task.description = "Test task"
+
+ with self.assertRaises(Exception):
+ await self.orchestration_manager.run_orchestration(
+ user_id=self.test_user_id,
+ input_task=input_task,
+ plan_id="plan-123",
+ )
+
+ db_factory_mock.get_database.assert_awaited_with(user_id=self.test_user_id)
+ mock_memory_store.get_plan_by_plan_id.assert_awaited_with(plan_id="plan-123")
+ mock_memory_store.update_plan.assert_awaited_once()
+ self.assertEqual(mock_plan.overall_status, "failed")
+
+ async def test_run_orchestration_db_failure_does_not_mask_original_error(self):
+ """If the DB update itself fails, the original orchestration error must still
+ propagate (the DB error is logged and swallowed)."""
+ mock_workflow = Mock()
+ mock_workflow.executors = {}
+ original_error = RuntimeError("Workflow boom")
+ mock_workflow.run = Mock(side_effect=original_error)
+ orchestration_config.get_current_orchestration.return_value = mock_workflow
+
+ db_factory_mock = sys.modules['common.database.database_factory'].DatabaseFactory
+ db_factory_mock.get_database = AsyncMock(side_effect=Exception("DB unavailable"))
+
+ input_task = Mock()
+ input_task.description = "Test task"
+
+ with self.assertRaises(RuntimeError) as ctx:
+ await self.orchestration_manager.run_orchestration(
+ user_id=self.test_user_id,
+ input_task=input_task,
+ plan_id="plan-123",
+ )
+ self.assertIn("Workflow boom", str(ctx.exception))
+
+ async def test_run_orchestration_skips_db_update_when_no_plan_id(self):
+ """When plan_id is not provided, the orchestration must not touch the DB on failure."""
+ mock_workflow = Mock()
+ mock_workflow.executors = {}
+ mock_workflow.run = Mock(side_effect=Exception("Workflow execution failed"))
+ orchestration_config.get_current_orchestration.return_value = mock_workflow
+
+ db_factory_mock = sys.modules['common.database.database_factory'].DatabaseFactory
+ db_factory_mock.get_database = AsyncMock()
+
+ input_task = Mock()
+ input_task.description = "Test task"
+
+ with self.assertRaises(Exception):
+ await self.orchestration_manager.run_orchestration(
+ user_id=self.test_user_id,
+ input_task=input_task,
+ )
+
+ db_factory_mock.get_database.assert_not_awaited()
+
class TestExtractResponseText(IsolatedAsyncioTestCase):
"""Test _extract_response_text method for various input types."""