diff --git a/.github/workflows/deploy-linux.yml b/.github/workflows/deploy-linux.yml new file mode 100644 index 00000000..3674f1c7 --- /dev/null +++ b/.github/workflows/deploy-linux.yml @@ -0,0 +1,267 @@ +name: Deploy-Test-Cleanup (v2) Linux +permissions: + contents: read + actions: read +on: + workflow_run: + workflows: ["Build Docker and Optional Push"] + types: + - completed + branches: + - main + - dev + - demo + workflow_dispatch: + inputs: + azure_location: + description: 'Azure Location For Deployment' + required: false + default: 'australiaeast' + type: choice + options: + - 'australiaeast' + - 'centralus' + - 'eastasia' + - 'eastus2' + - 'japaneast' + - 'northeurope' + - 'southeastasia' + - 'uksouth' + resource_group_name: + description: 'Resource Group Name (Optional)' + required: false + default: '' + type: string + waf_enabled: + description: 'Enable WAF' + required: false + default: false + type: boolean + EXP: + description: 'Enable EXP' + required: false + default: false + type: boolean + build_docker_image: + description: 'Build & Push Docker Image (Optional)' + required: false + default: false + type: boolean + cleanup_resources: + description: 'Cleanup Deployed Resources' + required: false + default: false + type: boolean + run_e2e_tests: + description: 'Run End-to-End Tests' + required: false + default: 'GoldenPath-Testing' + type: choice + options: + - 'GoldenPath-Testing' + - 'Smoke-Testing' + - 'None' + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + description: 'Log Analytics Workspace ID (Optional)' + required: false + default: '' + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + description: 'AI Project Resource ID (Optional)' + required: false + default: '' + type: string + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + + schedule: + - cron: '0 5,17 * * *' # Runs at 5:00 AM and 5:00 PM GMT + +jobs: + validate-inputs: + runs-on: ubuntu-latest + outputs: + validation_passed: ${{ steps.validate.outputs.passed }} + azure_location: ${{ steps.validate.outputs.azure_location }} + resource_group_name: ${{ steps.validate.outputs.resource_group_name }} + waf_enabled: ${{ steps.validate.outputs.waf_enabled }} + exp: ${{ steps.validate.outputs.exp }} + build_docker_image: ${{ steps.validate.outputs.build_docker_image }} + cleanup_resources: ${{ steps.validate.outputs.cleanup_resources }} + run_e2e_tests: ${{ steps.validate.outputs.run_e2e_tests }} + azure_env_log_analytics_workspace_id: ${{ steps.validate.outputs.azure_env_log_analytics_workspace_id }} + azure_existing_ai_project_resource_id: ${{ steps.validate.outputs.azure_existing_ai_project_resource_id }} + existing_webapp_url: ${{ steps.validate.outputs.existing_webapp_url }} + steps: + - name: Validate Workflow Input Parameters + id: validate + shell: bash + env: + 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_EXP: ${{ github.event.inputs.EXP }} + INPUT_BUILD_DOCKER_IMAGE: ${{ github.event.inputs.build_docker_image }} + INPUT_CLEANUP_RESOURCES: ${{ github.event.inputs.cleanup_resources }} + INPUT_RUN_E2E_TESTS: ${{ github.event.inputs.run_e2e_tests }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ github.event.inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ github.event.inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + INPUT_EXISTING_WEBAPP_URL: ${{ github.event.inputs.existing_webapp_url }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate azure_location (Azure region format) + LOCATION="${INPUT_AZURE_LOCATION:-australiaeast}" + + if [[ ! "$LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: azure_location '$LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… azure_location: '$LOCATION' is valid" + fi + + # Validate resource_group_name (Azure naming convention, optional) + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + if [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters (length: ${#INPUT_RESOURCE_GROUP_NAME})" + VALIDATION_FAILED=true + else + echo "βœ… resource_group_name: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + else + echo "βœ… resource_group_name: Not provided (will be auto-generated)" + fi + + # Validate waf_enabled (boolean) + WAF_ENABLED="${INPUT_WAF_ENABLED:-false}" + if [[ "$WAF_ENABLED" != "true" && "$WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: waf_enabled must be 'true' or 'false', got: '$WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… waf_enabled: '$WAF_ENABLED' is valid" + fi + + # Validate EXP (boolean) + EXP_ENABLED="${INPUT_EXP:-false}" + if [[ "$EXP_ENABLED" != "true" && "$EXP_ENABLED" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$EXP_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$EXP_ENABLED' is valid" + fi + + # Validate build_docker_image (boolean) + BUILD_DOCKER="${INPUT_BUILD_DOCKER_IMAGE:-false}" + if [[ "$BUILD_DOCKER" != "true" && "$BUILD_DOCKER" != "false" ]]; then + echo "❌ ERROR: build_docker_image must be 'true' or 'false', got: '$BUILD_DOCKER'" + VALIDATION_FAILED=true + else + echo "βœ… build_docker_image: '$BUILD_DOCKER' is valid" + fi + + # Validate cleanup_resources (boolean) + CLEANUP_RESOURCES="${INPUT_CLEANUP_RESOURCES:-false}" + if [[ "$CLEANUP_RESOURCES" != "true" && "$CLEANUP_RESOURCES" != "false" ]]; then + echo "❌ ERROR: cleanup_resources must be 'true' or 'false', got: '$CLEANUP_RESOURCES'" + VALIDATION_FAILED=true + else + echo "βœ… cleanup_resources: '$CLEANUP_RESOURCES' is valid" + fi + + # Validate run_e2e_tests (specific allowed values) + TEST_OPTION="${INPUT_RUN_E2E_TESTS:-GoldenPath-Testing}" + if [[ "$TEST_OPTION" != "GoldenPath-Testing" && "$TEST_OPTION" != "Smoke-Testing" && "$TEST_OPTION" != "None" ]]; then + echo "❌ ERROR: run_e2e_tests must be one of: GoldenPath-Testing, Smoke-Testing, None, got: '$TEST_OPTION'" + VALIDATION_FAILED=true + else + echo "βœ… run_e2e_tests: '$TEST_OPTION' is valid" + fi + + # Validate AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID (optional, Azure Resource ID format) + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + if [[ ! "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/[rR]esource[gG]roups/[^/]+/providers/[mM]icrosoft\.[oO]perational[iI]nsights/workspaces/[^/]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}" + echo " Got: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Valid Resource ID format" + fi + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Not provided (optional)" + fi + + # Validate AZURE_EXISTING_AI_PROJECT_RESOURCE_ID (optional, Azure Resource ID format) + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + if [[ ! "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/resourceGroups/[^/]+/providers/(Microsoft\.MachineLearningServices/(workspaces|projects)/[^/]+|Microsoft\.CognitiveServices/accounts/[^/]+/projects/[^/]+)$ ]]; then + echo "❌ ERROR: AZURE_EXISTING_AI_PROJECT_RESOURCE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}" + echo " Got: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Valid Resource ID format" + fi + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Not provided (optional)" + fi + + # Validate existing_webapp_url (optional, must start with https) + if [[ -n "$INPUT_EXISTING_WEBAPP_URL" ]]; then + if [[ ! "$INPUT_EXISTING_WEBAPP_URL" =~ ^https:// ]]; then + echo "❌ ERROR: existing_webapp_url must start with 'https://', got: '$INPUT_EXISTING_WEBAPP_URL'" + VALIDATION_FAILED=true + else + echo "βœ… existing_webapp_url: '$INPUT_EXISTING_WEBAPP_URL' is valid" + fi + else + echo "βœ… existing_webapp_url: Not provided (will perform deployment)" + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + # Output validated values + echo "passed=true" >> $GITHUB_OUTPUT + echo "azure_location=$LOCATION" >> $GITHUB_OUTPUT + echo "resource_group_name=$INPUT_RESOURCE_GROUP_NAME" >> $GITHUB_OUTPUT + echo "waf_enabled=$WAF_ENABLED" >> $GITHUB_OUTPUT + echo "exp=$EXP_ENABLED" >> $GITHUB_OUTPUT + echo "build_docker_image=$BUILD_DOCKER" >> $GITHUB_OUTPUT + echo "cleanup_resources=$CLEANUP_RESOURCES" >> $GITHUB_OUTPUT + echo "run_e2e_tests=$TEST_OPTION" >> $GITHUB_OUTPUT + echo "azure_env_log_analytics_workspace_id=$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" >> $GITHUB_OUTPUT + echo "azure_existing_ai_project_resource_id=$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" >> $GITHUB_OUTPUT + echo "existing_webapp_url=$INPUT_EXISTING_WEBAPP_URL" >> $GITHUB_OUTPUT + + Run: + needs: validate-inputs + if: needs.validate-inputs.outputs.validation_passed == 'true' + uses: ./.github/workflows/deploy-orchestrator.yml + with: + runner_os: ubuntu-latest + azure_location: ${{ needs.validate-inputs.outputs.azure_location }} + resource_group_name: ${{ needs.validate-inputs.outputs.resource_group_name }} + waf_enabled: ${{ needs.validate-inputs.outputs.waf_enabled == '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' }} + run_e2e_tests: ${{ needs.validate-inputs.outputs.run_e2e_tests }} + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ needs.validate-inputs.outputs.azure_env_log_analytics_workspace_id }} + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ needs.validate-inputs.outputs.azure_existing_ai_project_resource_id }} + existing_webapp_url: ${{ needs.validate-inputs.outputs.existing_webapp_url }} + trigger_type: ${{ github.event_name }} + secrets: inherit diff --git a/.github/workflows/deploy-orchestrator.yml b/.github/workflows/deploy-orchestrator.yml new file mode 100644 index 00000000..fea49ef8 --- /dev/null +++ b/.github/workflows/deploy-orchestrator.yml @@ -0,0 +1,144 @@ +name: Deployment orchestrator + +permissions: + contents: read + actions: read + +on: + workflow_call: + inputs: + runner_os: + description: 'Runner OS (ubuntu-latest or windows-latest)' + required: true + type: string + azure_location: + description: 'Azure Location For Deployment' + required: false + default: 'australiaeast' + type: string + resource_group_name: + description: 'Resource Group Name (Optional)' + required: false + default: '' + type: string + waf_enabled: + description: 'Enable WAF' + required: false + default: false + type: boolean + EXP: + description: 'Enable EXP' + required: false + default: false + type: boolean + build_docker_image: + description: 'Build And Push Docker Image (Optional)' + required: false + default: false + type: boolean + cleanup_resources: + description: 'Cleanup Deployed Resources' + required: false + default: false + type: boolean + run_e2e_tests: + description: 'Run End-to-End Tests' + required: false + default: 'GoldenPath-Testing' + type: string + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + description: 'Log Analytics Workspace ID (Optional)' + required: false + default: '' + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + description: 'AI Project Resource ID (Optional)' + required: false + default: '' + type: string + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + trigger_type: + description: 'Trigger type (workflow_dispatch, pull_request, schedule)' + required: true + type: string + +env: + AZURE_DEV_COLLECT_TELEMETRY: ${{ vars.AZURE_DEV_COLLECT_TELEMETRY }} + +jobs: + docker-build: + uses: ./.github/workflows/job-docker-build.yml + with: + trigger_type: ${{ inputs.trigger_type }} + build_docker_image: ${{ inputs.build_docker_image }} + secrets: inherit + + deploy: + if: "!cancelled() && (needs.docker-build.result == 'success' || needs.docker-build.result == 'skipped') && (inputs.trigger_type != 'workflow_dispatch' || inputs.existing_webapp_url == '' || inputs.existing_webapp_url == null)" + needs: docker-build + uses: ./.github/workflows/job-deploy.yml + with: + trigger_type: ${{ inputs.trigger_type }} + runner_os: ${{ inputs.runner_os }} + azure_location: ${{ inputs.azure_location }} + resource_group_name: ${{ inputs.resource_group_name }} + waf_enabled: ${{ inputs.waf_enabled }} + EXP: ${{ inputs.EXP }} + build_docker_image: ${{ inputs.build_docker_image }} + existing_webapp_url: ${{ inputs.existing_webapp_url }} + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + docker_image_tag: ${{ needs.docker-build.outputs.IMAGE_TAG }} + run_e2e_tests: ${{ inputs.run_e2e_tests }} + cleanup_resources: ${{ inputs.cleanup_resources }} + secrets: inherit + + e2e-test: + if: "!cancelled() && ((needs.deploy.result == 'success' && needs.deploy.outputs.CONTAINER_WEB_APPURL != '') || (inputs.existing_webapp_url != '' && inputs.existing_webapp_url != null)) && (inputs.trigger_type != 'workflow_dispatch' || (inputs.run_e2e_tests != 'None' && inputs.run_e2e_tests != '' && inputs.run_e2e_tests != null))" + needs: [docker-build, deploy] + uses: ./.github/workflows/test-automation-v2.yml + with: + TEST_URL: ${{ needs.deploy.outputs.CONTAINER_WEB_APPURL || inputs.existing_webapp_url }} + TEST_SUITE: ${{ inputs.trigger_type == 'workflow_dispatch' && inputs.run_e2e_tests || 'GoldenPath-Testing' }} + secrets: inherit + + send-notification: + if: "!cancelled()" + needs: [docker-build, deploy, e2e-test] + + uses: ./.github/workflows/job-send-notification.yml + with: + trigger_type: ${{ inputs.trigger_type }} + waf_enabled: ${{ inputs.waf_enabled }} + EXP: ${{ inputs.EXP }} + run_e2e_tests: ${{ inputs.run_e2e_tests }} + existing_webapp_url: ${{ inputs.existing_webapp_url }} + deploy_result: ${{ needs.deploy.result }} + e2e_test_result: ${{ needs.e2e-test.result }} + CONTAINER_WEB_APPURL: ${{ needs.deploy.outputs.CONTAINER_WEB_APPURL }} + RESOURCE_GROUP_NAME: ${{ needs.deploy.outputs.RESOURCE_GROUP_NAME }} + QUOTA_FAILED: ${{ needs.deploy.outputs.QUOTA_FAILED }} + TEST_SUCCESS: ${{ needs.e2e-test.outputs.TEST_SUCCESS }} + TEST_REPORT_URL: ${{ needs.e2e-test.outputs.TEST_REPORT_URL }} + secrets: inherit + + cleanup-deployment: + if: "always() && needs.deploy.outputs.RESOURCE_GROUP_NAME != '' && inputs.existing_webapp_url == '' && (inputs.trigger_type != 'workflow_dispatch' || inputs.cleanup_resources)" + needs: [docker-build, deploy, e2e-test] + + uses: ./.github/workflows/job-cleanup-deployment.yml + with: + runner_os: ${{ inputs.runner_os }} + trigger_type: ${{ inputs.trigger_type }} + cleanup_resources: ${{ inputs.cleanup_resources }} + existing_webapp_url: ${{ inputs.existing_webapp_url }} + RESOURCE_GROUP_NAME: ${{ needs.deploy.outputs.RESOURCE_GROUP_NAME }} + AZURE_LOCATION: ${{ needs.deploy.outputs.AZURE_LOCATION }} + AZURE_ENV_OPENAI_LOCATION: ${{ needs.deploy.outputs.AZURE_ENV_OPENAI_LOCATION }} + ENV_NAME: ${{ needs.deploy.outputs.ENV_NAME }} + IMAGE_TAG: ${{ needs.deploy.outputs.IMAGE_TAG }} + secrets: inherit diff --git a/.github/workflows/deploy-windows.yml b/.github/workflows/deploy-windows.yml new file mode 100644 index 00000000..2987af50 --- /dev/null +++ b/.github/workflows/deploy-windows.yml @@ -0,0 +1,263 @@ +name: Deploy-Test-Cleanup (v2) Windows +permissions: + contents: read + actions: read +on: + workflow_dispatch: + inputs: + azure_location: + description: 'Azure Location For Deployment' + required: false + default: 'australiaeast' + type: choice + options: + - 'australiaeast' + - 'centralus' + - 'eastasia' + - 'eastus2' + - 'japaneast' + - 'northeurope' + - 'southeastasia' + - 'uksouth' + resource_group_name: + description: 'Resource Group Name (Optional)' + required: false + default: '' + type: string + + waf_enabled: + description: 'Enable WAF' + required: false + default: false + type: boolean + EXP: + description: 'Enable EXP' + required: false + default: false + type: boolean + build_docker_image: + description: 'Build & Push Docker Image (Optional)' + required: false + default: false + type: boolean + + cleanup_resources: + description: 'Cleanup Deployed Resources' + required: false + default: false + type: boolean + + run_e2e_tests: + description: 'Run End-to-End Tests' + required: false + default: 'GoldenPath-Testing' + type: choice + options: + - 'GoldenPath-Testing' + - 'Smoke-Testing' + - 'None' + + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + description: 'Log Analytics Workspace ID (Optional)' + required: false + default: '' + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + description: 'AI Project Resource ID (Optional)' + required: false + default: '' + type: string + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + + # schedule: + # - cron: '0 5,17 * * *' # Runs at 5:00 AM and 5:00 PM GMT + +jobs: + validate-inputs: + runs-on: ubuntu-latest + outputs: + validation_passed: ${{ steps.validate.outputs.passed }} + azure_location: ${{ steps.validate.outputs.azure_location }} + resource_group_name: ${{ steps.validate.outputs.resource_group_name }} + waf_enabled: ${{ steps.validate.outputs.waf_enabled }} + exp: ${{ steps.validate.outputs.exp }} + build_docker_image: ${{ steps.validate.outputs.build_docker_image }} + cleanup_resources: ${{ steps.validate.outputs.cleanup_resources }} + run_e2e_tests: ${{ steps.validate.outputs.run_e2e_tests }} + azure_env_log_analytics_workspace_id: ${{ steps.validate.outputs.azure_env_log_analytics_workspace_id }} + azure_existing_ai_project_resource_id: ${{ steps.validate.outputs.azure_existing_ai_project_resource_id }} + existing_webapp_url: ${{ steps.validate.outputs.existing_webapp_url }} + steps: + - name: Validate Workflow Input Parameters + id: validate + shell: bash + env: + 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_EXP: ${{ github.event.inputs.EXP }} + INPUT_BUILD_DOCKER_IMAGE: ${{ github.event.inputs.build_docker_image }} + INPUT_CLEANUP_RESOURCES: ${{ github.event.inputs.cleanup_resources }} + INPUT_RUN_E2E_TESTS: ${{ github.event.inputs.run_e2e_tests }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ github.event.inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ github.event.inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + INPUT_EXISTING_WEBAPP_URL: ${{ github.event.inputs.existing_webapp_url }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate azure_location (Azure region format) + LOCATION="${INPUT_AZURE_LOCATION:-australiaeast}" + + if [[ ! "$LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: azure_location '$LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… azure_location: '$LOCATION' is valid" + fi + + # Validate resource_group_name (Azure naming convention, optional) + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + if [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters (length: ${#INPUT_RESOURCE_GROUP_NAME})" + VALIDATION_FAILED=true + else + echo "βœ… resource_group_name: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + else + echo "βœ… resource_group_name: Not provided (will be auto-generated)" + fi + + # Validate waf_enabled (boolean) + WAF_ENABLED="${INPUT_WAF_ENABLED:-false}" + if [[ "$WAF_ENABLED" != "true" && "$WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: waf_enabled must be 'true' or 'false', got: '$WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… waf_enabled: '$WAF_ENABLED' is valid" + fi + + # Validate EXP (boolean) + EXP_ENABLED="${INPUT_EXP:-false}" + if [[ "$EXP_ENABLED" != "true" && "$EXP_ENABLED" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$EXP_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$EXP_ENABLED' is valid" + fi + + # Validate build_docker_image (boolean) + BUILD_DOCKER="${INPUT_BUILD_DOCKER_IMAGE:-false}" + if [[ "$BUILD_DOCKER" != "true" && "$BUILD_DOCKER" != "false" ]]; then + echo "❌ ERROR: build_docker_image must be 'true' or 'false', got: '$BUILD_DOCKER'" + VALIDATION_FAILED=true + else + echo "βœ… build_docker_image: '$BUILD_DOCKER' is valid" + fi + + # Validate cleanup_resources (boolean) + CLEANUP_RESOURCES="${INPUT_CLEANUP_RESOURCES:-false}" + if [[ "$CLEANUP_RESOURCES" != "true" && "$CLEANUP_RESOURCES" != "false" ]]; then + echo "❌ ERROR: cleanup_resources must be 'true' or 'false', got: '$CLEANUP_RESOURCES'" + VALIDATION_FAILED=true + else + echo "βœ… cleanup_resources: '$CLEANUP_RESOURCES' is valid" + fi + + # Validate run_e2e_tests (specific allowed values) + TEST_OPTION="${INPUT_RUN_E2E_TESTS:-GoldenPath-Testing}" + if [[ "$TEST_OPTION" != "GoldenPath-Testing" && "$TEST_OPTION" != "Smoke-Testing" && "$TEST_OPTION" != "None" ]]; then + echo "❌ ERROR: run_e2e_tests must be one of: GoldenPath-Testing, Smoke-Testing, None, got: '$TEST_OPTION'" + VALIDATION_FAILED=true + else + echo "βœ… run_e2e_tests: '$TEST_OPTION' is valid" + fi + + # Validate AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID (optional, Azure Resource ID format) + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + if [[ ! "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/[rR]esource[gG]roups/[^/]+/providers/[mM]icrosoft\.[oO]perational[iI]nsights/workspaces/[^/]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}" + echo " Got: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Valid Resource ID format" + fi + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Not provided (optional)" + fi + + # Validate AZURE_EXISTING_AI_PROJECT_RESOURCE_ID (optional, Azure Resource ID format) + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + if [[ ! "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/resourceGroups/[^/]+/providers/(Microsoft\.MachineLearningServices/(workspaces|projects)/[^/]+|Microsoft\.CognitiveServices/accounts/[^/]+/projects/[^/]+)$ ]]; then + echo "❌ ERROR: AZURE_EXISTING_AI_PROJECT_RESOURCE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}" + echo " Got: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Valid Resource ID format" + fi + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Not provided (optional)" + fi + + # Validate existing_webapp_url (optional, must start with https) + if [[ -n "$INPUT_EXISTING_WEBAPP_URL" ]]; then + if [[ ! "$INPUT_EXISTING_WEBAPP_URL" =~ ^https:// ]]; then + echo "❌ ERROR: existing_webapp_url must start with 'https://', got: '$INPUT_EXISTING_WEBAPP_URL'" + VALIDATION_FAILED=true + else + echo "βœ… existing_webapp_url: '$INPUT_EXISTING_WEBAPP_URL' is valid" + fi + else + echo "βœ… existing_webapp_url: Not provided (will perform deployment)" + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + # Output validated values + echo "passed=true" >> $GITHUB_OUTPUT + echo "azure_location=$LOCATION" >> $GITHUB_OUTPUT + echo "resource_group_name=$INPUT_RESOURCE_GROUP_NAME" >> $GITHUB_OUTPUT + echo "waf_enabled=$WAF_ENABLED" >> $GITHUB_OUTPUT + echo "exp=$EXP_ENABLED" >> $GITHUB_OUTPUT + echo "build_docker_image=$BUILD_DOCKER" >> $GITHUB_OUTPUT + echo "cleanup_resources=$CLEANUP_RESOURCES" >> $GITHUB_OUTPUT + echo "run_e2e_tests=$TEST_OPTION" >> $GITHUB_OUTPUT + echo "azure_env_log_analytics_workspace_id=$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" >> $GITHUB_OUTPUT + echo "azure_existing_ai_project_resource_id=$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" >> $GITHUB_OUTPUT + echo "existing_webapp_url=$INPUT_EXISTING_WEBAPP_URL" >> $GITHUB_OUTPUT + + Run: + needs: validate-inputs + if: needs.validate-inputs.outputs.validation_passed == 'true' + uses: ./.github/workflows/deploy-orchestrator.yml + with: + runner_os: windows-latest + azure_location: ${{ needs.validate-inputs.outputs.azure_location }} + resource_group_name: ${{ needs.validate-inputs.outputs.resource_group_name }} + waf_enabled: ${{ needs.validate-inputs.outputs.waf_enabled == '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' }} + run_e2e_tests: ${{ needs.validate-inputs.outputs.run_e2e_tests }} + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ needs.validate-inputs.outputs.azure_env_log_analytics_workspace_id }} + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ needs.validate-inputs.outputs.azure_existing_ai_project_resource_id }} + existing_webapp_url: ${{ needs.validate-inputs.outputs.existing_webapp_url }} + trigger_type: ${{ github.event_name }} + secrets: inherit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 29facdff..b5291338 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,7 +110,7 @@ jobs: id: generate_solution_prefix run: | set -e - COMMON_PART="ccb" + COMMON_PART="byocc" TIMESTAMP=$(date +%s) UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 4) UNIQUE_SOLUTION_PREFIX="${COMMON_PART}${UPDATED_TIMESTAMP}" diff --git a/.github/workflows/job-cleanup-deployment.yml b/.github/workflows/job-cleanup-deployment.yml new file mode 100644 index 00000000..e1afa455 --- /dev/null +++ b/.github/workflows/job-cleanup-deployment.yml @@ -0,0 +1,111 @@ +name: Cleanup Deployment Job + +permissions: + contents: read + actions: read +on: + workflow_call: + inputs: + runner_os: + description: 'Runner OS (ubuntu-latest or windows-latest)' + required: true + type: string + trigger_type: + description: 'Trigger type (workflow_dispatch, pull_request, schedule)' + required: true + type: string + cleanup_resources: + description: 'Cleanup Deployed Resources' + required: false + default: false + type: boolean + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + RESOURCE_GROUP_NAME: + description: 'Resource Group Name to cleanup' + required: true + type: string + AZURE_LOCATION: + description: 'Azure Location' + required: true + type: string + AZURE_ENV_OPENAI_LOCATION: + description: 'Azure OpenAI Location' + required: true + type: string + ENV_NAME: + description: 'Environment Name' + required: true + type: string + IMAGE_TAG: + description: 'Docker Image Tag' + required: true + type: string + +jobs: + cleanup-deployment: + runs-on: ${{ inputs.runner_os }} + continue-on-error: true + env: + RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + ENV_NAME: ${{ inputs.ENV_NAME }} + IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + steps: + + - name: Login to Azure + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Delete Resource Group (Optimized Cleanup) + id: delete_rg + shell: bash + run: | + set -e + echo "πŸ—‘οΈ Starting optimized resource cleanup..." + echo "Deleting resource group: ${{ env.RESOURCE_GROUP_NAME }}" + + az group delete \ + --name "${{ env.RESOURCE_GROUP_NAME }}" \ + --yes \ + --no-wait + + echo "βœ… Resource group deletion initiated (running asynchronously)" + echo "Note: Resources will be cleaned up in the background" + + - name: Logout from Azure + if: always() + shell: bash + run: | + azd auth logout || true + az logout || echo "Warning: Failed to logout from Azure CLI" + echo "Logged out from Azure." + + - name: Generate Cleanup Job Summary + if: always() + shell: bash + run: | + echo "## 🧹 Cleanup Job Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| **Resource Group deletion Status** | ${{ steps.delete_rg.outcome == 'success' && 'βœ… Initiated' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Resource Group** | \`${{ env.RESOURCE_GROUP_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ steps.delete_rg.outcome }}" == "success" ]]; then + echo "### βœ… Cleanup Details" >> $GITHUB_STEP_SUMMARY + echo "- Successfully initiated deletion for Resource Group \`${{ env.RESOURCE_GROUP_NAME }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Cleanup Failed" >> $GITHUB_STEP_SUMMARY + echo "- Cleanup process encountered an error" >> $GITHUB_STEP_SUMMARY + echo "- Manual cleanup may be required for:" >> $GITHUB_STEP_SUMMARY + echo " - Resource Group: \`${{ env.RESOURCE_GROUP_NAME }}\`" >> $GITHUB_STEP_SUMMARY + echo "- Check the cleanup-deployment job logs for detailed error information" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/job-deploy-linux.yml b/.github/workflows/job-deploy-linux.yml new file mode 100644 index 00000000..66483f76 --- /dev/null +++ b/.github/workflows/job-deploy-linux.yml @@ -0,0 +1,546 @@ +name: Deploy Steps - Linux + +permissions: + contents: read + actions: read +on: + workflow_call: + inputs: + ENV_NAME: + required: true + type: string + AZURE_ENV_OPENAI_LOCATION: + required: true + type: string + AZURE_LOCATION: + required: true + type: string + RESOURCE_GROUP_NAME: + required: true + type: string + IMAGE_TAG: + required: true + type: string + BUILD_DOCKER_IMAGE: + required: true + type: string + EXP: + required: true + type: string + WAF_ENABLED: + required: false + type: string + default: 'false' + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + required: false + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + required: false + type: string + E2E_TEST: + required: false + type: string + default: 'false' + outputs: + CONTAINER_WEB_APPURL: + description: "Container Web App URL" + value: ${{ jobs.deploy-linux.outputs.WEBAPP_URL }} + +jobs: + deploy-linux: + runs-on: ubuntu-latest + env: + AZURE_DEV_COLLECT_TELEMETRY: ${{ vars.AZURE_DEV_COLLECT_TELEMETRY }} + outputs: + WEBAPP_URL: ${{ steps.get_output_linux.outputs.WEBAPP_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Validate Workflow Input Parameters + shell: bash + env: + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_BUILD_DOCKER_IMAGE: ${{ inputs.BUILD_DOCKER_IMAGE }} + INPUT_EXP: ${{ inputs.EXP }} + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate ENV_NAME (required, alphanumeric and hyphens) + if [[ -z "$INPUT_ENV_NAME" ]]; then + echo "❌ ERROR: ENV_NAME is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_ENV_NAME" =~ ^[a-zA-Z0-9-]+$ ]]; then + echo "❌ ERROR: ENV_NAME '$INPUT_ENV_NAME' is invalid. Must contain only alphanumerics and hyphens" + VALIDATION_FAILED=true + else + echo "βœ… ENV_NAME: '$INPUT_ENV_NAME' is valid" + fi + + # Validate AZURE_ENV_OPENAI_LOCATION (required, Azure region format) + if [[ -z "$INPUT_AZURE_ENV_OPENAI_LOCATION" ]]; then + echo "❌ ERROR: AZURE_ENV_OPENAI_LOCATION is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_AZURE_ENV_OPENAI_LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_OPENAI_LOCATION '$INPUT_AZURE_ENV_OPENAI_LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_OPENAI_LOCATION: '$INPUT_AZURE_ENV_OPENAI_LOCATION' is valid" + fi + + # Validate AZURE_LOCATION (required, Azure region format) + if [[ -z "$INPUT_AZURE_LOCATION" ]]; then + echo "❌ ERROR: AZURE_LOCATION is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_AZURE_LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: AZURE_LOCATION '$INPUT_AZURE_LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_LOCATION: '$INPUT_AZURE_LOCATION' is valid" + fi + + # Validate RESOURCE_GROUP_NAME (required, Azure naming convention) + if [[ -z "$INPUT_RESOURCE_GROUP_NAME" ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters" + VALIDATION_FAILED=true + else + echo "βœ… RESOURCE_GROUP_NAME: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + + # Validate IMAGE_TAG (required, Docker tag pattern) + if [[ -z "$INPUT_IMAGE_TAG" ]]; then + echo "❌ ERROR: IMAGE_TAG is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_IMAGE_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "❌ ERROR: IMAGE_TAG '$INPUT_IMAGE_TAG' is invalid. Must start with alphanumeric or underscore, max 128 characters" + VALIDATION_FAILED=true + else + echo "βœ… IMAGE_TAG: '$INPUT_IMAGE_TAG' is valid" + fi + + # Validate BUILD_DOCKER_IMAGE (required, must be 'true' or 'false') + if [[ "$INPUT_BUILD_DOCKER_IMAGE" != "true" && "$INPUT_BUILD_DOCKER_IMAGE" != "false" ]]; then + echo "❌ ERROR: BUILD_DOCKER_IMAGE must be 'true' or 'false', got: '$INPUT_BUILD_DOCKER_IMAGE'" + VALIDATION_FAILED=true + else + echo "βœ… BUILD_DOCKER_IMAGE: '$INPUT_BUILD_DOCKER_IMAGE' is valid" + fi + + # Validate EXP (required, must be 'true' or 'false') + if [[ "$INPUT_EXP" != "true" && "$INPUT_EXP" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$INPUT_EXP'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$INPUT_EXP' is valid" + fi + + # Validate WAF_ENABLED (must be 'true' or 'false') + if [[ "$INPUT_WAF_ENABLED" != "true" && "$INPUT_WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: WAF_ENABLED must be 'true' or 'false', got: '$INPUT_WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… WAF_ENABLED: '$INPUT_WAF_ENABLED' is valid" + fi + + # Validate AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID (optional, if provided must be valid Resource ID) + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + if [[ ! "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/[rR]esource[gG]roups/[^/]+/providers/[mM]icrosoft\.[oO]perational[iI]nsights/workspaces/[^/]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}" + echo " Got: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Valid Resource ID format" + fi + fi + + # Validate AZURE_EXISTING_AI_PROJECT_RESOURCE_ID (optional, if provided must be valid Resource ID) + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + if [[ ! "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/resourceGroups/[^/]+/providers/(Microsoft\.MachineLearningServices/(workspaces|projects)/[^/]+|Microsoft\.CognitiveServices/accounts/[^/]+/projects/[^/]+)$ ]]; then + echo "❌ ERROR: AZURE_EXISTING_AI_PROJECT_RESOURCE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}" + echo " Got: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Valid Resource ID format" + fi + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + - name: Configure Parameters Based on WAF Setting + shell: bash + env: + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + run: | + 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" + else + echo "πŸ”§ Configuring Non-WAF deployment - using default main.parameters.json..." + fi + + - name: Install azd + uses: Azure/setup-azd@v2 + + - name: Login to AZD + id: login-azure + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + azd auth login --client-id ${{ secrets.AZURE_CLIENT_ID }} --client-secret ${{ secrets.AZURE_CLIENT_SECRET }} --tenant-id ${{ secrets.AZURE_TENANT_ID }} + + - name: Deploy using azd up and extract values (Linux) + id: get_output_linux + shell: bash + env: + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_BUILD_DOCKER_IMAGE: ${{ inputs.BUILD_DOCKER_IMAGE }} + INPUT_EXP: ${{ inputs.EXP }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + run: | + set -e + + echo "Creating environment..." + azd env new "$INPUT_ENV_NAME" --no-prompt + echo "Environment created: $INPUT_ENV_NAME" + + echo "Setting default subscription..." + azd config set defaults.subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # Set additional parameters + azd env set AZURE_SUBSCRIPTION_ID="${{ secrets.AZURE_SUBSCRIPTION_ID }}" + azd env set AZURE_ENV_AI_SERVICE_LOCATION="$INPUT_AZURE_ENV_OPENAI_LOCATION" + azd env set AZURE_LOCATION="$INPUT_AZURE_LOCATION" + azd env set AZURE_RESOURCE_GROUP="$INPUT_RESOURCE_GROUP_NAME" + azd env set AZURE_ENV_IMAGETAG="$INPUT_IMAGE_TAG" + + if [[ "$INPUT_BUILD_DOCKER_IMAGE" == "true" ]]; then + ACR_NAME="${{ secrets.ACR_TEST_LOGIN_SERVER }}" + azd env set AZURE_ENV_ACR_NAME="$ACR_NAME" + echo "Set ACR name to: $ACR_NAME" + else + echo "Skipping ACR name configuration (using existing image)" + fi + + if [[ "$INPUT_EXP" == "true" ]]; then + echo "βœ… EXP ENABLED - Setting EXP parameters..." + + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + EXP_LOG_ANALYTICS_ID="$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" + else + EXP_LOG_ANALYTICS_ID="${{ vars.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }}" + fi + + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + EXP_AI_PROJECT_ID="$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" + else + EXP_AI_PROJECT_ID="${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" + fi + + echo "AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: $EXP_LOG_ANALYTICS_ID" + echo "AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: $EXP_AI_PROJECT_ID" + azd env set AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID="$EXP_LOG_ANALYTICS_ID" + azd env set AZURE_EXISTING_AI_PROJECT_RESOURCE_ID="$EXP_AI_PROJECT_ID" + else + echo "❌ EXP DISABLED - Skipping EXP parameters" + fi + + azd up --no-prompt + echo "βœ… Deployment succeeded" + + WEBAPP_URL=$(azd env get-value WEB_APP_URL) + echo "WEBAPP_URL=${WEBAPP_URL}" >> $GITHUB_ENV + echo "WEBAPP_URL=${WEBAPP_URL}" >> $GITHUB_OUTPUT + + - name: Setup Python Environment for Post-Deployment + shell: bash + run: | + echo "Setting up Python environment..." + python3 --version + pip3 --version + + # Upgrade pip + python3 -m pip install --upgrade pip + + # - name: Enable Public Network Access for Data Upload (WAF Only) + # if: inputs.WAF_ENABLED == 'true' + # shell: bash + # env: + # INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + # INPUT_EXP: ${{ inputs.EXP }} + # INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + # run: | + # echo "πŸ”“ Temporarily enabling public network access for data upload..." + # SUBSCRIPTION_ID=$(az account show --query id -o tsv) + # SP_OBJECT_ID=$(az ad sp show --id ${{ secrets.AZURE_CLIENT_ID }} --query id -o tsv) + + # # Get Cosmos DB account name and enable public access + # COSMOS_ACCOUNT=$(az cosmosdb list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv) + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "Enabling public access for Cosmos DB: $COSMOS_ACCOUNT" + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-network-access Enabled + + # # Get GitHub Actions runner public IP and add to firewall + # RUNNER_IP=$(curl -s https://api.ipify.org) + # echo "GitHub Actions Runner IP: $RUNNER_IP" + # echo "Adding runner IP to Cosmos DB firewall rules..." + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" \ + # --ip-range-filter "$RUNNER_IP" + # echo "βœ… Cosmos DB public access enabled and runner IP added to firewall" + # else + # echo "⚠️ No Cosmos DB account found in resource group" + # fi + + # # Get AI Services account - check current RG first, then extract from EXP project ID if needed + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[?kind=='AIServices'].name | [0]" -o tsv) + # AI_SERVICES_RG="$INPUT_RESOURCE_GROUP_NAME" + + # # If EXP mode, extract AI Services account name directly from the project resource ID + # if [[ -z "$AI_SERVICES_ACCOUNT" && "$INPUT_EXP" == "true" ]]; then + # echo "EXP mode: Extracting AI Services from existing AI project resource ID..." + # if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + # EXP_PROJECT_ID="$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" + # else + # EXP_PROJECT_ID="${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" + # fi + # # Extract resource group: .../resourceGroups/{rg}/... + # EXP_RG=$(echo "$EXP_PROJECT_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # # Extract account name: .../accounts/{accountName}/projects/... + # EXP_ACCOUNT=$(echo "$EXP_PROJECT_ID" | sed -n 's|.*/accounts/\([^/]*\)/.*|\1|p') + # if [[ -n "$EXP_ACCOUNT" && -n "$EXP_RG" ]]; then + # AI_SERVICES_ACCOUNT="$EXP_ACCOUNT" + # AI_SERVICES_RG="$EXP_RG" + # echo "Found AI Services from EXP project: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # fi + # fi + + # if [[ -n "$AI_SERVICES_ACCOUNT" ]]; then + # echo "Enabling public access for AI Services: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # az rest --method PATCH \ + # --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${AI_SERVICES_RG}/providers/Microsoft.CognitiveServices/accounts/${AI_SERVICES_ACCOUNT}?api-version=2024-10-01" \ + # --body '{"properties":{"publicNetworkAccess":"Enabled"}}' + # echo "βœ… AI Services public access enabled" + + # # Assign Cognitive Services roles to service principal for embeddings and OpenAI access + # echo "Assigning Cognitive Services roles to service principal..." + # AI_SERVICES_ID=$(az cognitiveservices account show --name "$AI_SERVICES_ACCOUNT" --resource-group "$AI_SERVICES_RG" --query id -o tsv) + # az role assignment create --assignee-object-id "$SP_OBJECT_ID" --assignee-principal-type ServicePrincipal \ + # --role "Cognitive Services OpenAI User" --scope "$AI_SERVICES_ID" 2>/dev/null || echo "OpenAI User role may already be assigned" + # az role assignment create --assignee-object-id "$SP_OBJECT_ID" --assignee-principal-type ServicePrincipal \ + # --role "Cognitive Services User" --scope "$AI_SERVICES_ID" 2>/dev/null || echo "Cognitive Services User role may already be assigned" + # echo "βœ… Cognitive Services roles assigned" + # else + # echo "⚠️ No AI Services account found" + # fi + + # # Get AI Search service and enable public access + # SEARCH_SERVICE=$(az search service list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv) + # if [[ -n "$SEARCH_SERVICE" ]]; then + # echo "Enabling public access for AI Search: $SEARCH_SERVICE" + # az search service update --name "$SEARCH_SERVICE" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-access enabled + # echo "βœ… AI Search public access enabled" + # else + # echo "⚠️ No AI Search service found in resource group" + # fi + + # # Wait for network changes to propagate (5 minutes for Cosmos DB with private endpoints) + # echo "⏳ Waiting 5 minutes for Cosmos DB network changes to propagate..." + # sleep 300 + + # # Verify Cosmos DB is accessible before proceeding + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "πŸ” Verifying Cosmos DB connectivity..." + # COSMOS_ENDPOINT="https://${COSMOS_ACCOUNT}.documents.azure.com:443/" + # MAX_RETRIES=10 + # RETRY_COUNT=0 + # while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do + # HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${COSMOS_ENDPOINT}" --max-time 10 2>/dev/null || echo "000") + # if [[ "$HTTP_CODE" == "401" || "$HTTP_CODE" == "200" ]]; then + # echo "βœ… Cosmos DB is accessible (HTTP $HTTP_CODE - auth required, network OK)" + # break + # fi + # RETRY_COUNT=$((RETRY_COUNT + 1)) + # echo "⏳ Cosmos DB not yet accessible (HTTP $HTTP_CODE), retry $RETRY_COUNT/$MAX_RETRIES..." + # sleep 30 + # done + # if [[ $RETRY_COUNT -eq $MAX_RETRIES ]]; then + # echo "⚠️ Cosmos DB connectivity check timed out, proceeding anyway..." + # fi + # fi + # echo "βœ… Network changes should now be propagated" + + - name: Run Data Upload Scripts + shell: bash + run: | + echo "πŸ“Š Running data upload scripts to populate product catalogs and create search indexes..." + + # Navigate to workspace root + cd $GITHUB_WORKSPACE + + # Run the data upload script (it will pull parameters from azd env and install its own requirements) + bash ./infra/scripts/data_scripts/run_upload_data_scripts.sh + + if [[ $? -eq 0 ]]; then + echo "βœ… Data upload completed successfully" + else + echo "❌ Data upload failed" + exit 1 + fi + - name: Run Agent Creation Scripts + shell: bash + run: | + echo "πŸ€– Running agent creation scripts to set up Azure AI Foundry agents..." + + # Navigate to workspace root + cd $GITHUB_WORKSPACE + + # Run the agent creation script (it will pull parameters from azd env and install its own requirements) + bash ./infra/scripts/agent_scripts/run_create_agents_scripts.sh + + if [[ $? -eq 0 ]]; then + echo "βœ… Agent creation completed successfully" + else + echo "❌ Agent creation failed" + exit 1 + fi + + - name: Wait for Data Propagation + shell: bash + run: | + echo "⏳ Waiting 5 minutes for data to propagate and indexes to update..." + sleep 300 + echo "βœ… Wait completed, data should now be available" + + - name: Wait for App Readiness (E2E Tests) + if: inputs.E2E_TEST == 'true' || (inputs.WAF_ENABLED == 'false' && inputs.EXP == 'false') + shell: bash + run: | + echo "⏳ E2E tests or Golden Path deployment - Waiting 10 minutes for app to be fully ready..." + sleep 600 + echo "βœ… App readiness wait completed" + + # - name: Restore Private Network Access (WAF Only) + # if: always() && inputs.WAF_ENABLED == 'true' + # shell: bash + # env: + # INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + # INPUT_EXP: ${{ inputs.EXP }} + # INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + # run: | + # echo "πŸ”’ Restoring private network access settings..." + # SUBSCRIPTION_ID=$(az account show --query id -o tsv 2>/dev/null) + + # # Get Cosmos DB account name + # COSMOS_ACCOUNT=$(az cosmosdb list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "Disabling public access for Cosmos DB: $COSMOS_ACCOUNT" + # # Clear IP rules first + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --ip-range-filter "" || true + # # Then disable public network access + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-network-access Disabled || true + # echo "βœ… Cosmos DB public access disabled and firewall rules cleared" + # fi + + # # Get AI Services account - check current RG first, then EXP RG if needed + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # AI_SERVICES_RG="$INPUT_RESOURCE_GROUP_NAME" + + # # If EXP mode and no AI Services in current RG, get from existing AI project RG + # if [[ -z "$AI_SERVICES_ACCOUNT" && "$INPUT_EXP" == "true" ]]; then + # if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + # EXP_RG=$(echo "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # else + # EXP_RG=$(echo "${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # fi + # if [[ -n "$EXP_RG" ]]; then + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$EXP_RG" --query "[0].name" -o tsv 2>/dev/null) + # AI_SERVICES_RG="$EXP_RG" + # fi + # fi + + # if [[ -n "$AI_SERVICES_ACCOUNT" ]]; then + # echo "Disabling public access for AI Services: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # az rest --method PATCH \ + # --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${AI_SERVICES_RG}/providers/Microsoft.CognitiveServices/accounts/${AI_SERVICES_ACCOUNT}?api-version=2024-10-01" \ + # --body '{"properties":{"publicNetworkAccess":"Disabled"}}' || true + # echo "βœ… AI Services public access disabled" + # fi + + # # Get AI Search service and disable public access + # SEARCH_SERVICE=$(az search service list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # if [[ -n "$SEARCH_SERVICE" ]]; then + # echo "Disabling public access for AI Search: $SEARCH_SERVICE" + # az search service update --name "$SEARCH_SERVICE" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-access disabled || true + # echo "βœ… AI Search public access disabled" + # fi + + # echo "πŸ”’ Private network access restored" + + - name: Generate Deployment Summary + if: always() + shell: bash + env: + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + INPUT_EXP: ${{ inputs.EXP }} + CONFIG_TYPE : ${{ inputs.WAF_ENABLED == 'true' && inputs.EXP == 'true' && 'WAF + EXP' || inputs.WAF_ENABLED == 'true' && inputs.EXP != 'true' && 'WAF + Non-EXP' || inputs.WAF_ENABLED != 'true' && inputs.EXP == 'true' && 'Non-WAF + EXP' || 'Non-WAF + Non-EXP' }} + run: | + echo "## πŸš€ Deploy Job Summary (Linux)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| **Job Status** | ${{ job.status == 'success' && 'βœ… Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Resource Group** | \`$INPUT_RESOURCE_GROUP_NAME \` |" >> $GITHUB_STEP_SUMMARY + echo "| **Configuration Type** | \`$CONFIG_TYPE\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Azure Region (Infrastructure)** | \`$INPUT_AZURE_LOCATION\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Azure OpenAI Region** | \`$INPUT_AZURE_ENV_OPENAI_LOCATION\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Docker Image Tag** | \`$INPUT_IMAGE_TAG\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ job.status }}" == "success" ]]; then + echo "### βœ… Deployment Details" >> $GITHUB_STEP_SUMMARY + echo "- **Container Web App URL**: [${{ steps.get_output_linux.outputs.WEBAPP_URL }}](${{ steps.get_output_linux.outputs.WEBAPP_URL }})" >> $GITHUB_STEP_SUMMARY + echo "- Successfully deployed to Azure with all resources configured" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "- Deployment process encountered an error" >> $GITHUB_STEP_SUMMARY + echo "- Check the deployment steps above for detailed error information" >> $GITHUB_STEP_SUMMARY + fi + + - name: Logout from Azure + if: always() + shell: bash + run: | + az logout || true + echo "Logged out from Azure." diff --git a/.github/workflows/job-deploy-windows.yml b/.github/workflows/job-deploy-windows.yml new file mode 100644 index 00000000..00a5993b --- /dev/null +++ b/.github/workflows/job-deploy-windows.yml @@ -0,0 +1,553 @@ +name: Deploy Steps - Windows + +permissions: + contents: read + actions: read + +on: + workflow_call: + inputs: + ENV_NAME: + required: true + type: string + AZURE_ENV_OPENAI_LOCATION: + required: true + type: string + AZURE_LOCATION: + required: true + type: string + RESOURCE_GROUP_NAME: + required: true + type: string + IMAGE_TAG: + required: true + type: string + BUILD_DOCKER_IMAGE: + required: true + type: string + EXP: + required: true + type: string + WAF_ENABLED: + required: false + type: string + default: 'false' + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + required: false + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + required: false + type: string + E2E_TEST: + required: false + type: string + default: 'false' + outputs: + CONTAINER_WEB_APPURL: + description: "Container Web App URL" + value: ${{ jobs.deploy-windows.outputs.WEBAPP_URL }} + +jobs: + deploy-windows: + runs-on: windows-latest + env: + AZURE_DEV_COLLECT_TELEMETRY: ${{ vars.AZURE_DEV_COLLECT_TELEMETRY }} + outputs: + WEBAPP_URL: ${{ steps.get_output_windows.outputs.WEBAPP_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Validate Workflow Input Parameters + shell: bash + env: + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_BUILD_DOCKER_IMAGE: ${{ inputs.BUILD_DOCKER_IMAGE }} + INPUT_EXP: ${{ inputs.EXP }} + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate ENV_NAME (required, alphanumeric and hyphens) + if [[ -z "$INPUT_ENV_NAME" ]]; then + echo "❌ ERROR: ENV_NAME is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_ENV_NAME" =~ ^[a-zA-Z0-9-]+$ ]]; then + echo "❌ ERROR: ENV_NAME '$INPUT_ENV_NAME' is invalid. Must contain only alphanumerics and hyphens" + VALIDATION_FAILED=true + else + echo "βœ… ENV_NAME: '$INPUT_ENV_NAME' is valid" + fi + + # Validate AZURE_ENV_OPENAI_LOCATION (required, Azure region format) + if [[ -z "$INPUT_AZURE_ENV_OPENAI_LOCATION" ]]; then + echo "❌ ERROR: AZURE_ENV_OPENAI_LOCATION is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_AZURE_ENV_OPENAI_LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_OPENAI_LOCATION '$INPUT_AZURE_ENV_OPENAI_LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_OPENAI_LOCATION: '$INPUT_AZURE_ENV_OPENAI_LOCATION' is valid" + fi + + # Validate AZURE_LOCATION (required, Azure region format) + if [[ -z "$INPUT_AZURE_LOCATION" ]]; then + echo "❌ ERROR: AZURE_LOCATION is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_AZURE_LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: AZURE_LOCATION '$INPUT_AZURE_LOCATION' is invalid. Must contain only lowercase letters and numbers" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_LOCATION: '$INPUT_AZURE_LOCATION' is valid" + fi + + # Validate RESOURCE_GROUP_NAME (required, Azure naming convention) + if [[ -z "$INPUT_RESOURCE_GROUP_NAME" ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters" + VALIDATION_FAILED=true + else + echo "βœ… RESOURCE_GROUP_NAME: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + + # Validate IMAGE_TAG (required, Docker tag pattern) + if [[ -z "$INPUT_IMAGE_TAG" ]]; then + echo "❌ ERROR: IMAGE_TAG is required but not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_IMAGE_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "❌ ERROR: IMAGE_TAG '$INPUT_IMAGE_TAG' is invalid. Must start with alphanumeric or underscore, max 128 characters" + VALIDATION_FAILED=true + else + echo "βœ… IMAGE_TAG: '$INPUT_IMAGE_TAG' is valid" + fi + + # Validate BUILD_DOCKER_IMAGE (required, must be 'true' or 'false') + if [[ "$INPUT_BUILD_DOCKER_IMAGE" != "true" && "$INPUT_BUILD_DOCKER_IMAGE" != "false" ]]; then + echo "❌ ERROR: BUILD_DOCKER_IMAGE must be 'true' or 'false', got: '$INPUT_BUILD_DOCKER_IMAGE'" + VALIDATION_FAILED=true + else + echo "βœ… BUILD_DOCKER_IMAGE: '$INPUT_BUILD_DOCKER_IMAGE' is valid" + fi + + # Validate EXP (required, must be 'true' or 'false') + if [[ "$INPUT_EXP" != "true" && "$INPUT_EXP" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$INPUT_EXP'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$INPUT_EXP' is valid" + fi + + # Validate WAF_ENABLED (must be 'true' or 'false') + if [[ "$INPUT_WAF_ENABLED" != "true" && "$INPUT_WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: WAF_ENABLED must be 'true' or 'false', got: '$INPUT_WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… WAF_ENABLED: '$INPUT_WAF_ENABLED' is valid" + fi + + # Validate AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID (optional, if provided must be valid Resource ID) + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + if [[ ! "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/[rR]esource[gG]roups/[^/]+/providers/[mM]icrosoft\.[oO]perational[iI]nsights/workspaces/[^/]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}" + echo " Got: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Valid Resource ID format" + fi + fi + + # Validate AZURE_EXISTING_AI_PROJECT_RESOURCE_ID (optional, if provided must be valid Resource ID) + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + if [[ ! "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/resourceGroups/[^/]+/providers/(Microsoft\.MachineLearningServices/(workspaces|projects)/[^/]+|Microsoft\.CognitiveServices/accounts/[^/]+/projects/[^/]+)$ ]]; then + echo "❌ ERROR: AZURE_EXISTING_AI_PROJECT_RESOURCE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}" + echo " Got: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Valid Resource ID format" + fi + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + - name: Configure Parameters Based on WAF Setting + shell: bash + env: + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + run: | + 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" + else + echo "πŸ”§ Configuring Non-WAF deployment - using default main.parameters.json..." + fi + + - name: Install azd + uses: Azure/setup-azd@v2 + + - name: Login to AZD + id: login-azure + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + azd auth login --client-id ${{ secrets.AZURE_CLIENT_ID }} --client-secret ${{ secrets.AZURE_CLIENT_SECRET }} --tenant-id ${{ secrets.AZURE_TENANT_ID }} + + + - name: Deploy using azd up and extract values (Windows) + id: get_output_windows + shell: pwsh + env: + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_BUILD_DOCKER_IMAGE: ${{ inputs.BUILD_DOCKER_IMAGE }} + INPUT_EXP: ${{ inputs.EXP }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + run: | + $ErrorActionPreference = "Stop" + Write-Host "Starting azd deployment..." + Write-Host "EXP: $env:INPUT_EXP" + Write-Host "Using Docker Image Tag: $env:INPUT_IMAGE_TAG" + + Write-Host "Creating environment..." + azd env new "$env:INPUT_ENV_NAME" --no-prompt + Write-Host "Environment created: $env:INPUT_ENV_NAME" + + Write-Host "Setting default subscription..." + azd config set defaults.subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # Set additional parameters + azd env set AZURE_SUBSCRIPTION_ID="${{ secrets.AZURE_SUBSCRIPTION_ID }}" + azd env set AZURE_ENV_AI_SERVICE_LOCATION="$env:INPUT_AZURE_ENV_OPENAI_LOCATION" + azd env set AZURE_LOCATION="$env:INPUT_AZURE_LOCATION" + azd env set AZURE_RESOURCE_GROUP="$env:INPUT_RESOURCE_GROUP_NAME" + azd env set AZURE_ENV_IMAGETAG="$env:INPUT_IMAGE_TAG" + + # Set ACR name only when building Docker image + if ("$env:INPUT_BUILD_DOCKER_IMAGE" -eq "true") { + $ACR_NAME = "${{ secrets.ACR_TEST_LOGIN_SERVER }}" + azd env set AZURE_ENV_ACR_NAME="$ACR_NAME" + Write-Host "Set ACR name to: $ACR_NAME" + } else { + Write-Host "Skipping ACR name configuration (using existing image)" + } + + if ("$env:INPUT_EXP" -eq "true") { + Write-Host "EXP ENABLED βœ… - Setting EXP parameters..." + + # Set EXP variables dynamically + if ("$env:INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" -ne "") { + $EXP_LOG_ANALYTICS_ID = "$env:INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" + } else { + $EXP_LOG_ANALYTICS_ID = "${{ vars.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }}" + } + + if ("$env:INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" -ne "") { + $EXP_AI_PROJECT_ID = "$env:INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" + } else { + $EXP_AI_PROJECT_ID = "${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" + } + + Write-Host "AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: $EXP_LOG_ANALYTICS_ID" + Write-Host "AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: $EXP_AI_PROJECT_ID" + azd env set AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID="$EXP_LOG_ANALYTICS_ID" + azd env set AZURE_EXISTING_AI_PROJECT_RESOURCE_ID="$EXP_AI_PROJECT_ID" + } else { + Write-Host "EXP DISABLED - Skipping EXP parameters" + } + + # Deploy using azd up + azd up --no-prompt + Write-Host "βœ… Deployment succeeded." + + $WEBAPP_URL = azd env get-value WEB_APP_URL + "WEBAPP_URL=$WEBAPP_URL" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "WEBAPP_URL=$WEBAPP_URL" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + # - name: Enable Public Network Access for Data Upload (WAF Only) + # if: inputs.WAF_ENABLED == 'true' + # shell: bash + # env: + # INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + # INPUT_EXP: ${{ inputs.EXP }} + # INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + # run: | + # echo "πŸ”“ Temporarily enabling public network access for data upload..." + # SUBSCRIPTION_ID=$(az account show --query id -o tsv) + # SP_OBJECT_ID=$(az ad sp show --id ${{ secrets.AZURE_CLIENT_ID }} --query id -o tsv) + + # # Get Cosmos DB account name and enable public access + # COSMOS_ACCOUNT=$(az cosmosdb list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv) + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "Enabling public access for Cosmos DB: $COSMOS_ACCOUNT" + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-network-access Enabled + + # # Get GitHub Actions runner public IP and add to firewall + # RUNNER_IP=$(curl -s https://api.ipify.org) + # echo "GitHub Actions Runner IP: $RUNNER_IP" + # echo "Adding runner IP to Cosmos DB firewall rules..." + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" \ + # --ip-range-filter "$RUNNER_IP" + # echo "βœ… Cosmos DB public access enabled and runner IP added to firewall" + # else + # echo "⚠️ No Cosmos DB account found in resource group" + # fi + + # # Get AI Services account - check current RG first, then extract from EXP project ID if needed + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[?kind=='AIServices'].name | [0]" -o tsv) + # AI_SERVICES_RG="$INPUT_RESOURCE_GROUP_NAME" + + # # If EXP mode, extract AI Services account name directly from the project resource ID + # if [[ -z "$AI_SERVICES_ACCOUNT" && "$INPUT_EXP" == "true" ]]; then + # echo "EXP mode: Extracting AI Services from existing AI project resource ID..." + # if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + # EXP_PROJECT_ID="$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" + # else + # EXP_PROJECT_ID="${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" + # fi + # # Extract resource group: .../resourceGroups/{rg}/... + # EXP_RG=$(echo "$EXP_PROJECT_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # # Extract account name: .../accounts/{accountName}/projects/... + # EXP_ACCOUNT=$(echo "$EXP_PROJECT_ID" | sed -n 's|.*/accounts/\([^/]*\)/.*|\1|p') + # if [[ -n "$EXP_ACCOUNT" && -n "$EXP_RG" ]]; then + # AI_SERVICES_ACCOUNT="$EXP_ACCOUNT" + # AI_SERVICES_RG="$EXP_RG" + # echo "Found AI Services from EXP project: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # fi + # fi + + # if [[ -n "$AI_SERVICES_ACCOUNT" ]]; then + # echo "Enabling public access for AI Services: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # az rest --method PATCH \ + # --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${AI_SERVICES_RG}/providers/Microsoft.CognitiveServices/accounts/${AI_SERVICES_ACCOUNT}?api-version=2024-10-01" \ + # --body '{"properties":{"publicNetworkAccess":"Enabled"}}' + # echo "βœ… AI Services public access enabled" + + # # Assign Cognitive Services roles to service principal for embeddings and OpenAI access + # echo "Assigning Cognitive Services roles to service principal..." + # AI_SERVICES_ID=$(az cognitiveservices account show --name "$AI_SERVICES_ACCOUNT" --resource-group "$AI_SERVICES_RG" --query id -o tsv) + # az role assignment create --assignee-object-id "$SP_OBJECT_ID" --assignee-principal-type ServicePrincipal \ + # --role "Cognitive Services OpenAI User" --scope "$AI_SERVICES_ID" 2>/dev/null || echo "OpenAI User role may already be assigned" + # az role assignment create --assignee-object-id "$SP_OBJECT_ID" --assignee-principal-type ServicePrincipal \ + # --role "Cognitive Services User" --scope "$AI_SERVICES_ID" 2>/dev/null || echo "Cognitive Services User role may already be assigned" + # echo "βœ… Cognitive Services roles assigned" + # else + # echo "⚠️ No AI Services account found" + # fi + + # # Get AI Search service and enable public access + # SEARCH_SERVICE=$(az search service list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv) + # if [[ -n "$SEARCH_SERVICE" ]]; then + # echo "Enabling public access for AI Search: $SEARCH_SERVICE" + # az search service update --name "$SEARCH_SERVICE" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-access enabled + # echo "βœ… AI Search public access enabled" + # else + # echo "⚠️ No AI Search service found in resource group" + # fi + + # # Wait for network changes to propagate (5 minutes for Cosmos DB with private endpoints) + # echo "⏳ Waiting 5 minutes for Cosmos DB network changes to propagate..." + # sleep 300 + + # # Verify Cosmos DB is accessible before proceeding + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "πŸ” Verifying Cosmos DB connectivity..." + # COSMOS_ENDPOINT="https://${COSMOS_ACCOUNT}.documents.azure.com:443/" + # MAX_RETRIES=10 + # RETRY_COUNT=0 + # while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do + # HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${COSMOS_ENDPOINT}" --max-time 10 2>/dev/null || echo "000") + # if [[ "$HTTP_CODE" == "401" || "$HTTP_CODE" == "200" ]]; then + # echo "βœ… Cosmos DB is accessible (HTTP $HTTP_CODE - auth required, network OK)" + # break + # fi + # RETRY_COUNT=$((RETRY_COUNT + 1)) + # echo "⏳ Cosmos DB not yet accessible (HTTP $HTTP_CODE), retry $RETRY_COUNT/$MAX_RETRIES..." + # sleep 30 + # done + # if [[ $RETRY_COUNT -eq $MAX_RETRIES ]]; then + # echo "⚠️ Cosmos DB connectivity check timed out, proceeding anyway..." + # fi + # fi + # echo "βœ… Network changes should now be propagated" + + - name: Run Post-Deployment Data Upload Scripts + shell: pwsh + run: | + Write-Host "Running data upload scripts..." + + # Get service principal object ID for role assignments + $SP_OBJECT_ID = az ad sp show --id ${{ secrets.AZURE_CLIENT_ID }} --query id -o tsv + Write-Host "Service Principal Object ID: $SP_OBJECT_ID" + + # Export for scripts to use + $env:AZURE_PRINCIPAL_ID = $SP_OBJECT_ID + $env:AZURE_CLIENT_ID = "${{ secrets.AZURE_CLIENT_ID }}" + + bash ./infra/scripts/data_scripts/run_upload_data_scripts.sh + Write-Host "βœ… Data upload completed successfully" + - name: Setup Python 3.11 for Agent Scripts + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run Post-Deployment Agent Creation Scripts + shell: pwsh + run: | + Write-Host "Running agent creation scripts..." + + # Get service principal object ID for role assignments + $SP_OBJECT_ID = az ad sp show --id ${{ secrets.AZURE_CLIENT_ID }} --query id -o tsv + Write-Host "Service Principal Object ID: $SP_OBJECT_ID" + + # Export for scripts to use + $env:AZURE_PRINCIPAL_ID = $SP_OBJECT_ID + $env:AZURE_CLIENT_ID = "${{ secrets.AZURE_CLIENT_ID }}" + + bash ./infra/scripts/agent_scripts/run_create_agents_scripts.sh + + - name: Wait for Data Propagation + shell: bash + run: | + echo "⏳ Waiting 5 minutes for data to propagate and indexes to update..." + sleep 300 + echo "βœ… Wait completed, data should now be available" + + - name: Wait for App Readiness (E2E Tests) + if: inputs.E2E_TEST == 'true' || (inputs.WAF_ENABLED == 'false' && inputs.EXP == 'false') + shell: bash + run: | + echo "⏳ E2E tests or Golden Path deployment - Waiting 10 minutes for app to be fully ready..." + sleep 600 + echo "βœ… App readiness wait completed" + + # - name: Restore Private Network Access (WAF Only) + # if: always() && inputs.WAF_ENABLED == 'true' + # shell: bash + # env: + # INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + # INPUT_EXP: ${{ inputs.EXP }} + # INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + # run: | + # echo "πŸ”’ Restoring private network access settings..." + # SUBSCRIPTION_ID=$(az account show --query id -o tsv 2>/dev/null) + + # # Get Cosmos DB account name + # COSMOS_ACCOUNT=$(az cosmosdb list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # if [[ -n "$COSMOS_ACCOUNT" ]]; then + # echo "Disabling public access for Cosmos DB: $COSMOS_ACCOUNT" + # # Clear IP rules first + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --ip-range-filter "" || true + # # Then disable public network access + # az cosmosdb update --name "$COSMOS_ACCOUNT" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-network-access Disabled || true + # echo "βœ… Cosmos DB public access disabled and firewall rules cleared" + # fi + + # # Get AI Services account - check current RG first, then EXP RG if needed + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # AI_SERVICES_RG="$INPUT_RESOURCE_GROUP_NAME" + + # # If EXP mode and no AI Services in current RG, get from existing AI project RG + # if [[ -z "$AI_SERVICES_ACCOUNT" && "$INPUT_EXP" == "true" ]]; then + # if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + # EXP_RG=$(echo "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # else + # EXP_RG=$(echo "${{ vars.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }}" | sed -n 's|.*/resourceGroups/\([^/]*\)/.*|\1|p') + # fi + # if [[ -n "$EXP_RG" ]]; then + # AI_SERVICES_ACCOUNT=$(az cognitiveservices account list --resource-group "$EXP_RG" --query "[0].name" -o tsv 2>/dev/null) + # AI_SERVICES_RG="$EXP_RG" + # fi + # fi + + # if [[ -n "$AI_SERVICES_ACCOUNT" ]]; then + # echo "Disabling public access for AI Services: $AI_SERVICES_ACCOUNT (RG: $AI_SERVICES_RG)" + # az rest --method PATCH \ + # --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${AI_SERVICES_RG}/providers/Microsoft.CognitiveServices/accounts/${AI_SERVICES_ACCOUNT}?api-version=2024-10-01" \ + # --body '{"properties":{"publicNetworkAccess":"Disabled"}}' || true + # echo "βœ… AI Services public access disabled" + # fi + + # # Get AI Search service and disable public access + # SEARCH_SERVICE=$(az search service list --resource-group "$INPUT_RESOURCE_GROUP_NAME" --query "[0].name" -o tsv 2>/dev/null) + # if [[ -n "$SEARCH_SERVICE" ]]; then + # echo "Disabling public access for AI Search: $SEARCH_SERVICE" + # az search service update --name "$SEARCH_SERVICE" --resource-group "$INPUT_RESOURCE_GROUP_NAME" --public-access disabled || true + # echo "βœ… AI Search public access disabled" + # fi + + # echo "πŸ”’ Private network access restored" + + - name: Generate Deployment Summary + if: always() + shell: bash + env: + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_AZURE_LOCATION: ${{ inputs.AZURE_LOCATION }} + INPUT_AZURE_ENV_OPENAI_LOCATION: ${{ inputs.AZURE_ENV_OPENAI_LOCATION }} + INPUT_IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + INPUT_WAF_ENABLED: ${{ inputs.WAF_ENABLED }} + INPUT_EXP: ${{ inputs.EXP }} + JOB_STATUS: ${{ job.status }} + run: | + echo "## πŸš€ Deploy Job Summary (Windows)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| **Job Status** | ${{ job.status == 'success' && 'βœ… Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + CONFIG_TYPE="Non-WAF + Non-EXP" + if [[ "$INPUT_WAF_ENABLED" == "true" && "$INPUT_EXP" == "true" ]]; then + CONFIG_TYPE="WAF + EXP" + elif [[ "$INPUT_WAF_ENABLED" == "true" && "$INPUT_EXP" != "true" ]]; then + CONFIG_TYPE="WAF + Non-EXP" + elif [[ "$INPUT_WAF_ENABLED" != "true" && "$INPUT_EXP" == "true" ]]; then + CONFIG_TYPE="Non-WAF + EXP" + fi + echo "| **Configuration Type** | \`$CONFIG_TYPE\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Resource Group** | \`$INPUT_RESOURCE_GROUP_NAME\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Azure Region (Infrastructure)** | \`$INPUT_AZURE_LOCATION\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Azure OpenAI Region** | \`$INPUT_AZURE_ENV_OPENAI_LOCATION\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Docker Image Tag** | \`$INPUT_IMAGE_TAG\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$JOB_STATUS" == "success" ]; then + echo "### βœ… Deployment Details" >> $GITHUB_STEP_SUMMARY + echo "- **Container Web App URL**: [${{ steps.get_output_windows.outputs.WEBAPP_URL }}](${{ steps.get_output_windows.outputs.WEBAPP_URL }})" >> $GITHUB_STEP_SUMMARY + echo "- Successfully deployed to Azure with all resources configured" >> $GITHUB_STEP_SUMMARY + echo "- Schemas registered and sample data uploaded successfully" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY + echo "- Deployment process encountered an error" >> $GITHUB_STEP_SUMMARY + echo "- Check the deployment steps above for detailed error information" >> $GITHUB_STEP_SUMMARY + fi + + - name: Logout from Azure + if: always() + shell: bash + run: | + az logout || true + echo "Logged out from Azure." diff --git a/.github/workflows/job-deploy.yml b/.github/workflows/job-deploy.yml new file mode 100644 index 00000000..8d661b08 --- /dev/null +++ b/.github/workflows/job-deploy.yml @@ -0,0 +1,497 @@ +name: Deploy Job + +permissions: + contents: read + actions: read +on: + workflow_call: + inputs: + trigger_type: + description: 'Trigger type (workflow_dispatch, pull_request, schedule)' + required: true + type: string + runner_os: + description: 'Runner OS (ubuntu-latest or windows-latest)' + required: true + type: string + azure_location: + description: 'Azure Location For Deployment' + required: false + default: 'australiaeast' + type: string + resource_group_name: + description: 'Resource Group Name (Optional)' + required: false + default: '' + type: string + waf_enabled: + description: 'Enable WAF' + required: false + default: false + type: boolean + EXP: + description: 'Enable EXP' + required: false + default: false + type: boolean + build_docker_image: + description: 'Build And Push Docker Image (Optional)' + required: false + default: false + type: boolean + cleanup_resources: + description: 'Cleanup Deployed Resources' + required: false + default: false + type: boolean + run_e2e_tests: + description: 'Run End-to-End Tests' + required: false + default: 'GoldenPath-Testing' + type: string + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: + description: 'Log Analytics Workspace ID (Optional)' + required: false + default: '' + type: string + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: + description: 'AI Project Resource ID (Optional)' + required: false + default: '' + type: string + docker_image_tag: + description: 'Docker Image Tag from build job' + required: false + default: '' + type: string + outputs: + RESOURCE_GROUP_NAME: + description: "Resource Group Name" + value: ${{ jobs.azure-setup.outputs.RESOURCE_GROUP_NAME }} + CONTAINER_WEB_APPURL: + description: "Container Web App URL" + value: ${{ jobs.deploy-linux.outputs.CONTAINER_WEB_APPURL || jobs.deploy-windows.outputs.CONTAINER_WEB_APPURL }} + ENV_NAME: + description: "Environment Name" + value: ${{ jobs.azure-setup.outputs.ENV_NAME }} + AZURE_LOCATION: + description: "Azure Location" + value: ${{ jobs.azure-setup.outputs.AZURE_LOCATION }} + AZURE_ENV_OPENAI_LOCATION: + description: "Azure OpenAI Location" + value: ${{ jobs.azure-setup.outputs.AZURE_ENV_OPENAI_LOCATION }} + IMAGE_TAG: + description: "Docker Image Tag Used" + value: ${{ jobs.azure-setup.outputs.IMAGE_TAG }} + QUOTA_FAILED: + description: "Quota Check Failed Flag" + value: ${{ jobs.azure-setup.outputs.QUOTA_FAILED }} + +env: + GPT_MIN_CAPACITY: 150 + BRANCH_NAME: ${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + WAF_ENABLED: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.waf_enabled || false) || false }} + EXP: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.EXP || false) || false }} + CLEANUP_RESOURCES: ${{ inputs.trigger_type != 'workflow_dispatch' || inputs.cleanup_resources }} + # RUN_E2E_TESTS: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.run_e2e_tests || 'GoldenPath-Testing') || 'GoldenPath-Testing' }} + BUILD_DOCKER_IMAGE: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.build_docker_image || false) || false }} + +jobs: + azure-setup: + name: Azure Setup + if: inputs.trigger_type != 'workflow_dispatch' || inputs.existing_webapp_url == '' || inputs.existing_webapp_url == null + runs-on: ubuntu-latest + outputs: + RESOURCE_GROUP_NAME: ${{ steps.check_create_rg.outputs.RESOURCE_GROUP_NAME }} + ENV_NAME: ${{ steps.generate_env_name.outputs.ENV_NAME }} + AZURE_LOCATION: ${{ steps.set_region.outputs.AZURE_LOCATION }} + AZURE_ENV_OPENAI_LOCATION: ${{ steps.set_region.outputs.AZURE_ENV_OPENAI_LOCATION }} + IMAGE_TAG: ${{ steps.determine_image_tag.outputs.IMAGE_TAG }} + QUOTA_FAILED: ${{ steps.quota_failure_output.outputs.QUOTA_FAILED }} + + steps: + - name: Validate Workflow Input Parameters + shell: bash + env: + INPUT_AZURE_LOCATION: ${{ inputs.azure_location }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.resource_group_name }} + INPUT_EXP: ${{ inputs.EXP }} + INPUT_WAF_ENABLED: ${{ inputs.waf_enabled }} + INPUT_CLEANUP_RESOURCES: ${{ inputs.cleanup_resources }} + # INPUT_RUN_E2E_TESTS: ${{ inputs.run_e2e_tests }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + INPUT_DOCKER_IMAGE_TAG: ${{ inputs.docker_image_tag }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate azure_location (Azure region format) + if [[ -n "$INPUT_AZURE_LOCATION" ]]; then + if [[ ! "$INPUT_AZURE_LOCATION" =~ ^[a-z0-9]+$ ]]; then + echo "❌ ERROR: azure_location '$INPUT_AZURE_LOCATION' is invalid. Must contain only lowercase letters and numbers (e.g., 'australiaeast', 'westus2')" + VALIDATION_FAILED=true + else + echo "βœ… azure_location: '$INPUT_AZURE_LOCATION' is valid" + fi + fi + + # Validate resource_group_name (Azure resource group naming convention) + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + if [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: resource_group_name '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters" + VALIDATION_FAILED=true + else + echo "βœ… resource_group_name: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + fi + + # Validate waf_enabled (boolean) + if [[ "$INPUT_WAF_ENABLED" != "true" && "$INPUT_WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: waf_enabled must be 'true' or 'false', got: '$INPUT_WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… waf_enabled: '$INPUT_WAF_ENABLED' is valid" + fi + + # Validate EXP (boolean) + if [[ "$INPUT_EXP" != "true" && "$INPUT_EXP" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$INPUT_EXP'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$INPUT_EXP' is valid" + fi + + # Validate cleanup_resources (boolean) + if [[ "$INPUT_CLEANUP_RESOURCES" != "true" && "$INPUT_CLEANUP_RESOURCES" != "false" ]]; then + echo "❌ ERROR: cleanup_resources must be 'true' or 'false', got: '$INPUT_CLEANUP_RESOURCES'" + VALIDATION_FAILED=true + else + echo "βœ… cleanup_resources: '$INPUT_CLEANUP_RESOURCES' is valid" + fi + + # # Validate run_e2e_tests (specific allowed values) + # if [[ -n "$INPUT_RUN_E2E_TESTS" ]]; then + # ALLOWED_VALUES=("None" "GoldenPath-Testing" "Smoke-Testing") + # if [[ ! " ${ALLOWED_VALUES[@]} " =~ " ${INPUT_RUN_E2E_TESTS} " ]]; then + # echo "❌ ERROR: run_e2e_tests '$INPUT_RUN_E2E_TESTS' is invalid. Allowed values: ${ALLOWED_VALUES[*]}" + # VALIDATION_FAILED=true + # else + # echo "βœ… run_e2e_tests: '$INPUT_RUN_E2E_TESTS' is valid" + # fi + # fi + + # Validate AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID (Azure Resource ID format) + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]]; then + if [[ ! "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/[rR]esource[gG]roups/[^/]+/providers/[mM]icrosoft\.[oO]perational[iI]nsights/workspaces/[^/]+$ ]]; then + echo "❌ ERROR: AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}" + echo " Got: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: Valid Resource ID format" + fi + fi + + # Validate AZURE_EXISTING_AI_PROJECT_RESOURCE_ID (Azure Resource ID format) + if [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + if [[ ! "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" =~ ^/subscriptions/[a-fA-F0-9-]+/resourceGroups/[^/]+/providers/(Microsoft\.MachineLearningServices/(workspaces|projects)/[^/]+|Microsoft\.CognitiveServices/accounts/[^/]+/projects/[^/]+)$ ]]; then + echo "❌ ERROR: AZURE_EXISTING_AI_PROJECT_RESOURCE_ID is invalid. Must be a valid Azure Resource ID format:" + echo " /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}" + echo " Got: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + VALIDATION_FAILED=true + else + echo "βœ… AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: Valid Resource ID format" + fi + fi + + # Validate docker_image_tag (Docker tag pattern) + if [[ -n "$INPUT_DOCKER_IMAGE_TAG" ]]; then + # Docker tags: lowercase and uppercase letters, digits, underscores, periods, and hyphens + # Cannot start with period or hyphen, max 128 characters + if [[ ! "$INPUT_DOCKER_IMAGE_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "❌ ERROR: docker_image_tag '$INPUT_DOCKER_IMAGE_TAG' is invalid. Must:" + echo " - Start with alphanumeric or underscore" + echo " - Contain only alphanumerics, underscores, periods, hyphens" + echo " - Be max 128 characters" + VALIDATION_FAILED=true + else + echo "βœ… docker_image_tag: '$INPUT_DOCKER_IMAGE_TAG' is valid" + fi + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + - name: Validate and Auto-Configure EXP + shell: bash + env: + INPUT_EXP: ${{ inputs.EXP }} + INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + run: | + echo "πŸ” Validating EXP configuration..." + + if [[ "$INPUT_EXP" != "true" ]]; then + if [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]] || [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]]; then + echo "πŸ”§ AUTO-ENABLING EXP: EXP parameter values were provided but EXP was not explicitly enabled." + echo "" + echo "You provided values for:" + [[ -n "$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID" ]] && echo " - Azure Log Analytics Workspace ID: '$INPUT_AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID'" + [[ -n "$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID" ]] && echo " - Azure AI Project Resource ID: '$INPUT_AZURE_EXISTING_AI_PROJECT_RESOURCE_ID'" + echo "" + echo "βœ… Automatically enabling EXP to use these values." + echo "EXP=true" >> $GITHUB_ENV + echo "πŸ“Œ EXP has been automatically enabled for this deployment." + fi + fi + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Login to Azure + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Run Quota Check + id: quota-check + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_REGIONS: ${{ vars.AZURE_REGIONS }} + GPT_MIN_CAPACITY: ${{ env.GPT_MIN_CAPACITY }} + run: | + + chmod +x infra/scripts/checkquota.sh + if ! infra/scripts/checkquota.sh; then + # If quota check fails due to insufficient quota, set the flag + if grep -q "No region with sufficient quota found" infra/scripts/checkquota.sh; then + echo "QUOTA_FAILED=true" >> $GITHUB_ENV + fi + exit 1 # Fail the pipeline if any other failure occurs + fi + + - name: Set Quota Failure Output + id: quota_failure_output + if: env.QUOTA_FAILED == 'true' + shell: bash + run: | + echo "QUOTA_FAILED=true" >> $GITHUB_OUTPUT + echo "Quota check failed - will notify via separate notification job" + + - name: Fail Pipeline if Quota Check Fails + if: env.QUOTA_FAILED == 'true' + shell: bash + run: exit 1 + + - name: Set Deployment Region + id: set_region + shell: bash + env: + INPUT_TRIGGER_TYPE: ${{ inputs.trigger_type }} + INPUT_AZURE_LOCATION: ${{ inputs.azure_location }} + run: | + echo "Selected Region from Quota Check: $VALID_REGION" + echo "AZURE_ENV_OPENAI_LOCATION=$VALID_REGION" >> $GITHUB_ENV + echo "AZURE_ENV_OPENAI_LOCATION=$VALID_REGION" >> $GITHUB_OUTPUT + + if [[ "$INPUT_TRIGGER_TYPE" == "workflow_dispatch" && -n "$INPUT_AZURE_LOCATION" ]]; then + USER_SELECTED_LOCATION="$INPUT_AZURE_LOCATION" + echo "Using user-selected Azure location: $USER_SELECTED_LOCATION" + echo "AZURE_LOCATION=$USER_SELECTED_LOCATION" >> $GITHUB_ENV + echo "AZURE_LOCATION=$USER_SELECTED_LOCATION" >> $GITHUB_OUTPUT + else + echo "Using location from quota check for automatic triggers: $VALID_REGION" + echo "AZURE_LOCATION=$VALID_REGION" >> $GITHUB_ENV + echo "AZURE_LOCATION=$VALID_REGION" >> $GITHUB_OUTPUT + fi + + - name: Generate Resource Group Name + id: generate_rg_name + shell: bash + env: + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.resource_group_name }} + run: | + # Check if a resource group name was provided as input + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + echo "Using provided Resource Group name: $INPUT_RESOURCE_GROUP_NAME" + echo "RESOURCE_GROUP_NAME=$INPUT_RESOURCE_GROUP_NAME" >> $GITHUB_ENV + else + echo "Generating a unique resource group name..." + ACCL_NAME="byocc" # Account name as specified + SHORT_UUID=$(uuidgen | cut -d'-' -f1) + UNIQUE_RG_NAME="arg-${ACCL_NAME}-${SHORT_UUID}" + echo "RESOURCE_GROUP_NAME=${UNIQUE_RG_NAME}" >> $GITHUB_ENV + echo "Generated RESOURCE_GROUP_NAME: ${UNIQUE_RG_NAME}" + fi + + - name: Install Bicep CLI + shell: bash + run: az bicep install + + - name: Check and Create Resource Group + id: check_create_rg + shell: bash + run: | + set -e + 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 || { 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." + fi + echo "RESOURCE_GROUP_NAME=$RESOURCE_GROUP_NAME" >> $GITHUB_OUTPUT + echo "RESOURCE_GROUP_NAME=$RESOURCE_GROUP_NAME" >> $GITHUB_ENV + + - name: Generate Unique Solution Prefix + id: generate_solution_prefix + shell: bash + run: | + set -e + COMMON_PART="psldg" + TIMESTAMP=$(date +%s) + UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 6) + UNIQUE_SOLUTION_PREFIX="${COMMON_PART}${UPDATED_TIMESTAMP}" + echo "SOLUTION_PREFIX=${UNIQUE_SOLUTION_PREFIX}" >> $GITHUB_ENV + echo "Generated SOLUTION_PREFIX: ${UNIQUE_SOLUTION_PREFIX}" + + - name: Determine Docker Image Tag + id: determine_image_tag + shell: bash + env: + INPUT_DOCKER_IMAGE_TAG: ${{ inputs.docker_image_tag }} + run: | + if [[ "${{ env.BUILD_DOCKER_IMAGE }}" == "true" ]]; then + if [[ -n "$INPUT_DOCKER_IMAGE_TAG" ]]; then + IMAGE_TAG="$INPUT_DOCKER_IMAGE_TAG" + echo "πŸ”— Using Docker image tag from build job: $IMAGE_TAG" + else + echo "❌ Docker build job failed or was skipped, but BUILD_DOCKER_IMAGE is true" + exit 1 + fi + else + echo "🏷️ Using existing Docker image based on branch..." + BRANCH_NAME="${{ env.BRANCH_NAME }}" + echo "Current branch: $BRANCH_NAME" + + if [[ "$BRANCH_NAME" == "main" ]]; then + IMAGE_TAG="latest" + elif [[ "$BRANCH_NAME" == "dev" ]]; then + IMAGE_TAG="dev" + elif [[ "$BRANCH_NAME" == "demo" ]]; then + IMAGE_TAG="demo" + else + IMAGE_TAG="latest" + echo "Using default for branch '$BRANCH_NAME' - image tag: latest" + fi + echo "Using existing Docker image tag: $IMAGE_TAG" + fi + + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Generate Unique Environment Name + id: generate_env_name + shell: bash + run: | + COMMON_PART="pslc" + TIMESTAMP=$(date +%s) + UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 6) + UNIQUE_ENV_NAME="${COMMON_PART}${UPDATED_TIMESTAMP}" + echo "ENV_NAME=${UNIQUE_ENV_NAME}" >> $GITHUB_ENV + echo "Generated Environment Name: ${UNIQUE_ENV_NAME}" + echo "ENV_NAME=${UNIQUE_ENV_NAME}" >> $GITHUB_OUTPUT + + - name: Display Workflow Configuration to GitHub Summary + shell: bash + env: + INPUT_TRIGGER_TYPE: ${{ inputs.trigger_type }} + INPUT_AZURE_LOCATION: ${{ inputs.azure_location }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.resource_group_name }} + run: | + echo "## πŸ“‹ Workflow Configuration Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Configuration | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---------------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | \`${{ env.BRANCH_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **WAF Enabled** | ${{ env.WAF_ENABLED == 'true' && 'βœ… Yes' || '❌ No' }} |" >> $GITHUB_STEP_SUMMARY + echo "| **EXP Enabled** | ${{ env.EXP == 'true' && 'βœ… Yes' || '❌ No' }} |" >> $GITHUB_STEP_SUMMARY + # echo "| **Run E2E Tests** | \`${{ env.RUN_E2E_TESTS }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Cleanup Resources** | ${{ env.CLEANUP_RESOURCES == 'true' && 'βœ… Yes' || '❌ No' }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Docker Image** | ${{ env.BUILD_DOCKER_IMAGE == 'true' && 'βœ… Yes' || '❌ No' }} |" >> $GITHUB_STEP_SUMMARY + + if [[ "$INPUT_TRIGGER_TYPE" == "workflow_dispatch" && -n "$INPUT_AZURE_LOCATION" ]]; then + echo "| **Azure Location** | \`$INPUT_AZURE_LOCATION\` (User Selected) |" >> $GITHUB_STEP_SUMMARY + fi + + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + echo "| **Resource Group** | \`$INPUT_RESOURCE_GROUP_NAME\` (Pre-specified) |" >> $GITHUB_STEP_SUMMARY + else + echo "| **Resource Group** | \`${{ env.RESOURCE_GROUP_NAME }}\` (Auto-generated) |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "$INPUT_TRIGGER_TYPE" != "workflow_dispatch" ]]; then + echo "ℹ️ **Note:** Automatic Trigger - Using Non-WAF + Non-EXP configuration" >> $GITHUB_STEP_SUMMARY + else + echo "ℹ️ **Note:** Manual Trigger - Using user-specified configuration" >> $GITHUB_STEP_SUMMARY + fi + + deploy-linux: + name: Deploy on Linux + needs: azure-setup + if: inputs.runner_os == 'ubuntu-latest' && !cancelled() && needs.azure-setup.result == 'success' + uses: ./.github/workflows/job-deploy-linux.yml + with: + ENV_NAME: ${{ needs.azure-setup.outputs.ENV_NAME }} + AZURE_ENV_OPENAI_LOCATION: ${{ needs.azure-setup.outputs.AZURE_ENV_OPENAI_LOCATION }} + AZURE_LOCATION: ${{ needs.azure-setup.outputs.AZURE_LOCATION }} + RESOURCE_GROUP_NAME: ${{ needs.azure-setup.outputs.RESOURCE_GROUP_NAME }} + IMAGE_TAG: ${{ needs.azure-setup.outputs.IMAGE_TAG }} + BUILD_DOCKER_IMAGE: ${{ inputs.build_docker_image || 'false' }} + EXP: ${{ inputs.EXP || 'false' }} + WAF_ENABLED: ${{ inputs.waf_enabled == true && 'true' || 'false' }} + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + secrets: inherit + + deploy-windows: + name: Deploy on Windows + needs: azure-setup + if: inputs.runner_os == 'windows-latest' && !cancelled() && needs.azure-setup.result == 'success' + uses: ./.github/workflows/job-deploy-windows.yml + with: + ENV_NAME: ${{ needs.azure-setup.outputs.ENV_NAME }} + AZURE_ENV_OPENAI_LOCATION: ${{ needs.azure-setup.outputs.AZURE_ENV_OPENAI_LOCATION }} + AZURE_LOCATION: ${{ needs.azure-setup.outputs.AZURE_LOCATION }} + RESOURCE_GROUP_NAME: ${{ needs.azure-setup.outputs.RESOURCE_GROUP_NAME }} + IMAGE_TAG: ${{ needs.azure-setup.outputs.IMAGE_TAG }} + BUILD_DOCKER_IMAGE: ${{ inputs.build_docker_image || 'false' }} + EXP: ${{ inputs.EXP || 'false' }} + WAF_ENABLED: ${{ inputs.waf_enabled == true && 'true' || 'false' }} + AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID: ${{ inputs.AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID }} + AZURE_EXISTING_AI_PROJECT_RESOURCE_ID: ${{ inputs.AZURE_EXISTING_AI_PROJECT_RESOURCE_ID }} + secrets: inherit diff --git a/.github/workflows/job-docker-build.yml b/.github/workflows/job-docker-build.yml new file mode 100644 index 00000000..0cb58768 --- /dev/null +++ b/.github/workflows/job-docker-build.yml @@ -0,0 +1,135 @@ +name: Docker Build Job + +on: + workflow_call: + inputs: + trigger_type: + description: 'Trigger type (workflow_dispatch, pull_request, schedule)' + required: true + type: string + build_docker_image: + description: 'Build And Push Docker Image (Optional)' + required: false + default: false + type: boolean + outputs: + IMAGE_TAG: + description: "Generated Docker Image Tag" + value: ${{ jobs.docker-build.outputs.IMAGE_TAG }} + +env: + BRANCH_NAME: ${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + +jobs: + docker-build: + if: inputs.trigger_type == 'workflow_dispatch' && inputs.build_docker_image == true + runs-on: ubuntu-latest + outputs: + IMAGE_TAG: ${{ steps.generate_docker_tag.outputs.IMAGE_TAG }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Determine Tag Name Based on Branch + id: determine_tag + run: | + BRANCH_NAME="${{ github.ref_name }}" + if [[ "$BRANCH_NAME" == "main" ]]; then + echo "tagname=latest_waf" >> $GITHUB_OUTPUT + elif [[ "$BRANCH_NAME" == "dev" ]]; then + echo "tagname=dev" >> $GITHUB_OUTPUT + elif [[ "$BRANCH_NAME" == "demo" ]]; then + echo "tagname=demo" >> $GITHUB_OUTPUT + elif [[ "$BRANCH_NAME" == "dependabotchanges" ]]; then + echo "tagname=dependabotchanges" >> $GITHUB_OUTPUT + elif [[ "$BRANCH_NAME" == "PSL-US-27776" ]]; then + echo "tagname=prerelease" >> $GITHUB_OUTPUT + else + # Clean branch name for use as Docker tag + CLEAN_BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') + echo "tagname=$CLEAN_BRANCH_NAME" >> $GITHUB_OUTPUT + fi + - name: Generate Unique Docker Image Tag + id: generate_docker_tag + shell: bash + run: | + echo "πŸ”¨ Building new Docker image - generating unique tag..." + BRANCH_NAME="${{ github.head_ref || github.ref_name }}" + CLEAN_BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') + UNIQUE_TAG="${CLEAN_BRANCH_NAME}-${{ steps.date.outputs.date }}-${{ github.run_number }}" + echo "IMAGE_TAG=$UNIQUE_TAG" >> $GITHUB_ENV + echo "IMAGE_TAG=$UNIQUE_TAG" >> $GITHUB_OUTPUT + echo "Generated unique Docker tag: $UNIQUE_TAG" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Azure Container Registry + uses: azure/docker-login@v2 + with: + login-server: ${{ secrets.ACR_LOGIN_SERVER }} + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + + - name: Output ACR Login Server + run: | + echo "ACR Login Server: ${{ secrets.ACR_LOGIN_SERVER }}" + - name: Build and Push Docker Image for WebApp + uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_SUMMARY: false + with: + context: ./src/App + file: ./src/App/Dockerfile + push: true + tags: | + ${{ secrets.ACR_LOGIN_SERVER || 'acrlogin.azurecr.io' }}/frontend:${{ steps.determine_tag.outputs.tagname }} + ${{ secrets.ACR_LOGIN_SERVER || 'acrlogin.azurecr.io' }}/frontend:${{ steps.generate_docker_tag.outputs.IMAGE_TAG }} + - name: Build and Push Docker Image for API + uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_SUMMARY: false + with: + context: ./src/api + file: ./src/api/Dockerfile + push: true + tags: | + ${{ secrets.ACR_LOGIN_SERVER || 'acrlogin.azurecr.io' }}/backend:${{ steps.determine_tag.outputs.tagname }} + ${{ secrets.ACR_LOGIN_SERVER || 'acrlogin.azurecr.io' }}/backend:${{ steps.generate_docker_tag.outputs.IMAGE_TAG }} + - name: Verify Docker Image Build + shell: bash + run: | + echo "βœ… Docker image successfully built and pushed" + echo "Image tag: ${{ steps.generate_docker_tag.outputs.IMAGE_TAG }}" + + - name: Generate Docker Build Summary + if: always() + shell: bash + run: | + ACR_NAME=$(echo "${{ secrets.ACR_LOGIN_SERVER }}") + echo "## 🐳 Docker Build Job Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| **Job Status** | ${{ job.status == 'success' && 'βœ… Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Image Tag** | \`${{ steps.generate_docker_tag.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Branch Tag** | \`${{ steps.determine_tag.outputs.tagname }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | ${{ env.BRANCH_NAME }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ job.status }}" == "success" ]]; then + echo "### βœ… Build Details" >> $GITHUB_STEP_SUMMARY + echo "Successfully built and pushed two Docker images to ACR:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Built Images:**" >> $GITHUB_STEP_SUMMARY + echo "- \`${ACR_NAME}/frontend:${{ steps.determine_tag.outputs.tagname }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${ACR_NAME}/frontend:${{ steps.generate_docker_tag.outputs.IMAGE_TAG }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${ACR_NAME}/backend:${{ steps.determine_tag.outputs.tagname }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${ACR_NAME}/backend:${{ steps.generate_docker_tag.outputs.IMAGE_TAG }}\`" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Build Failed" >> $GITHUB_STEP_SUMMARY + echo "- Docker build process encountered an error" >> $GITHUB_STEP_SUMMARY + echo "- Check the docker-build job for detailed error information" >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.github/workflows/job-send-notification.yml b/.github/workflows/job-send-notification.yml new file mode 100644 index 00000000..5821f970 --- /dev/null +++ b/.github/workflows/job-send-notification.yml @@ -0,0 +1,436 @@ +name: Send Notification Job +permissions: + contents: read + actions: read +on: + workflow_call: + inputs: + trigger_type: + description: 'Trigger type (workflow_dispatch, pull_request, schedule)' + required: true + type: string + waf_enabled: + description: 'Enable WAF' + required: false + default: false + type: boolean + EXP: + description: 'Enable EXP' + required: false + default: false + type: boolean + run_e2e_tests: + description: 'Run End-to-End Tests' + required: false + default: 'GoldenPath-Testing' + type: string + existing_webapp_url: + description: 'Existing Container WebApp URL (Skips Deployment)' + required: false + default: '' + type: string + deploy_result: + description: 'Deploy job result (success, failure, skipped)' + required: true + type: string + e2e_test_result: + description: 'E2E test job result (success, failure, skipped)' + required: false + default: '' + type: string + CONTAINER_WEB_APPURL: + description: 'Container Web App URL' + required: false + default: '' + type: string + RESOURCE_GROUP_NAME: + description: 'Resource Group Name' + required: false + default: '' + type: string + QUOTA_FAILED: + description: 'Quota Check Failed Flag' + required: false + default: 'false' + type: string + TEST_SUCCESS: + description: 'Test Success Flag' + required: false + default: '' + type: string + TEST_REPORT_URL: + description: 'Test Report URL' + required: false + default: '' + type: string + +env: + GPT_MIN_CAPACITY: 100 + BRANCH_NAME: ${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + WAF_ENABLED: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.waf_enabled || false) || false }} + EXP: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.EXP || false) || false }} + RUN_E2E_TESTS: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.run_e2e_tests || 'GoldenPath-Testing') || 'GoldenPath-Testing' }} + +jobs: + send-notification: + runs-on: ubuntu-latest + continue-on-error: true + env: + accelerator_name: "customer-chatbot-solution-accelerator" + steps: + - name: Validate Workflow Input Parameters + shell: bash + env: + INPUT_TRIGGER_TYPE: ${{ inputs.trigger_type }} + INPUT_WAF_ENABLED: ${{ inputs.waf_enabled }} + INPUT_EXP: ${{ inputs.EXP }} + # INPUT_RUN_E2E_TESTS: ${{ inputs.run_e2e_tests }} + INPUT_EXISTING_WEBAPP_URL: ${{ inputs.existing_webapp_url }} + INPUT_DEPLOY_RESULT: ${{ inputs.deploy_result }} + # INPUT_E2E_TEST_RESULT: ${{ inputs.e2e_test_result }} + INPUT_CONTAINER_WEB_APPURL: ${{ inputs.CONTAINER_WEB_APPURL }} + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + INPUT_QUOTA_FAILED: ${{ inputs.QUOTA_FAILED }} + INPUT_TEST_SUCCESS: ${{ inputs.TEST_SUCCESS }} + INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }} + run: | + echo "πŸ” Validating workflow input parameters..." + VALIDATION_FAILED=false + + # Validate trigger_type (required - alphanumeric with underscores) + if [[ -z "$INPUT_TRIGGER_TYPE" ]]; then + echo "❌ ERROR: trigger_type is required but was not provided" + VALIDATION_FAILED=true + elif [[ ! "$INPUT_TRIGGER_TYPE" =~ ^[a-zA-Z0-9_]+$ ]]; then + echo "❌ ERROR: trigger_type '$INPUT_TRIGGER_TYPE' is invalid. Must contain only alphanumeric characters and underscores" + VALIDATION_FAILED=true + else + echo "βœ… trigger_type: '$INPUT_TRIGGER_TYPE' is valid" + fi + + # Validate waf_enabled (boolean) + if [[ "$INPUT_WAF_ENABLED" != "true" && "$INPUT_WAF_ENABLED" != "false" ]]; then + echo "❌ ERROR: waf_enabled must be 'true' or 'false', got: '$INPUT_WAF_ENABLED'" + VALIDATION_FAILED=true + else + echo "βœ… waf_enabled: '$INPUT_WAF_ENABLED' is valid" + fi + + # Validate EXP (boolean) + if [[ "$INPUT_EXP" != "true" && "$INPUT_EXP" != "false" ]]; then + echo "❌ ERROR: EXP must be 'true' or 'false', got: '$INPUT_EXP'" + VALIDATION_FAILED=true + else + echo "βœ… EXP: '$INPUT_EXP' is valid" + fi + + # # Validate run_e2e_tests (specific allowed values) + # if [[ -n "$INPUT_RUN_E2E_TESTS" ]]; then + # ALLOWED_VALUES=("None" "GoldenPath-Testing" "Smoke-Testing") + # if [[ ! " ${ALLOWED_VALUES[@]} " =~ " ${INPUT_RUN_E2E_TESTS} " ]]; then + # echo "❌ ERROR: run_e2e_tests '$INPUT_RUN_E2E_TESTS' is invalid. Allowed values: ${ALLOWED_VALUES[*]}" + # VALIDATION_FAILED=true + # else + # echo "βœ… run_e2e_tests: '$INPUT_RUN_E2E_TESTS' is valid" + # fi + # fi + + # Validate existing_webapp_url (must start with https if provided) + if [[ -n "$INPUT_EXISTING_WEBAPP_URL" ]]; then + if [[ ! "$INPUT_EXISTING_WEBAPP_URL" =~ ^https:// ]]; then + echo "❌ ERROR: existing_webapp_url must start with 'https://', got: '$INPUT_EXISTING_WEBAPP_URL'" + VALIDATION_FAILED=true + else + echo "βœ… existing_webapp_url: '$INPUT_EXISTING_WEBAPP_URL' is valid" + fi + fi + + # Validate deploy_result (required, must be specific values) + if [[ -z "$INPUT_DEPLOY_RESULT" ]]; then + echo "❌ ERROR: deploy_result is required but not provided" + VALIDATION_FAILED=true + else + ALLOWED_DEPLOY_RESULTS=("success" "failure" "skipped") + if [[ ! " ${ALLOWED_DEPLOY_RESULTS[@]} " =~ " ${INPUT_DEPLOY_RESULT} " ]]; then + echo "❌ ERROR: deploy_result '$INPUT_DEPLOY_RESULT' is invalid. Allowed values: ${ALLOWED_DEPLOY_RESULTS[*]}" + VALIDATION_FAILED=true + else + echo "βœ… deploy_result: '$INPUT_DEPLOY_RESULT' is valid" + fi + fi + + # # Validate e2e_test_result (required, must be specific values) + # if [[ -z "$INPUT_E2E_TEST_RESULT" ]]; then + # echo "❌ ERROR: e2e_test_result is required but not provided" + # VALIDATION_FAILED=true + # else + # ALLOWED_TEST_RESULTS=("success" "failure" "skipped") + # if [[ ! " ${ALLOWED_TEST_RESULTS[@]} " =~ " ${INPUT_E2E_TEST_RESULT} " ]]; then + # echo "❌ ERROR: e2e_test_result '$INPUT_E2E_TEST_RESULT' is invalid. Allowed values: ${ALLOWED_TEST_RESULTS[*]}" + # VALIDATION_FAILED=true + # else + # echo "βœ… e2e_test_result: '$INPUT_E2E_TEST_RESULT' is valid" + # fi + # fi + + # Validate CONTAINER_WEB_APPURL (must start with https if provided) + if [[ -n "$INPUT_CONTAINER_WEB_APPURL" ]]; then + if [[ ! "$INPUT_CONTAINER_WEB_APPURL" =~ ^https:// ]]; then + echo "❌ ERROR: CONTAINER_WEB_APPURL must start with 'https://', got: '$INPUT_CONTAINER_WEB_APPURL'" + VALIDATION_FAILED=true + else + echo "βœ… CONTAINER_WEB_APPURL: '$INPUT_CONTAINER_WEB_APPURL' is valid" + fi + fi + + # Validate RESOURCE_GROUP_NAME (Azure resource group naming convention if provided) + if [[ -n "$INPUT_RESOURCE_GROUP_NAME" ]]; then + if [[ ! "$INPUT_RESOURCE_GROUP_NAME" =~ ^[a-zA-Z0-9._\(\)-]+$ ]] || [[ "$INPUT_RESOURCE_GROUP_NAME" =~ \.$ ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' is invalid. Must contain only alphanumerics, periods, underscores, hyphens, and parentheses. Cannot end with period." + VALIDATION_FAILED=true + elif [[ ${#INPUT_RESOURCE_GROUP_NAME} -gt 90 ]]; then + echo "❌ ERROR: RESOURCE_GROUP_NAME '$INPUT_RESOURCE_GROUP_NAME' exceeds 90 characters" + VALIDATION_FAILED=true + else + echo "βœ… RESOURCE_GROUP_NAME: '$INPUT_RESOURCE_GROUP_NAME' is valid" + fi + fi + + # Validate QUOTA_FAILED (must be 'true' or 'false' or empty - defaults to 'false') + if [[ -n "$INPUT_QUOTA_FAILED" && "$INPUT_QUOTA_FAILED" != "true" && "$INPUT_QUOTA_FAILED" != "false" ]]; then + echo "❌ ERROR: QUOTA_FAILED must be 'true', 'false', or empty, got: '$INPUT_QUOTA_FAILED'" + VALIDATION_FAILED=true + else + echo "βœ… QUOTA_FAILED: '${INPUT_QUOTA_FAILED:-false}' is valid" + fi + + # Validate TEST_SUCCESS (must be 'true' or 'false' or empty) + if [[ -n "$INPUT_TEST_SUCCESS" ]]; then + if [[ "$INPUT_TEST_SUCCESS" != "true" && "$INPUT_TEST_SUCCESS" != "false" ]]; then + echo "❌ ERROR: TEST_SUCCESS must be 'true', 'false', or empty, got: '$INPUT_TEST_SUCCESS'" + VALIDATION_FAILED=true + else + echo "βœ… TEST_SUCCESS: '$INPUT_TEST_SUCCESS' is valid" + fi + fi + + # Validate TEST_REPORT_URL (must start with https if provided) + if [[ -n "$INPUT_TEST_REPORT_URL" ]]; then + if [[ ! "$INPUT_TEST_REPORT_URL" =~ ^https:// ]]; then + echo "❌ ERROR: TEST_REPORT_URL must start with 'https://', got: '$INPUT_TEST_REPORT_URL'" + VALIDATION_FAILED=true + else + echo "βœ… TEST_REPORT_URL: '$INPUT_TEST_REPORT_URL' is valid" + fi + fi + + # Fail workflow if any validation failed + if [[ "$VALIDATION_FAILED" == "true" ]]; then + echo "" + echo "❌ Parameter validation failed. Please correct the errors above and try again." + exit 1 + fi + + echo "" + echo "βœ… All input parameters validated successfully!" + + # - name: Determine Test Suite Display Name + # id: test_suite + # shell: bash + # env: + # RUN_E2E_TESTS: ${{ env.RUN_E2E_TESTS }} + # run: | + # if [ "$RUN_E2E_TESTS" = "GoldenPath-Testing" ]; then + # TEST_SUITE_NAME="Golden Path Testing" + # elif [ "$RUN_E2E_TESTS" = "Smoke-Testing" ]; then + # TEST_SUITE_NAME="Smoke Testing" + # elif [ "$RUN_E2E_TESTS" = "None" ]; then + # TEST_SUITE_NAME="None" + # else + # TEST_SUITE_NAME="$RUN_E2E_TESTS" + # fi + # echo "TEST_SUITE_NAME=$TEST_SUITE_NAME" >> $GITHUB_OUTPUT + # echo "Test Suite: $TEST_SUITE_NAME" + + - name: Send Quota Failure Notification + if: inputs.deploy_result == 'failure' && inputs.QUOTA_FAILED == 'true' + shell: bash + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + ACCELERATOR_NAME: ${{ env.accelerator_name }} + LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} + 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 in the requested regions.

