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:
StageStatus
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 }}
+
+ VIEW PIPELINE RUN +
+
+ + + +
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:
StageStatus
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 }}
+
+ INVESTIGATE FAILURE +
+
+ + + +
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" + } 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:
StageStatus
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:
StageStatus
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

+ + + + + + + + + + ${TEST_DETAIL_ROWS} +
Resource Group${RESOURCE_GROUP}
Web App URL${WEBAPP_URL}
Triggered By${{ github.actor }}
Branch${{ env.BRANCH_NAME }}
+
+ VIEW PIPELINE RUN +
+
+ + + +
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:
StageStatus
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" } - 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
+
+ INVESTIGATE FAILURE +
+
+ + + +
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:
StageStatus
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" } - 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

+ + + + + + + + + + ${REPORT_ROW} +
Target URL${EXISTING_URL}
Triggered By${{ github.actor }}
Branch${{ env.BRANCH_NAME }}
Test Suite${TEST_SUITE_NAME}
+
+ VIEW PIPELINE RUN +
+
+ + + +
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:
StageStatus
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" } - 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

+ + + + + + + + + + ${REPORT_ROW} +
Target URL${EXISTING_URL}
Triggered By${{ github.actor }}
Branch${{ env.BRANCH_NAME }}
Test Suite${TEST_SUITE_NAME}
+
+ INVESTIGATE FAILURE +
+
+ + + +
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."""