Issue Details:
β€’ Quota check failed for GPT model
β€’ Required GPT Capacity: ${{ env.GPT_MIN_CAPACITY }}
β€’ Checked Regions: ${{ vars.AZURE_REGIONS }}

Run URL: ${RUN_URL}

Please resolve the quota issue and retry the deployment.

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Failed (Insufficient Quota)" + } + EOF + ) + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send quota failure notification" + + - name: Send Deployment Failure Notification + if: inputs.deploy_result == 'failure' && inputs.QUOTA_FAILED != 'true' + shell: bash + env: + INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} + ACCELERATOR_NAME: ${{ env.accelerator_name }} + LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} + WAF_ENABLED: ${{ env.WAF_ENABLED }} + EXP: ${{ env.EXP }} + run: | + 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 process has encountered an issue and has failed to complete successfully.

Deployment Details:
β€’ Resource Group: ${RESOURCE_GROUP}
β€’ WAF Enabled: ${WAF_ENABLED}
β€’ EXP Enabled: ${EXP}

Run URL: ${RUN_URL}

Please investigate the deployment failure at your earliest convenience.

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Failed" + } + EOF + ) + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send deployment failure notification" + + - name: Send Success Notification + if: inputs.deploy_result == 'success' && (inputs.e2e_test_result == 'skipped' || inputs.TEST_SUCCESS == 'true') + shell: bash + env: + 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 }} + # 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 }} + EXP: ${{ env.EXP }} + WAF_ENABLED: ${{ env.WAF_ENABLED }} + + run: | + 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" + # TEST_SUITE_NAME="${{ steps.test_suite.outputs.TEST_SUITE_NAME }}" + + 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.

Deployment Details:
β€’ Resource Group: ${RESOURCE_GROUP}
β€’ Web App URL: ${WEBAPP_URL}
β€’ E2E Tests: Skipped (as configured)

Configuration:
β€’ WAF Enabled: ${WAF_ENABLED}
β€’ EXP Enabled: ${EXP}

Run URL: ${RUN_URL}

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Deployment Success" + } + EOF + ) + else + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the ${ACCELERATOR_NAME} deployment and testing process has completed successfully.

Deployment Details:
β€’ Resource Group: ${RESOURCE_GROUP}
β€’ Web App URL: ${WEBAPP_URL}
β€’ E2E Tests: Passed βœ…
β€’ Test Suite: ${TEST_SUITE_NAME}
β€’ Test Report: View Report

Configuration:
β€’ WAF Enabled: ${WAF_ENABLED}
β€’ EXP Enabled: ${EXP}

Run URL: ${RUN_URL}

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Test Automation - Success" + } + EOF + ) + fi + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send success notification" + + - name: Send Test Failure Notification + if: inputs.deploy_result == 'success' + + # 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 }} + ACCELERATOR_NAME: ${{ env.accelerator_name }} + LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} + # 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" + # TEST_SUITE_NAME="${{ steps.test_suite.outputs.TEST_SUITE_NAME }}" + + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that ${ACCELERATOR_NAME} accelerator test automation process has encountered issues and failed to complete successfully.

Deployment Details:
β€’ Resource Group: ${RESOURCE_GROUP}
β€’ Web App URL: ${WEBAPP_URL}
β€’ Deployment Status: βœ… Success
β€’ E2E Tests: ❌ Failed
β€’ Test Suite: ${TEST_SUITE_NAME}

Test Details:
β€’ Test Report: View Report

Run URL: ${RUN_URL}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Test Automation - Failed" + } + EOF + ) + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send test failure notification" + + - name: Send Existing URL Success Notification + if: inputs.deploy_result == 'skipped' && inputs.existing_webapp_url != '' + # if: inputs.deploy_result == 'skipped' && inputs.existing_webapp_url != '' && inputs.e2e_test_result == 'success' && (inputs.TEST_SUCCESS == 'true' || inputs.TEST_SUCCESS == '') + + shell: bash + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + INPUT_EXISTING_WEBAPP_URL: ${{ inputs.existing_webapp_url }} + INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }} + ACCELERATOR_NAME: ${{ env.accelerator_name }} + LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} + 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" + # TEST_SUITE_NAME="${{ steps.test_suite.outputs.TEST_SUITE_NAME }}" + + EMAIL_BODY=$(cat <Dear Team,

The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and testing process has completed successfully.

Test Results:
β€’ Status: βœ… Passed
β€’ Test Suite: ${TEST_SUITE_NAME}
${TEST_REPORT_URL:+β€’ Test Report: View Report}
β€’ Target URL: ${EXISTING_URL}

Deployment: Skipped

Run URL: ${RUN_URL}

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Test Automation Passed " + } + EOF + ) + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send existing URL success notification" + + - name: Send Existing URL Test Failure Notification + if: inputs.deploy_result == 'skipped' && inputs.existing_webapp_url != '' && inputs.e2e_test_result == 'failure' + shell: bash + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + INPUT_EXISTING_WEBAPP_URL: ${{ inputs.existing_webapp_url }} + INPUT_TEST_REPORT_URL: ${{ inputs.TEST_REPORT_URL }} + ACCELERATOR_NAME: ${{ env.accelerator_name }} + LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} + 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" + TEST_SUITE_NAME="${{ steps.test_suite.outputs.TEST_SUITE_NAME }}" + + EMAIL_BODY=$(cat <Dear Team,

The ${ACCELERATOR_NAME} pipeline executed against the specified Target URL and the test automation has encountered issues and failed to complete successfully.

Failure Details:
β€’ Target URL: ${EXISTING_URL}
${TEST_REPORT_URL:+β€’ Test Report: View Report}
β€’ Test Suite: ${TEST_SUITE_NAME}
β€’ Deployment: Skipped

Run URL: ${RUN_URL}

Best regards,
Your Automation Team

", + "subject": "${ACCELERATOR_NAME} Pipeline - Test Automation Failed " + } + EOF + ) + + curl -X POST "${LOGICAPP_URL}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send existing URL test failure notification" diff --git a/.github/workflows/test-automation-v2.yml b/.github/workflows/test-automation-v2.yml new file mode 100644 index 00000000..cdacf56e --- /dev/null +++ b/.github/workflows/test-automation-v2.yml @@ -0,0 +1,196 @@ +name: Test Automation BYOCC - v2 + +permissions: + contents: read + actions: read +on: + workflow_call: + inputs: + TEST_URL: + required: true + type: string + description: "Web URL for BYOCC" + TEST_SUITE: + required: false + type: string + default: "GoldenPath-Testing" + description: "Test suite to run: 'Smoke-Testing', 'GoldenPath-Testing' " + outputs: + TEST_SUCCESS: + description: "Whether tests passed" + value: ${{ jobs.test.outputs.TEST_SUCCESS }} + TEST_REPORT_URL: + description: "URL to test report artifact" + value: ${{ jobs.test.outputs.TEST_REPORT_URL }} + +env: + url: ${{ inputs.TEST_URL }} + accelerator_name: "Code Modernization" + test_suite: ${{ inputs.TEST_SUITE }} + +jobs: + test: + runs-on: ubuntu-latest + outputs: + TEST_SUCCESS: ${{ steps.test1.outcome == 'success' || steps.test2.outcome == 'success' || steps.test3.outcome == 'success' }} + TEST_REPORT_URL: ${{ steps.upload_report.outputs.artifact-url }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r tests/e2e-test/requirements.txt + + - name: Ensure browsers are installed + run: python -m playwright install --with-deps chromium + + - name: Validate URL + run: | + if [ -z "${{ env.url }}" ]; then + echo "ERROR: No URL provided for testing" + exit 1 + fi + echo "Testing URL: ${{ env.url }}" + echo "Test Suite: ${{ env.test_suite }}" + + + - name: Wait for Application to be Ready + run: | + echo "Waiting for application to be ready at ${{ env.url }} " + max_attempts=10 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt: Checking if application is ready..." + if curl -f -s "${{ env.url }}" > /dev/null; then + echo "Application is ready!" + break + + fi + + if [ $attempt -eq $max_attempts ]; then + echo "Application is not ready after $max_attempts attempts" + exit 1 + fi + + echo "Application not ready, waiting 30 seconds..." + sleep 30 + attempt=$((attempt + 1)) + done + + - name: Run tests(1) + id: test1 + run: | + if [ "${{ env.test_suite }}" == "GoldenPath-Testing" ]; then + xvfb-run pytest -m gp --html=report/report.html --self-contained-html + else + xvfb-run pytest --html=report/report.html --self-contained-html + fi + working-directory: tests/e2e-test + continue-on-error: true + + - name: Sleep for 30 seconds + if: ${{ steps.test1.outcome == 'failure' }} + run: sleep 30s + shell: bash + + - name: Run tests(2) + id: test2 + if: ${{ steps.test1.outcome == 'failure' }} + run: | + if [ "${{ env.test_suite }}" == "GoldenPath-Testing" ]; then + xvfb-run pytest -m gp --html=report/report.html --self-contained-html + else + xvfb-run pytest --html=report/report.html --self-contained-html + fi + working-directory: tests/e2e-test + continue-on-error: true + + - name: Sleep for 60 seconds + if: ${{ steps.test2.outcome == 'failure' }} + run: sleep 60s + shell: bash + + - name: Run tests(3) + id: test3 + if: ${{ steps.test2.outcome == 'failure' }} + run: | + if [ "${{ env.test_suite }}" == "GoldenPath-Testing" ]; then + xvfb-run pytest -m gp --html=report/report.html --self-contained-html + else + xvfb-run pytest --html=report/report.html --self-contained-html + fi + working-directory: tests/e2e-test + + - name: Upload test report + id: upload_report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: test-report + path: | + tests/e2e-test/report/* + tests/e2e-test/screenshots/* + + - name: Generate E2E Test Summary + if: always() + run: | + # Determine test suite type for title + if [ "${{ env.test_suite }}" == "GoldenPath-Testing" ]; then + echo "## πŸ§ͺ E2E Test Job Summary : Golden Path Testing" >> $GITHUB_STEP_SUMMARY + else + echo "## πŸ§ͺ E2E Test Job Summary : Smoke Testing" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + + # Determine overall test result + OVERALL_SUCCESS="${{ steps.test1.outcome == 'success' || steps.test2.outcome == 'success' || steps.test3.outcome == 'success' }}" + if [[ "$OVERALL_SUCCESS" == "true" ]]; then + echo "| **Job Status** | βœ… Success |" >> $GITHUB_STEP_SUMMARY + else + echo "| **Job Status** | ❌ Failed |" >> $GITHUB_STEP_SUMMARY + fi + + echo "| **Target URL** | [${{ env.url }}](${{ env.url }}) |" >> $GITHUB_STEP_SUMMARY + echo "| **Test Suite** | \`${{ env.test_suite }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Test Report** | [Download Artifact](${{ steps.upload_report.outputs.artifact-url }}) |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### πŸ“‹ Test Execution Details" >> $GITHUB_STEP_SUMMARY + echo "| Attempt | Status | Notes |" >> $GITHUB_STEP_SUMMARY + echo "|---------|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Test Run 1** | ${{ steps.test1.outcome == 'success' && 'βœ… Passed' || '❌ Failed' }} | Initial test execution |" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.test1.outcome }}" == "failure" ]]; then + echo "| **Test Run 2** | ${{ steps.test2.outcome == 'success' && 'βœ… Passed' || steps.test2.outcome == 'failure' && '❌ Failed' || '⏸️ Skipped' }} | Retry after 30s delay |" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "${{ steps.test2.outcome }}" == "failure" ]]; then + echo "| **Test Run 3** | ${{ steps.test3.outcome == 'success' && 'βœ… Passed' || steps.test3.outcome == 'failure' && '❌ Failed' || '⏸️ Skipped' }} | Final retry after 60s delay |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "$OVERALL_SUCCESS" == "true" ]]; then + echo "### βœ… Test Results" >> $GITHUB_STEP_SUMMARY + echo "- End-to-end tests completed successfully" >> $GITHUB_STEP_SUMMARY + echo "- Application is functioning as expected" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Test Results" >> $GITHUB_STEP_SUMMARY + echo "- All test attempts failed" >> $GITHUB_STEP_SUMMARY + echo "- Check the e2e-test/test job for detailed error information" >> $GITHUB_STEP_SUMMARY + fi diff --git a/README.md b/README.md index 43797973..a127ce7c 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ Follow the quick deploy steps on the deployment guide to deploy this solution to [Click here to launch the deployment guide](./documents/DeploymentGuide.md) -| [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/customer-chatbot-solution-accelerator) | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/customer-chatbot-solution-accelerator) | -|---|---| +| [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/customer-chatbot-solution-accelerator) | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/customer-chatbot-solution-accelerator) | [![Open in Visual Studio Code Web](https://img.shields.io/static/v1?style=for-the-badge&label=Visual%20Studio%20Code%20(Web)&message=Open&color=blue&logo=visualstudiocode&logoColor=white)](https://vscode.dev/azure/?vscode-azure-exp=foundry&agentPayload=eyJiYXNlVXJsIjogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9taWNyb3NvZnQvY3VzdG9tZXItY2hhdGJvdC1zb2x1dGlvbi1hY2NlbGVyYXRvci9yZWZzL2hlYWRzL21haW4vaW5mcmEvdnNjb2RlX3dlYiIsICJpbmRleFVybCI6ICIvaW5kZXguanNvbiIsICJ2YXJpYWJsZXMiOiB7ImFnZW50SWQiOiAiIiwgImNvbm5lY3Rpb25TdHJpbmciOiAiIiwgInRocmVhZElkIjogIiIsICJ1c2VyTWVzc2FnZSI6ICIiLCAicGxheWdyb3VuZE5hbWUiOiAiIiwgImxvY2F0aW9uIjogIiIsICJzdWJzY3JpcHRpb25JZCI6ICIiLCAicmVzb3VyY2VJZCI6ICIiLCAicHJvamVjdFJlc291cmNlSWQiOiAiIiwgImVuZHBvaW50IjogIiJ9LCAiY29kZVJvdXRlIjogWyJhaS1wcm9qZWN0cy1zZGsiLCAicHl0aG9uIiwgImRlZmF1bHQtYXp1cmUtYXV0aCIsICJjb25uZWN0aW9uU3RyaW5nIl19) +|---|---|---| > ⚠️ **Important: Check Azure OpenAI Quota Availability** > To ensure sufficient quota is available in your subscription, please follow [quota check instructions guide](./documents/QuotaCheck.md) before you deploy the solution. @@ -166,6 +166,19 @@ Check out similar solution accelerators | Solution Accelerator | Description | |---|---| | [Agentic Applications for Unified Data Foundation](https://github.com/microsoft/agentic-applications-for-unified-data-foundation-solution-accelerator) | Empowers organizations to make faster, smarter decisions at scale by leveraging agentic AI solutions built on a unified data foundation with Microsoft Fabric. | +|[GPT-RAG Accelerator](https://github.com/Azure/gpt-rag)| Secure enterprise GPT assistant framework that uses Retrieval-Augmented Generation to ground answers on your data. It provides a ready architecture (Azure OpenAI + knowledge search) for building AI chatbots that β€œknow” your enterprise content, with built-in security and scalability.| +|[Document Processing Accelerator](https://github.com/Azure/doc-proc-solution-accelerator/) | Modular document AI pipeline that automatically extracts, analyzes, and indexes information from unstructured documents (PDFs, images, etc.) at scale. It offers plug-and-play components for OCR, classification, summarization, and integration to search or chatbots – speeding up data ingestion with enterprise security.| + +
+ +πŸ’‘ Want to get familiar with Microsoft's AI and Data Engineering best practices? Check out our playbooks to learn more + +| Playbook | Description | +|:---|:---| +| [AI playbook](https://learn.microsoft.com/en-us/ai/playbook/) | The Artificial Intelligence (AI) Playbook provides enterprise software engineers with solutions, capabilities, and code developed to solve real-world AI problems. | +| [Data playbook](https://learn.microsoft.com/en-us/data-engineering/playbook/understanding-data-playbook) | The data playbook provides enterprise software engineers with solutions which contain code developed to solve real-world problems. Everything in the playbook is developed with, and validated by, some of Microsoft's largest and most influential customers and partners. | + +
## Provide feedback diff --git a/documents/DeploymentGuide.md b/documents/DeploymentGuide.md index b3c1c269..55e0622d 100644 --- a/documents/DeploymentGuide.md +++ b/documents/DeploymentGuide.md @@ -119,7 +119,7 @@ Select one of the following options to deploy the Customer Chatbot Solution Acce
Option C: Visual Studio Code Web - [![Open in Visual Studio Code Web](https://img.shields.io/static/v1?style=for-the-badge&label=Visual%20Studio%20Code%20(Web)&message=Open&color=blue&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/azure/?vscode-azure-exp=foundry&agentPayload=eyJiYXNlVXJsIjogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9taWNyb3NvZnQvY3VzdG9tZXItY2hhdGJvdC1zb2x1dGlvbi1hY2NlbGVyYXRvci9yZWZzL2hlYWRzL21haW4vaW5mcmEvdnNjb2RlX3dlYiIsICJpbmRleFVybCI6ICIvaW5kZXguanNvbiIsICJ2YXJpYWJsZXMiOiB7ImFnZW50SWQiOiAiIiwgImNvbm5lY3Rpb25TdHJpbmciOiAiIiwgInRocmVhZElkIjogIiIsICJ1c2VyTWVzc2FnZSI6ICIiLCAicGxheWdyb3VuZE5hbWUiOiAiIiwgImxvY2F0aW9uIjogIiIsICJzdWJzY3JpcHRpb25JZCI6ICIiLCAicmVzb3VyY2VJZCI6ICIiLCAicHJvamVjdFJlc291cmNlSWQiOiAiIiwgImVuZHBvaW50IjogIiJ9LCAiY29kZVJvdXRlIjogWyJhaS1wcm9qZWN0cy1zZGsiLCAicHl0aG9uIiwgImRlZmF1bHQtYXp1cmUtYXV0aCIsICJlbmRwb2ludCJdfQ==) + [![Open in Visual Studio Code Web](https://img.shields.io/static/v1?style=for-the-badge&label=Visual%20Studio%20Code%20(Web)&message=Open&color=blue&logo=visualstudiocode&logoColor=white)](https://vscode.dev/azure/?vscode-azure-exp=foundry&agentPayload=eyJiYXNlVXJsIjogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9taWNyb3NvZnQvY3VzdG9tZXItY2hhdGJvdC1zb2x1dGlvbi1hY2NlbGVyYXRvci9yZWZzL2hlYWRzL21haW4vaW5mcmEvdnNjb2RlX3dlYiIsICJpbmRleFVybCI6ICIvaW5kZXguanNvbiIsICJ2YXJpYWJsZXMiOiB7ImFnZW50SWQiOiAiIiwgImNvbm5lY3Rpb25TdHJpbmciOiAiIiwgInRocmVhZElkIjogIiIsICJ1c2VyTWVzc2FnZSI6ICIiLCAicGxheWdyb3VuZE5hbWUiOiAiIiwgImxvY2F0aW9uIjogIiIsICJzdWJzY3JpcHRpb25JZCI6ICIiLCAicmVzb3VyY2VJZCI6ICIiLCAicHJvamVjdFJlc291cmNlSWQiOiAiIiwgImVuZHBvaW50IjogIiJ9LCAiY29kZVJvdXRlIjogWyJhaS1wcm9qZWN0cy1zZGsiLCAicHl0aG9uIiwgImRlZmF1bHQtYXp1cmUtYXV0aCIsICJjb25uZWN0aW9uU3RyaW5nIl19) 1. Click the badge above (may take a few minutes to load) 2. Sign in with your Azure account when prompted diff --git a/documents/TroubleShootingSteps.md b/documents/TroubleShootingSteps.md index 7071cf92..654dfe3d 100644 --- a/documents/TroubleShootingSteps.md +++ b/documents/TroubleShootingSteps.md @@ -164,4 +164,4 @@ Use these as quick reference guides to unblock your deployments. --------------------------------- πŸ’‘ Note: If you encounter any other issues, you can refer to the [Common Deployment Errors](https://learn.microsoft.com/en-us/azure/azure-resource-manager/troubleshooting/common-deployment-errors) documentation. -If the problem persists, you can also raise a bug in our [Customer Chatbot GitHub Issues](https://github.com/microsoft/customer-chatbot-solution-accelerator/issues) for further support. \ No newline at end of file +If the problem persists, you can also raise a bug in our [Customer Chatbot GitHub Issues](https://github.com/microsoft/customer-chatbot-solution-accelerator/issues) for further support. diff --git a/infra/scripts/agent_scripts/requirements.txt b/infra/scripts/agent_scripts/requirements.txt index 63e0edcf..ebfb6d91 100644 --- a/infra/scripts/agent_scripts/requirements.txt +++ b/infra/scripts/agent_scripts/requirements.txt @@ -1,6 +1,7 @@ -agent-framework-azure-ai==1.0.0b260130 -agent-framework-core==1.0.0b260130 +agent-framework-azure-ai==1.0.0rc1 +agent-framework-core==1.0.0rc1 azure-ai-projects==2.0.0b3 azure-identity==1.25.1 python-dotenv==1.1.1 -aiohttp==3.13.3 \ No newline at end of file +aiohttp==3.13.3 +opentelemetry-semantic-conventions-ai==0.4.13 \ No newline at end of file diff --git a/infra/scripts/agent_scripts/run_create_agents_scripts.sh b/infra/scripts/agent_scripts/run_create_agents_scripts.sh index 6195f023..ab084d0e 100644 --- a/infra/scripts/agent_scripts/run_create_agents_scripts.sh +++ b/infra/scripts/agent_scripts/run_create_agents_scripts.sh @@ -26,6 +26,7 @@ apiAppName="" searchEndpoint="" azSubscriptionId="" original_foundry_public_access="" +SKIP_ROLE_ASSIGNMENT=false function test_azd_installed() { if command -v azd &> /dev/null; then @@ -437,32 +438,69 @@ echo "Subscription ID: $azSubscriptionId" echo "===============================================" echo "" -echo "Getting signed in user id" -signed_user_id=$(az ad signed-in-user show --query id -o tsv) +echo "Getting principal id (user or service principal)" +# Temporarily disable exit on error for principal detection +set +e +# Try to get signed-in user first (for interactive logins) +signed_user_id=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) -echo "Checking if the user has Azure AI User role on the AI Foundry" -role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ - --role "53ca6127-db72-4b80-b1b0-d745d6d5456d" \ - --scope "$aiFoundryResourceId" \ - --assignee "$signed_user_id" \ - --query "[].roleDefinitionId" -o tsv) +# If that fails, we're likely using a service principal - get its object ID +if [ -z "$signed_user_id" ]; then + echo "Not logged in as user, checking for service principal..." + + # Get account type - use jq if available, otherwise use grep/sed + account_type=$(az account show --query 'user.type' -o tsv 2>/dev/null) + + if [ "$account_type" = "servicePrincipal" ]; then + sp_name=$(az account show --query 'user.name' -o tsv 2>/dev/null) + echo "Logged in as service principal: $sp_name" + signed_user_id=$(az ad sp show --id "$sp_name" --query id -o tsv 2>/dev/null) + + if [ -z "$signed_user_id" ]; then + echo "Warning: Could not get service principal object ID. Attempting to continue without role assignment..." + echo "Note: Ensure the service principal has necessary permissions assigned at subscription/resource group level." + SKIP_ROLE_ASSIGNMENT=true + else + echo "Service principal object ID: $signed_user_id" + fi + else + echo "Warning: Could not determine principal ID (type: $account_type). Attempting to continue without role assignment..." + SKIP_ROLE_ASSIGNMENT=true + fi +else + echo "Logged in as user: $signed_user_id" +fi +# Re-enable exit on error +set -e -if [ -z "$role_assignment" ]; then - echo "User does not have the Azure AI User role. Assigning the role..." - MSYS_NO_PATHCONV=1 az role assignment create \ - --assignee "$signed_user_id" \ +echo "Checking if the principal has Azure AI User role on the AI Foundry" + +if [ "$SKIP_ROLE_ASSIGNMENT" != "true" ] && [ -n "$signed_user_id" ]; then + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ --role "53ca6127-db72-4b80-b1b0-d745d6d5456d" \ --scope "$aiFoundryResourceId" \ - --output none - - if [ $? -eq 0 ]; then - echo "Azure AI User role assigned successfully." + --assignee "$signed_user_id" \ + --query "[].roleDefinitionId" -o tsv) + + if [ -z "$role_assignment" ]; then + echo "Principal does not have the Azure AI User role. Assigning the role..." + MSYS_NO_PATHCONV=1 az role assignment create \ + --assignee "$signed_user_id" \ + --role "53ca6127-db72-4b80-b1b0-d745d6d5456d" \ + --scope "$aiFoundryResourceId" \ + --output none + + if [ $? -eq 0 ]; then + echo "Azure AI User role assigned successfully." + else + echo "Failed to assign Azure AI User role." + exit 1 + fi else - echo "Failed to assign Azure AI User role." - exit 1 + echo "Principal already has the Azure AI User role." fi else - echo "User already has the Azure AI User role." + echo "Skipping role assignment (will rely on existing permissions)" fi diff --git a/infra/scripts/azure_credential_utils.py b/infra/scripts/azure_credential_utils.py index 64fd1f4d..862a161d 100644 --- a/infra/scripts/azure_credential_utils.py +++ b/infra/scripts/azure_credential_utils.py @@ -1,4 +1,4 @@ -from azure.identity import DefaultAzureCredential, ManagedIdentityCredential +from azure.identity import AzureCliCredential, ManagedIdentityCredential APP_ENV = "dev" # Change to 'dev' for local development @@ -19,7 +19,7 @@ def get_azure_credential(client_id=None): """ if APP_ENV == "dev": return ( - DefaultAzureCredential() - ) # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + AzureCliCredential() + ) # CodeQL [SM05139] Okay use of AzureCliCredential as it is only used in development else: return ManagedIdentityCredential(client_id=client_id) diff --git a/infra/scripts/checkquota.sh b/infra/scripts/checkquota.sh index 4a759dbe..8a1876d7 100644 --- a/infra/scripts/checkquota.sh +++ b/infra/scripts/checkquota.sh @@ -1,14 +1,25 @@ #!/bin/bash +# Function to trim leading and trailing whitespace +trim() { + local var="$*" + # Remove leading whitespace + var="${var#"${var%%[![:space:]]*}"}" + # Remove trailing whitespace + var="${var%"${var##*[![:space:]]}"}" + printf '%s' "$var" +} + # List of Azure regions to check for quota (update as needed) IFS=', ' read -ra REGIONS <<< "$AZURE_REGIONS" -SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID}" +# Trim whitespace from environment variables to avoid issues with leading/trailing spaces +SUBSCRIPTION_ID=$(trim "${AZURE_SUBSCRIPTION_ID}") GPT_MIN_CAPACITY="${GPT_MIN_CAPACITY:-10}" EMBEDDING_MIN_CAPACITY="${EMBEDDING_MIN_CAPACITY:-10}" -AZURE_CLIENT_ID="${AZURE_CLIENT_ID}" -AZURE_TENANT_ID="${AZURE_TENANT_ID}" -AZURE_CLIENT_SECRET="${AZURE_CLIENT_SECRET}" +AZURE_CLIENT_ID=$(trim "${AZURE_CLIENT_ID}") +AZURE_TENANT_ID=$(trim "${AZURE_TENANT_ID}") +AZURE_CLIENT_SECRET=$(trim "${AZURE_CLIENT_SECRET}") # Authenticate using Managed Identity echo "Authentication using Managed Identity..." diff --git a/infra/scripts/data_scripts/azure_credential_utils.py b/infra/scripts/data_scripts/azure_credential_utils.py index 64fd1f4d..db584d4a 100644 --- a/infra/scripts/data_scripts/azure_credential_utils.py +++ b/infra/scripts/data_scripts/azure_credential_utils.py @@ -1,4 +1,4 @@ -from azure.identity import DefaultAzureCredential, ManagedIdentityCredential +from azure.identity import AzureCliCredential, ManagedIdentityCredential APP_ENV = "dev" # Change to 'dev' for local development @@ -14,12 +14,12 @@ def get_azure_credential(client_id=None): client_id (str, optional): The client ID for the managed identity. Defaults to None. Returns: - azure.identity.DefaultAzureCredential or azure.identity.ManagedIdentityCredential: + azure.identity.AzureCliCredential or azure.identity.ManagedIdentityCredential: The Azure credential object. """ if APP_ENV == "dev": return ( - DefaultAzureCredential() - ) # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + AzureCliCredential() + ) # CodeQL [SM05139] Okay use of AzureCliCredential as it is only used in development else: return ManagedIdentityCredential(client_id=client_id) diff --git a/infra/scripts/data_scripts/run_upload_data_scripts.sh b/infra/scripts/data_scripts/run_upload_data_scripts.sh index 94b193c5..c5223364 100644 --- a/infra/scripts/data_scripts/run_upload_data_scripts.sh +++ b/infra/scripts/data_scripts/run_upload_data_scripts.sh @@ -125,6 +125,7 @@ function get_values_from_az_deployment() { original_foundry_public_access="" original_cosmos_public_access="" original_cosmos_ip_filter="" +SKIP_ROLE_ASSIGNMENT=false # Function to enable public network access temporarily enable_public_access() { @@ -161,15 +162,35 @@ enable_public_access() { echo "Cosmos DB public access is '$original_cosmos_public_access' - enabling access" - # Add current IP to firewall rules and enable public network access - echo "Adding current IP ($current_ip) to Cosmos DB firewall..." + # Build array of individual IPs to handle NAT/proxy variations (current IP Β±2) + IFS='.' read -r ip1 ip2 ip3 ip4 <<< "$current_ip" + + echo "Adding multiple IPs to Cosmos DB firewall to handle NAT/proxy variations..." + echo " Base IP: $current_ip" + + # Build JSON array with current IP and Β±2 range + ip_rules="[" + for offset in -2 -1 0 1 2; do + new_octet=$((ip4 + offset)) + if [ $new_octet -ge 0 ] && [ $new_octet -le 255 ]; then + if [ "$ip_rules" != "[" ]; then + ip_rules="${ip_rules}," + fi + ip_rules="${ip_rules}{\"ipAddressOrRange\":\"${ip1}.${ip2}.${ip3}.${new_octet}\"}" + fi + done + ip_rules="${ip_rules}]" + + echo " Adding 5 IPs: ${ip1}.${ip2}.${ip3}.$((ip4-2)) to ${ip1}.${ip2}.${ip3}.$((ip4+2))" + + # Add multiple IPs to firewall rules and enable public network access if MSYS_NO_PATHCONV=1 az resource update \ --ids "$cosmos_resource_id" \ --api-version 2021-04-15 \ - --set "properties.ipRules=[{\"ipAddressOrRange\":\"$current_ip\"}]" \ + --set "properties.ipRules=$ip_rules" \ --set "properties.publicNetworkAccess=Enabled" \ --output none; then - echo "βœ“ Cosmos DB firewall updated to allow current IP" + echo "βœ“ Cosmos DB firewall updated with multiple IPs for NAT handling" echo "βœ“ Cosmos DB public network access enabled" # Wait longer for changes to propagate @@ -395,110 +416,145 @@ echo "Subscription ID: $azSubscriptionId" echo "===============================================" echo "" -echo "Getting signed in user id" -signed_user_id=$(az ad signed-in-user show --query id -o tsv) - -echo "Checking if the user has Search roles on the AI Search Service" -# search service contributor role id: 7ca78c08-252a-4471-8644-bb5ff32d4ba0 -# search index data contributor role id: 8ebe5a00-799e-43f5-93ac-243d3dce84a7 -# search index data reader role id: 1407120a-92aa-4202-b7e9-c0e197c71c8f - -role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ - --role "7ca78c08-252a-4471-8644-bb5ff32d4ba0" \ - --scope "$aiSearchResourceId" \ - --assignee "$signed_user_id" \ - --query "[].roleDefinitionId" -o tsv) - -if [ -z "$role_assignment" ]; then - echo "User does not have the search service contributor role. Assigning the role..." - MSYS_NO_PATHCONV=1 az role assignment create \ - --assignee "$signed_user_id" \ - --role "7ca78c08-252a-4471-8644-bb5ff32d4ba0" \ - --scope "$aiSearchResourceId" \ - --output none - - if [ $? -eq 0 ]; then - echo "Search service contributor role assigned successfully." +echo "Getting principal id (user or service principal)" +# Temporarily disable exit on error for principal detection +set +e +# Try to get signed-in user first (for interactive logins) +signed_user_id=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) + +# If that fails, we're likely using a service principal - get its object ID +if [ -z "$signed_user_id" ]; then + echo "Not logged in as user, checking for service principal..." + account_info=$(az account show --query '{name:user.name, type:user.type}' -o json) + account_type=$(echo "$account_info" | jq -r '.type') + + if [ "$account_type" = "servicePrincipal" ]; then + sp_name=$(echo "$account_info" | jq -r '.name') + echo "Logged in as service principal: $sp_name" + signed_user_id=$(az ad sp show --id "$sp_name" --query id -o tsv 2>/dev/null) + + if [ -z "$signed_user_id" ]; then + echo "Warning: Could not get service principal object ID. Attempting to continue without role assignment..." + echo "Note: Ensure the service principal has necessary permissions assigned at subscription/resource group level." + SKIP_ROLE_ASSIGNMENT=true + else + echo "Service principal object ID: $signed_user_id" + fi else - echo "Failed to assign search service contributor role." - exit 1 + echo "Warning: Could not determine principal ID (type: $account_type). Attempting to continue without role assignment..." + SKIP_ROLE_ASSIGNMENT=true fi else - echo "User already has the search service contributor role." + echo "Logged in as user: $signed_user_id" fi +# Re-enable exit on error +set -e -role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ - --role "8ebe5a00-799e-43f5-93ac-243d3dce84a7" \ - --scope "$aiSearchResourceId" \ - --assignee "$signed_user_id" \ - --query "[].roleDefinitionId" -o tsv) +if [ "$SKIP_ROLE_ASSIGNMENT" != "true" ] && [ -n "$signed_user_id" ]; then + echo "Checking if the principal has Search roles on the AI Search Service" + # search service contributor role id: 7ca78c08-252a-4471-8644-bb5ff32d4ba0 + # search index data contributor role id: 8ebe5a00-799e-43f5-93ac-243d3dce84a7 + # search index data reader role id: 1407120a-92aa-4202-b7e9-c0e197c71c8f -if [ -z "$role_assignment" ]; then - echo "User does not have the search index data contributor role. Assigning the role..." - MSYS_NO_PATHCONV=1 az role assignment create \ + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ + --role "7ca78c08-252a-4471-8644-bb5ff32d4ba0" \ + --scope "$aiSearchResourceId" \ --assignee "$signed_user_id" \ + --query "[].roleDefinitionId" -o tsv) + + if [ -z "$role_assignment" ]; then + echo "Principal does not have the search service contributor role. Assigning the role..." + MSYS_NO_PATHCONV=1 az role assignment create \ + --assignee "$signed_user_id" \ + --role "7ca78c08-252a-4471-8644-bb5ff32d4ba0" \ + --scope "$aiSearchResourceId" \ + --output none + + if [ $? -eq 0 ]; then + echo "Search service contributor role assigned successfully." + else + echo "Failed to assign search service contributor role." + exit 1 + fi + else + echo "Principal already has the search service contributor role." + fi + + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ --role "8ebe5a00-799e-43f5-93ac-243d3dce84a7" \ --scope "$aiSearchResourceId" \ - --output none - - if [ $? -eq 0 ]; then - echo "Search index data contributor role assigned successfully." + --assignee "$signed_user_id" \ + --query "[].roleDefinitionId" -o tsv) + + if [ -z "$role_assignment" ]; then + echo "Principal does not have the search index data contributor role. Assigning the role..." + MSYS_NO_PATHCONV=1 az role assignment create \ + --assignee "$signed_user_id" \ + --role "8ebe5a00-799e-43f5-93ac-243d3dce84a7" \ + --scope "$aiSearchResourceId" \ + --output none + + if [ $? -eq 0 ]; then + echo "Search index data contributor role assigned successfully." + else + echo "Failed to assign search index data contributor role." + exit 1 + fi else - echo "Failed to assign search index data contributor role." - exit 1 + echo "Principal already has the search index data contributor role." fi -else - echo "User already has the search index data contributor role." -fi - -role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ - --role "1407120a-92aa-4202-b7e9-c0e197c71c8f" \ - --scope "$aiSearchResourceId" \ - --assignee "$signed_user_id" \ - --query "[].roleDefinitionId" -o tsv) -if [ -z "$role_assignment" ]; then - echo "User does not have the search index data reader role. Assigning the role..." - MSYS_NO_PATHCONV=1 az role assignment create \ - --assignee "$signed_user_id" \ + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ --role "1407120a-92aa-4202-b7e9-c0e197c71c8f" \ --scope "$aiSearchResourceId" \ - --output none - - if [ $? -eq 0 ]; then - echo "Search index data reader role assigned successfully." + --assignee "$signed_user_id" \ + --query "[].roleDefinitionId" -o tsv) + + if [ -z "$role_assignment" ]; then + echo "Principal does not have the search index data reader role. Assigning the role..." + MSYS_NO_PATHCONV=1 az role assignment create \ + --assignee "$signed_user_id" \ + --role "1407120a-92aa-4202-b7e9-c0e197c71c8f" \ + --scope "$aiSearchResourceId" \ + --output none + + if [ $? -eq 0 ]; then + echo "Search index data reader role assigned successfully." + else + echo "Failed to assign search index data reader role." + exit 1 + fi else - echo "Failed to assign search index data reader role." - exit 1 + echo "Principal already has the search index data reader role." fi -else - echo "User already has the search index data reader role." -fi -# Check if the user has the Cosmos DB Built-in Data Contributor role -echo "Checking if user has the Cosmos DB Built-in Data Contributor role" -roleExists=$(az cosmosdb sql role assignment list \ - --resource-group $resource_group \ - --account-name $cosmosdb_account \ - --query "[?roleDefinitionId.ends_with(@, '00000000-0000-0000-0000-000000000002') && principalId == '$signed_user_id']" -o tsv) - -# Check if the role exists -if [ -n "$roleExists" ]; then - echo "User already has the Cosmos DB Built-in Data Contributer role." -else - echo "User does not have the Cosmos DB Built-in Data Contributer role. Assigning the role." - MSYS_NO_PATHCONV=1 az cosmosdb sql role assignment create \ + # Check if the principal has the Cosmos DB Built-in Data Contributor role + echo "Checking if principal has the Cosmos DB Built-in Data Contributor role" + roleExists=$(az cosmosdb sql role assignment list \ --resource-group $resource_group \ --account-name $cosmosdb_account \ - --role-definition-id 00000000-0000-0000-0000-000000000002 \ - --principal-id $signed_user_id \ - --scope "/" \ - --output none - if [ $? -eq 0 ]; then - echo "Cosmos DB Built-in Data Contributer role assigned successfully." + --query "[?roleDefinitionId.ends_with(@, '00000000-0000-0000-0000-000000000002') && principalId == '$signed_user_id']" -o tsv) + + # Check if the role exists + if [ -n "$roleExists" ]; then + echo "Principal already has the Cosmos DB Built-in Data Contributer role." else - echo "Failed to assign Cosmos DB Built-in Data Contributer role." + echo "Principal does not have the Cosmos DB Built-in Data Contributer role. Assigning the role." + MSYS_NO_PATHCONV=1 az cosmosdb sql role assignment create \ + --resource-group $resource_group \ + --account-name $cosmosdb_account \ + --role-definition-id 00000000-0000-0000-0000-000000000002 \ + --principal-id $signed_user_id \ + --scope "/" \ + --output none + if [ $? -eq 0 ]; then + echo "Cosmos DB Built-in Data Contributer role assigned successfully." + else + echo "Failed to assign Cosmos DB Built-in Data Contributer role." + fi fi +else + echo "Skipping role assignments (will rely on existing permissions)" fi # role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list \ diff --git a/src/api/requirements.txt b/src/api/requirements.txt index ee6cfdcc..e6f71e4c 100644 --- a/src/api/requirements.txt +++ b/src/api/requirements.txt @@ -6,8 +6,8 @@ uvicorn[standard]==0.40.0 pydantic[email]==2.11.10 # Azure Services -agent-framework-azure-ai==1.0.0b260212 -agent-framework-core==1.0.0b260212 +agent-framework-azure-ai==1.0.0rc1 +agent-framework-core==1.0.0rc1 azure-identity==1.25.2 azure-search-documents==11.7.0b1 azure-ai-projects==2.0.0b3 @@ -22,6 +22,7 @@ opentelemetry-exporter-otlp-proto-http azure-monitor-events-extension opentelemetry-sdk==1.39.0 opentelemetry-api==1.39.0 +opentelemetry-semantic-conventions-ai==0.4.13 opentelemetry-semantic-conventions==0.60b0 opentelemetry-instrumentation==0.60b0 azure-monitor-opentelemetry==1.8.6 diff --git a/tests/e2e-test/config/constants.py b/tests/e2e-test/config/constants.py index 38d43aa4..9751fbfb 100644 --- a/tests/e2e-test/config/constants.py +++ b/tests/e2e-test/config/constants.py @@ -29,8 +29,8 @@ # Construct the absolute path to the JSON file # note: may have to remove 'tests/e2e-test' from below when running locally json_file_path = os.path.join( - repo_root, "testdata", "golden_path_data.json" -) + repo_root,"tests/e2e-test","testdata", "golden_path_data.json" + ) # Admin Page input data diff --git a/tests/e2e-test/pages/webUserPage.py b/tests/e2e-test/pages/webUserPage.py index 724a43d7..a861030c 100644 --- a/tests/e2e-test/pages/webUserPage.py +++ b/tests/e2e-test/pages/webUserPage.py @@ -206,6 +206,10 @@ def verify_response_contains_keywords(self, response_text, keywords): def ask_question_and_verify(self, question, expected_keywords): """Ask a question and verify the response contains expected content""" + # Count existing AI response containers BEFORE asking the question + ai_response_selector = 'div[class*="bg-muted"]' + initial_response_count = self.page.locator(ai_response_selector).count() + # Clear any existing input first text_area = self.page.locator(self.TYPE_QUESTION_TEXT_AREA) text_area.click() @@ -226,15 +230,122 @@ def ask_question_and_verify(self, question, expected_keywords): # Wait for response with longer timeout self.wait_for_response(timeout=45000) - # Wait extra time to ensure new response has arrived + # Wait for a NEW response to appear (response count should increase) + try: + self.page.wait_for_function( + f"""(expectedCount) => {{ + const responses = document.querySelectorAll('div[class*="bg-muted"]'); + return responses.length > expectedCount; + }}""", + arg=initial_response_count, + timeout=60000 + ) + except: + pass + + # Wait extra time to ensure new response has fully loaded self.page.wait_for_timeout(5000) - # Get the response - response = self.get_last_response() + # Get the LATEST response specifically (the last one in the list) + response = self.get_latest_ai_response() # Verify response contains expected content contains_keyword, found_keyword = self.verify_response_contains_keywords(response, expected_keywords) return response, contains_keyword, found_keyword + + def get_latest_ai_response(self): + """Get the text content of the LATEST/MOST RECENT AI response only""" + import re + + # Wait for any dynamic content to load + self.page.wait_for_timeout(3000) + + # Method 1: Look for AI response containers within the chat panel + # These are typically marked with timestamps and contain the AI icon + chat_panel_selectors = [ + # AI responses in chat panel - look for containers with AI indicator and timestamp + 'div[class*="bg-muted"]:has(svg):not(:has(img[alt*="Paint"]))', + # Messages within the chat scroll area + '[data-radix-scroll-area-viewport] div[class*="bg-muted"]', + # Direct chat message containers + 'div[class*="rounded-lg"][class*="bg-muted"]' + ] + + for selector in chat_panel_selectors: + try: + response_elements = self.page.locator(selector) + response_count = response_elements.count() + + if response_count > 0: + # Get the LAST response element (most recent) + last_response = response_elements.nth(response_count - 1) + response_text = last_response.text_content() + + if response_text and len(response_text.strip()) > 20: + cleaned = re.sub(r'\s+', ' ', response_text).strip() + # Validate this looks like an AI text response, not a product card + if not self._is_product_card_text(cleaned): + return cleaned + except: + continue + + # Method 2: Parse the full page and extract the latest AI response by timestamp + full_page_text = self.page.locator('body').text_content() + + # Split by timestamps (e.g., "Jan 30, 10:49 AM") + timestamp_pattern = r'(Jan \d+, \d+:\d+ [AP]M)' + parts = re.split(timestamp_pattern, full_page_text) + + # Find the last AI response (typically follows a timestamp and doesn't contain "You") + ai_responses = [] + for i in range(len(parts) - 1, 0, -1): + part = parts[i].strip() + # Skip timestamps themselves + if re.match(timestamp_pattern, part): + continue + # Skip empty or very short parts + if len(part) < 30: + continue + # Skip user messages (typically short and followed by user indicator) + if 'You' in parts[i-1] if i > 0 else False: + continue + # Check if this looks like an AI response about the topic + if not self._is_product_card_text(part): + cleaned = re.sub(r'\s+', ' ', part).strip() + if len(cleaned) > 30: + ai_responses.append(cleaned) + + if ai_responses: + return ai_responses[0] # Return the most recent non-product-card response + + # Fallback to the original method if the above doesn't work + return self.get_last_response() + + def _is_product_card_text(self, text): + """Check if text appears to be from a product card rather than an AI response""" + # Product cards typically contain repeated patterns of product names and prices + product_indicators = [ + 'Blue Ash', + 'Cloud Drift', + 'Fog Harbor', + 'Glacier Tint', + 'Showing 16 results', + 'Products' + ] + + # Count how many product indicators are present + indicator_count = sum(1 for indicator in product_indicators if indicator in text) + + # If the text contains multiple product names in sequence, it's likely a product card list + if indicator_count >= 3: + return True + + # Check for the pattern of multiple "59.50 USD" which indicates product listing + price_count = text.count('59.50 USD') + if price_count >= 2: + return True + + return False \ No newline at end of file diff --git a/tests/e2e-test/tests/test_byocc.py b/tests/e2e-test/tests/test_byocc.py index d9b8dc7d..a0b36b90 100644 --- a/tests/e2e-test/tests/test_byocc.py +++ b/tests/e2e-test/tests/test_byocc.py @@ -140,10 +140,10 @@ def test_28907_golden_path_demo_script(self, page): # Extra wait to ensure response is fully loaded page.wait_for_timeout(8000) - # Get response with multiple attempts if needed + # Get response with multiple attempts if needed - use get_latest_ai_response for better accuracy response = "" for attempt in range(3): - response = web_user_page.get_last_response() + response = web_user_page.get_latest_ai_response() if any(keyword.lower() in response.lower() for keyword in dissatisfaction_data["expected_responses"]): break logger.info(f"Attempt {attempt + 1}: Waiting longer for dissatisfaction response...")