diff --git a/.env.example b/.env.example index 5b61935..393e44d 100644 --- a/.env.example +++ b/.env.example @@ -33,9 +33,10 @@ AZURE_DEVOPS_ORG=your-org-name AZURE_DEVOPS_PAT=abcdef1234567890abcdef1234567890abcdef1234567890 # Microsoft Teams Bot -# Note: TEAMS_APP_ID and TEAMS_APP_PASSWORD are stored in Key Vault, not .env -# The bot loads credentials from Key Vault at runtime using managed identity -AZURE_KEY_VAULT_NAME=your-key-vault-name +# Note: TEAMS_APP_ID and TEAMS_APP_PASSWORD are managed via GitHub Secrets +# and set as environment variables during deployment +TEAMS_APP_ID=your-teams-app-id +TEAMS_APP_PASSWORD=your-teams-app-password # Local Development PORT=8000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 470ce6e..7a53fde 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,21 @@ name: Deploy Pennie to Azure +# Deployment triggers: +# - Push to main -> Deploy to test environment +# - Tag v*.*.* -> Deploy to production +# - Manual workflow_dispatch -> Choose environment + on: push: branches: [main] + tags: + - 'v*.*.*' workflow_dispatch: inputs: environment: description: 'Environment to deploy to' required: true - default: 'prod' + default: 'test' type: choice options: - test @@ -23,12 +30,56 @@ env: NODE_VERSION: '20.x' jobs: + # Determine which environment to deploy to based on trigger + set-environment: + name: Set Environment + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'test') || (startsWith(github.ref, 'refs/tags/v') && 'prod') || 'test' }} + outputs: + environment: ${{ steps.set-env.outputs.environment }} + is_production: ${{ steps.set-env.outputs.is_production }} + deployment_enabled: ${{ steps.set-env.outputs.deployment_enabled }} + resource_group: ${{ steps.set-env.outputs.resource_group }} + steps: + - name: Determine environment + id: set-env + run: | + # Manual dispatch uses input + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + ENV="${{ github.event.inputs.environment }}" + echo "environment=$ENV" >> $GITHUB_OUTPUT + if [[ "$ENV" == "prod" ]]; then + echo "is_production=true" >> $GITHUB_OUTPUT + echo "resource_group=TMinus15Agents" >> $GITHUB_OUTPUT + else + echo "is_production=false" >> $GITHUB_OUTPUT + echo "resource_group=TMinus15Agents-${ENV^}" >> $GITHUB_OUTPUT + fi + # Tags starting with v deploy to production + elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "environment=prod" >> $GITHUB_OUTPUT + echo "is_production=true" >> $GITHUB_OUTPUT + echo "resource_group=TMinus15Agents" >> $GITHUB_OUTPUT + # Push to main deploys to test (when test VM exists) + else + echo "environment=test" >> $GITHUB_OUTPUT + echo "is_production=false" >> $GITHUB_OUTPUT + echo "resource_group=TMinus15Agents-Test" >> $GITHUB_OUTPUT + fi + + # Output deployment enabled flag (from environment variable) + echo "deployment_enabled=${{ vars.AZURE_DEPLOYMENT_ENABLED }}" >> $GITHUB_OUTPUT + + echo "Trigger: ${{ github.event_name }}, Ref: ${{ github.ref }}" + cat $GITHUB_OUTPUT + deploy-infrastructure: name: Deploy Infrastructure runs-on: ubuntu-latest - environment: ${{ github.event.inputs.environment || 'prod' }} - # Skip infrastructure deployment - VM already exists - if: false + needs: [set-environment] + environment: ${{ needs.set-environment.outputs.environment }} + # Only deploy infrastructure when explicitly enabled per environment + if: ${{ needs.set-environment.outputs.deployment_enabled == 'true' }} steps: - name: Checkout code @@ -39,17 +90,38 @@ jobs: with: creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Resolve RDP source IP from dynamic DNS + id: resolve-dns + run: | + # Resolve dynamic DNS hostname to IP for RDP access restriction + # Uses ADMIN_DDNS_HOSTNAME secret (e.g., bank.knowall.ai) + DDNS_HOSTNAME="${{ secrets.ADMIN_DDNS_HOSTNAME }}" + if [ -n "$DDNS_HOSTNAME" ]; then + echo "Resolving $DDNS_HOSTNAME..." + RESOLVED_IP=$(dig +short "$DDNS_HOSTNAME" | head -1) + if [ -n "$RESOLVED_IP" ]; then + echo "Resolved to: $RESOLVED_IP" + echo "rdp_source_ip=$RESOLVED_IP" >> $GITHUB_OUTPUT + else + echo "::warning::Could not resolve $DDNS_HOSTNAME - RDP will be disabled" + echo "rdp_source_ip=" >> $GITHUB_OUTPUT + fi + else + echo "No ADMIN_DDNS_HOSTNAME configured - RDP will be disabled" + echo "rdp_source_ip=" >> $GITHUB_OUTPUT + fi + - name: Deploy Bicep templates uses: azure/arm-deploy@v2 with: subscriptionId: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - scope: subscription - region: uksouth + resourceGroupName: ${{ needs.set-environment.outputs.resource_group }} template: ./infra/main.bicep parameters: > - @./infra/main.parameters.${{ github.event.inputs.environment || 'prod' }}.json - environmentName=${{ github.event.inputs.environment || 'prod' }} - teamsAppId=${{ secrets.TEAMS_APP_ID }} + @./infra/main.parameters.${{ needs.set-environment.outputs.environment }}.json + environmentName=${{ needs.set-environment.outputs.environment }} + vmAdminPassword=${{ secrets.VM_ADMIN_PASSWORD }} + allowedRdpSourceIP=${{ steps.resolve-dns.outputs.rdp_source_ip }} failOnStdErr: false - name: Get deployment outputs @@ -58,6 +130,114 @@ jobs: echo "Getting deployment outputs..." # Extract outputs from deployment (VM IP, Key Vault name, etc.) + - name: Grant VM access to Azure OpenAI + run: | + # VM's managed identity needs "Cognitive Services OpenAI User" role to call Azure OpenAI + ENV="${{ needs.set-environment.outputs.environment }}" + RG="${{ needs.set-environment.outputs.resource_group }}" + + # Get VM's managed identity principal ID + VM_PRINCIPAL_ID=$(az vm show \ + --resource-group "$RG" \ + --name "pennie-vm-${ENV}" \ + --query "identity.principalId" -o tsv 2>/dev/null || echo "") + + if [ -z "$VM_PRINCIPAL_ID" ]; then + echo "::warning::Could not get VM principal ID - Azure OpenAI access may need manual setup" + exit 0 + fi + + echo "VM Principal ID: $VM_PRINCIPAL_ID" + + # Azure OpenAI resource is in TMinus15Agents resource group (shared by all environments) + OPENAI_RESOURCE_ID="/subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/tminus15agents/providers/Microsoft.CognitiveServices/accounts/benw-mgan4638-eastus2" + + # Check if role assignment already exists + EXISTING=$(az role assignment list \ + --assignee "$VM_PRINCIPAL_ID" \ + --role "Cognitive Services OpenAI User" \ + --scope "$OPENAI_RESOURCE_ID" \ + --query "[].id" -o tsv 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Role assignment already exists" + else + echo "Creating role assignment..." + az role assignment create \ + --assignee "$VM_PRINCIPAL_ID" \ + --role "Cognitive Services OpenAI User" \ + --scope "$OPENAI_RESOURCE_ID" \ + 2>/dev/null || echo "::warning::Failed to create role assignment - may need manual setup" + echo "Role assignment created" + fi + + - name: Ensure Azure Bot registration exists + run: | + # Azure Bot Service registration is required for Teams messaging + # This creates the bot registration if it doesn't exist + ENV="${{ needs.set-environment.outputs.environment }}" + RG="${{ needs.set-environment.outputs.resource_group }}" + APP_ID="${{ secrets.TEAMS_APP_ID }}" + + if [ "$ENV" = "prod" ]; then + BOT_NAME="pennie-bot" + BOT_DISPLAY="Pennie the Prepper" + else + BOT_NAME="pennie-bot-${ENV}" + BOT_DISPLAY="Pennie the Prepper (${ENV})" + fi + + # Get VM FQDN for messaging endpoint + VM_FQDN=$(az network public-ip show \ + --resource-group "$RG" \ + --name "pennie-pip-${ENV}" \ + --query "dnsSettings.fqdn" -o tsv 2>/dev/null || echo "") + + if [ -z "$VM_FQDN" ]; then + echo "::warning::Could not get VM FQDN - bot registration may need manual endpoint update" + VM_FQDN="pennie-${ENV}.uksouth.cloudapp.azure.com" + fi + + ENDPOINT="https://${VM_FQDN}/api/messages" + echo "Bot endpoint: $ENDPOINT" + + # Check if bot registration exists + EXISTING=$(az resource list \ + --resource-type "Microsoft.BotService/botServices" \ + --query "[?name=='$BOT_NAME'].name" -o tsv 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Bot registration '$BOT_NAME' already exists" + # Update endpoint if it changed + az bot update \ + --resource-group "$RG" \ + --name "$BOT_NAME" \ + --endpoint "$ENDPOINT" \ + 2>/dev/null || echo "::warning::Could not update bot endpoint" + else + echo "Creating bot registration '$BOT_NAME'..." + # Azure Bot registration with SingleTenant app type + # Note: MultiTenant is deprecated, use SingleTenant with tenant ID + az bot create \ + --resource-group "$RG" \ + --name "$BOT_NAME" \ + --app-type SingleTenant \ + --tenant-id "${{ secrets.AZURE_TENANT_ID }}" \ + --appid "$APP_ID" \ + --endpoint "$ENDPOINT" \ + --display-name "$BOT_DISPLAY" \ + --description "Pennie the Prepper - AI Business Analyst for Teams" + + # Enable Teams channel + echo "Enabling Teams channel..." + az bot msteams create \ + --resource-group "$RG" \ + --name "$BOT_NAME" \ + 2>/dev/null || echo "::warning::Teams channel may already exist" + + echo "✅ Bot registration '$BOT_NAME' created with Teams channel" + fi + build-bot: name: Build Teams Bot runs-on: windows-latest @@ -89,10 +269,10 @@ jobs: deploy-bot: name: Deploy Bot to VM runs-on: windows-latest - needs: [build-bot] - environment: ${{ github.event.inputs.environment || 'prod' }} + needs: [set-environment, build-bot] + environment: ${{ needs.set-environment.outputs.environment }} # Deploy to existing VM (infrastructure deployment optional) - if: ${{ vars.AZURE_DEPLOYMENT_ENABLED == 'true' }} + if: ${{ needs.set-environment.outputs.deployment_enabled == 'true' }} steps: - name: Checkout code @@ -111,8 +291,12 @@ jobs: - name: Create deployment package run: | + # Include deployment and configuration scripts in the package + Copy-Item ./scripts/deploy-bot-to-vm.ps1 ./publish/ + Copy-Item ./scripts/configure-bot-settings.ps1 ./publish/ + Copy-Item ./scripts/configure-ssl.ps1 ./publish/ Compress-Archive -Path ./publish/* -DestinationPath ./pennie-bot.zip - Write-Host "Created deployment package: pennie-bot.zip" + Write-Host "Created deployment package: pennie-bot.zip (includes deploy, config, and SSL scripts)" - name: Upload to Azure Storage run: | @@ -154,106 +338,259 @@ jobs: - name: Deploy to Windows VM run: | - $vmName = "pennie-vm-${{ github.event.inputs.environment || 'prod' }}" - $rgName = "${{ secrets.AZURE_RESOURCE_GROUP }}" - $keyVaultName = "${{ secrets.AZURE_KEY_VAULT_NAME }}" - - # Download and extract package on VM, preserving appsettings.json - $uploadScript = @' - param([string]$PackageUrl) - - $TempDir = "C:\Temp" - $PackagePath = "$TempDir\pennie-bot.zip" - $ExtractPath = "C:\Pennie\bot" - $AppSettingsPath = "$ExtractPath\appsettings.json" - $AppSettingsBackup = "$TempDir\appsettings.json.backup" - - New-Item -ItemType Directory -Path $TempDir -Force | Out-Null - New-Item -ItemType Directory -Path $ExtractPath -Force | Out-Null - - # CRITICAL: Backup appsettings.json before deployment - # This file contains VM-specific configuration (AppId, TenantId, KeyVault name) - if (Test-Path $AppSettingsPath) { - Write-Host "Backing up existing appsettings.json..." - Copy-Item -Path $AppSettingsPath -Destination $AppSettingsBackup -Force - } + # Mask all secrets to prevent accidental exposure in logs (fixes #73) + # Note: GitHub auto-masks values referenced as ${{ secrets.X }}, but + # values decoded from Base64 or passed to scripts need explicit masking + $teamsAppId = "${{ secrets.TEAMS_APP_ID }}" + $teamsAppPassword = "${{ secrets.TEAMS_APP_PASSWORD }}" + $azureOpenAiEndpoint = "${{ secrets.AZURE_OPENAI_ENDPOINT }}" + $azureOpenAiAssistantId = "${{ secrets.AZURE_OPENAI_ASSISTANT_ID }}" + $leEmail = "${{ secrets.LE_EMAIL }}" + + # Add masks for all sensitive values + Write-Output "::add-mask::$teamsAppId" + Write-Output "::add-mask::$teamsAppPassword" + if ($azureOpenAiEndpoint) { Write-Output "::add-mask::$azureOpenAiEndpoint" } + if ($azureOpenAiAssistantId) { Write-Output "::add-mask::$azureOpenAiAssistantId" } + if ($leEmail) { Write-Output "::add-mask::$leEmail" } + + $vmName = "pennie-vm-${{ github.event.inputs.environment || 'test' }}" + $rgName = "${{ needs.set-environment.outputs.resource_group }}" + $packageUrl = $env:PACKAGE_URL + + Write-Host "Deploying to VM: $vmName in RG: $rgName" + Write-Host "Package URL length: $($packageUrl.Length) chars" + + # Encode URL as Base64 to safely pass through (URL contains special chars) + $urlBytes = [System.Text.Encoding]::UTF8.GetBytes($packageUrl) + $urlBase64 = [Convert]::ToBase64String($urlBytes) + Write-Host "URL encoded to Base64 ($($urlBase64.Length) chars)" + + # Step 1: Write Base64 URL to a file on the VM + Write-Host "Step 1: Writing Base64 URL to VM file..." + az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts "New-Item -ItemType Directory -Path 'C:\Temp' -Force | Out-Null; Set-Content -Path 'C:\Temp\package-url.txt' -Value '$urlBase64' -Force; Write-Host 'URL file created'" + + # Step 2: Decode URL and download package + Write-Host "Step 2: Downloading package on VM..." + az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts "`$b64 = (Get-Content 'C:\Temp\package-url.txt' -Raw).Trim(); `$url = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(`$b64)); [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri `$url -OutFile 'C:\Temp\pennie-bot.zip' -UseBasicParsing; Write-Host ('Downloaded: ' + (Get-Item 'C:\Temp\pennie-bot.zip').Length + ' bytes')" + + # Step 3: Extract package (uses fresh appsettings.json from repo) + # Note: Backup/restore removed - all config now comes from GitHub Secrets via configure-bot-settings.ps1 + Write-Host "Step 3: Extracting package..." + az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts "New-Item -ItemType Directory -Path 'C:\Pennie\bot' -Force | Out-Null; Expand-Archive -Path 'C:\Temp\pennie-bot.zip' -DestinationPath 'C:\Pennie\bot' -Force; Write-Host 'Extracted files:'; Get-ChildItem 'C:\Pennie\bot' | ForEach-Object { Write-Host `$_.Name }" + + # Step 4: Run the service deployment script (stop service, copy files, start service) + Write-Host "Step 4: Running service deployment..." + az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts "& 'C:\Pennie\bot\deploy-bot-to-vm.ps1'" + + # Step 5: Setup SSL certificate using Let's Encrypt + # Requires LE_EMAIL secret to be configured + Write-Host "Step 5: Setting up Let's Encrypt SSL certificate..." - Write-Host "Downloading deployment package..." - Invoke-WebRequest -Uri $PackageUrl -OutFile $PackagePath - - Write-Host "Extracting to $ExtractPath..." - Expand-Archive -Path $PackagePath -DestinationPath $ExtractPath -Force - - # CRITICAL: Restore appsettings.json after deployment - # The deployed package contains template values, not production configuration - if (Test-Path $AppSettingsBackup) { - Write-Host "Restoring appsettings.json from backup..." - Copy-Item -Path $AppSettingsBackup -Destination $AppSettingsPath -Force - Remove-Item -Path $AppSettingsBackup -Force - Write-Host "appsettings.json restored successfully" - } else { - Write-Host "WARNING: No appsettings.json backup found - manual configuration required" + # Get VM FQDN (needed for certificate) + $vmFqdn = az network public-ip show --resource-group $rgName --name "pennie-pip-${{ github.event.inputs.environment || 'test' }}" --query "dnsSettings.fqdn" -o tsv + if (-not $vmFqdn) { + Write-Error "Could not get VM FQDN - required for SSL certificate" + exit 1 } + Write-Host "VM FQDN: $vmFqdn" - Write-Host "Upload complete" - '@ + # Verify LE_EMAIL secret is configured + $leEmail = "${{ secrets.LE_EMAIL }}" + if (-not $leEmail) { + Write-Error "LE_EMAIL secret not configured. Required for Let's Encrypt certificate." + exit 1 + } + # Run the SSL configuration script on the VM az vm run-command invoke ` --resource-group $rgName ` --name $vmName ` --command-id RunPowerShellScript ` - --scripts $uploadScript ` - --parameters "PackageUrl=$env:PACKAGE_URL" + --scripts "& 'C:\Pennie\bot\configure-ssl.ps1' -Fqdn '$vmFqdn' -Email '$leEmail'" + + # Step 6: Configure bot credentials and URLs from GitHub Secrets + Write-Host "Step 6: Configuring bot credentials..." + # (vmFqdn already set in step 5) + + # Use environment-specific backend URL (test uses test backend, prod uses prod) + $env = "${{ github.event.inputs.environment || 'test' }}" + $backendUrl = "https://pennie-backend-$env.azurewebsites.net" + Write-Host "Backend URL: $backendUrl" + + # Debug: Show lengths of original secrets BEFORE encoding + Write-Host "DEBUG - Original secret lengths (before Base64):" + Write-Host " TEAMS_APP_ID: $($teamsAppId.Length) chars" + Write-Host " TEAMS_APP_PASSWORD: $($teamsAppPassword.Length) chars" + Write-Host " AZURE_OPENAI_ENDPOINT: $($azureOpenAiEndpoint.Length) chars" + Write-Host " AZURE_OPENAI_ASSISTANT_ID: $($azureOpenAiAssistantId.Length) chars" + + # Fail early if required secrets are missing + if ($teamsAppId.Length -eq 0) { + Write-Error "TEAMS_APP_ID secret is empty. Check environment secrets." + } - # Run deployment script - $deployScript = Get-Content ./scripts/deploy-bot-to-vm.ps1 -Raw + # Encode ALL credentials as Base64 to avoid escaping issues in remote script + # Note: Variables $teamsAppId, etc. are already defined and masked at start of step + $teamsAppIdB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($teamsAppId)) + $teamsAppPasswordB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($teamsAppPassword)) + $openAiEndpointB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($azureOpenAiEndpoint)) + $openAiAssistantIdB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($azureOpenAiAssistantId)) + + Write-Host "Encoded values (Base64 lengths):" + Write-Host " Teams App ID: $($teamsAppIdB64.Length) chars" + Write-Host " Teams Password: $($teamsAppPasswordB64.Length) chars" + Write-Host " OpenAI Endpoint: $($openAiEndpointB64.Length) chars" + Write-Host " OpenAI Assistant ID: $($openAiAssistantIdB64.Length) chars" + + # Run the configure script on the VM with ALL values Base64-encoded + # Values are embedded directly in the script (--parameters flag doesn't work reliably) + # NOTE: Avoid curly braces in inline scripts - they get corrupted by az vm run-command + # NOTE: Use $() subexpression syntax to force variable expansion in multiline strings + $configScript = "Write-Host 'Decoding Base64 values...'; " + ` + "`$appId = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$($teamsAppIdB64)')); " + ` + "`$password = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$($teamsAppPasswordB64)')); " + ` + "`$openAiEndpoint = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$($openAiEndpointB64)')); " + ` + "`$openAiAssistantId = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$($openAiAssistantIdB64)')); " + ` + "Write-Host ('Decoded - Teams App ID: ' + `$appId.Length + ' chars'); " + ` + "Write-Host ('Decoded - OpenAI Endpoint: ' + `$openAiEndpoint.Length + ' chars'); " + ` + "Write-Host ('Decoded - OpenAI Assistant ID: ' + `$openAiAssistantId.Length + ' chars'); " + ` + "& 'C:\Pennie\bot\configure-bot-settings.ps1' " + ` + "-ConfigPath 'C:\Pennie\bot\appsettings.json' " + ` + "-TeamsAppId `$appId " + ` + "-TeamsAppPassword `$password " + ` + "-VmFqdn '$($vmFqdn)' " + ` + "-BackendUrl '$($backendUrl)' " + ` + "-AzureOpenAiEndpoint `$openAiEndpoint " + ` + "-AzureOpenAiAssistantId `$openAiAssistantId; " + ` + "Write-Host 'Configuration script completed'" + + Write-Host "Config script length: $($configScript.Length) chars" + $configResult = az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts $configScript + # Show full config result for debugging + Write-Host "Config result:" + Write-Host $configResult + + # Step 6b: Verify configuration was applied + # NOTE: Avoid curly braces in inline scripts - they get corrupted by az vm run-command + Write-Host "Verifying OpenAI configuration..." + $verifyResult = az vm run-command invoke ` + --resource-group $rgName ` + --name $vmName ` + --command-id RunPowerShellScript ` + --scripts "`$config = Get-Content 'C:\Pennie\bot\appsettings.json' -Raw | ConvertFrom-Json; ` + `$endpoint = `$config.'AZURE_OPENAI_ENDPOINT'; ` + `$assistantId = `$config.'AZURE_OPENAI_ASSISTANT_ID'; ` + Write-Host ('AZURE_OPENAI_ENDPOINT length: ' + `$endpoint.Length + ' chars'); ` + Write-Host ('AZURE_OPENAI_ASSISTANT_ID length: ' + `$assistantId.Length + ' chars'); ` + `$isEmpty = [string]::IsNullOrEmpty(`$endpoint) -or [string]::IsNullOrEmpty(`$assistantId); ` + Write-Host ('Settings configured: ' + (-not `$isEmpty))" + Write-Host "Verify result:" + Write-Host $verifyResult + + # Step 7: Restart service to apply new configuration + Write-Host "Step 7: Restarting service..." az vm run-command invoke ` --resource-group $rgName ` --name $vmName ` --command-id RunPowerShellScript ` - --scripts $deployScript ` - --parameters "KeyVaultName=$keyVaultName" + --scripts "Restart-Service PennieBot; Start-Sleep -Seconds 5; Get-Service PennieBot | Format-Table Name, Status" Write-Host "✅ Bot deployed successfully to $vmName" run-smoke-tests: name: Run Smoke Tests runs-on: ubuntu-latest - needs: [deploy-bot] - environment: ${{ github.event.inputs.environment || 'prod' }} - if: ${{ vars.AZURE_DEPLOYMENT_ENABLED == 'true' && (needs.deploy-bot.result == 'success' || needs.deploy-bot.result == 'skipped') }} + needs: [set-environment, deploy-bot] + environment: ${{ needs.set-environment.outputs.environment }} + if: ${{ needs.set-environment.outputs.deployment_enabled == 'true' && (needs.deploy-bot.result == 'success' || needs.deploy-bot.result == 'skipped') }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - name: Azure Login + uses: azure/login@v2 with: - python-version: '3.10' + creds: ${{ secrets.AZURE_CREDENTIALS }} - - name: Install dependencies + - name: Run endpoint connectivity tests run: | - pip install pytest requests + ENV="${{ needs.set-environment.outputs.environment }}" + echo "Running smoke tests for $ENV environment..." + + # Make test script executable and run it + chmod +x ./tests/bot-endpoint-test.sh + ./tests/bot-endpoint-test.sh "$ENV" - - name: Run smoke tests + - name: Verify bot responds to health check run: | - # TODO: Implement smoke tests - # pytest tests/smoke/ --env=${{ github.event.inputs.environment || 'prod' }} - echo "Smoke tests would run here" + ENV="${{ needs.set-environment.outputs.environment }}" + RG="${{ needs.set-environment.outputs.resource_group }}" + + # Get bot FQDN + VM_FQDN=$(az network public-ip show \ + --resource-group "$RG" \ + --name "pennie-pip-${ENV}" \ + --query "dnsSettings.fqdn" -o tsv 2>/dev/null || echo "") + + if [ -z "$VM_FQDN" ]; then + echo "::warning::Could not get VM FQDN" + exit 0 + fi + + # Wait for bot to be ready (up to 60 seconds) + echo "Waiting for bot health endpoint..." + for i in {1..12}; do + HEALTH=$(curl -s -k "https://${VM_FQDN}/health" 2>/dev/null || echo "") + if [ "$HEALTH" = "Healthy" ]; then + echo "✅ Bot health check passed" + exit 0 + fi + echo "Attempt $i/12: Bot not ready yet..." + sleep 5 + done + + echo "::error::Bot health check failed after 60 seconds" + exit 1 - name: Notify on failure if: failure() run: | - echo "Deployment smoke tests failed! Rolling back..." - # TODO: Implement rollback logic + echo "::error::Deployment smoke tests failed!" + echo "" + echo "Troubleshooting steps:" + echo " 1. Check bot logs: ./scripts/bot-logs.sh ${{ needs.set-environment.outputs.environment }}" + echo " 2. Restart service: ./scripts/bot-restart.sh ${{ needs.set-environment.outputs.environment }}" + echo " 3. Run endpoint tests: ./tests/bot-endpoint-test.sh ${{ needs.set-environment.outputs.environment }}" + # Note: Rollback would require storing previous deployment artifact notify-deployment: name: Notify Deployment Status runs-on: ubuntu-latest - needs: [run-smoke-tests] - if: ${{ vars.AZURE_DEPLOYMENT_ENABLED == 'true' && always() }} + needs: [set-environment, run-smoke-tests] + if: ${{ needs.set-environment.outputs.deployment_enabled == 'true' && always() }} steps: - name: Send notification diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c212b3..3a1cffe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,6 @@ jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest - needs: [build-bot] steps: - name: Checkout code @@ -83,19 +82,37 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Run tests + - name: Restore dependencies + run: dotnet restore ./Pennie.sln + + - name: Build solution + run: dotnet build ./Pennie.sln --configuration Release --no-restore + + - name: Run unit tests run: | - # TODO: Implement unit tests - # dotnet test ./tests/unit/ --configuration Release --collect:"XPlat Code Coverage" - echo "Unit tests would run here" + dotnet test ./tests/PennieBot.Tests.csproj \ + --configuration Release \ + --no-build \ + --verbosity normal \ + --logger "trx;LogFileName=test-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./TestResults - name: Upload coverage if: always() uses: codecov/codecov-action@v4 with: - files: ./coverage.xml + directory: ./TestResults flags: unittests name: codecov-umbrella + fail_ci_if_error: false validate-bicep: name: Validate Bicep Templates diff --git a/.gitignore b/.gitignore index ce6a9d7..d326b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ .env.*.local *.env +# Local development config (contains secrets) +appsettings.local.json +bot/appsettings.local.json + # Azure and Keys *.pfx *.p12 @@ -118,3 +122,4 @@ ApplicationInsights.config bot/*.zip bot/publish-*/ bot/teams-manifest/*.zip +src/*.zip diff --git a/CLAUDE.md b/CLAUDE.md index 15a85e3..49d5d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,14 +105,26 @@ Pennie uses Microsoft's official Azure DevOps MCP Server for work item operation ## Deployment Strategy -### Target Environment (KnowAll Ltd - Internal Deployment) -- **Resource Group**: `TMinus15Agents` (existing in KnowAll Ltd tenant) +### Target Environments (KnowAll Ltd - Internal Deployment) + +**CRITICAL: Each environment uses a SEPARATE resource group. Never deploy test to prod or vice versa.** + +| Environment | Resource Group | VM Name | Description | +|-------------|----------------|---------|-------------| +| **Production** | `TMinus15Agents` | `pennie-vm-prod` | Live production environment | +| **Test** | `TMinus15Agents-Test` | `pennie-vm-test` | Test/staging environment (uses Spot VM) | + - **Location**: `uksouth` (single-region deployment for UK data residency) - **Subscription**: See `.env` file (not committed to Git) - **AI Hub**: `knowall-ai-foundry` (existing, UK South) - **AI Project**: `T-Minus-15 Agents` (existing) - **OpenAI Model**: GPT-4o (2024-08-06) - verified available in UK South +**GitHub Environment Configuration**: +- Each GitHub environment (`prod`, `test`) has its own `AZURE_RESOURCE_GROUP` secret +- The workflow reads from the environment-scoped secret, not a repo-level secret +- Test environment uses Spot VM (`useSpotVM: true`) for 60-80% cost savings + **Note for Other Deployers**: This is KnowAll's internal configuration. Choose your own region based on compliance needs. GPT-4o is available in UK South, East US 2, Sweden Central, and other regions. ### Deployment Scripts @@ -146,7 +158,7 @@ az deployment sub create \ --template-file infra/main.bicep \ --parameters environmentName=prod ``` -- Deploys AI Foundry Hub, Project, Storage, Key Vault, Monitoring +- Deploys AI Foundry Hub, Project, Storage, Monitoring - Windows VM for Teams Bot (future phase) ### GitHub Actions Workflow @@ -179,7 +191,7 @@ Values already configured in `.env`: - All components must reside within the organization's Azure tenant - No external services required -- Secrets managed via GitHub Secrets and Azure Key Vault +- Secrets managed via GitHub Secrets (set as environment variables during deployment) - Authentication uses managed identity, not PAT tokens where possible ## Repository Structure diff --git a/Pennie.sln b/Pennie.sln new file mode 100644 index 0000000..b797a57 --- /dev/null +++ b/Pennie.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PennieBot", "bot\PennieBot.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PennieBot.Tests", "tests\PennieBot.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/agent-config.json b/agent-config.json index 8b4f80b..69f3077 100644 --- a/agent-config.json +++ b/agent-config.json @@ -137,7 +137,7 @@ "owner": "Ben Weeks", "owner_email": "ben.weeks@outlook.com", "license": "MIT", - "repository": "https://github.com/benweeks/GetPenn.ie", + "repository": "https://github.com/KnowAll-AI/GetPenn.ie", "deployment_region": "uksouth", "model_info": { "name": "GPT-4o", diff --git a/bot/Bots/MediaBot.cs b/bot/Bots/MediaBot.cs index d73570f..9610984 100644 --- a/bot/Bots/MediaBot.cs +++ b/bot/Bots/MediaBot.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.Bot.Builder; using Microsoft.Bot.Schema; +using PennieBot.Helpers; using PennieBot.Services; namespace PennieBot.Bots; @@ -52,7 +53,7 @@ protected override async Task OnMessageActivityAsync( // Check for simple join commands (like "join", "come join", "join us") // These can auto-join if we're in a meeting context - if (IsSimpleJoinCommand(text)) + if (MeetingHelpers.IsSimpleJoinCommand(text)) { await HandleSimpleJoinCommandAsync(turnContext, cancellationToken); return; @@ -84,7 +85,7 @@ private async Task HandleGeneralConversationAsync( // Strip @mention markup (e.g., "Pennie") from user messages // Teams adds this XML when users @mention the bot in group chats - userMessage = StripAtMentions(userMessage); + userMessage = MeetingHelpers.StripAtMentions(userMessage); _logger.LogInformation("Forwarding message to Pennie: {Message}", userMessage); @@ -145,8 +146,8 @@ private async Task HandleJoinMeetingRequestAsync( // - "join meeting 396 240 783 591 15 tj3HN9jw" // - "join meeting id 396240783591 passcode tj3HN9jw" - var meetingId = ExtractMeetingId(originalText); - var passcode = ExtractPasscode(originalText); + var meetingId = MeetingHelpers.ExtractMeetingId(originalText); + var passcode = MeetingHelpers.ExtractPasscode(originalText); if (string.IsNullOrEmpty(meetingId)) { @@ -219,137 +220,6 @@ await turnContext.SendActivityAsync( } } - /// - /// Extract meeting ID from a message. Handles formats like "396 240 783 591 15" or "39624078359115". - /// - private static string? ExtractMeetingId(string text) - { - // Pattern 1: "id:" or "id :" followed by digits and spaces - var regexTimeout = TimeSpan.FromMilliseconds(100); - var idPattern = new System.Text.RegularExpressions.Regex( - @"id\s*:?\s*([\d\s]+)", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, - regexTimeout); - System.Text.RegularExpressions.Match match; - try - { - match = idPattern.Match(text); - } - catch (System.Text.RegularExpressions.RegexMatchTimeoutException) - { - return null; // Input too complex, reject - } - if (match.Success) - { - var id = match.Groups[1].Value.Trim(); - // Stop at "passcode" or end of digits - var passcodeIndex = id.IndexOf("passcode", StringComparison.OrdinalIgnoreCase); - if (passcodeIndex > 0) - { - id = id.Substring(0, passcodeIndex).Trim(); - } - // Remove any non-digit/space chars at the end - id = System.Text.RegularExpressions.Regex.Replace(id, @"[^\d\s]+$", "").Trim(); - if (IsValidMeetingIdFormat(id)) - { - return id; - } - } - - // Pattern 2: Look for a sequence of numbers that could be a meeting ID (10-30 digits) - var numberPattern = new System.Text.RegularExpressions.Regex( - @"(\d[\d\s]{9,30})", - System.Text.RegularExpressions.RegexOptions.None, - regexTimeout); - try - { - match = numberPattern.Match(text); - if (match.Success) - { - var id = match.Groups[1].Value.Trim(); - if (IsValidMeetingIdFormat(id)) - { - return id; - } - } - } - catch (System.Text.RegularExpressions.RegexMatchTimeoutException) - { - return null; // Input too complex, reject - } - - return null; - } - - /// - /// Validate that a meeting ID has the correct format (10-15 digits when spaces are removed). - /// - private static bool IsValidMeetingIdFormat(string? meetingId) - { - if (string.IsNullOrWhiteSpace(meetingId)) - { - return false; - } - - // Remove spaces and validate digit count - var digitsOnly = meetingId.Replace(" ", ""); - - // Teams meeting IDs are typically 10-15 digits - if (digitsOnly.Length < 10 || digitsOnly.Length > 15) - { - return false; - } - - // Ensure all characters are digits - return digitsOnly.All(char.IsDigit); - } - - /// - /// Extract passcode from a message. - /// - private static string? ExtractPasscode(string text) - { - var regexTimeout = TimeSpan.FromMilliseconds(100); - - // Pattern 1: "passcode:" or "passcode :" followed by alphanumeric - var passcodePattern = new System.Text.RegularExpressions.Regex( - @"passcode\s*:?\s*([a-zA-Z0-9]+)", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, - regexTimeout); - try - { - var match = passcodePattern.Match(text); - if (match.Success) - { - return match.Groups[1].Value; - } - } - catch (System.Text.RegularExpressions.RegexMatchTimeoutException) - { - return null; // Input too complex, reject - } - - // Pattern 2: Look for alphanumeric string after the meeting ID (8+ chars) - var lastWordPattern = new System.Text.RegularExpressions.Regex( - @"\s([a-zA-Z][a-zA-Z0-9]{5,})$", - System.Text.RegularExpressions.RegexOptions.None, - regexTimeout); - try - { - var match = lastWordPattern.Match(text.Trim()); - if (match.Success) - { - return match.Groups[1].Value; - } - } - catch (System.Text.RegularExpressions.RegexMatchTimeoutException) - { - return null; // Input too complex, reject - } - - return null; - } - /// /// Called when the bot is added to a conversation (meeting invite). /// @@ -588,73 +458,6 @@ private async Task OnTranscriptReceivedAsync( } } - /// - /// Check if the text is a simple join command (without explicit meeting ID). - /// - private static bool IsSimpleJoinCommand(string text) - { - // Remove bot mention from text for cleaner matching - var cleanText = StripAtMentions(text); - - // Check for simple join patterns - var joinPatterns = new[] - { - "join", - "come join", - "join us", - "join the meeting", - "join the call", - "join this meeting", - "join this call", - "please join", - "can you join" - }; - - foreach (var pattern in joinPatterns) - { - if (cleanText.Contains(pattern)) - { - return true; - } - } - - return false; - } - - /// - /// Strip @mention markup from Teams messages. - /// Teams wraps @mentions in XML like: "Pennie what projects do we have?" - /// or with attributes: "Pennie what projects do we have?" - /// This strips the markup so Pennie receives clean text. - /// - private static string StripAtMentions(string text) - { - if (string.IsNullOrEmpty(text)) - { - return text; - } - - // Remove ... tags (Teams @mention markup) - // Handles optional attributes like Name - // Uses timeout to prevent ReDoS attacks - try - { - var cleanText = System.Text.RegularExpressions.Regex.Replace( - text, - @"]*>.*?", - "", - System.Text.RegularExpressions.RegexOptions.None, - TimeSpan.FromMilliseconds(100)); - - return cleanText.Trim(); - } - catch (System.Text.RegularExpressions.RegexMatchTimeoutException) - { - // If regex times out, return original text - return text.Trim(); - } - } - /// /// Handle a simple join command by detecting meeting context and auto-joining. /// diff --git a/bot/Controllers/MeetingController.cs b/bot/Controllers/MeetingController.cs index 58da9b2..1956c98 100644 --- a/bot/Controllers/MeetingController.cs +++ b/bot/Controllers/MeetingController.cs @@ -113,9 +113,9 @@ await _transcriptionService.StartTranscriptionAsync( HttpContext.RequestAborted); transcriptionEnabled = true; } - catch (InvalidOperationException ex) when (ex.Message.Contains("AZURE-SPEECH-KEY")) + catch (InvalidOperationException ex) when (ex.Message.Contains("AZURE_SPEECH_KEY")) { - _logger.LogWarning("Transcription disabled: AZURE-SPEECH-KEY not configured. Meeting will join without transcription."); + _logger.LogWarning("Transcription disabled: AZURE_SPEECH_KEY not configured. Meeting will join without transcription."); } catch (Exception ex) { @@ -242,9 +242,9 @@ await _transcriptionService.StartTranscriptionAsync( HttpContext.RequestAborted); transcriptionEnabled = true; } - catch (InvalidOperationException ex) when (ex.Message.Contains("AZURE-SPEECH-KEY")) + catch (InvalidOperationException ex) when (ex.Message.Contains("AZURE_SPEECH_KEY")) { - _logger.LogWarning("Transcription disabled: AZURE-SPEECH-KEY not configured. Meeting will join without transcription."); + _logger.LogWarning("Transcription disabled: AZURE_SPEECH_KEY not configured. Meeting will join without transcription."); } catch (Exception ex) { diff --git a/bot/Helpers/MeetingHelpers.cs b/bot/Helpers/MeetingHelpers.cs new file mode 100644 index 0000000..6d4eee2 --- /dev/null +++ b/bot/Helpers/MeetingHelpers.cs @@ -0,0 +1,210 @@ +using System.Text.RegularExpressions; + +namespace PennieBot.Helpers; + +/// +/// Helper methods for parsing meeting-related data from user messages. +/// These methods are internal to allow unit testing via InternalsVisibleTo. +/// +internal static class MeetingHelpers +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + + /// + /// Extract meeting ID from a message. Handles formats like "396 240 783 591 15" or "39624078359115". + /// + internal static string? ExtractMeetingId(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + // Pattern 1: "id:" or "id :" followed by digits and spaces + var idPattern = new Regex( + @"id\s*:?\s*([\d\s]+)", + RegexOptions.IgnoreCase, + RegexTimeout); + + Match match; + try + { + match = idPattern.Match(text); + } + catch (RegexMatchTimeoutException) + { + return null; // Input too complex, reject + } + + if (match.Success) + { + var id = match.Groups[1].Value.Trim(); + // Stop at "passcode" or end of digits + var passcodeIndex = id.IndexOf("passcode", StringComparison.OrdinalIgnoreCase); + if (passcodeIndex > 0) + { + id = id.Substring(0, passcodeIndex).Trim(); + } + // Remove any non-digit/space chars at the end (with timeout for ReDoS protection) + id = Regex.Replace(id, @"[^\d\s]+$", "", RegexOptions.None, RegexTimeout).Trim(); + if (IsValidMeetingIdFormat(id)) + { + return id; + } + } + + // Pattern 2: Look for a sequence of numbers that could be a meeting ID (10-30 characters including spaces) + var numberPattern = new Regex( + @"(\d[\d\s]{9,29})", + RegexOptions.None, + RegexTimeout); + + try + { + match = numberPattern.Match(text); + if (match.Success) + { + var id = match.Groups[1].Value.Trim(); + if (IsValidMeetingIdFormat(id)) + { + return id; + } + } + } + catch (RegexMatchTimeoutException) + { + return null; // Input too complex, reject + } + + return null; + } + + /// + /// Validate that a meeting ID has the correct format (10-15 digits when spaces are removed). + /// + internal static bool IsValidMeetingIdFormat(string? meetingId) + { + if (string.IsNullOrWhiteSpace(meetingId)) + { + return false; + } + + // Remove spaces and validate digit count + var digitsOnly = meetingId.Replace(" ", ""); + + // Teams meeting IDs are typically 10-15 digits + if (digitsOnly.Length < 10 || digitsOnly.Length > 15) + { + return false; + } + + // Ensure all characters are digits + return digitsOnly.All(char.IsDigit); + } + + /// + /// Extract passcode from a message. + /// + internal static string? ExtractPasscode(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + // Pattern 1: "passcode:" or "passcode :" followed by alphanumeric + var passcodePattern = new Regex( + @"passcode\s*:?\s*([a-zA-Z0-9]+)", + RegexOptions.IgnoreCase, + RegexTimeout); + + try + { + var match = passcodePattern.Match(text); + if (match.Success) + { + return match.Groups[1].Value; + } + } + catch (RegexMatchTimeoutException) + { + return null; + } + + return null; + } + + /// + /// Check if the text is a simple join command (without explicit meeting ID). + /// + internal static bool IsSimpleJoinCommand(string text) + { + // Remove bot mention from text for cleaner matching + var cleanText = StripAtMentions(text); + + // Check for simple join patterns + var simpleJoinPatterns = new[] + { + "join", + "join meeting", + "join the meeting", + "join this meeting", + "join call", + "join the call", + "join this call" + }; + + var normalizedText = cleanText.ToLowerInvariant().Trim(); + + foreach (var pattern in simpleJoinPatterns) + { + if (normalizedText == pattern || normalizedText.StartsWith(pattern + " ")) + { + // Make sure it's not followed by a meeting ID + var remainder = normalizedText.Length > pattern.Length + ? normalizedText.Substring(pattern.Length).Trim() + : ""; + + // If the remainder contains digits that look like a meeting ID, it's not a simple join + if (!string.IsNullOrEmpty(remainder) && remainder.Any(char.IsDigit)) + { + return false; + } + + return true; + } + } + + return false; + } + + /// + /// Strip @mention markup from Teams messages. + /// Teams wraps @mentions in XML like: "<at>Pennie</at> what projects do we have?" + /// or with attributes: "<at id="...">Pennie</at> what projects do we have?" + /// This strips the markup so Pennie receives clean text. + /// + internal static string StripAtMentions(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + // Remove ... tags (Teams @mention markup) + // Handles optional attributes like Name + // Uses timeout to prevent ReDoS attacks + try + { + var cleanText = Regex.Replace( + text, + @"]*>.*?", + "", + RegexOptions.None, + RegexTimeout); + + return cleanText.Trim(); + } + catch (RegexMatchTimeoutException) + { + // If regex times out, return original text + return text.Trim(); + } + } +} diff --git a/bot/PennieBot.csproj b/bot/PennieBot.csproj index 1fcffa3..44893dc 100644 --- a/bot/PennieBot.csproj +++ b/bot/PennieBot.csproj @@ -9,6 +9,11 @@ pennie-bot-secrets + + + + + diff --git a/bot/Program.cs b/bot/Program.cs index 19bf17b..4da9167 100644 --- a/bot/Program.cs +++ b/bot/Program.cs @@ -1,5 +1,3 @@ -using Azure.Identity; -using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Bot.Connector.Authentication; @@ -10,19 +8,12 @@ var builder = WebApplication.CreateBuilder(args); // Configuration +// Load order: appsettings.json -> appsettings.{Environment}.json -> appsettings.local.json -> env vars +// Later files override earlier ones. appsettings.local.json is gitignored for developer secrets. +// Secrets are managed via GitHub Secrets and set as environment variables during deployment. +builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true); builder.Configuration.AddEnvironmentVariables(); -// Add Key Vault if configured -// Uses Azure.Extensions.AspNetCore.Configuration.Secrets with DefaultAzureCredential -// On the VM, this uses the managed identity for authentication -var keyVaultName = builder.Configuration["AZURE_KEY_VAULT_NAME"]; -if (!string.IsNullOrEmpty(keyVaultName)) -{ - var keyVaultUri = new Uri($"https://{keyVaultName}.vault.azure.net/"); - builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential()); - Console.WriteLine($"Key Vault configuration loaded from: {keyVaultName}"); -} - // Application Insights builder.Services.AddApplicationInsightsTelemetry(options => { @@ -50,9 +41,7 @@ // Only register PennieAgentClient if Azure OpenAI is configured // This is optional - the bot can still handle simple queries via HTTP client -// Note: Config keys try dashes first (Azure Key Vault convention), then underscores for backward compatibility -var openaiEndpoint = builder.Configuration["AZURE-OPENAI-ENDPOINT"] - ?? builder.Configuration["AZURE_OPENAI_ENDPOINT"]; +var openaiEndpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]; if (!string.IsNullOrEmpty(openaiEndpoint)) { builder.Services.AddSingleton(); diff --git a/bot/Properties/launchSettings.json b/bot/Properties/launchSettings.json new file mode 100644 index 0000000..cb57cee --- /dev/null +++ b/bot/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:3979;http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/bot/README.md b/bot/README.md index 5c8c8ca..242befe 100644 --- a/bot/README.md +++ b/bot/README.md @@ -48,7 +48,6 @@ Set these in Azure Key Vault or Windows environment variables: | `AZURE_SPEECH_KEY` | Azure Speech Services API key | | `AZURE_LOCATION` | Azure region (e.g., `uksouth`) | | `PENNIE_AGENT_ENDPOINT` | Pennie AI Foundry Agent endpoint URL | -| `AZURE_KEY_VAULT_NAME` | Azure Key Vault name for secrets | | `APPLICATIONINSIGHTS_CONNECTION_STRING` | Application Insights connection string | ### appsettings.json diff --git a/bot/Services/NullPennieAgentClient.cs b/bot/Services/NullPennieAgentClient.cs index 024f9ae..e98dee6 100644 --- a/bot/Services/NullPennieAgentClient.cs +++ b/bot/Services/NullPennieAgentClient.cs @@ -11,7 +11,7 @@ public class NullPennieAgentClient : IPennieAgentClient public NullPennieAgentClient(ILogger logger) { _logger = logger; - _logger.LogWarning("PennieAgentClient is disabled - AZURE-OPENAI-ENDPOINT not configured"); + _logger.LogWarning("PennieAgentClient is disabled - AZURE_OPENAI_ENDPOINT not configured"); } public Task SendTranscriptAsync(TranscriptionResult result, CancellationToken cancellationToken = default) diff --git a/bot/Services/PennieAgentClient.cs b/bot/Services/PennieAgentClient.cs index 38567fe..e185c83 100644 --- a/bot/Services/PennieAgentClient.cs +++ b/bot/Services/PennieAgentClient.cs @@ -57,15 +57,12 @@ public PennieAgentClient( // IMPORTANT: The Azure.AI.OpenAI.Assistants SDK requires an Azure OpenAI endpoint // in the format: https://{resource-name}.openai.azure.com // This is different from AI Foundry project URLs which use .services.ai.azure.com - // Note: Config keys try dashes first (Azure Key Vault convention), then underscores for backward compatibility - var endpoint = _configuration["AZURE-OPENAI-ENDPOINT"] - ?? _configuration["AZURE_OPENAI_ENDPOINT"] - ?? throw new InvalidOperationException("AZURE-OPENAI-ENDPOINT not configured. " + + var endpoint = _configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT not configured. " + "Expected format: https://{resource}.openai.azure.com"); - _assistantId = _configuration["AZURE-OPENAI-ASSISTANT-ID"] - ?? _configuration["AZURE_OPENAI_ASSISTANT_ID"] - ?? throw new InvalidOperationException("AZURE-OPENAI-ASSISTANT-ID not configured"); + _assistantId = _configuration["AZURE_OPENAI_ASSISTANT_ID"] + ?? throw new InvalidOperationException("AZURE_OPENAI_ASSISTANT_ID not configured"); _backendUrl = _configuration["AZURE_FUNCTIONS_BACKEND_URL"] ?? "https://pennie-backend-prod.azurewebsites.net"; // Default to production backend diff --git a/bot/Services/SpeechTranscriptionService.cs b/bot/Services/SpeechTranscriptionService.cs index 59d2ca8..35b7d33 100644 --- a/bot/Services/SpeechTranscriptionService.cs +++ b/bot/Services/SpeechTranscriptionService.cs @@ -25,13 +25,13 @@ public SpeechTranscriptionService( _logger = logger; _configuration = configuration; - // Log Speech configuration at startup to verify Key Vault loading - var speechKey = configuration["AZURE-SPEECH-KEY"]; - var speechRegion = configuration["AZURE-LOCATION"] ?? "uksouth"; + // Log Speech configuration at startup to verify loading + var speechKey = configuration["AZURE_SPEECH_KEY"]; + var speechRegion = configuration["AZURE_LOCATION"] ?? "uksouth"; if (string.IsNullOrEmpty(speechKey)) { - _logger.LogWarning("STARTUP: AZURE-SPEECH-KEY is NOT configured - transcription will be disabled"); + _logger.LogWarning("STARTUP: AZURE_SPEECH_KEY is NOT configured - transcription will be disabled"); } else { @@ -61,10 +61,10 @@ public async Task StartTranscriptionAsync( { _logger.LogInformation("Starting transcription for meeting {MeetingId}", meetingId); - // Create Speech configuration (uses dashes for Key Vault compatibility) - var speechKey = _configuration["AZURE-SPEECH-KEY"] - ?? throw new InvalidOperationException("AZURE-SPEECH-KEY not configured"); - var speechRegion = _configuration["AZURE-LOCATION"] ?? "uksouth"; + // Create Speech configuration + var speechKey = _configuration["AZURE_SPEECH_KEY"] + ?? throw new InvalidOperationException("AZURE_SPEECH_KEY not configured"); + var speechRegion = _configuration["AZURE_LOCATION"] ?? "uksouth"; // Get speech language from configuration, default to en-GB for UK users var speechLanguage = _configuration["SpeechRecognitionLanguage"] ?? "en-GB"; diff --git a/bot/appsettings.Test.json b/bot/appsettings.Test.json new file mode 100644 index 0000000..2c2d14b --- /dev/null +++ b/bot/appsettings.Test.json @@ -0,0 +1,34 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.Bot": "Debug", + "Microsoft.Graph.Communications": "Information" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Debug" + } + } + }, + + "BotBaseUrl": "https://pennie-test-{unique}.uksouth.cloudapp.azure.com", + + "MediaPlatform": { + "ServiceFqdn": "pennie-test-{unique}.uksouth.cloudapp.azure.com", + "InstancePublicPort": 8445, + "InstanceInternalPort": 8445, + "CallSignalingPort": 9441, + "CallNotificationUrl": "https://pennie-test-{unique}.uksouth.cloudapp.azure.com/api/calling", + "CertificateThumbprint": "", + "MediaDnsName": "pennie-test-{unique}.uksouth.cloudapp.azure.com", + "MediaInstanceExternalPort": 20000, + "UseApplicationHostedMedia": true + }, + + "AZURE_LOCATION": "uksouth", + "SpeechRecognitionLanguage": "en-GB", + + "AZURE_FUNCTIONS_BACKEND_URL": "https://pennie-backend-test.azurewebsites.net" +} diff --git a/bot/appsettings.json b/bot/appsettings.json index 34f3be3..fcdb373 100644 --- a/bot/appsettings.json +++ b/bot/appsettings.json @@ -20,28 +20,27 @@ "TeamsAppId": "", "TeamsAppPassword": "", "AzureTenantId": "", - "BotBaseUrl": "https://pennie-prod-mmdxqm3w7kjwm.uksouth.cloudapp.azure.com", + "BotBaseUrl": "", "MediaPlatform": { - "ServiceFqdn": "pennie-prod-mmdxqm3w7kjwm.uksouth.cloudapp.azure.com", + "ServiceFqdn": "", "InstancePublicPort": 8445, "InstanceInternalPort": 8445, "CallSignalingPort": 9441, - "CallNotificationUrl": "https://pennie-prod-mmdxqm3w7kjwm.uksouth.cloudapp.azure.com/api/calling", + "CallNotificationUrl": "", "CertificateThumbprint": "", - "MediaDnsName": "pennie-prod-mmdxqm3w7kjwm.uksouth.cloudapp.azure.com", + "MediaDnsName": "", "MediaInstanceExternalPort": 20000, - "UseApplicationHostedMedia": true + "UseApplicationHostedMedia": false }, - "AZURE-SPEECH-KEY": "", - "AZURE-LOCATION": "uksouth", + "AZURE_SPEECH_KEY": "", + "AZURE_LOCATION": "uksouth", "SpeechRecognitionLanguage": "en-GB", - "AZURE-OPENAI-ENDPOINT": "", - "AZURE-OPENAI-ASSISTANT-ID": "", - "AZURE_FUNCTIONS_BACKEND_URL": "https://pennie-backend-prod.azurewebsites.net", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_ASSISTANT_ID": "", + "AZURE_FUNCTIONS_BACKEND_URL": "", - "APPLICATIONINSIGHTS_CONNECTION_STRING": "", - "AZURE_KEY_VAULT_NAME": "" + "APPLICATIONINSIGHTS_CONNECTION_STRING": "" } diff --git a/bot/appsettings.local.json.template b/bot/appsettings.local.json.template new file mode 100644 index 0000000..3f53df6 --- /dev/null +++ b/bot/appsettings.local.json.template @@ -0,0 +1,40 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.Bot": "Debug" + } + }, + + "// LOCAL DEV": "Copy this file to appsettings.local.json and fill in values", + + "// Bot Registration": "Create a bot registration in Azure Portal", + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + + "// Teams App": "Same as bot registration for single-tenant apps", + "TeamsAppId": "", + "TeamsAppPassword": "", + "AzureTenantId": "", + + "// Dev Tunnel": "Run 'devtunnel host -p 3978 --allow-anonymous' and use the URL", + "BotBaseUrl": "https://YOUR-TUNNEL-ID.euw.devtunnels.ms", + + "// Media Platform": "Disabled for local dev - audio capture requires Windows Server", + "MediaPlatform": { + "UseApplicationHostedMedia": false + }, + + "// Speech Services": "Get key from Azure Portal > Speech Services > Keys", + "AZURE_SPEECH_KEY": "", + "AZURE_LOCATION": "uksouth", + "SpeechRecognitionLanguage": "en-GB", + + "// Pennie Agent": "Get from AI Foundry or scripts/deploy-agent.sh output", + "AZURE_OPENAI_ENDPOINT": "https://YOUR-RESOURCE.openai.azure.com", + "AZURE_OPENAI_ASSISTANT_ID": "asst_YOUR_ASSISTANT_ID", + + "// Backend": "Use prod backend or run locally with 'func start' in backend/", + "AZURE_FUNCTIONS_BACKEND_URL": "https://pennie-backend-prod.azurewebsites.net" +} diff --git a/bot/teams-manifest/manifest.json b/bot/teams-manifest/manifest.prod.json similarity index 100% rename from bot/teams-manifest/manifest.json rename to bot/teams-manifest/manifest.prod.json diff --git a/bot/teams-manifest/manifest.test.json b/bot/teams-manifest/manifest.test.json new file mode 100644 index 0000000..d65ef18 --- /dev/null +++ b/bot/teams-manifest/manifest.test.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json", + "manifestVersion": "1.17", + "version": "1.6.0", + "id": "0f3c7372-d0f6-4da4-8334-dd03feb521c9", + "developer": { + "name": "KnowAll Ltd", + "websiteUrl": "https://getpenn.ie", + "privacyUrl": "https://getpenn.ie/privacy", + "termsOfUseUrl": "https://getpenn.ie/terms" + }, + "name": { + "short": "Pennie the Prepper (Test)", + "full": "Pennie the Prepper - Azure DevOps Assistant (Test)" + }, + "description": { + "short": "AI assistant for Azure DevOps backlog management (TEST)", + "full": "Pennie the Prepper is an AI-powered assistant that helps you manage Azure DevOps projects. Ask questions like 'What projects do we have in DevOps?' and get instant answers. This is the TEST environment." + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "accentColor": "#9C27B0", + "bots": [ + { + "botId": "131b79ec-a659-4b35-aaf8-92185d97e457", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "supportsFiles": false, + "isNotificationOnly": false, + "supportsCalling": true, + "supportsVideo": false, + "commandLists": [ + { + "scopes": [ + "personal", + "team", + "groupChat" + ], + "commands": [ + { + "title": "help", + "description": "Show available commands" + }, + { + "title": "projects", + "description": "List Azure DevOps projects" + } + ] + } + ] + } + ], + "configurableTabs": [ + { + "configurationUrl": "https://pennie-test-vgn7kzlubtavo.uksouth.cloudapp.azure.com/config.html", + "canUpdateConfiguration": true, + "scopes": ["team", "groupChat"], + "context": [ + "meetingChatTab", + "meetingDetailsTab", + "meetingSidePanel" + ] + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "pennie-test-vgn7kzlubtavo.uksouth.cloudapp.azure.com" + ], + "webApplicationInfo": { + "id": "131b79ec-a659-4b35-aaf8-92185d97e457", + "resource": "api://botid-131b79ec-a659-4b35-aaf8-92185d97e457" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { + "name": "OnlineMeeting.ReadBasic.Chat", + "type": "Delegated" + }, + { + "name": "OnlineMeetingParticipant.Read.Chat", + "type": "Delegated" + }, + { + "name": "OnlineMeetingParticipant.ToggleIncomingAudio.Chat", + "type": "Delegated" + } + ] + } + }, + "meetingExtensionDefinition": { + "scenes": [], + "supportsStreaming": false + } +} diff --git a/bot/teams-manifest/pennie-app-v1.0.1.zip b/bot/teams-manifest/pennie-app-v1.0.1.zip deleted file mode 100644 index b00aa03..0000000 Binary files a/bot/teams-manifest/pennie-app-v1.0.1.zip and /dev/null differ diff --git a/bot/teams-manifest/pennie-app-v1.1.0.zip b/bot/teams-manifest/pennie-app-v1.1.0.zip deleted file mode 100644 index cb4cbd8..0000000 Binary files a/bot/teams-manifest/pennie-app-v1.1.0.zip and /dev/null differ diff --git a/bot/teams-manifest/pennie-app-v1.6.0.zip b/bot/teams-manifest/pennie-app-v1.6.0.zip deleted file mode 100644 index e0f9f2b..0000000 Binary files a/bot/teams-manifest/pennie-app-v1.6.0.zip and /dev/null differ diff --git a/bot/teams-manifest/pennie-teams-app.zip b/bot/teams-manifest/pennie-teams-app.zip deleted file mode 100644 index 35b6439..0000000 Binary files a/bot/teams-manifest/pennie-teams-app.zip and /dev/null differ diff --git a/docs/BRAND_GUIDE.adoc b/docs/BRAND_GUIDE.adoc new file mode 100644 index 0000000..ff08baf --- /dev/null +++ b/docs/BRAND_GUIDE.adoc @@ -0,0 +1,691 @@ += Pennie the Prepper - Brand Guidelines +:toc: left +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +*Version*: 1.0 + +*Last Updated*: 2025-10-11 + +*Status*: Draft + +This document defines the visual identity, brand colors, typography, and design principles for Pennie the Prepper. + +== Brand Identity + +=== Brand Positioning + +*Pennie the Prepper* is an AI-powered meeting assistant that transforms Teams meetings into actionable Azure DevOps work items using the T-Minus-15 methodology. + +*Brand Personality:* + +* *Professional*: Enterprise-grade, reliable, trustworthy +* *Efficient*: Streamlines meeting outcomes, saves time +* *Intelligent*: AI-powered, context-aware, proactive +* *Organized*: Methodical, structured, preparation-focused +* *Approachable*: Friendly assistant, not intimidating + +*Brand Voice:* + +* Clear and concise +* Professional but conversational +* Action-oriented +* Helpful and supportive +* Technical when needed, accessible always + +== Color Palette + +=== Primary Colors + +Our primary palette combines *KnowAll's lime green brand identity* with *Microsoft Teams/Azure blue* for platform consistency. + +==== Lime Green (KnowAll Brand) + +[source] +---- +Primary Lime: #BEF264 (Tailwind lime-300) +Bright Lime: #84CC16 (Tailwind lime-500) +Dark Lime: #65A30D (Tailwind lime-600) +---- + +*Usage:* + +* Primary accent color +* Call-to-action buttons +* Success states +* Highlights and emphasis +* Links and interactive elements + +*CSS Variables:* + +[source,css] +---- +--pennie-lime-light: #BEF264; +--pennie-lime: #84CC16; +--pennie-lime-dark: #65A30D; +---- + +==== Azure Blue (Microsoft Ecosystem) + +[source] +---- +Primary Blue: #0078D4 (Microsoft Azure blue) +Light Blue: #50E6FF (Azure accent) +Teams Blue: #6264A7 (Microsoft Teams purple-blue) +---- + +*Usage:* + +* Secondary brand color +* Azure/Teams integration visuals +* Information states +* Neutral accents + +*CSS Variables:* + +[source,css] +---- +--pennie-azure: #0078D4; +--pennie-azure-light: #50E6FF; +--pennie-teams: #6264A7; +---- + +=== Secondary Colors + +==== Dark Theme (KnowAll Inspired) + +[source] +---- +Black: #000000 (Pure black) +Gray 950: #0A0A0A (Near black backgrounds) +Gray 900: #171717 (Dark backgrounds) +Gray 800: #262626 (Elevated surfaces) +Gray 700: #404040 (Borders, dividers) +---- + +*Usage:* + +* Dark mode backgrounds +* High-contrast layouts +* Technical/developer interfaces +* Diagram backgrounds + +==== Light Theme (Microsoft Inspired) + +[source] +---- +White: #FFFFFF (Pure white) +Gray 50: #F9FAFB (Light backgrounds) +Gray 100: #F3F4F6 (Subtle backgrounds) +Gray 200: #E5E7EB (Borders) +Gray 300: #D1D5DB (Dividers) +---- + +*Usage:* + +* Light mode backgrounds +* Documentation pages +* Teams integration UI +* Presentation slides + +=== Semantic Colors + +==== Success + +[source] +---- +Success Green: #10B981 (Tailwind emerald-500) +Success Light: #D1FAE5 (Tailwind emerald-100) +Success Dark: #047857 (Tailwind emerald-700) +---- + +*Usage:* Completed actions, successful work item creation, confirmations + +==== Warning + +[source] +---- +Warning Orange: #F59E0B (Tailwind amber-500) +Warning Light: #FEF3C7 (Tailwind amber-100) +Warning Dark: #B45309 (Tailwind amber-700) +---- + +*Usage:* Pending actions, clarification needed, cautions + +==== Error + +[source] +---- +Error Red: #EF4444 (Tailwind red-500) +Error Light: #FEE2E2 (Tailwind red-100) +Error Dark: #B91C1C (Tailwind red-700) +---- + +*Usage:* Errors, failed operations, critical alerts + +==== Info + +[source] +---- +Info Blue: #3B82F6 (Tailwind blue-500) +Info Light: #DBEAFE (Tailwind blue-100) +Info Dark: #1E40AF (Tailwind blue-700) +---- + +*Usage:* Information, tips, neutral states + +=== Color Usage Matrix + +[cols="1,1,1,1"] +|=== +|Element |Light Mode |Dark Mode |Accent + +|*Primary Action* +|Lime 500 +|Lime 300 +|— + +|*Secondary Action* +|Azure Blue +|Azure Light +|— + +|*Background* +|White / Gray 50 +|Gray 950 / 900 +|— + +|*Surface* +|White +|Gray 900 / 800 +|— + +|*Text Primary* +|Gray 900 +|White +|— + +|*Text Secondary* +|Gray 600 +|Gray 400 +|— + +|*Border* +|Gray 200 +|Gray 700 +|— + +|*Link* +|Lime 600 +|Lime 300 +|Lime 500 (hover) + +|*Code Block* +|Gray 100 +|Gray 900 +|Lime 500 (syntax) +|=== + +== Typography + +=== Font Families + +==== Primary Font: Segoe UI + +[source,css] +---- +font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif; +---- + +*Rationale:* Native to Windows and Microsoft Teams, ensures consistency across the Microsoft ecosystem. + +*Weights:* + +* *Light (300)*: Large display text +* *Regular (400)*: Body text, paragraphs +* *Semibold (600)*: Subheadings, emphasis +* *Bold (700)*: Headings, strong emphasis + +==== Monospace Font: Cascadia Code + +[source,css] +---- +font-family: 'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace; +---- + +*Usage:* Code snippets, JSON examples, technical documentation, developer-facing content + +=== Type Scale + +[cols="1,1,1,1,1"] +|=== +|Element |Size |Weight |Line Height |Letter Spacing + +|*H1 Display* +|48px (3rem) +|Light 300 +|1.2 +|-0.02em + +|*H1* +|36px (2.25rem) +|Semibold 600 +|1.2 +|-0.01em + +|*H2* +|30px (1.875rem) +|Semibold 600 +|1.3 +|0 + +|*H3* +|24px (1.5rem) +|Semibold 600 +|1.4 +|0 + +|*H4* +|20px (1.25rem) +|Semibold 600 +|1.4 +|0 + +|*H5* +|18px (1.125rem) +|Semibold 600 +|1.5 +|0 + +|*Body Large* +|18px (1.125rem) +|Regular 400 +|1.6 +|0 + +|*Body* +|16px (1rem) +|Regular 400 +|1.6 +|0 + +|*Body Small* +|14px (0.875rem) +|Regular 400 +|1.5 +|0 + +|*Caption* +|12px (0.75rem) +|Regular 400 +|1.4 +|0.01em + +|*Code* +|14px (0.875rem) +|Regular 400 +|1.6 +|0 +|=== + +=== Typography Examples + +*Headings:* + +[source,css] +---- +h1 { + font-size: 36px; + font-weight: 600; + line-height: 1.2; + color: var(--gray-900); /* Light mode */ + color: var(--white); /* Dark mode */ +} + +h2 { + font-size: 30px; + font-weight: 600; + line-height: 1.3; + margin-top: 2rem; + margin-bottom: 1rem; +} +---- + +*Body Text:* + +[source,css] +---- +body { + font-family: 'Segoe UI', sans-serif; + font-size: 16px; + font-weight: 400; + line-height: 1.6; + color: #262626; /* Gray 800 */ +} +---- + +*Code Blocks:* + +[source,css] +---- +code { + font-family: 'Cascadia Code', monospace; + font-size: 14px; + background: #F3F4F6; /* Light mode */ + background: #171717; /* Dark mode */ + padding: 0.2em 0.4em; + border-radius: 3px; +} +---- + +== Logo & Iconography + +=== Logo Variations + +*Primary Logo:* + +* Full color version with "Pennie the Prepper" wordmark +* Minimum size: 120px width +* Clear space: 20px on all sides + +*Icon-Only:* + +* Square format (512x512) +* Works at small sizes (32x32) +* Recognizable without text + +*Monochrome:* + +* Single-color versions for special uses +* White on dark backgrounds +* Dark on light backgrounds + +=== Icon Style + +*Design Principles:* + +* *Stroke weight*: 2px for 24x24 icons +* *Corner radius*: 2px for rounded elements +* *Padding*: 2-3px from icon edges +* *Style*: Outlined (not filled) for consistency with Fluent UI + +*Inspiration:* + +* Microsoft Fluent UI System Icons +* Phosphor Icons (outline style) +* Heroicons (outline variant) + +*Status Icons:* + +* Listening: Waveform or microphone icon +* Processing: Spinning gear or dots +* Creating: Plus icon or pencil +* Idle: Clock or pause icon +* Error: X or exclamation triangle + +== Layout & Spacing + +=== Spacing Scale (8px base unit) + +[source] +---- +4px (0.25rem) - Tiny +8px (0.5rem) - XXS +12px (0.75rem) - XS +16px (1rem) - SM (base) +24px (1.5rem) - MD +32px (2rem) - LG +48px (3rem) - XL +64px (4rem) - 2XL +96px (6rem) - 3XL +---- + +*Usage:* + +* Component padding: 16px (SM) +* Section spacing: 48px (XL) +* Card margins: 24px (MD) +* Button padding: 12px 24px (XS MD) + +=== Grid System + +*12-column grid* with 24px gutters + +*Breakpoints:* + +[source] +---- +Mobile: < 640px +Tablet: 640px - 1024px +Desktop: > 1024px +Wide: > 1440px +---- + +=== Container Max Widths + +[source] +---- +Documentation: 896px (prose width) +Dashboard: 1280px +Full bleed: 100% +---- + +== Image Guidelines + +=== Photography Style + +*Preferred:* + +* Professional meeting environments +* Diverse teams collaborating +* Modern office spaces +* Clean, uncluttered backgrounds + +*Avoid:* + +* Stock photo "corporate" clichés +* Overly staged scenarios +* Distracting backgrounds +* Low resolution or pixelated images + +=== Image Treatments + +*Overlays:* + +[source,css] +---- +background: linear-gradient(135deg, rgba(0,0,0,0.7), rgba(132,204,22,0.3)); +---- + +*Filters:* + +* Subtle desaturation for backgrounds +* Lime green tint for brand consistency +* High contrast for readability + +== Design Principles + +=== 1. Clarity Over Cleverness + +* Clear, direct communication +* No jargon without explanation +* Obvious interaction patterns + +=== 2. Consistency + +* Reuse components and patterns +* Match Microsoft Teams design language +* Predictable behavior + +=== 3. Accessibility + +* WCAG 2.1 AA minimum +* Color contrast ratios: 4.5:1 (text), 3:1 (UI) +* Keyboard navigation support +* Screen reader friendly + +=== 4. Performance + +* Optimize images (WebP, lazy loading) +* Minimize file sizes +* Fast, responsive interactions + +=== 5. Microsoft Ecosystem Alignment + +* Follow Fluent UI design patterns +* Match Teams visual language +* Consistent with Azure branding + +== Motion & Animation + +=== Animation Principles + +*Timing:* + +[source] +---- +Fast: 100-200ms (micro-interactions, hovers) +Medium: 200-400ms (transitions, reveals) +Slow: 400-600ms (major state changes) +---- + +*Easing:* + +[source,css] +---- +--ease-in: cubic-bezier(0.4, 0, 1, 1); +--ease-out: cubic-bezier(0, 0, 0.2, 1); +--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); +---- + +*Common Animations:* + +* Fade in: `opacity 0 → 1` (300ms) +* Slide in: `translateY(20px) → 0` (400ms) +* Scale up: `scale(0.95) → 1` (200ms) + +*Avoid:* + +* Excessive animation +* Parallax effects +* Continuous animations (drain battery) +* Motion without purpose + +== Tone of Voice + +=== Writing Style + +*Do:* + +* Use active voice ("Pennie creates work items") +* Be concise and direct +* Use "you" and "your" (second person) +* Explain technical terms when first used +* Use examples and real scenarios + +*Don't:* + +* Use passive voice ("Work items are created") +* Be overly formal or corporate +* Use unexplained acronyms +* Make assumptions about user knowledge +* Use humor at the expense of clarity + +=== Example Copy + +*Good:* + +[quote] +____ +Pennie listens to your Teams meeting and automatically creates Azure DevOps work items. You'll see Epics, Features, and User Stories appear in real-time as decisions are made. +____ + +*Bad:* + +[quote] +____ +Leveraging advanced NLP algorithms, our solution facilitates the transformation of verbal discourse into structured work item artifacts within your project management ecosystem. +____ + +== Common Mistakes to Avoid + +=== Color + +* Using lime green for errors (use red) +* Low contrast text (fails accessibility) +* Mixing warm and cool grays +* Overusing bright lime (use as accent only) + +=== Typography + +* Too many font weights in one view +* Line lengths over 75 characters (prose) +* All caps for long text +* Centered body text + +=== Layout + +* Inconsistent spacing +* Not aligning to grid +* Cluttered interfaces +* Ignoring mobile view + +=== Icons + +* Mixing icon styles (outline + filled) +* Inconsistent icon sizes in the same context +* Using icons without labels in complex UIs +* Custom icons that don't match Fluent UI style + +== Brand Assets + +All brand assets are located in `/assets/` directory: + +[source] +---- +assets/ +├── avatars/ # Pennie avatars (512x512) +├── icons/ # App icons (16-512px) +├── teams/ # Teams-specific assets +├── banners/ # Marketing banners +├── diagrams/ # Architecture diagrams +├── social/ # Social media assets +└── brand/ # Logo files, color swatches +---- + +*See:* link:../assets/README.md[assets/README.md] for complete asset list. + +== Version History + +[cols="1,1,3"] +|=== +|Version |Date |Changes + +|1.0 +|2025-10-11 +|Initial brand guidelines created + +| +| +|KnowAll lime green palette integrated + +| +| +|Microsoft Teams/Azure alignment defined + +| +| +|Typography scale established +|=== + +== Questions? + +For questions about brand guidelines: + +* *Documentation*: See link:SOLUTION_DESIGN.adoc[SOLUTION_DESIGN.adoc] +* *Issues*: https://github.com/bengweeks/GetPenn.ie/issues[GitHub Issues] +* *Assets*: link:../assets/README.md[assets/README.md] + +''' + +*Maintained by*: KnowAll Design Team + +*Last Review*: 2025-10-11 diff --git a/docs/BRAND_GUIDE.md b/docs/BRAND_GUIDE.md deleted file mode 100644 index 6ad2cef..0000000 --- a/docs/BRAND_GUIDE.md +++ /dev/null @@ -1,534 +0,0 @@ -# Pennie the Prepper - Brand Guidelines - -**Version**: 1.0 -**Last Updated**: 2025-10-11 -**Status**: Draft - -This document defines the visual identity, brand colors, typography, and design principles for Pennie the Prepper. - ---- - -## 🎨 Brand Identity - -### Brand Positioning - -**Pennie the Prepper** is an AI-powered meeting assistant that transforms Teams meetings into actionable Azure DevOps work items using the T-Minus-15 methodology. - -**Brand Personality:** -- **Professional**: Enterprise-grade, reliable, trustworthy -- **Efficient**: Streamlines meeting outcomes, saves time -- **Intelligent**: AI-powered, context-aware, proactive -- **Organized**: Methodical, structured, preparation-focused -- **Approachable**: Friendly assistant, not intimidating - -**Brand Voice:** -- Clear and concise -- Professional but conversational -- Action-oriented -- Helpful and supportive -- Technical when needed, accessible always - ---- - -## 🎨 Color Palette - -### Primary Colors - -Our primary palette combines **KnowAll's lime green brand identity** with **Microsoft Teams/Azure blue** for platform consistency. - -#### Lime Green (KnowAll Brand) -``` -Primary Lime: #BEF264 (Tailwind lime-300) -Bright Lime: #84CC16 (Tailwind lime-500) -Dark Lime: #65A30D (Tailwind lime-600) -``` - -**Usage:** -- Primary accent color -- Call-to-action buttons -- Success states -- Highlights and emphasis -- Links and interactive elements - -**CSS Variables:** -```css ---pennie-lime-light: #BEF264; ---pennie-lime: #84CC16; ---pennie-lime-dark: #65A30D; -``` - -#### Azure Blue (Microsoft Ecosystem) -``` -Primary Blue: #0078D4 (Microsoft Azure blue) -Light Blue: #50E6FF (Azure accent) -Teams Blue: #6264A7 (Microsoft Teams purple-blue) -``` - -**Usage:** -- Secondary brand color -- Azure/Teams integration visuals -- Information states -- Neutral accents - -**CSS Variables:** -```css ---pennie-azure: #0078D4; ---pennie-azure-light: #50E6FF; ---pennie-teams: #6264A7; -``` - ---- - -### Secondary Colors - -#### Dark Theme (KnowAll Inspired) -``` -Black: #000000 (Pure black) -Gray 950: #0A0A0A (Near black backgrounds) -Gray 900: #171717 (Dark backgrounds) -Gray 800: #262626 (Elevated surfaces) -Gray 700: #404040 (Borders, dividers) -``` - -**Usage:** -- Dark mode backgrounds -- High-contrast layouts -- Technical/developer interfaces -- Diagram backgrounds - -#### Light Theme (Microsoft Inspired) -``` -White: #FFFFFF (Pure white) -Gray 50: #F9FAFB (Light backgrounds) -Gray 100: #F3F4F6 (Subtle backgrounds) -Gray 200: #E5E7EB (Borders) -Gray 300: #D1D5DB (Dividers) -``` - -**Usage:** -- Light mode backgrounds -- Documentation pages -- Teams integration UI -- Presentation slides - ---- - -### Semantic Colors - -#### Success -``` -Success Green: #10B981 (Tailwind emerald-500) -Success Light: #D1FAE5 (Tailwind emerald-100) -Success Dark: #047857 (Tailwind emerald-700) -``` - -**Usage:** Completed actions, successful work item creation, confirmations - -#### Warning -``` -Warning Orange: #F59E0B (Tailwind amber-500) -Warning Light: #FEF3C7 (Tailwind amber-100) -Warning Dark: #B45309 (Tailwind amber-700) -``` - -**Usage:** Pending actions, clarification needed, cautions - -#### Error -``` -Error Red: #EF4444 (Tailwind red-500) -Error Light: #FEE2E2 (Tailwind red-100) -Error Dark: #B91C1C (Tailwind red-700) -``` - -**Usage:** Errors, failed operations, critical alerts - -#### Info -``` -Info Blue: #3B82F6 (Tailwind blue-500) -Info Light: #DBEAFE (Tailwind blue-100) -Info Dark: #1E40AF (Tailwind blue-700) -``` - -**Usage:** Information, tips, neutral states - ---- - -### Color Usage Matrix - -| Element | Light Mode | Dark Mode | Accent | -|---------|------------|-----------|--------| -| **Primary Action** | Lime 500 | Lime 300 | — | -| **Secondary Action** | Azure Blue | Azure Light | — | -| **Background** | White / Gray 50 | Gray 950 / 900 | — | -| **Surface** | White | Gray 900 / 800 | — | -| **Text Primary** | Gray 900 | White | — | -| **Text Secondary** | Gray 600 | Gray 400 | — | -| **Border** | Gray 200 | Gray 700 | — | -| **Link** | Lime 600 | Lime 300 | Lime 500 (hover) | -| **Code Block** | Gray 100 | Gray 900 | Lime 500 (syntax) | - ---- - -## ✍️ Typography - -### Font Families - -#### Primary Font: Segoe UI -```css -font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif; -``` - -**Rationale:** Native to Windows and Microsoft Teams, ensures consistency across the Microsoft ecosystem. - -**Weights:** -- **Light (300)**: Large display text -- **Regular (400)**: Body text, paragraphs -- **Semibold (600)**: Subheadings, emphasis -- **Bold (700)**: Headings, strong emphasis - -#### Monospace Font: Cascadia Code -```css -font-family: 'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace; -``` - -**Usage:** Code snippets, JSON examples, technical documentation, developer-facing content - ---- - -### Type Scale - -| Element | Size | Weight | Line Height | Letter Spacing | -|---------|------|--------|-------------|----------------| -| **H1 Display** | 48px (3rem) | Light 300 | 1.2 | -0.02em | -| **H1** | 36px (2.25rem) | Semibold 600 | 1.2 | -0.01em | -| **H2** | 30px (1.875rem) | Semibold 600 | 1.3 | 0 | -| **H3** | 24px (1.5rem) | Semibold 600 | 1.4 | 0 | -| **H4** | 20px (1.25rem) | Semibold 600 | 1.4 | 0 | -| **H5** | 18px (1.125rem) | Semibold 600 | 1.5 | 0 | -| **Body Large** | 18px (1.125rem) | Regular 400 | 1.6 | 0 | -| **Body** | 16px (1rem) | Regular 400 | 1.6 | 0 | -| **Body Small** | 14px (0.875rem) | Regular 400 | 1.5 | 0 | -| **Caption** | 12px (0.75rem) | Regular 400 | 1.4 | 0.01em | -| **Code** | 14px (0.875rem) | Regular 400 | 1.6 | 0 | - ---- - -### Typography Examples - -**Headings:** -```css -h1 { - font-size: 36px; - font-weight: 600; - line-height: 1.2; - color: var(--gray-900); /* Light mode */ - color: var(--white); /* Dark mode */ -} - -h2 { - font-size: 30px; - font-weight: 600; - line-height: 1.3; - margin-top: 2rem; - margin-bottom: 1rem; -} -``` - -**Body Text:** -```css -body { - font-family: 'Segoe UI', sans-serif; - font-size: 16px; - font-weight: 400; - line-height: 1.6; - color: #262626; /* Gray 800 */ -} -``` - -**Code Blocks:** -```css -code { - font-family: 'Cascadia Code', monospace; - font-size: 14px; - background: #F3F4F6; /* Light mode */ - background: #171717; /* Dark mode */ - padding: 0.2em 0.4em; - border-radius: 3px; -} -``` - ---- - -## 🎭 Logo & Iconography - -### Logo Variations - -**Primary Logo:** -- Full color version with "Pennie the Prepper" wordmark -- Minimum size: 120px width -- Clear space: 20px on all sides - -**Icon-Only:** -- Square format (512x512) -- Works at small sizes (32x32) -- Recognizable without text - -**Monochrome:** -- Single-color versions for special uses -- White on dark backgrounds -- Dark on light backgrounds - -### Icon Style - -**Design Principles:** -- **Stroke weight**: 2px for 24x24 icons -- **Corner radius**: 2px for rounded elements -- **Padding**: 2-3px from icon edges -- **Style**: Outlined (not filled) for consistency with Fluent UI - -**Inspiration:** -- Microsoft Fluent UI System Icons -- Phosphor Icons (outline style) -- Heroicons (outline variant) - -**Status Icons:** -- Listening: Waveform or microphone icon -- Processing: Spinning gear or dots -- Creating: Plus icon or pencil -- Idle: Clock or pause icon -- Error: X or exclamation triangle - ---- - -## 📐 Layout & Spacing - -### Spacing Scale (8px base unit) - -``` -4px (0.25rem) - Tiny -8px (0.5rem) - XXS -12px (0.75rem) - XS -16px (1rem) - SM (base) -24px (1.5rem) - MD -32px (2rem) - LG -48px (3rem) - XL -64px (4rem) - 2XL -96px (6rem) - 3XL -``` - -**Usage:** -- Component padding: 16px (SM) -- Section spacing: 48px (XL) -- Card margins: 24px (MD) -- Button padding: 12px 24px (XS MD) - -### Grid System - -**12-column grid** with 24px gutters - -**Breakpoints:** -``` -Mobile: < 640px -Tablet: 640px - 1024px -Desktop: > 1024px -Wide: > 1440px -``` - -### Container Max Widths - -``` -Documentation: 896px (prose width) -Dashboard: 1280px -Full bleed: 100% -``` - ---- - -## 🖼️ Image Guidelines - -### Photography Style - -**Preferred:** -- Professional meeting environments -- Diverse teams collaborating -- Modern office spaces -- Clean, uncluttered backgrounds - -**Avoid:** -- Stock photo "corporate" clichés -- Overly staged scenarios -- Distracting backgrounds -- Low resolution or pixelated images - -### Image Treatments - -**Overlays:** -```css -background: linear-gradient(135deg, rgba(0,0,0,0.7), rgba(132,204,22,0.3)); -``` - -**Filters:** -- Subtle desaturation for backgrounds -- Lime green tint for brand consistency -- High contrast for readability - ---- - -## 🎯 Design Principles - -### 1. Clarity Over Cleverness -- Clear, direct communication -- No jargon without explanation -- Obvious interaction patterns - -### 2. Consistency -- Reuse components and patterns -- Match Microsoft Teams design language -- Predictable behavior - -### 3. Accessibility -- WCAG 2.1 AA minimum -- Color contrast ratios: 4.5:1 (text), 3:1 (UI) -- Keyboard navigation support -- Screen reader friendly - -### 4. Performance -- Optimize images (WebP, lazy loading) -- Minimize file sizes -- Fast, responsive interactions - -### 5. Microsoft Ecosystem Alignment -- Follow Fluent UI design patterns -- Match Teams visual language -- Consistent with Azure branding - ---- - -## 🎬 Motion & Animation - -### Animation Principles - -**Timing:** -``` -Fast: 100-200ms (micro-interactions, hovers) -Medium: 200-400ms (transitions, reveals) -Slow: 400-600ms (major state changes) -``` - -**Easing:** -```css ---ease-in: cubic-bezier(0.4, 0, 1, 1); ---ease-out: cubic-bezier(0, 0, 0.2, 1); ---ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); -``` - -**Common Animations:** -- Fade in: `opacity 0 → 1` (300ms) -- Slide in: `translateY(20px) → 0` (400ms) -- Scale up: `scale(0.95) → 1` (200ms) - -**Avoid:** -- Excessive animation -- Parallax effects -- Continuous animations (drain battery) -- Motion without purpose - ---- - -## 💬 Tone of Voice - -### Writing Style - -**Do:** -- ✅ Use active voice ("Pennie creates work items") -- ✅ Be concise and direct -- ✅ Use "you" and "your" (second person) -- ✅ Explain technical terms when first used -- ✅ Use examples and real scenarios - -**Don't:** -- ❌ Use passive voice ("Work items are created") -- ❌ Be overly formal or corporate -- ❌ Use unexplained acronyms -- ❌ Make assumptions about user knowledge -- ❌ Use humor at the expense of clarity - -### Example Copy - -**Good:** -> "Pennie listens to your Teams meeting and automatically creates Azure DevOps work items. You'll see Epics, Features, and User Stories appear in real-time as decisions are made." - -**Bad:** -> "Leveraging advanced NLP algorithms, our solution facilitates the transformation of verbal discourse into structured work item artifacts within your project management ecosystem." - ---- - -## 🚫 Common Mistakes to Avoid - -### Color -- ❌ Using lime green for errors (use red) -- ❌ Low contrast text (fails accessibility) -- ❌ Mixing warm and cool grays -- ❌ Overusing bright lime (use as accent only) - -### Typography -- ❌ Too many font weights in one view -- ❌ Line lengths over 75 characters (prose) -- ❌ All caps for long text -- ❌ Centered body text - -### Layout -- ❌ Inconsistent spacing -- ❌ Not aligning to grid -- ❌ Cluttered interfaces -- ❌ Ignoring mobile view - -### Icons -- ❌ Mixing icon styles (outline + filled) -- ❌ Inconsistent icon sizes in the same context -- ❌ Using icons without labels in complex UIs -- ❌ Custom icons that don't match Fluent UI style - ---- - -## 📦 Brand Assets - -All brand assets are located in `/assets/` directory: - -``` -assets/ -├── avatars/ # Pennie avatars (512x512) -├── icons/ # App icons (16-512px) -├── teams/ # Teams-specific assets -├── banners/ # Marketing banners -├── diagrams/ # Architecture diagrams -├── social/ # Social media assets -└── brand/ # Logo files, color swatches -``` - -**See:** [assets/README.md](../assets/README.md) for complete asset list. - ---- - -## 🔄 Version History - -| Version | Date | Changes | -|---------|------|---------| -| 1.0 | 2025-10-11 | Initial brand guidelines created | -| | | - KnowAll lime green palette integrated | -| | | - Microsoft Teams/Azure alignment defined | -| | | - Typography scale established | - ---- - -## 📞 Questions? - -For questions about brand guidelines: -- **Documentation**: See [SOLUTION_DESIGN.adoc](SOLUTION_DESIGN.adoc) -- **Issues**: [GitHub Issues](https://github.com/bengweeks/GetPenn.ie/issues) -- **Assets**: [assets/README.md](../assets/README.md) - ---- - -**Maintained by**: KnowAll Design Team -**Last Review**: 2025-10-11 diff --git a/docs/DEPLOYMENT.adoc b/docs/DEPLOYMENT.adoc index b8cb01f..3929c5b 100644 --- a/docs/DEPLOYMENT.adoc +++ b/docs/DEPLOYMENT.adoc @@ -7,7 +7,7 @@ This guide provides step-by-step instructions for deploying Pennie the Prepper to your Azure environment. The deployment process is largely automated through scripts and Bicep templates, with a few manual steps for security approvals. -**Current Status** (November 2025): Infrastructure (Phase 1) ✅ complete. Teams bot integration (Phase 2) ✅ deployed with Key Vault integration. +**Current Status** (December 2025): All infrastructure deployed. Teams bot running. Secrets managed via GitHub Secrets. == Architecture Overview @@ -27,11 +27,11 @@ This guide provides step-by-step instructions for deploying Pennie the Prepper t |Windows Server VM |✅ Deployed -|Hosts Teams Media Bot (future phase) +|Hosts Teams Media Bot with Graph Communications SDK -|Key Vault -|✅ Deployed -|Secure storage for bot credentials +|GitHub Secrets +|✅ Configured +|Secure storage for bot credentials and deployment secrets |Application Insights |✅ Deployed @@ -39,7 +39,7 @@ This guide provides step-by-step instructions for deploying Pennie the Prepper t |Teams Media Bot |✅ Deployed -|C# bot with Graph SDK, Key Vault integration for credentials +|C# bot with Graph SDK for real-time audio capture |Function Call Handler |✅ Implemented @@ -120,9 +120,6 @@ AZURE_LOCATION=uksouth AZURE_DEVOPS_ORG=your-org-name AZURE_DEVOPS_PAT=your-personal-access-token -# Key Vault (bot loads credentials at runtime) -AZURE_KEY_VAULT_NAME=pennie-kv-xxxxxx - # AI Foundry (configured in Step 4) AZURE_AI_FOUNDRY_PROJECT= AZURE_AI_FOUNDRY_ENDPOINT= @@ -637,57 +634,58 @@ Deploy the bot from your local machine using the automated script: **What This Does**: -1. Loads configuration from `.env` (only needs `AZURE_KEY_VAULT_NAME`) -2. Builds bot locally with .NET SDK -3. Publishes for Windows (`win-x64`, self-contained) -4. Injects Key Vault name into `appsettings.json` -5. Creates deployment package (ZIP ~65MB) -6. Uploads to Azure Blob Storage -7. Generates SAS URL (1-hour expiry) -8. Deploys to VM via `az vm run-command invoke`: +1. Builds bot locally with .NET SDK +2. Publishes for Windows (`win-x64`, self-contained) +3. Creates deployment package (ZIP ~65MB) +4. Uploads to Azure Blob Storage +5. Generates SAS URL (1-hour expiry) +6. Deploys to VM via `az vm run-command invoke`: - Stops PennieBot service - - **Backs up appsettings.json** (preserves VM configuration) - - Downloads and extracts new version - - **Restores appsettings.json from backup** + - Downloads and extracts new version (uses fresh template from repo) + - **Configures appsettings.json** via `configure-bot-settings.ps1` with credentials from GitHub Secrets + - Sets up SSL certificate via Let's Encrypt - Starts PennieBot service -9. Verifies health endpoint +7. Verifies health endpoint -=== Configuration Preservation During Deployment +=== Configuration via GitHub Secrets -**CRITICAL**: The deployment process preserves `appsettings.json` on the VM. +All configuration is managed through **GitHub Secrets** in environment-specific settings (test/prod). -The VM's `appsettings.json` contains environment-specific configuration that must not be overwritten: +**Required GitHub Secrets** (per environment): -[source,json] ----- -{ - "MicrosoftAppId": "", - "MicrosoftAppTenantId": "", - "MicrosoftAppType": "SingleTenant", - "AZURE_KEY_VAULT_NAME": "" -} ----- +[cols="1,2"] +|=== +|Secret |Description + +|`TEAMS_APP_ID` +|Microsoft App ID for Teams bot -**Why This Matters**: +|`TEAMS_APP_PASSWORD` +|Microsoft App Password for Teams bot -* The deployed package contains default/template values, not production configuration -* Without backup/restore, deployment would break the bot's authentication -* Secrets (MicrosoftAppPassword) remain in Key Vault - only non-secrets in appsettings.json +|`AZURE_OPENAI_ENDPOINT` +|Azure OpenAI endpoint URL (e.g., `https://your-openai.openai.azure.com`) -**Both deployment methods preserve the configuration**: +|`AZURE_OPENAI_ASSISTANT_ID` +|Azure OpenAI Assistant ID for Pennie AI -1. **GitHub Actions** (`.github/workflows/deploy.yml`): Backs up before extract, restores after -2. **Local script** (`scripts/deploy-bot-to-vm.ps1`): Backs up before build, restores after +|`AZURE_CREDENTIALS` +|Service principal credentials for Azure CLI -**Key Vault Integration**: +|`LE_EMAIL` +|Email address for Let's Encrypt certificate renewal +|=== + +**How Configuration Works**: -The bot loads credentials from Key Vault at runtime using managed identity: +1. GitHub Actions reads secrets from the target environment (test or prod) +2. Secrets are Base64-encoded and passed securely to the VM via `az vm run-command` +3. `configure-bot-settings.ps1` decodes and writes values to `appsettings.json` +4. Bot service is restarted to apply new configuration -* `MicrosoftAppId` - Bot Framework App ID -* `MicrosoftAppPassword` - Bot Framework App Password -* `AZURE-FUNCTIONS-BACKEND-URL` - Backend API URL (optional) +**All configuration keys use underscores** (e.g., `AZURE_OPENAI_ENDPOINT`, not dashes). -**No credentials are stored in** `.env` **or** `appsettings.json` **- only the Key Vault name**. +**No Key Vault or backup/restore is required** - configuration is freshly applied on each deployment. **Duration**: ~2-3 minutes @@ -874,6 +872,171 @@ Get-Content C:\Pennie\logs\bot-stdout.log -Tail 100 **Tracked in GitHub Issue**: #4 (Complete Teams Bot Integration) - **✅ Complete** +== First-Time Environment Setup + +When deploying to a **new environment** (test, prod), certain steps must be completed manually before the CI/CD workflow can deploy successfully. These steps cannot be automated due to security requirements. + +=== Why Manual Setup is Required + +[cols="1,2,2"] +|=== +|Step |Why Manual? |Security Concern + +|Azure AD App Registration +|Requires `Application.ReadWrite.All` permission +|Giving CI/CD this permission is acceptable + +|**Admin Consent** +|Requires Global Admin or Privileged Role Admin +|**Never give CI/CD these permissions** - too powerful + +|Teams Manifest Upload +|Requires Teams Administrator or `AppCatalog.ReadWrite.All` with admin consent +|High-privilege operation that should be audited +|=== + +=== Per-Environment Setup Checklist + +For each new environment (e.g., `test`), complete these one-time steps: + +==== 1. Create Azure AD App Registration + +Run the automated script: + +[source,bash] +---- +# For test environment +./scripts/setup-bot-app-registration.sh --env test + +# For production +./scripts/setup-bot-app-registration.sh --env prod +---- + +This creates: + +* App registration with appropriate name (e.g., "Pennie the Prepper (Test)") +* Graph API permissions (not yet consented) +* Client secret (2-year expiry) +* Optionally stores credentials in GitHub Secrets + +==== 2. Grant Admin Consent (REQUIRES ADMIN) + +This step **cannot be automated** without giving CI/CD Global Admin permissions. + +**Azure Portal Method** (Recommended): + +1. Go to: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/ +2. Click "Grant admin consent for [Your Organization]" +3. Confirm the consent prompt + +**Azure CLI Method** (Requires admin permissions): + +[source,bash] +---- +az ad app permission admin-consent --id +---- + +==== 3. Create Teams App Manifest + +For test environment, create a separate manifest: + +[source,bash] +---- +# Copy production manifest +cp bot/teams-manifest/manifest.json bot/teams-manifest/manifest.test.json + +# Edit manifest.test.json: +# - id: Generate new UUID (uuidgen) +# - name.short: "Pennie the Prepper (Test)" +# - name.full: "Pennie the Prepper - AI Business Analyst (Test)" +# - accentColor: "#9C27B0" (purple for test) +# - bots[0].botId: + +# Create app package +cd bot/teams-manifest +zip pennie-app-test.zip manifest.test.json color.png outline.png +---- + +==== 4. Upload Teams App to Organization + +**Option 1 - Teams Admin Center** (first-time): + +1. Go to: https://admin.teams.microsoft.com +2. Navigate to: Teams apps → Manage apps +3. Click: Upload new app +4. Select: `pennie-app-test.zip` +5. Approve for your organization + +**Option 2 - Script** (if you have permissions): + +[source,bash] +---- +./scripts/deploy-teams-app.sh --env test --create +---- + +==== 4a. Make App Visible to Users + +After uploading, the app is in the catalog but users must **add** it to use it. + +**Where Users Find Custom Apps**: + +1. In Teams, go to **Apps** (left sidebar) +2. Look for **"Built for your org"** section +3. Find "Pennie the Prepper" and click **Add** + +NOTE: Custom apps do NOT appear in regular Teams search. Users must look in "Built for your org". + +**Pre-Install for All Users (Optional)**: + +To automatically install the app for everyone (so they don't need to manually add it): + +1. **Teams Admin Center** → **Teams apps** → **Setup policies** +2. Edit **Global (Org-wide default)** or create a new policy +3. Under **Installed apps**, click **Add apps** +4. Search for "Pennie the Prepper" and add it +5. Optionally **pin** it to make it visible in users' sidebar + +[cols="1,2"] +|=== +|Action |Who it affects + +|Upload to Admin Center +|Makes app **available** in org catalog + +|User clicks "Add" +|Installs app **for that user only** + +|Admin pins via Setup Policy +|Pre-installs for **everyone** in policy +|=== + +==== 5. Configure GitHub Environment Secrets + +Set these secrets in GitHub for the environment: + +[source,bash] +---- +gh secret set TEAMS_APP_ID --env test --body "" +gh secret set TEAMS_APP_PASSWORD --env test --body "" +gh secret set AZURE_RESOURCE_GROUP --env test --body "TMinus15Agents-Test" +gh secret set AZURE_STORAGE_ACCOUNT --env test --body "" +---- + +==== 6. Enable Deployments + +Set the environment variable to enable CI/CD deployments: + +1. Go to: GitHub Repository → Settings → Environments → test +2. Add variable: `AZURE_DEPLOYMENT_ENABLED` = `true` + +=== After First-Time Setup + +Once the above steps are complete, the CI/CD workflow (`deploy.yml`) handles all subsequent deployments automatically: + +* Infrastructure updates via Bicep +* Bot code deployments to VM +* Configuration via GitHub Secrets (no backup/restore needed) + == Step 6: Verification === Infrastructure Checklist @@ -955,11 +1118,11 @@ Expected secrets: === Current Completion Status -* **Phase 1 Infrastructure**: 100% complete -* **Backend API**: 100% deployed and tested -* **Agent Configuration**: 100% deployed -* **Bot Integration**: 100% deployed with Key Vault integration -* **Overall MVP**: 100% complete +* **Infrastructure**: ✅ Deployed (Windows VM, Azure Functions, Application Insights) +* **Backend API**: ✅ Deployed and tested (9 HTTP endpoints) +* **Agent Configuration**: ✅ Deployed (Pennie in East US 2) +* **Bot Integration**: ✅ Deployed (Teams Media Bot with Graph SDK) +* **Secrets Management**: ✅ Configured (GitHub Secrets) === Recent Deployments @@ -1082,32 +1245,35 @@ Wait 10 seconds for role propagation, then retry. === Secrets Management -The project uses a layered secrets management approach: +The project uses GitHub Secrets for secrets management: [cols="1,2,2"] |=== |Location |Purpose |Contents -|Azure Key Vault -|Production runtime secrets -|`MicrosoftAppId`, `MicrosoftAppPassword`, `AZURE-FUNCTIONS-BACKEND-URL` +|GitHub Secrets (Repository) +|Shared across environments +|`AZURE_CREDENTIALS`, `AZURE_SUBSCRIPTION_ID`, `AZURE_TENANT_ID` -|`.env` file -|Local configuration (non-secrets) -|`AZURE_KEY_VAULT_NAME`, `AZURE_RESOURCE_GROUP`, region settings +|GitHub Secrets (Environment: prod) +|Production-specific +|`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_ASSISTANT_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_STORAGE_ACCOUNT` -|GitHub Secrets -|CI/CD pipelines -|Azure credentials, subscription IDs (future) +|GitHub Secrets (Environment: test) +|Test-specific +|`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_ASSISTANT_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_STORAGE_ACCOUNT` + +|`appsettings.json` (on VM) +|Runtime configuration +|Written by `configure-bot-settings.ps1` from GitHub Secrets during deployment |=== **Key Principles**: -* Bot credentials **never** stored in `.env` or `appsettings.json` -* Bot loads secrets from Key Vault at startup using managed identity -* Managed identity authenticates to Key Vault (no API keys needed) +* All credentials managed via GitHub Secrets (per environment) +* Configuration written to VM during deployment via `configure-bot-settings.ps1` * **Never commit secrets to Git** -* `.env` file excluded from source control (`.gitignore`) +* `appsettings.json` is freshly configured on each deployment (no backup/restore needed) === Network Security @@ -1119,11 +1285,113 @@ The project uses a layered secrets management approach: === Access Control * RBAC enforced on all Azure resources -* Key Vault access via managed identity * Admin consent required for Graph API permissions * Azure DevOps PAT with minimal scopes -== Appendix A: Automated Deployment Scripts +=== GitHub Actions RBAC Requirements + +The GitHub Actions service principal (`github-actions-pennie`) requires specific Azure RBAC roles for CI/CD deployments. + +**Prerequisites:** + +Resource groups must be created manually before deployment (one-time setup per environment): + +[source,bash] +---- +# Create resource group for test environment +az group create --name TMinus15Agents-Test --location uksouth + +# Create resource group for production +az group create --name TMinus15Agents --location uksouth +---- + +**Per Resource Group Roles:** + +Each environment's resource group (e.g., `TMinus15Agents-Test`, `TMinus15Agents`) requires: + +[source,bash] +---- +# 1. Contributor role - to deploy infrastructure and manage resources +az role assignment create \ + --assignee "" \ + --role "Contributor" \ + --scope "/subscriptions//resourceGroups/" + +# 2. Storage Blob Data Contributor - to upload deployment packages +az role assignment create \ + --assignee "" \ + --role "Storage Blob Data Contributor" \ + --scope "/subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/" +---- + +**Example for Test Environment:** + +[source,bash] +---- +# Get subscription ID +SUBSCRIPTION_ID=$(az account show --query id -o tsv) +SP_APP_ID="3c841dbd-21b0-4493-8ac3-112924744601" # github-actions-pennie + +# Contributor on resource group +az role assignment create \ + --assignee "$SP_APP_ID" \ + --role "Contributor" \ + --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/TMinus15Agents-Test" + +# Storage Blob Data Contributor on storage account +az role assignment create \ + --assignee "$SP_APP_ID" \ + --role "Storage Blob Data Contributor" \ + --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/TMinus15Agents-Test/providers/Microsoft.Storage/storageAccounts/pennietest294b6c75" +---- + +NOTE: Role assignments can take up to 5 minutes to propagate. If the workflow fails with permission errors immediately after granting roles, wait a few minutes and retry. + +== Appendix A: Deployment Scripts + +=== Overview + +The project includes several deployment scripts. The GitHub Actions workflow handles automated CI/CD deployment, while manual scripts provide backup options for local development and emergency fixes. + +[cols="2,1,3"] +|=== +|Script |Used by CI/CD? |Purpose + +|`deploy-bot-to-vm.ps1` +|✅ Yes +|Runs ON the VM to restart the bot service after deployment + +|`deploy-bot.sh` +|❌ No +|Manual deployment from your Linux/Mac PC + +|`deploy-bot-remote.sh` +|❌ No +|Manual deployment from your PC to VM via Azure CLI + +|`deploy-teams-app.sh` +|❌ No +|Manual Teams app package upload to organization catalog + +|`setup-bot-app-registration.sh` +|❌ No +|One-time setup: Creates Azure AD app registration for bot + +|`deploy-agent.sh` +|❌ No +|Deploys Pennie AI Foundry agent with function calling + +|`deploy-backend.sh` +|❌ No +|Deploys Azure Functions backend for Azure DevOps integration +|=== + +**When to use manual scripts:** + +* Deploying without waiting for CI/CD pipeline +* Emergency fixes when GitHub Actions is unavailable +* Local development and testing +* One-time setup tasks (app registration, agent deployment) === setup-bot-app-registration.sh @@ -1133,20 +1401,42 @@ Located at: `scripts/setup-bot-app-registration.sh` **What It Does**: -1. Creates app registration -2. Adds Graph API permissions -3. Generates client secret -4. Stores credentials in Key Vault -5. Updates `.env` file -6. Provides instructions for manual admin consent +1. Creates app registration (environment-specific naming) +2. Adds Graph API permissions (Calls.AccessMedia.All, Calls.JoinGroupCall.All, OnlineMeetings.ReadWrite.All) +3. Generates client secret (2-year expiry) +4. Optionally sets GitHub Secrets automatically +5. Provides instructions for manual admin consent **Usage**: [source,bash] ---- +# Production app registration ./scripts/setup-bot-app-registration.sh + +# Test app registration (purple accent, "(Test)" suffix) +./scripts/setup-bot-app-registration.sh --env test ---- +**Environment Differences**: + +[cols="1,2,2"] +|=== +|Setting |Production |Test + +|App Name +|Pennie the Prepper Bot +|Pennie the Prepper (Test) + +|Accent Color +|#9DFF0A (Green) +|#9C27B0 (Purple) + +|Secret Name +|PennieBot-Prod-Secret +|PennieBot-Test-Secret +|=== + === deploy-agent.sh Located at: `scripts/deploy-agent.sh` @@ -1176,26 +1466,47 @@ Located at: `scripts/deploy-teams-app.sh` **What It Does**: -1. Creates Teams app package from manifest.json and icons (with `--create` flag) -2. Gets access token for Microsoft Graph API -3. Checks if app already exists in catalog -4. Uploads new app or updates existing app -5. Provides manual upload instructions on failure +1. Selects environment-specific manifest (`manifest.prod.json` or `manifest.test.json`) +2. Creates Teams app package with correct `manifest.json` inside (with `--create` flag) +3. Gets access token for Microsoft Graph API +4. Checks if app already exists in catalog +5. Uploads new app or updates existing app +6. Provides manual upload instructions on failure **Usage**: [source,bash] ---- -# Deploy existing package -./scripts/deploy-teams-app.sh +# Build and deploy production package +./scripts/deploy-teams-app.sh --env prod --create -# Create package then deploy -./scripts/deploy-teams-app.sh --create +# Build and deploy test package +./scripts/deploy-teams-app.sh --env test --create + +# Deploy existing package (defaults to prod) +./scripts/deploy-teams-app.sh --env prod # Show help ./scripts/deploy-teams-app.sh --help ---- +**Environment-Specific Manifests**: + +[cols="1,2,2"] +|=== +|Environment |Manifest File |Package Created + +|prod +|`manifest.prod.json` +|`pennie-app-prod-v1.6.0.zip` + +|test +|`manifest.test.json` +|`pennie-app-test-v1.6.0.zip` +|=== + +NOTE: Teams requires the manifest to be named exactly `manifest.json` inside the zip. The script handles this automatically by copying the environment-specific manifest. + **Required Permissions**: * `AppCatalog.ReadWrite.All` (Application permission with admin consent), OR diff --git a/docs/DEVELOPMENT.adoc b/docs/DEVELOPMENT.adoc new file mode 100644 index 0000000..f5ea14e --- /dev/null +++ b/docs/DEVELOPMENT.adoc @@ -0,0 +1,279 @@ += Development Guide: Pennie the Prepper + +== Prerequisites + +=== Required Software + +* **.NET 8 SDK**: https://dotnet.microsoft.com/download/dotnet/8.0 +* **Azure CLI**: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli +* **Git**: https://git-scm.com/downloads +* **IDE**: Visual Studio 2022, VS Code, or JetBrains Rider + +=== Azure Resources (for full testing) + +* Azure subscription with contributor access +* Azure DevOps organization access +* Bot Framework registration (for Teams integration) + +== Quick Start + +=== 1. Clone the Repository + +[source,bash] +---- +git clone https://github.com/knowall-ai/GetPenn.ie.git +cd GetPenn.ie +---- + +=== 2. Build the Solution + +[source,bash] +---- +# Restore dependencies and build +dotnet build Pennie.sln + +# Run unit tests +dotnet test Pennie.sln +---- + +=== 3. Configure Local Development + +[source,bash] +---- +# Copy the template to create your local config +cp bot/appsettings.local.json.template bot/appsettings.local.json + +# Edit with your Azure credentials (file is gitignored) +# See template for required values +---- + +=== 4. Run the Bot Locally + +[source,bash] +---- +cd bot +dotnet run + +# Bot will start on http://localhost:3978 +---- + +== Project Structure + +[source] +---- +GetPenn.ie/ +├── bot/ # Teams Media Bot (C# .NET 8) +│ ├── Bots/ # Bot activity handlers +│ │ └── MediaBot.cs # Main bot logic +│ ├── Helpers/ # Utility classes +│ │ └── MeetingHelpers.cs # Meeting ID parsing, @mention stripping +│ ├── Services/ # External service integrations +│ ├── Properties/ +│ │ └── launchSettings.json # IDE launch profiles +│ ├── appsettings.json # Base configuration +│ ├── appsettings.Development.json # Development overrides +│ ├── appsettings.Test.json # Test environment config +│ └── appsettings.local.json.template # Template for local secrets +├── tests/ # Unit tests (xUnit) +│ ├── Helpers/ +│ │ └── MeetingHelpersTests.cs +│ └── PennieBot.Tests.csproj +├── infra/ # Infrastructure as Code (Bicep) +│ ├── modules/ +│ │ └── windows-vm.bicep # VM template (supports Spot VMs) +│ ├── main.parameters.json # Production parameters +│ └── main.parameters.test.json # Test environment parameters +├── docs/ # Documentation +├── scripts/ # Deployment and utility scripts +├── Pennie.sln # Solution file +└── CLAUDE.md # AI assistant context +---- + +== Configuration Hierarchy + +.NET loads configuration in this order (later overrides earlier): + +1. `appsettings.json` - Base settings (committed) +2. `appsettings.{Environment}.json` - Environment-specific (committed) +3. `appsettings.local.json` - Developer secrets (gitignored) +4. Environment variables + +=== Environment Detection + +Set `ASPNETCORE_ENVIRONMENT` to control which config is loaded: + +* `Development` - loads `appsettings.Development.json` +* `Test` - loads `appsettings.Test.json` +* `Production` - loads only `appsettings.json` + +== Running Tests + +=== Unit Tests + +[source,bash] +---- +# Run all tests +dotnet test Pennie.sln + +# Run with verbose output +dotnet test Pennie.sln --verbosity normal + +# Run specific test class +dotnet test --filter "FullyQualifiedName~MeetingHelpersTests" + +# Run with coverage +dotnet test Pennie.sln --collect:"XPlat Code Coverage" +---- + +=== Test Categories + +[cols="1,2,1"] +|=== +| Location | Description | Count + +| `tests/Helpers/` +| Unit tests for helper methods +| 51 tests +|=== + +== Development Workflow + +=== Making Changes + +1. **Create a feature branch**: ++ +[source,bash] +---- +git checkout main +git pull origin main +git checkout -b feature/your-feature-name +---- + +2. **Make your changes and add tests** + +3. **Run tests locally**: ++ +[source,bash] +---- +dotnet test Pennie.sln +---- + +4. **Commit with descriptive message**: ++ +[source,bash] +---- +git add . +git commit -m "feat: Add your feature description" +---- + +5. **Push and create PR**: ++ +[source,bash] +---- +git push -u origin feature/your-feature-name +gh pr create --title "Your PR title" --body "Description" +---- + +=== CI/CD Pipeline + +Pull requests automatically run: + +* Lint and format checks +* Unit tests +* Build verification + +Merging to `main` deploys to the test environment. + +Creating a tag `v*.*.*` deploys to production. + +== Local Testing with Teams + +=== Option 1: Dev Tunnel (Recommended) + +Use Azure Dev Tunnels to expose your local bot to Teams: + +[source,bash] +---- +# Install dev tunnel CLI (one time) +curl -sL https://aka.ms/DevTunnelCliInstall | bash + +# Create a tunnel +devtunnel create --allow-anonymous + +# Host the tunnel on port 3978 +devtunnel host -p 3978 --allow-anonymous +---- + +Update `appsettings.local.json` with the tunnel URL: + +[source,json] +---- +{ + "BotBaseUrl": "https://YOUR-TUNNEL-ID.euw.devtunnels.ms" +} +---- + +=== Option 2: Bot Framework Emulator + +For testing without Teams: + +1. Download Bot Framework Emulator from https://github.com/microsoft/BotFramework-Emulator/releases +2. Run the bot locally (`dotnet run`) +3. Connect emulator to `http://localhost:3978/api/messages` + +== Test Environment + +A dedicated test environment with a Spot VM is available for manual testing. +See link:TESTING.adoc[TESTING.adoc] for details on: + +* Starting/stopping the test VM +* Cost savings with Spot VMs +* Distinguishing test from production in Teams + +== Troubleshooting + +=== Build Errors + +**"The type or namespace 'Xunit' could not be found"** + +Ensure you're building the solution, not just the bot project: + +[source,bash] +---- +dotnet build Pennie.sln # Correct +dotnet build bot/PennieBot.csproj # Won't include test dependencies +---- + +**"appsettings.local.json not found"** + +This file is gitignored. Copy from template: + +[source,bash] +---- +cp bot/appsettings.local.json.template bot/appsettings.local.json +---- + +=== Runtime Errors + +**"MicrosoftAppId is empty"** + +You need a Bot Framework registration for Teams integration. For local testing without Teams, use the Bot Framework Emulator. + +**"Azure Speech Services key not configured"** + +Audio transcription requires Speech Services. For chat-only testing, this is optional. + +== Contributing + +1. Follow the existing code style +2. Add unit tests for new functionality +3. Update documentation if adding features +4. Keep PRs focused and small + +== Related Documentation + +* link:REQUIREMENTS.adoc[REQUIREMENTS.adoc] - Project requirements (T-Minus-15 methodology) +* link:SOLUTION_DESIGN.adoc[SOLUTION_DESIGN.adoc] - Architecture and design decisions +* link:TESTING.adoc[TESTING.adoc] - Comprehensive test strategy +* link:DEPLOYMENT.adoc[DEPLOYMENT.adoc] - Production deployment guide +* link:TROUBLESHOOTING.adoc[TROUBLESHOOTING.adoc] - Common issues and solutions diff --git a/docs/TESTING.adoc b/docs/TESTING.adoc index 3f0b4e7..771b0f6 100644 --- a/docs/TESTING.adoc +++ b/docs/TESTING.adoc @@ -599,9 +599,10 @@ Speaker: Ben Weeks | Timestamp: 00:02:10 | Text: Let's support both OAuth and SA === Local Development -* **Bot**: Bot Framework Emulator +* **Bot**: Bot Framework Emulator or dev tunnel to local instance +* **Config**: `appsettings.local.json` (gitignored, copy from template) * **Speech**: Azure Speech Services (dev subscription) -* **DevOps**: Test project in Azure DevOps +* **DevOps**: KnowAll project in Azure DevOps (same as prod) * **AI Agent**: Local AI Foundry project instance === Continuous Integration (GitHub Actions) @@ -611,17 +612,89 @@ Speaker: Ben Weeks | Timestamp: 00:02:10 | Text: Let's support both OAuth and SA * **DevOps**: Test project (separate from prod) * **AI Agent**: Test AI Foundry project -=== Staging +=== Test Environment (Manual Testing) -* **Deployment**: Separate Windows VM in staging resource group -* **Teams**: Test Teams tenant (if available) or production tenant with test meetings -* **DevOps**: Staging project in Azure DevOps -* **AI Agent**: Staging AI Foundry project +The test environment uses a **Spot VM** for significant cost savings (~80% cheaper than standard VMs). + +**Infrastructure**: + +* **Resource Group**: `TMinus15Agents-Test` +* **VM Name**: `pennie-vm-test` +* **VM Type**: Azure Spot VM (Standard_D2s_v3) +* **Eviction Policy**: Deallocate (VM stops but disk preserved) +* **Auto-Shutdown**: Enabled at 7pm GMT (saves costs overnight) +* **Teams App**: "Pennie Test" with purple accent (#9C27B0) + +**Cost Savings**: + +[cols="1,1,1"] +|=== +| Type | Monthly Cost | Savings + +| Standard VM +| ~$70-90 +| - + +| Spot VM +| ~$15-25 +| 60-80% + +| Spot VM + Auto-shutdown +| ~$10-15 +| 80-85% +|=== + +**Spot VM Behavior**: + +* Azure can evict the VM with 30 seconds notice if they need capacity +* Eviction is rare (typically during high-demand periods) +* When evicted with `Deallocate` policy, the VM stops but disk is preserved +* Restart the VM to continue testing (no data loss) +* For test environment, occasional eviction is acceptable + +**Starting the Test VM**: + +[source,bash] +---- +# Start the test VM for manual testing +az vm start -g TMinus15Agents-Test -n pennie-vm-test + +# Check VM status +az vm show -g TMinus15Agents-Test -n pennie-vm-test --query "powerState" -o tsv + +# Stop the test VM when done (saves money) +az vm deallocate -g TMinus15Agents-Test -n pennie-vm-test --no-wait +---- + +**Deploying to Test**: + +[source,bash] +---- +# Deploy infrastructure (creates Spot VM if not exists) +az deployment group create \ + --resource-group TMinus15Agents-Test \ + --template-file infra/modules/windows-vm.bicep \ + --parameters @infra/windows-vm.parameters.test.json + +# Deploy bot code to test VM +# (Use GitHub Actions workflow or manual deployment) +---- + +**Distinguishing Test from Production in Teams**: + +The test bot "Pennie Test" has: + +* Purple accent color (#9C27B0) vs green (#9DFF0A) for production +* Name suffix "(Test Environment)" in description +* Separate bot registration (different App ID) +* Points to test backend: `pennie-backend-test.azurewebsites.net` === Production * **Deployment**: Production Windows VM in `TMinus15Agents` resource group +* **VM Type**: Standard VM (no eviction risk) * **Teams**: Production Teams tenant (KnowAll AI) +* **Teams App**: "Pennie the Prepper" with green accent (#9DFF0A) * **DevOps**: Production Azure DevOps project * **AI Agent**: Production AI Foundry project (`knowall-ai-foundry`) diff --git a/docs/TROUBLESHOOTING.adoc b/docs/TROUBLESHOOTING.adoc index e683f9f..cb229de 100644 --- a/docs/TROUBLESHOOTING.adoc +++ b/docs/TROUBLESHOOTING.adoc @@ -85,8 +85,8 @@ | **Audio receiving but no transcription output** | Audio packets forwarded to Speech Services but no transcription results returned. **Diagnostic**: Check AUDIO-ANALYSIS logs for RMS values: `LastRMS=15-50` indicates very quiet/silent audio. **Root cause**: Microphone volume too low or muted in Teams settings. Azure Speech Services requires adequate audio signal strength (RMS > 100) to detect speech. **Fix**: Increase microphone volume in Windows Sound Settings or Teams device settings. **RMS reference values**: RMS ~15-50 = near silence, RMS ~100-300 = normal speech, RMS >500 = loud speech. Also ensure: (1) correct microphone selected in Teams, (2) microphone not muted, (3) no background noise suppression reducing legitimate speech. -| **Azure Key Vault config keys not matching code (dashes vs underscores)** -| Code uses `_configuration["AZURE_OPENAI_ENDPOINT"]` but Key Vault has `AZURE-OPENAI-ENDPOINT`. **Root cause**: Azure Key Vault doesn't allow underscores in secret names, so dashes are used. However, the Azure Key Vault configuration provider does NOT automatically convert dashes to underscores. **Fix**: Use dashes in code to match Key Vault: `_configuration["AZURE-OPENAI-ENDPOINT"]`. This applies to all Key Vault secrets. Check existing code patterns (e.g., `AZURE-SPEECH-KEY` already uses dashes). +| **Configuration key naming convention** +| All configuration keys use underscores (e.g., `AZURE_OPENAI_ENDPOINT`, `AZURE_SPEECH_KEY`). This applies to appsettings.json, environment variables, and GitHub Secrets. **Note**: Azure Key Vault doesn't allow underscores in secret names, so if using Key Vault in the future, you would need to either use dashes in Key Vault and add fallback code, or use a different secrets management approach. | **Azure.AI.OpenAI.Assistants SDK returns 404 "No assistant found"** | SDK calls succeed but assistant not found despite existing in AI Foundry portal. **Root cause**: AI Foundry PROJECT agents (created via AI Foundry portal) use different API path (`/api/projects/{project}/assistants`) than OpenAI resource assistants (`/openai/assistants`). The `Azure.AI.OpenAI.Assistants` SDK only queries the OpenAI resource level. **Fix**: Either (1) create assistant at OpenAI resource level: `az rest --method POST --url "https://{resource}.openai.azure.com/openai/assistants?api-version=2024-05-01-preview" --resource https://cognitiveservices.azure.com --body @assistant.json`, or (2) use the AI Foundry Agents SDK instead. **Verify existing assistants**: `az rest --method GET --url "https://{resource}.openai.azure.com/openai/assistants?api-version=2024-05-01-preview" --resource https://cognitiveservices.azure.com`. @@ -100,4 +100,25 @@ | **Storage account SAS URL fails with "Public access is not permitted"** | Deployment script generates SAS URL but VM can't download: "Public access is not permitted on this storage account". **Root cause**: Storage account has `allowBlobPublicAccess: false`. SAS tokens should still work but may require account key authentication. **Fix options**: (1) Enable public blob access: `az storage account update --name {account} --allow-blob-public-access true`, (2) Generate SAS with account key: `az storage blob generate-sas --account-name {account} --account-key "{key}" --container-name {container} --name {blob} --permissions r --expiry {expiry} --full-uri`, (3) Use managed identity with Storage Blob Data Reader role instead of SAS. +| **Test VM bot not responding to Teams messages (only listens on localhost:5000)** +| Production bot works but test VM bot doesn't respond. **Diagnostic**: Check listening ports on VM: `netstat -an \| findstr LISTEN`. Prod shows `0.0.0.0:443` and `0.0.0.0:5000`, test only shows `127.0.0.1:5000`. **Root cause**: Test VM missing SSL certificate and HTTPS binding. Teams requires HTTPS endpoint. Production has SSL cert bound via `netsh http add sslcert`. **Fix**: Deploy workflow now includes Step 7 that automatically: (1) Creates self-signed SSL certificate for VM FQDN, (2) Binds cert to port 443 via `netsh http add sslcert ipport=0.0.0.0:443`, (3) Sets `ASPNETCORE_URLS=https://0.0.0.0:443;http://0.0.0.0:5000`. Re-run deploy workflow for test environment to apply. **Manual fix**: RDP to VM and run: `$cert = New-SelfSignedCertificate -DnsName "{vm-fqdn}" -CertStoreLocation Cert:\LocalMachine\My; netsh http add sslcert ipport=0.0.0.0:443 certhash=$($cert.Thumbprint) appid='{00000000-0000-0000-0000-000000000000}'; [Environment]::SetEnvironmentVariable('ASPNETCORE_URLS', 'https://0.0.0.0:443;http://0.0.0.0:5000', 'Machine'); Restart-Service PennieBot`. + +| **RDP access open to internet (security vulnerability)** +| Azure NSG allows RDP (port 3389) from any IP (`sourceAddressPrefix: '*'`). **Fix**: Bicep now uses `allowedRdpSourceIP` parameter. If not provided, RDP rule is not created (secure default). To enable RDP for your dynamic IP: (1) Set GitHub Secret `ADMIN_DDNS_HOSTNAME` to your dynamic DNS hostname (e.g., `robotechy.ddns.net`), (2) Deploy workflow resolves hostname to IP at deploy time, (3) NSG rule created allowing only that IP. **Manual update**: `az network nsg rule update -g {rg} --nsg-name {nsg} -n AllowRDP --source-address-prefixes {your-ip}`. + +| **Hardcoded VM password in Bicep template (security vulnerability)** +| VM admin password was hardcoded in `windows-vm.bicep` as `P@ssw0rd!${uniqueString(...)}`. **Fix**: Password now uses `@secure() param adminPassword` requiring `VM_ADMIN_PASSWORD` GitHub Secret. VM deployment fails if secret not set (secure by design). **Set secret**: `gh secret set VM_ADMIN_PASSWORD --env test` and `gh secret set VM_ADMIN_PASSWORD --env prod`. + +| **Test bot endpoint healthy but Teams messages fail (missing Azure Bot registration)** +| Endpoint tests pass (`./tests/bot-endpoint-test.sh test` shows healthy), Direct Line works for prod, but test environment Teams messages show "Failed to send". **Root cause**: No Azure Bot Service registration exists for the test environment. The bot VM is deployed and running, but without a Bot Service registration, Teams has no way to route messages to the bot endpoint. Production has `pennie-bot` registration, test has none. **Diagnostic**: `az resource list --resource-type "Microsoft.BotService/botServices" -o table` shows only prod bot. **Fix**: Deploy workflow now includes "Ensure Azure Bot registration exists" step that: (1) Checks if `pennie-bot-{env}` exists, (2) Creates it if missing with correct endpoint URL, (3) Enables Teams channel. Re-run deploy workflow for test environment: `gh workflow run deploy.yml -f environment=test`. **Manual fix**: `az bot create --resource-group TMinus15Agents-Test --name pennie-bot-test --kind registration --sku F0 --appid {TEAMS_APP_ID} --endpoint "https://{vm-fqdn}/api/messages"` then `az bot msteams create --resource-group TMinus15Agents-Test --name pennie-bot-test`. + +| **Test VM not responding (Spot VM evicted by Azure)** +| Test environment suddenly stops working, health endpoint returns connection refused. **Root cause**: Test VM uses Azure Spot pricing (60-80% cheaper), but Azure can evict the VM when capacity is needed. This is expected behavior. **Diagnostic**: `az vm get-instance-view -g TMinus15Agents-Test -n pennie-vm-test --query "instanceView.statuses[1].displayStatus" -o tsv` returns "VM deallocated" instead of "VM running". **Recovery**: Start the VM: `az vm start -g TMinus15Agents-Test -n pennie-vm-test`, then verify: `./tests/bot-endpoint-test.sh test`. **Prevention**: Production VM uses regular pricing (not Spot) to avoid eviction. Test VM uses Spot with `evictionPolicy: Deallocate` to preserve disk on eviction. **Monitoring**: Consider adding Azure Monitor alert for VM deallocated state. **Cost trade-off**: Spot VMs save 60-80% but require occasional manual restart after eviction. + +| **appsettings.json overwritten after deployment (secrets lost)** +| Bot starts but fails authentication or missing configuration after redeployment. **Root cause**: `dotnet build --output C:\Pennie\bot` copies the source `appsettings.json` (with empty placeholder values) to the output directory, overwriting any configured `appsettings.json` that was previously deployed. **Symptoms**: Bot health endpoint works but Teams messages fail with auth errors. OpenAI/Speech settings reset to empty strings. **Fix**: The `deploy-bot-to-vm.ps1` script now backs up `appsettings.json` before build and restores it after: `Copy-Item appsettings.json $env:TEMP\appsettings.json.backup` before build, then `Copy-Item $env:TEMP\appsettings.json.backup appsettings.json` after build. **Script execution order matters**: (1) Extract bot package, (2) Backup existing appsettings.json, (3) Build from source (if applicable), (4) Restore appsettings.json, (5) Run configure-bot-settings.ps1 to inject secrets, (6) Start service. **Manual recovery**: If appsettings.json was overwritten, re-run the configure-bot-settings.ps1 script or redeploy via GitHub Actions workflow. + +| **Teams bot returns InternalServerError (HTTP 500) when sending messages** +| Bot health endpoint works but Teams messages or Azure Portal "Test in Web Chat" returns "HTTP status code InternalServerError". **Root cause**: VM's managed identity doesn't have RBAC permission to call Azure OpenAI. The bot receives the message, tries to call Azure OpenAI via `DefaultAzureCredential`, and gets 401 Unauthorized. **Diagnostic**: Check bot logs for `Azure.RequestFailedException` with status 401. Verify VM role assignments: `az role assignment list --assignee {vm-principal-id} --all`. **Fix**: Grant "Cognitive Services OpenAI User" role to VM's managed identity: `az role assignment create --assignee {vm-principal-id} --role "Cognitive Services OpenAI User" --scope "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{openai-resource}"`. Get VM principal ID: `az vm show -g {rg} -n {vm} --query "identity.principalId" -o tsv`. Restart bot service after granting role. **Automated fix**: Deploy workflow now includes "Grant VM access to Azure OpenAI" step that automatically grants this role during infrastructure deployment. + |=== diff --git a/infra/main.bicep b/infra/main.bicep index 9bfabf0..76d1acc 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1,17 +1,16 @@ // Main Bicep orchestration for Pennie the Prepper -// Deploys all infrastructure in single Azure region - -targetScope = 'subscription' +// Deploys all infrastructure to an existing resource group +// +// Prerequisites: +// - Resource group must be created manually before deployment +// - See docs/DEPLOYMENT.adoc for setup instructions // Parameters @description('Name of the environment (dev, test, prod)') param environmentName string = 'prod' @description('Primary Azure region for all resources') -param location string = 'uksouth' - -@description('Name of the resource group') -param resourceGroupName string = 'TMinus15Agents' +param location string = resourceGroup().location @description('Name of the Azure AI Foundry Hub (existing or new)') param aiHubName string @@ -25,9 +24,21 @@ param devOpsOrg string @description('Azure DevOps project name') param devOpsProject string -@description('Teams bot app ID (from Azure AD app registration)') +@description('Deploy AI services (OpenAI, Speech, AI Foundry). Set to false for test environments that share prod AI services.') +param deployAiServices bool = true + +@description('Deploy Windows VM for Teams Bot. Set to true to create the VM.') +param deployVM bool = true + +@description('Use Azure Spot VM for cost savings (60-80% cheaper, can be evicted by Azure)') +param useSpotVM bool = false + +@description('Admin password for VM - provide via GitHub Secrets') @secure() -param teamsAppId string +param vmAdminPassword string = '' + +@description('Allowed source IP for RDP access (resolve dynamic DNS to IP before deployment)') +param allowedRdpSourceIP string = '' @description('Tags to apply to all resources') param tags object = { @@ -37,16 +48,8 @@ param tags object = { CostCenter: 'AI-Agents' } -// Resource Group -resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location - tags: tags -} - // Module: Monitoring (Application Insights, Log Analytics, Storage) module monitoring './modules/monitoring.bicep' = { - scope: rg name: 'monitoring-deployment' params: { location: location @@ -55,21 +58,9 @@ module monitoring './modules/monitoring.bicep' = { } } -// Module: Key Vault (Secrets management) -module keyVault './modules/key-vault.bicep' = { - scope: rg - name: 'keyvault-deployment' - params: { - location: location - environmentName: environmentName - tags: tags - teamsAppId: teamsAppId - } -} - // Module: AI Services (AI Foundry, Speech Services, OpenAI) -module aiServices './modules/ai-services.bicep' = { - scope: rg +// Optional: Test environments can share production AI services +module aiServices './modules/ai-services.bicep' = if (deployAiServices) { name: 'ai-services-deployment' params: { location: location @@ -81,31 +72,40 @@ module aiServices './modules/ai-services.bicep' = { } // Module: Windows VM (Teams Media Bot + Node.js MCP Server) -module windowsVM './modules/windows-vm.bicep' = { - scope: rg +// Optional: Can be disabled for environments that don't need a VM +module windowsVM './modules/windows-vm.bicep' = if (deployVM && !empty(vmAdminPassword)) { name: 'windows-vm-deployment' params: { location: location environmentName: environmentName - keyVaultName: keyVault.outputs.keyVaultName applicationInsightsConnectionString: monitoring.outputs.applicationInsightsConnectionString devOpsOrg: devOpsOrg devOpsProject: devOpsProject + useSpotVM: useSpotVM + adminPassword: vmAdminPassword + allowedRdpSourceIP: allowedRdpSourceIP tags: tags } } // Outputs -output resourceGroupName string = rg.name +output resourceGroupName string = resourceGroup().name output location string = location -output keyVaultName string = keyVault.outputs.keyVaultName output applicationInsightsName string = monitoring.outputs.applicationInsightsName output applicationInsightsConnectionString string = monitoring.outputs.applicationInsightsConnectionString output storageAccountName string = monitoring.outputs.storageAccountName -output aiHubName string = aiServices.outputs.aiHubName -output aiProjectName string = aiServices.outputs.aiProjectName -output speechServiceEndpoint string = aiServices.outputs.speechServiceEndpoint -output openAiEndpoint string = aiServices.outputs.openAiEndpoint -output vmName string = windowsVM.outputs.vmName -output vmPublicIP string = windowsVM.outputs.vmPublicIP -output vmPrivateIP string = windowsVM.outputs.vmPrivateIP +#disable-next-line BCP318 // Condition matches module deployment condition +output aiHubName string = deployAiServices ? aiServices.outputs.aiHubName : 'not-deployed' +#disable-next-line BCP318 // Condition matches module deployment condition +output aiProjectName string = deployAiServices ? aiServices.outputs.aiProjectName : 'not-deployed' +#disable-next-line BCP318 // Condition matches module deployment condition +output speechServiceEndpoint string = deployAiServices ? aiServices.outputs.speechServiceEndpoint : 'not-deployed' +#disable-next-line BCP318 // Condition matches module deployment condition +output openAiEndpoint string = deployAiServices ? aiServices.outputs.openAiEndpoint : 'not-deployed' +// Note: VM outputs depend on deployVM flag, not exposing vmAdminPassword value +#disable-next-line BCP318 outputs-should-not-contain-secrets // Condition check, not exposing secret +output vmName string = (deployVM && !empty(vmAdminPassword)) ? windowsVM.outputs.vmName : 'not-deployed' +#disable-next-line BCP318 outputs-should-not-contain-secrets // Condition check, not exposing secret +output vmPublicIP string = (deployVM && !empty(vmAdminPassword)) ? windowsVM.outputs.vmPublicIP : 'not-deployed' +#disable-next-line BCP318 outputs-should-not-contain-secrets // Condition check, not exposing secret +output vmPrivateIP string = (deployVM && !empty(vmAdminPassword)) ? windowsVM.outputs.vmPrivateIP : 'not-deployed' diff --git a/infra/main.json b/infra/main.json index 503b7bc..23cd8bd 100644 --- a/infra/main.json +++ b/infra/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.38.33.27573", - "templateHash": "1251764891236423214" + "templateHash": "8376742722193489235" } }, "parameters": { @@ -55,12 +55,6 @@ "description": "Azure DevOps project name" } }, - "teamsAppId": { - "type": "securestring", - "metadata": { - "description": "Teams bot app ID (from Azure AD app registration)" - } - }, "tags": { "type": "object", "defaultValue": { @@ -229,134 +223,6 @@ "[subscriptionResourceId('Microsoft.Resources/resourceGroups', parameters('resourceGroupName'))]" ] }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyvault-deployment", - "resourceGroup": "[parameters('resourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "environmentName": { - "value": "[parameters('environmentName')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "teamsAppId": { - "value": "[parameters('teamsAppId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "6266515167588546900" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "environmentName": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "teamsAppId": { - "type": "securestring" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2023-02-01", - "name": "[format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "properties": { - "sku": { - "family": "A", - "name": "standard" - }, - "tenantId": "[subscription().tenantId]", - "enableRbacAuthorization": true, - "enableSoftDelete": true, - "softDeleteRetentionInDays": 90, - "enablePurgeProtection": true, - "networkAcls": { - "defaultAction": "Allow", - "bypass": "AzureServices" - } - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-02-01", - "name": "[format('{0}/{1}', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)), 'teams-app-id')]", - "properties": { - "value": "[parameters('teamsAppId')]", - "contentType": "text/plain" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-02-01", - "name": "[format('{0}/{1}', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)), 'teams-app-password')]", - "properties": { - "value": "PLACEHOLDER-SET-VIA-PIPELINE", - "contentType": "text/plain" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-02-01", - "name": "[format('{0}/{1}', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)), 'devops-pat')]", - "properties": { - "value": "PLACEHOLDER-SET-VIA-PIPELINE", - "contentType": "text/plain" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)))]" - ] - } - ], - "outputs": { - "keyVaultName": { - "type": "string", - "value": "[format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id))]" - }, - "keyVaultId": { - "type": "string", - "value": "[resourceId('Microsoft.KeyVault/vaults', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id)))]" - }, - "keyVaultUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', format('pennie-kv-{0}-{1}', parameters('environmentName'), uniqueString(resourceGroup().id))), '2023-02-01').vaultUri]" - } - } - } - }, - "dependsOn": [ - "[subscriptionResourceId('Microsoft.Resources/resourceGroups', parameters('resourceGroupName'))]" - ] - }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -578,9 +444,6 @@ "environmentName": { "value": "[parameters('environmentName')]" }, - "keyVaultName": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'keyvault-deployment'), '2025-04-01').outputs.keyVaultName.value]" - }, "applicationInsightsConnectionString": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'monitoring-deployment'), '2025-04-01').outputs.applicationInsightsConnectionString.value]" }, @@ -601,7 +464,7 @@ "_generator": { "name": "bicep", "version": "0.38.33.27573", - "templateHash": "15961813009083017178" + "templateHash": "8358108851875092257" } }, "parameters": { @@ -611,9 +474,6 @@ "environmentName": { "type": "string" }, - "keyVaultName": { - "type": "string" - }, "applicationInsightsConnectionString": { "type": "string" }, @@ -639,6 +499,59 @@ "metadata": { "description": "VM size for the Windows Server" } + }, + "useSpotVM": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Use Azure Spot VM for cost savings (can be evicted)" + } + }, + "spotEvictionPolicy": { + "type": "string", + "defaultValue": "Deallocate", + "allowedValues": [ + "Deallocate", + "Delete" + ], + "metadata": { + "description": "Spot VM eviction policy: Deallocate (preserve disk) or Delete" + } + }, + "spotMaxPrice": { + "type": "int", + "defaultValue": -1, + "metadata": { + "description": "Max price for Spot VM (-1 = up to on-demand price)" + } + }, + "enableAutoShutdown": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Enable auto-shutdown schedule" + } + }, + "autoShutdownTime": { + "type": "string", + "defaultValue": "1900", + "metadata": { + "description": "Auto-shutdown time in 24h format (e.g., 1900 for 7pm)" + } + }, + "autoShutdownTimezone": { + "type": "string", + "defaultValue": "GMT Standard Time", + "metadata": { + "description": "Auto-shutdown timezone" + } + }, + "existingOpenAiResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource ID of an existing Azure OpenAI resource for RBAC (optional, for cross-region deployments)" + } } }, "resources": [ @@ -759,7 +672,7 @@ "apiVersion": "2023-09-01", "name": "[format('pennie-vm-{0}', parameters('environmentName'))]", "location": "[parameters('location')]", - "tags": "[parameters('tags')]", + "tags": "[union(parameters('tags'), if(parameters('useSpotVM'), createObject('SpotVM', 'true'), createObject()))]", "identity": { "type": "SystemAssigned" }, @@ -767,6 +680,9 @@ "hardwareProfile": { "vmSize": "[parameters('vmSize')]" }, + "priority": "[if(parameters('useSpotVM'), 'Spot', 'Regular')]", + "evictionPolicy": "[if(parameters('useSpotVM'), parameters('spotEvictionPolicy'), null())]", + "billingProfile": "[if(parameters('useSpotVM'), createObject('maxPrice', parameters('spotMaxPrice')), null())]", "osProfile": { "computerName": "[format('pennie-{0}', parameters('environmentName'))]", "adminUsername": "[parameters('adminUsername')]", @@ -815,6 +731,29 @@ "[resourceId('Microsoft.Network/networkInterfaces', format('pennie-nic-{0}', parameters('environmentName')))]" ] }, + { + "condition": "[parameters('enableAutoShutdown')]", + "type": "Microsoft.DevTestLab/schedules", + "apiVersion": "2018-09-15", + "name": "[format('shutdown-computevm-{0}', format('pennie-vm-{0}', parameters('environmentName')))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "status": "Enabled", + "taskType": "ComputeVmShutdownTask", + "dailyRecurrence": { + "time": "[parameters('autoShutdownTime')]" + }, + "timeZoneId": "[parameters('autoShutdownTimezone')]", + "targetResourceId": "[resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName')))]", + "notificationSettings": { + "status": "Disabled" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName')))]" + ] + }, { "type": "Microsoft.Compute/virtualMachines/extensions", "apiVersion": "2023-09-01", @@ -835,20 +774,6 @@ "dependsOn": [ "[resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName')))]" ] - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.KeyVault/vaults/{0}', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName'))), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[reference(resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName'))), '2023-09-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - }, - "dependsOn": [ - "[resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName')))]" - ] } ], "outputs": { @@ -875,12 +800,19 @@ "vmPrincipalId": { "type": "string", "value": "[reference(resourceId('Microsoft.Compute/virtualMachines', format('pennie-vm-{0}', parameters('environmentName'))), '2023-09-01', 'full').identity.principalId]" + }, + "isSpotVM": { + "type": "bool", + "value": "[parameters('useSpotVM')]" + }, + "autoShutdownEnabled": { + "type": "bool", + "value": "[parameters('enableAutoShutdown')]" } } } }, "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'keyvault-deployment')]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'monitoring-deployment')]", "[subscriptionResourceId('Microsoft.Resources/resourceGroups', parameters('resourceGroupName'))]" ] @@ -895,10 +827,6 @@ "type": "string", "value": "[parameters('location')]" }, - "keyVaultName": { - "type": "string", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'keyvault-deployment'), '2025-04-01').outputs.keyVaultName.value]" - }, "applicationInsightsName": { "type": "string", "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('resourceGroupName')), 'Microsoft.Resources/deployments', 'monitoring-deployment'), '2025-04-01').outputs.applicationInsightsName.value]" diff --git a/infra/main.parameters.prod.json b/infra/main.parameters.prod.json new file mode 100644 index 0000000..1b44d05 --- /dev/null +++ b/infra/main.parameters.prod.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "prod" + }, + "aiHubName": { + "value": "knowall-ai-foundry" + }, + "aiProjectName": { + "value": "T-Minus-15 Agents" + }, + "devOpsOrg": { + "value": "knowall-ai" + }, + "devOpsProject": { + "value": "KnowAll" + } + } +} diff --git a/infra/main.parameters.test.json b/infra/main.parameters.test.json index a635c65..bb29239 100644 --- a/infra/main.parameters.test.json +++ b/infra/main.parameters.test.json @@ -5,11 +5,14 @@ "environmentName": { "value": "test" }, - "location": { - "value": "uksouth" + "deployAiServices": { + "value": false }, - "resourceGroupName": { - "value": "TMinus15Agents-Test" + "deployVM": { + "value": true + }, + "useSpotVM": { + "value": true }, "aiHubName": { "value": "knowall-ai-foundry-test" @@ -18,18 +21,10 @@ "value": "T-Minus-15 Agents Test" }, "devOpsOrg": { - "value": "YourDevOpsOrg" + "value": "knowall-ai" }, "devOpsProject": { - "value": "YourDevOpsProject-Test" - }, - "teamsAppId": { - "reference": { - "keyVault": { - "id": "/subscriptions/{subscription-id}/resourceGroups/{rg-name}/providers/Microsoft.KeyVault/vaults/{vault-name}" - }, - "secretName": "teams-app-id-test" - } + "value": "KnowAll" } } } diff --git a/infra/modules/monitoring.bicep b/infra/modules/monitoring.bicep index 2168aba..4379a56 100644 --- a/infra/modules/monitoring.bicep +++ b/infra/modules/monitoring.bicep @@ -35,8 +35,9 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = { } // Storage Account (for logs, diagnostics, backups) +// Name must be 3-24 chars, lowercase alphanumeric only resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { - name: 'penniestorage${environmentName}${uniqueString(resourceGroup().id)}' + name: take('penniestorage${uniqueString(resourceGroup().id)}', 24) location: location tags: tags sku: { diff --git a/infra/modules/windows-vm.bicep b/infra/modules/windows-vm.bicep index 23dd1e0..64624f3 100644 --- a/infra/modules/windows-vm.bicep +++ b/infra/modules/windows-vm.bicep @@ -2,20 +2,48 @@ param location string param environmentName string -param keyVaultName string +#disable-next-line no-unused-params // Used in vmExtension commandToExecute string interpolation param applicationInsightsConnectionString string +#disable-next-line no-unused-params // Used in vmExtension commandToExecute string interpolation param devOpsOrg string +#disable-next-line no-unused-params // Used in vmExtension commandToExecute string interpolation param devOpsProject string param tags object @description('Admin username for the VM') param adminUsername string = 'pennieadmin' +@description('Admin password for the VM - REQUIRED: Must be provided via GitHub Secrets or parameters') +@secure() +param adminPassword string + +@description('Allowed source IP/CIDR for RDP access. Resolve your dynamic DNS hostname to IP before deployment. Default blocks all RDP.') +param allowedRdpSourceIP string = '' + @description('VM size for the Windows Server') param vmSize string = 'Standard_D2s_v3' // 2 vCPU, 8 GB RAM -@description('Resource ID of an existing Azure OpenAI resource for RBAC (optional, for cross-region deployments)') -param existingOpenAiResourceId string = '' +@description('Use Azure Spot VM for cost savings (can be evicted)') +param useSpotVM bool = false + +@description('Spot VM eviction policy: Deallocate (preserve disk) or Delete') +@allowed(['Deallocate', 'Delete']) +param spotEvictionPolicy string = 'Deallocate' + +@description('Max price for Spot VM (-1 = up to on-demand price)') +param spotMaxPrice int = -1 + +@description('Enable auto-shutdown schedule') +param enableAutoShutdown bool = false + +@description('Auto-shutdown time in 24h format (e.g., 1900 for 7pm)') +param autoShutdownTime string = '1900' + +@description('Auto-shutdown timezone') +param autoShutdownTimezone string = 'GMT Standard Time' + +// NOTE: If you need to grant VM access to an existing Azure OpenAI resource, use Azure CLI after deployment: +// az role assignment create --assignee --role "Cognitive Services OpenAI Contributor" --scope // Virtual Network resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = { @@ -43,12 +71,13 @@ resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = { } // Network Security Group +// RDP rule is only created if allowedRdpSourceIP is provided (security best practice) resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = { name: 'pennie-nsg-${environmentName}' location: location tags: tags properties: { - securityRules: [ + securityRules: concat([ { name: 'AllowHTTPS' properties: { @@ -62,6 +91,21 @@ resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = { destinationAddressPrefix: '*' } } + { + name: 'AllowHTTP' + properties: { + priority: 110 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '80' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + description: 'Required for ACME HTTP-01 challenge (SSL certificate)' + } + } + ], !empty(allowedRdpSourceIP) ? [ { name: 'AllowRDP' properties: { @@ -71,11 +115,11 @@ resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = { protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '3389' - sourceAddressPrefix: '*' // Restrict to your IP in production + sourceAddressPrefix: allowedRdpSourceIP destinationAddressPrefix: '*' } } - ] + ] : []) } } @@ -125,7 +169,7 @@ resource nic 'Microsoft.Network/networkInterfaces@2023-05-01' = { resource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = { name: 'pennie-vm-${environmentName}' location: location - tags: tags + tags: union(tags, useSpotVM ? { SpotVM: 'true' } : {}) identity: { type: 'SystemAssigned' } @@ -133,10 +177,16 @@ resource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = { hardwareProfile: { vmSize: vmSize } + // Spot VM configuration (60-80% cost savings, can be evicted) + priority: useSpotVM ? 'Spot' : 'Regular' + evictionPolicy: useSpotVM ? spotEvictionPolicy : null + billingProfile: useSpotVM ? { + maxPrice: spotMaxPrice + } : null osProfile: { computerName: 'pennie-${environmentName}' adminUsername: adminUsername - adminPassword: 'P@ssw0rd!${uniqueString(resourceGroup().id)}' // Change in production via Key Vault + adminPassword: adminPassword windowsConfiguration: { enableAutomaticUpdates: true provisionVMAgent: true @@ -179,6 +229,25 @@ resource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = { } } +// Auto-shutdown schedule (saves costs by stopping VM outside business hours) +resource autoShutdownSchedule 'Microsoft.DevTestLab/schedules@2018-09-15' = if (enableAutoShutdown) { + name: 'shutdown-computevm-${vm.name}' + location: location + tags: tags + properties: { + status: 'Enabled' + taskType: 'ComputeVmShutdownTask' + dailyRecurrence: { + time: autoShutdownTime + } + timeZoneId: autoShutdownTimezone + targetResourceId: vm.id + notificationSettings: { + status: 'Disabled' + } + } +} + // VM Extension: Custom Script to install dependencies resource vmExtension 'Microsoft.Compute/virtualMachines/extensions@2023-09-01' = { parent: vm @@ -233,38 +302,13 @@ resource vmExtension 'Microsoft.Compute/virtualMachines/extensions@2023-09-01' = } } -// Grant VM Managed Identity access to Key Vault -resource keyVaultReference 'Microsoft.KeyVault/vaults@2023-02-01' existing = { - name: keyVaultName -} - -resource keyVaultRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: keyVaultReference - name: guid(keyVaultReference.id, vm.id, 'Key Vault Secrets User') - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User - principalId: vm.identity.principalId - principalType: 'ServicePrincipal' - } -} - // Grant VM Managed Identity access to Azure OpenAI (if existing resource provided) -// Role: Cognitive Services OpenAI Contributor (a]001dd7-823b-4bf9-a81c-774440b5d111) -// Required for the bot to call Azure OpenAI APIs using managed identity -resource openAiReference 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = if (!empty(existingOpenAiResourceId)) { - name: last(split(existingOpenAiResourceId, '/')) - scope: resourceGroup(split(existingOpenAiResourceId, '/')[2], split(existingOpenAiResourceId, '/')[4]) -} - -resource openAiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(existingOpenAiResourceId)) { - scope: openAiReference - name: guid(existingOpenAiResourceId, vm.id, 'Cognitive Services OpenAI Contributor') - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a001dd7-823b-4bf9-a81c-774440b5d111') // Cognitive Services OpenAI Contributor - principalId: vm.identity.principalId - principalType: 'ServicePrincipal' - } -} +// NOTE: Cross-scope role assignment for OpenAI must be done via Azure CLI after deployment: +// az role assignment create \ +// --assignee \ +// --role "Cognitive Services OpenAI Contributor" \ +// --scope +// This is because Bicep doesn't support cross-resource-group role assignments in the same deployment. // Outputs output vmName string = vm.name @@ -273,3 +317,5 @@ output vmPublicIP string = publicIP.properties.ipAddress output vmPrivateIP string = nic.properties.ipConfigurations[0].properties.privateIPAddress output vmFQDN string = publicIP.properties.dnsSettings.fqdn output vmPrincipalId string = vm.identity.principalId +output isSpotVM bool = useSpotVM +output autoShutdownEnabled bool = enableAutoShutdown diff --git a/infra/windows-vm.parameters.json b/infra/windows-vm.parameters.json index 719a32d..4c93442 100644 --- a/infra/windows-vm.parameters.json +++ b/infra/windows-vm.parameters.json @@ -8,9 +8,6 @@ "environmentName": { "value": "prod" }, - "keyVaultName": { - "value": "pennie-kv-mmdxqm3w7kjwm" - }, "applicationInsightsConnectionString": { "value": "InstrumentationKey=a1269562-627e-423a-8a5d-6fec575521a0;IngestionEndpoint=https://uksouth-1.in.applicationinsights.azure.com/;LiveEndpoint=https://uksouth.livediagnostics.monitor.azure.com/;ApplicationId=0ac06a02-0a2f-43d2-8842-16db47f14b6d" }, diff --git a/infra/windows-vm.parameters.test.json b/infra/windows-vm.parameters.test.json new file mode 100644 index 0000000..82ea54b --- /dev/null +++ b/infra/windows-vm.parameters.test.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "value": "uksouth" + }, + "environmentName": { + "value": "test" + }, + "applicationInsightsConnectionString": { + "value": "" + }, + "devOpsOrg": { + "value": "knowall-ai" + }, + "devOpsProject": { + "value": "KnowAll" + }, + "useSpotVM": { + "value": true + }, + "spotEvictionPolicy": { + "value": "Deallocate" + }, + "spotMaxPrice": { + "value": -1 + }, + "enableAutoShutdown": { + "value": true + }, + "autoShutdownTime": { + "value": "1900" + }, + "autoShutdownTimezone": { + "value": "GMT Standard Time" + }, + "tags": { + "value": { + "Project": "Pennie", + "Environment": "Test", + "ManagedBy": "Bicep", + "Purpose": "Teams Bot VM (Spot)", + "CostCenter": "Development" + } + } + } +} diff --git a/scripts/configure-bot-settings.ps1 b/scripts/configure-bot-settings.ps1 new file mode 100644 index 0000000..ab191ab --- /dev/null +++ b/scripts/configure-bot-settings.ps1 @@ -0,0 +1,126 @@ +<# +.SYNOPSIS + Configures bot appsettings.json with credentials and URLs. +.DESCRIPTION + Updates the bot's appsettings.json with Teams credentials, backend URL, + media platform settings, and Azure OpenAI settings. Includes null safety checks. +.PARAMETER ConfigPath + Path to appsettings.json file +.PARAMETER TeamsAppId + Microsoft App ID for Teams bot +.PARAMETER TeamsAppPassword + Microsoft App Password for Teams bot +.PARAMETER VmFqdn + Fully qualified domain name of the VM +.PARAMETER BackendUrl + URL of the Azure Functions backend +.PARAMETER AzureOpenAiEndpoint + Azure OpenAI endpoint URL for Pennie AI (optional) +.PARAMETER AzureOpenAiAssistantId + Azure OpenAI Assistant ID for Pennie AI (optional) +#> +param( + [Parameter(Mandatory=$true)] + [string]$ConfigPath, + + [Parameter(Mandatory=$true)] + [string]$TeamsAppId, + + [Parameter(Mandatory=$true)] + [string]$TeamsAppPassword, + + [Parameter(Mandatory=$true)] + [string]$VmFqdn, + + [Parameter(Mandatory=$true)] + [string]$BackendUrl, + + [Parameter(Mandatory=$false)] + [string]$AzureOpenAiEndpoint = "", + + [Parameter(Mandatory=$false)] + [string]$AzureOpenAiAssistantId = "" +) + +$ErrorActionPreference = 'Stop' + +# Verify config file exists +if (-not (Test-Path $ConfigPath)) { + Write-Error "Configuration file not found: $ConfigPath" + exit 1 +} + +try { + # Read and parse JSON + $configContent = Get-Content $ConfigPath -Raw + if ([string]::IsNullOrWhiteSpace($configContent)) { + Write-Error "Configuration file is empty: $ConfigPath" + exit 1 + } + + $config = $configContent | ConvertFrom-Json + if ($null -eq $config) { + Write-Error "Failed to parse JSON from: $ConfigPath" + exit 1 + } + + Write-Host "Loaded configuration from $ConfigPath" + + # Set Teams/Bot credentials + $config.TeamsAppId = $TeamsAppId + $config.TeamsAppPassword = $TeamsAppPassword + $config.MicrosoftAppId = $TeamsAppId + $config.MicrosoftAppPassword = $TeamsAppPassword + + # Set Bot base URL + $config.BotBaseUrl = "https://$VmFqdn" + + # Ensure MediaPlatform section exists with null safety + if ($null -eq $config.MediaPlatform) { + Write-Host "Creating MediaPlatform section..." + $config | Add-Member -NotePropertyName 'MediaPlatform' -NotePropertyValue @{} -Force + } + + # Convert to hashtable for easier manipulation if it's a PSCustomObject + if ($config.MediaPlatform -is [PSCustomObject]) { + $mp = @{} + $config.MediaPlatform.PSObject.Properties | ForEach-Object { $mp[$_.Name] = $_.Value } + } else { + $mp = $config.MediaPlatform + } + + $mp.ServiceFqdn = $VmFqdn + $mp.CallNotificationUrl = "https://$VmFqdn/api/calling" + $mp.MediaDnsName = $VmFqdn + $mp.UseApplicationHostedMedia = $false + + # Reassign MediaPlatform + $config.MediaPlatform = $mp + + # Set backend URL + $config.AZURE_FUNCTIONS_BACKEND_URL = $BackendUrl + + # Set Azure OpenAI settings if provided (required for Pennie AI responses) + if (-not [string]::IsNullOrWhiteSpace($AzureOpenAiEndpoint)) { + $config | Add-Member -NotePropertyName 'AZURE_OPENAI_ENDPOINT' -NotePropertyValue $AzureOpenAiEndpoint -Force + Write-Host " - Azure OpenAI Endpoint: $AzureOpenAiEndpoint" + } + + if (-not [string]::IsNullOrWhiteSpace($AzureOpenAiAssistantId)) { + $config | Add-Member -NotePropertyName 'AZURE_OPENAI_ASSISTANT_ID' -NotePropertyValue $AzureOpenAiAssistantId -Force + Write-Host " - Azure OpenAI Assistant ID: $($AzureOpenAiAssistantId.Substring(0, [Math]::Min(15, $AzureOpenAiAssistantId.Length)))..." + } + + # Write back to file (depth 20 to handle deeply nested objects like Kestrel config) + $config | ConvertTo-Json -Depth 20 | Set-Content $ConfigPath -Encoding UTF8 + + Write-Host "Configuration updated successfully:" + Write-Host " - TeamsAppId: $($TeamsAppId.Substring(0, 8))..." + Write-Host " - BotBaseUrl: https://$VmFqdn" + Write-Host " - BackendUrl: $BackendUrl" + Write-Host " - MediaPlatform.ServiceFqdn: $VmFqdn" + +} catch { + Write-Error "Failed to configure bot settings: $_" + exit 1 +} diff --git a/scripts/configure-ssl.ps1 b/scripts/configure-ssl.ps1 new file mode 100644 index 0000000..f99aa4d --- /dev/null +++ b/scripts/configure-ssl.ps1 @@ -0,0 +1,212 @@ +<# +.SYNOPSIS + Configures SSL certificate for Pennie bot using Let's Encrypt. +.DESCRIPTION + Obtains a Let's Encrypt certificate using win-acme and configures Kestrel. + Requires LE_EMAIL secret to be configured. Fails if certificate cannot be obtained. +.PARAMETER Fqdn + Fully qualified domain name for the certificate +.PARAMETER Email + Email address for Let's Encrypt notifications (required) +.PARAMETER CertPath + Path to export PFX certificate (default: C:\Pennie\certs\pennie.pfx) +#> +param( + [Parameter(Mandatory=$true)] + [string]$Fqdn, + + [Parameter(Mandatory=$true)] + [string]$Email, + + [Parameter(Mandatory=$false)] + [string]$CertPath = "C:\Pennie\certs\pennie.pfx" +) + +$ErrorActionPreference = 'Stop' + +# Create certs directory +$certDir = Split-Path $CertPath -Parent +if (-not (Test-Path $certDir)) { + New-Item -ItemType Directory -Path $certDir -Force | Out-Null +} + +# Generate a random password for PFX +$pfxPassword = [System.Guid]::NewGuid().ToString().Substring(0, 16) +$pfxPasswordPath = Join-Path $certDir "pfx-password.txt" + +# Function to configure Kestrel with certificate +function Set-KestrelCertConfig { + param( + [string]$CertificatePath, + [string]$Password + ) + + $configPath = "C:\Pennie\bot\appsettings.json" + if (-not (Test-Path $configPath)) { + Write-Error "appsettings.json not found at $configPath" + return $false + } + + $config = Get-Content $configPath -Raw | ConvertFrom-Json + + # Configure Kestrel with PFX file (no AllowInvalid - LE certs are valid) + $kestrel = @{ + Endpoints = @{ + Https = @{ + Url = "https://0.0.0.0:443" + Certificate = @{ + Path = $CertificatePath + Password = $Password + } + } + Http = @{ + Url = "http://0.0.0.0:5000" + } + } + } + + $config | Add-Member -NotePropertyName 'Kestrel' -NotePropertyValue $kestrel -Force + $config | ConvertTo-Json -Depth 20 | Set-Content $configPath -Encoding UTF8 + + Write-Host "Kestrel configured with certificate: $CertificatePath" + return $true +} + +# Main logic +Write-Host "=== Let's Encrypt SSL Certificate Configuration ===" +Write-Host "FQDN: $Fqdn" +Write-Host "Email: $Email" +Write-Host "Certificate Path: $CertPath" +Write-Host "" + +# Download win-acme if not present +$wacmePath = "C:\Pennie\tools\win-acme" +$wacmeExe = Join-Path $wacmePath "wacs.exe" + +if (-not (Test-Path $wacmeExe)) { + Write-Host "Downloading win-acme..." + + if (-not (Test-Path $wacmePath)) { + New-Item -ItemType Directory -Path $wacmePath -Force | Out-Null + } + + $wacmeUrl = "https://github.com/win-acme/win-acme/releases/download/v2.2.9.1701/win-acme.v2.2.9.1701.x64.pluggable.zip" + $zipPath = Join-Path $wacmePath "win-acme.zip" + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $wacmeUrl -OutFile $zipPath -UseBasicParsing + Expand-Archive -Path $zipPath -DestinationPath $wacmePath -Force + Remove-Item $zipPath -Force + Write-Host "win-acme downloaded successfully" +} + +# Stop the bot service temporarily to free port 443 and use port 80 for HTTP-01 +$serviceName = "PennieBot" +$serviceWasRunning = $false + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($service -and $service.Status -eq 'Running') { + Write-Host "Stopping $serviceName service for certificate request..." + Stop-Service -Name $serviceName -Force + $serviceWasRunning = $true + Start-Sleep -Seconds 5 +} + +# Ensure Windows Firewall allows port 80 for ACME challenge +$firewallRuleName = "ACME HTTP-01 Challenge" +$existingRule = Get-NetFirewallRule -DisplayName $firewallRuleName -ErrorAction SilentlyContinue +if (-not $existingRule) { + Write-Host "Creating Windows Firewall rule for port 80 (ACME challenge)..." + New-NetFirewallRule -DisplayName $firewallRuleName ` + -Direction Inbound ` + -Protocol TCP ` + -LocalPort 80 ` + -Action Allow ` + -Profile Any | Out-Null + Write-Host "Firewall rule '$firewallRuleName' created" +} else { + Write-Host "Firewall rule '$firewallRuleName' already exists" +} + +try { + # Run win-acme with HTTP-01 validation + $wacmeArgs = @( + "--target", "manual", + "--host", $Fqdn, + "--validation", "selfhosting", + "--store", "pemfiles,pfxfile", + "--pemfilespath", $certDir, + "--pfxfilepath", $certDir, + "--pfxfilename", "pennie", + "--accepttos", + "--emailaddress", $Email, + "--pfxpassword", $pfxPassword, + "--force" + ) + + Write-Host "Running: $wacmeExe $($wacmeArgs -join ' ')" + $result = & $wacmeExe $wacmeArgs 2>&1 + $exitCode = $LASTEXITCODE + + Write-Host "win-acme output:" + Write-Host $result + + # Check if PFX certificate was created + $pfxFile = Join-Path $certDir "pennie.pfx" + if (-not (Test-Path $pfxFile)) { + # Try alternative naming + $pfxFile = Join-Path $certDir "$Fqdn.pfx" + } + + if ((Test-Path $pfxFile) -or $exitCode -eq 0) { + Write-Host "Let's Encrypt certificate obtained successfully!" + + # Copy to expected path if different + if ($pfxFile -ne $CertPath -and (Test-Path $pfxFile)) { + Copy-Item $pfxFile $CertPath -Force + } + + # Import into Windows cert store + $cert = Import-PfxCertificate -FilePath $CertPath -CertStoreLocation Cert:\LocalMachine\My -Password (ConvertTo-SecureString $pfxPassword -AsPlainText -Force) + + # Save password + Set-Content -Path $pfxPasswordPath -Value $pfxPassword -Encoding UTF8 + + # Configure Kestrel + Set-KestrelCertConfig -CertificatePath $CertPath -Password $pfxPassword + + Write-Host "" + Write-Host "=== Certificate Configuration Complete ===" + Write-Host "Type: Let's Encrypt" + Write-Host "Thumbprint: $($cert.Thumbprint)" + Write-Host "PFX Path: $CertPath" + + # Create scheduled task for renewal + Write-Host "" + Write-Host "Setting up automatic renewal..." + + $taskName = "Pennie-SSL-Renewal" + $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existingTask) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + } + + $action = New-ScheduledTaskAction -Execute $wacmeExe -Argument "--renew --baseuri https://acme-v02.api.letsencrypt.org/" + $trigger = New-ScheduledTaskTrigger -Daily -At "03:00" + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Description "Renew Let's Encrypt certificate for Pennie bot" + + Write-Host "Scheduled task '$taskName' created for daily renewal check" + exit 0 + } else { + Write-Error "Failed to obtain Let's Encrypt certificate. Check that port 80 is open and DNS is configured." + exit 1 + } +} finally { + # Restart service if it was running + if ($serviceWasRunning) { + Write-Host "Restarting $serviceName service..." + Start-Service -Name $serviceName + } +} diff --git a/scripts/deploy-bot-remote.sh b/scripts/deploy-bot-remote.sh index bba9db0..2706593 100755 --- a/scripts/deploy-bot-remote.sh +++ b/scripts/deploy-bot-remote.sh @@ -17,12 +17,10 @@ echo "" # Configuration RESOURCE_GROUP=${AZURE_RESOURCE_GROUP:-"TMinus15Agents"} VM_NAME=${VM_NAME:-"pennie-vm-prod"} -KEY_VAULT_NAME=${AZURE_KEY_VAULT_NAME:-"pennie-kv-mmdxqm3w7kjwm"} echo -e "${CYAN}Configuration:${NC}" echo " Resource Group: $RESOURCE_GROUP" echo " VM Name: $VM_NAME" -echo " Key Vault: $KEY_VAULT_NAME" echo "" # Step 1: Check prerequisites @@ -185,7 +183,6 @@ az vm run-command invoke \ --name "$VM_NAME" \ --command-id RunPowerShellScript \ --scripts @"$DEPLOY_SCRIPT" \ - --parameters "KeyVaultName=$KEY_VAULT_NAME" \ --output table # Step 6: Verify deployment diff --git a/scripts/deploy-bot-to-vm.ps1 b/scripts/deploy-bot-to-vm.ps1 index 5332a40..d96b2d7 100644 --- a/scripts/deploy-bot-to-vm.ps1 +++ b/scripts/deploy-bot-to-vm.ps1 @@ -38,50 +38,59 @@ try { Start-Sleep -Seconds 3 } Write-Host " Removing existing service" - nssm remove $ServiceName confirm + # Use sc.exe as nssm might not be installed yet + sc.exe delete $ServiceName 2>&1 | Out-Null } } catch { Write-Host " No existing service found (this is OK for first deployment)" -ForegroundColor Yellow } -# Step 2: Build the bot application +# Step 2: Check if bot is pre-built or needs building Write-Host "" -Write-Host "Step 2: Building bot application..." -ForegroundColor Cyan +Write-Host "Step 2: Preparing bot application..." -ForegroundColor Cyan + +$botExePath = Join-Path $BotDirectory "PennieBot.exe" $repoRoot = Split-Path -Parent $PSScriptRoot $botProjectPath = Join-Path $repoRoot "bot\PennieBot.csproj" -if (-not (Test-Path $botProjectPath)) { - Write-Host "ERROR: Bot project not found at $botProjectPath" -ForegroundColor Red - exit 1 -} +# If PennieBot.exe already exists and source code doesn't exist, skip build (pre-built deployment) +if ((Test-Path $botExePath) -and (-not (Test-Path $botProjectPath))) { + Write-Host " Pre-built deployment detected - skipping build step" -ForegroundColor Green + Write-Host " Bot executable found at: $botExePath" -ForegroundColor Green +} elseif (Test-Path $botProjectPath) { + # Build from source + Write-Host " Building from source..." + + # CRITICAL: Backup appsettings.json before build + $appSettingsPath = Join-Path $BotDirectory "appsettings.json" + $appSettingsBackup = Join-Path $env:TEMP "appsettings.json.backup" + if (Test-Path $appSettingsPath) { + Write-Host " Backing up existing appsettings.json..." + Copy-Item -Path $appSettingsPath -Destination $appSettingsBackup -Force + } -# CRITICAL: Backup appsettings.json before build -# This file contains VM-specific configuration that should not be overwritten -$appSettingsPath = Join-Path $BotDirectory "appsettings.json" -$appSettingsBackup = Join-Path $env:TEMP "appsettings.json.backup" -if (Test-Path $appSettingsPath) { - Write-Host " Backing up existing appsettings.json..." - Copy-Item -Path $appSettingsPath -Destination $appSettingsBackup -Force -} + Write-Host " Building project: $botProjectPath" + & dotnet build $botProjectPath --configuration $BuildConfiguration --output $BotDirectory -Write-Host " Building project: $botProjectPath" -& dotnet build $botProjectPath --configuration $BuildConfiguration --output $BotDirectory + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Build failed" -ForegroundColor Red + exit 1 + } -if ($LASTEXITCODE -ne 0) { - Write-Host "ERROR: Build failed" -ForegroundColor Red + # CRITICAL: Restore appsettings.json after build + if (Test-Path $appSettingsBackup) { + Write-Host " Restoring appsettings.json from backup..." + Copy-Item -Path $appSettingsBackup -Destination $appSettingsPath -Force + Remove-Item -Path $appSettingsBackup -Force + } + Write-Host " Build successful" -ForegroundColor Green +} else { + Write-Host "ERROR: Neither pre-built bot nor source code found" -ForegroundColor Red + Write-Host " Expected executable: $botExePath" -ForegroundColor Red + Write-Host " Or project file: $botProjectPath" -ForegroundColor Red exit 1 } -# CRITICAL: Restore appsettings.json after build -# The build may have overwritten it with the project's default appsettings.json -if (Test-Path $appSettingsBackup) { - Write-Host " Restoring appsettings.json from backup..." - Copy-Item -Path $appSettingsBackup -Destination $appSettingsPath -Force - Remove-Item -Path $appSettingsBackup -Force -} - -Write-Host " Build successful" -ForegroundColor Green - # Step 3: Configure appsettings from Key Vault Write-Host "" Write-Host "Step 3: Configuring application settings..." -ForegroundColor Cyan @@ -102,9 +111,9 @@ if ($KeyVaultName) { Write-Host " Ensure the secret exists and the VM has access to the Key Vault" -ForegroundColor Red } - $speechKey = az keyvault secret show --vault-name $KeyVaultName --name "AZURE-SPEECH-KEY" --query value -o tsv + $speechKey = az keyvault secret show --vault-name $KeyVaultName --name "AZURE_SPEECH_KEY" --query value -o tsv if ($LASTEXITCODE -ne 0) { - Write-Host "WARNING: AZURE-SPEECH-KEY not found in Key Vault (speech features may not work)" -ForegroundColor Yellow + Write-Host "WARNING: AZURE_SPEECH_KEY not found in Key Vault (speech features may not work)" -ForegroundColor Yellow } if (-not $teamsAppId -or -not $teamsAppPassword) { @@ -136,10 +145,79 @@ if ($KeyVaultName) { Write-Host " Ensure all required environment variables are set on the VM" -ForegroundColor Yellow } -# Step 4: Install bot as Windows Service using NSSM +# Step 4: Install NSSM if not present, then install bot as Windows Service Write-Host "" Write-Host "Step 4: Installing bot as Windows Service..." -ForegroundColor Cyan +# Check for NSSM in multiple locations (Chocolatey installs to different paths) +$nssmExe = $null +$nssmLocations = @( + "C:\ProgramData\chocolatey\bin\nssm.exe", + "C:\ProgramData\chocolatey\lib\nssm\tools\nssm.exe", + "C:\Tools\nssm\nssm.exe" +) + +foreach ($location in $nssmLocations) { + if (Test-Path $location) { + $nssmExe = $location + Write-Host " Found NSSM at: $location" -ForegroundColor Green + break + } +} + +# Also check PATH if not found in known locations +if (-not $nssmExe) { + $nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue + if ($nssmCmd) { + $nssmExe = $nssmCmd.Source + Write-Host " Found NSSM in PATH: $nssmExe" -ForegroundColor Green + } +} + +if (-not $nssmExe) { + Write-Host " NSSM not found - installing..." -ForegroundColor Yellow + + # Use GitHub mirror instead of nssm.cc which can be unreliable + $nssmZipUrl = "https://github.com/win-acme/win-acme/releases/download/v2.2.4.1500/nssm-2.24.zip" + $nssmZipPath = Join-Path $env:TEMP "nssm.zip" + $nssmExtractPath = Join-Path $env:TEMP "nssm" + $nssmInstallPath = "C:\Tools\nssm" + + # Download NSSM + Write-Host " Downloading NSSM from GitHub..." + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + try { + Invoke-WebRequest -Uri $nssmZipUrl -OutFile $nssmZipPath -UseBasicParsing + } catch { + # Fallback to nssm.cc if GitHub fails + Write-Host " GitHub download failed, trying nssm.cc..." -ForegroundColor Yellow + $nssmZipUrl = "https://nssm.cc/release/nssm-2.24.zip" + Invoke-WebRequest -Uri $nssmZipUrl -OutFile $nssmZipPath -UseBasicParsing + } + + # Extract + Write-Host " Extracting NSSM..." + Expand-Archive -Path $nssmZipPath -DestinationPath $nssmExtractPath -Force + + # Copy to install location + New-Item -ItemType Directory -Path $nssmInstallPath -Force | Out-Null + Copy-Item -Path "$nssmExtractPath\nssm-2.24\win64\nssm.exe" -Destination $nssmInstallPath -Force + + $nssmExe = "$nssmInstallPath\nssm.exe" + + # Verify installation + if (Test-Path $nssmExe) { + Write-Host " NSSM installed successfully to $nssmInstallPath" -ForegroundColor Green + } else { + Write-Host "ERROR: NSSM installation failed" -ForegroundColor Red + exit 1 + } + + # Cleanup + Remove-Item -Path $nssmZipPath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nssmExtractPath -Recurse -Force -ErrorAction SilentlyContinue +} + $botExePath = Join-Path $BotDirectory "PennieBot.exe" if (-not (Test-Path $botExePath)) { Write-Host "ERROR: Bot executable not found at $botExePath" -ForegroundColor Red @@ -147,19 +225,28 @@ if (-not (Test-Path $botExePath)) { } Write-Host " Installing service: $ServiceName" -nssm install $ServiceName $botExePath - -# Configure service -nssm set $ServiceName AppDirectory $BotDirectory -nssm set $ServiceName AppEnvironmentExtra "ASPNETCORE_ENVIRONMENT=Production" -nssm set $ServiceName DisplayName "Pennie the Prepper Teams Bot" -nssm set $ServiceName Description "AI-powered Teams bot for Azure DevOps backlog creation" -nssm set $ServiceName Start SERVICE_AUTO_START -nssm set $ServiceName AppStdout "C:\Pennie\logs\bot-stdout.log" -nssm set $ServiceName AppStderr "C:\Pennie\logs\bot-stderr.log" -nssm set $ServiceName AppRotateFiles 1 -nssm set $ServiceName AppRotateOnline 1 -nssm set $ServiceName AppRotateBytes 10485760 # 10MB +& $nssmExe install $ServiceName $botExePath + +# Configure service using full path to NSSM +Write-Host " Configuring service..." +& $nssmExe set $ServiceName AppDirectory $BotDirectory +& $nssmExe set $ServiceName AppEnvironmentExtra "ASPNETCORE_ENVIRONMENT=Production" +& $nssmExe set $ServiceName DisplayName "Pennie the Prepper Teams Bot" +& $nssmExe set $ServiceName Description "AI-powered Teams bot for Azure DevOps backlog creation" +& $nssmExe set $ServiceName Start SERVICE_AUTO_START + +# Create logs directory +$logsDir = "C:\Pennie\logs" +if (-not (Test-Path $logsDir)) { + New-Item -ItemType Directory -Path $logsDir -Force | Out-Null + Write-Host " Created logs directory: $logsDir" +} + +& $nssmExe set $ServiceName AppStdout "$logsDir\bot-stdout.log" +& $nssmExe set $ServiceName AppStderr "$logsDir\bot-stderr.log" +& $nssmExe set $ServiceName AppRotateFiles 1 +& $nssmExe set $ServiceName AppRotateOnline 1 +& $nssmExe set $ServiceName AppRotateBytes 10485760 # 10MB Write-Host " Service installed successfully" -ForegroundColor Green diff --git a/scripts/deploy-bot.sh b/scripts/deploy-bot.sh index 64983c1..acf234b 100755 --- a/scripts/deploy-bot.sh +++ b/scripts/deploy-bot.sh @@ -4,12 +4,17 @@ set -e # Deploy Pennie Teams Bot to Azure VM # # This script builds, packages, and deploys the Teams bot to the production VM. -# It reads credentials from .env and injects them into appsettings.json. +# Configuration is injected via appsettings.Production.json at deployment time. # # Prerequisites: # - Azure CLI logged in: az login # - .NET SDK installed -# - Environment variables set in .env file +# - Environment variables: AZURE_RESOURCE_GROUP (from .env or GitHub Secrets) +# +# Optional environment variables (override defaults): +# - AZURE_FUNCTIONS_BACKEND_URL: Backend API endpoint +# - TEAMS_APP_ID: Teams bot app ID +# - TEAMS_APP_PASSWORD: Teams bot app password # # Usage: # ./scripts/deploy-bot.sh @@ -29,7 +34,7 @@ TEMP_DIR="${TEMP:-/tmp}" PUBLISH_DIR="$TEMP_DIR/pennie-bot-publish" ZIP_FILE="$TEMP_DIR/pennie-bot-deploy.zip" -# Load environment variables from .env if available +# Load environment variables from .env if available (for local development) if [ -f "$PROJECT_ROOT/.env" ]; then echo "📄 Loading environment from .env" while IFS='=' read -r key value; do @@ -41,19 +46,16 @@ if [ -f "$PROJECT_ROOT/.env" ]; then value=$(echo "$value" | xargs) # Skip if key is empty after trimming [[ -z $key ]] && continue - # Export the variable (without eval to prevent command injection) - export "$key=$value" + # Only set if not already set (allow GitHub Secrets to override) + if [ -z "${!key}" ]; then + export "$key=$value" + fi done < "$PROJECT_ROOT/.env" -else - echo "❌ .env file not found at $PROJECT_ROOT/.env" - exit 1 fi # Validate required environment variables -# Note: Credentials are stored in Key Vault, not .env required_vars=( "AZURE_RESOURCE_GROUP" - "AZURE_KEY_VAULT_NAME" ) missing_vars=() @@ -78,16 +80,35 @@ CONTAINER_NAME="deployments" VERSION=$(date +%Y%m%d%H%M%S) BLOB_NAME="pennie-bot-$VERSION.zip" +# Default values (can be overridden by environment variables) +BACKEND_URL="${AZURE_FUNCTIONS_BACKEND_URL:-https://pennie-backend-prod.azurewebsites.net}" + echo "Configuration:" echo " Resource Group: $AZURE_RESOURCE_GROUP" echo " VM Name: $VM_NAME" -echo " Key Vault: $AZURE_KEY_VAULT_NAME" echo " Version: $VERSION" +echo " Backend URL: $BACKEND_URL" echo "" -echo "Note: Credentials loaded from Key Vault at runtime" -echo "" -# Step 1: Build the bot +# Step 1: Get VM FQDN for configuration +echo "🔍 Getting VM FQDN..." +VM_FQDN=$(az vm show -g "$AZURE_RESOURCE_GROUP" -n "$VM_NAME" -d --query "fqdns" -o tsv 2>/dev/null | head -1) + +if [ -z "$VM_FQDN" ]; then + # Fallback: try to get from public IP DNS name + VM_FQDN=$(az network public-ip list -g "$AZURE_RESOURCE_GROUP" --query "[?contains(name, 'pennie')].dnsSettings.fqdn" -o tsv 2>/dev/null | head -1) +fi + +if [ -z "$VM_FQDN" ]; then + echo "❌ Could not determine VM FQDN" + echo " Please set BOT_FQDN environment variable" + exit 1 +fi + +echo "✅ VM FQDN: $VM_FQDN" + +# Step 2: Build the bot +echo "" echo "🔨 Building bot..." dotnet build "$BOT_DIR/PennieBot.csproj" --configuration Release --verbosity minimal if [ $? -ne 0 ]; then @@ -96,7 +117,7 @@ if [ $? -ne 0 ]; then fi echo "✅ Build successful" -# Step 2: Publish for Windows +# Step 3: Publish for Windows echo "" echo "📦 Publishing for Windows..." rm -rf "$PUBLISH_DIR" @@ -113,27 +134,6 @@ if [ $? -ne 0 ]; then fi echo "✅ Published to $PUBLISH_DIR" -# Step 3: Update appsettings.json with Key Vault name -# Credentials are loaded from Key Vault at runtime using managed identity -echo "" -echo "🔐 Configuring Key Vault in appsettings.json..." -APPSETTINGS="$PUBLISH_DIR/appsettings.json" - -# Cross-platform JSON update using portable cp/sed/mv pattern -# Note: sed -i behaves differently on macOS vs Linux, so we use temp file approach -APPSETTINGS_TMP="$APPSETTINGS.tmp" -if grep -q '"AZURE_KEY_VAULT_NAME"' "$APPSETTINGS"; then - # Update existing key - sed "s|\"AZURE_KEY_VAULT_NAME\":.*|\"AZURE_KEY_VAULT_NAME\": \"$AZURE_KEY_VAULT_NAME\",|" "$APPSETTINGS" > "$APPSETTINGS_TMP" -else - # Add key after opening brace (insert as first property) - sed "s|^{|{\n \"AZURE_KEY_VAULT_NAME\": \"$AZURE_KEY_VAULT_NAME\",|" "$APPSETTINGS" > "$APPSETTINGS_TMP" -fi -mv "$APPSETTINGS_TMP" "$APPSETTINGS" - -echo "✅ Key Vault configured: $AZURE_KEY_VAULT_NAME" -echo " Bot will load MicrosoftAppId and MicrosoftAppPassword from Key Vault at startup" - # Step 4: Create zip archive (cross-platform) echo "" echo "📦 Creating deployment package..." @@ -204,8 +204,23 @@ echo "✅ SAS URL generated (expires in 1 hour)" echo "" echo "🚀 Deploying to VM..." -# Escape special characters in the URL for PowerShell -ESCAPED_URL=$(echo "$SAS_URL" | sed 's/&/`&/g') +# Create appsettings.Production.json content +# This injects environment-specific values at deployment time +APPSETTINGS_PROD=$(cat < Teams apps > Manage apps > Upload new app # 2. Or Teams client: Apps > Manage your apps > Upload a custom app -# 3. Select: bot/teams-manifest/pennie-app-v1.1.0.zip +# 3. Select: bot/teams-manifest/pennie-app-prod-v1.6.0.zip # # Subsequent updates (automated): -# ./scripts/deploy-teams-app.sh # Update existing app -# ./scripts/deploy-teams-app.sh --create # Create package then update +# ./scripts/deploy-teams-app.sh --env prod # Update prod app +# ./scripts/deploy-teams-app.sh --env test --create # Create test package then update # # Why can't we automate first-time upload? # - Microsoft Graph API restricts NEW app publishing via application credentials @@ -36,15 +36,24 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color +# Default environment +ENVIRONMENT="prod" + show_help() { echo "Deploy Teams App Package to Organization Catalog" echo "" echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" + echo " --env ENV Environment to deploy (prod or test). Default: prod" echo " --create Create the app package before deploying" echo " --help Show this help message" echo "" + echo "Examples:" + echo " $0 --env prod --create # Build and deploy production package" + echo " $0 --env test --create # Build and deploy test package" + echo " $0 --env prod # Deploy existing prod package" + echo "" echo -e "${YELLOW}IMPORTANT: First-time deployment requires manual upload.${NC}" echo "This script can only UPDATE existing apps in the catalog." echo "" @@ -57,57 +66,105 @@ show_help() { } create_package() { - echo -e "${YELLOW}Creating Teams app package...${NC}" + echo -e "${YELLOW}Creating Teams app package for $ENVIRONMENT environment...${NC}" + + # Check environment-specific manifest exists + ENV_MANIFEST="$MANIFEST_DIR/manifest.${ENVIRONMENT}.json" + if [ ! -f "$ENV_MANIFEST" ]; then + echo -e "${RED}ERROR: Manifest not found: $ENV_MANIFEST${NC}" + echo "Available manifests:" + ls -la "$MANIFEST_DIR"/manifest.*.json 2>/dev/null || echo " None found" + exit 1 + fi - # Get version from manifest - VERSION=$(jq -r '.version' "$MANIFEST_DIR/manifest.json") - PACKAGE_NAME="pennie-app-v${VERSION}.zip" + # Get version from environment-specific manifest + VERSION=$(jq -r '.version' "$ENV_MANIFEST") + PACKAGE_NAME="pennie-app-${ENVIRONMENT}-v${VERSION}.zip" cd "$MANIFEST_DIR" + + # Clean up any existing package and temp manifest rm -f "$PACKAGE_NAME" + rm -f manifest.json + + # Copy environment manifest to manifest.json (Teams requires this exact filename) + cp "$ENV_MANIFEST" manifest.json + + # Create the zip package zip "$PACKAGE_NAME" manifest.json color.png outline.png + # Clean up temporary manifest.json + rm -f manifest.json + echo -e "${GREEN}Created: $MANIFEST_DIR/$PACKAGE_NAME${NC}" cd - > /dev/null } # Parse arguments CREATE_PACKAGE=false -for arg in "$@"; do - case $arg in +while [[ $# -gt 0 ]]; do + case $1 in + --env) + ENVIRONMENT="$2" + if [[ "$ENVIRONMENT" != "prod" && "$ENVIRONMENT" != "test" ]]; then + echo -e "${RED}ERROR: Invalid environment '$ENVIRONMENT'. Must be 'prod' or 'test'${NC}" + exit 1 + fi + shift 2 + ;; --create) CREATE_PACKAGE=true + shift ;; --help|-h) show_help exit 0 ;; + *) + echo -e "${RED}ERROR: Unknown option '$1'${NC}" + show_help + exit 1 + ;; esac done +echo "Environment: $ENVIRONMENT" + # Create package if requested if [ "$CREATE_PACKAGE" = true ]; then create_package fi -# Find the latest app package -APP_PACKAGE=$(ls -t "$MANIFEST_DIR"/pennie-app-v*.zip 2>/dev/null | head -1) +# Environment-specific manifest +ENV_MANIFEST="$MANIFEST_DIR/manifest.${ENVIRONMENT}.json" + +if [ ! -f "$ENV_MANIFEST" ]; then + echo -e "${RED}ERROR: Manifest not found: $ENV_MANIFEST${NC}" + echo "Available manifests:" + ls -la "$MANIFEST_DIR"/manifest.*.json 2>/dev/null || echo " None found" + exit 1 +fi + +# Find the latest app package for this environment +APP_PACKAGE=$(ls -t "$MANIFEST_DIR"/pennie-app-${ENVIRONMENT}-v*.zip 2>/dev/null | head -1) if [ -z "$APP_PACKAGE" ]; then - echo -e "${RED}ERROR: No Teams app package found in $MANIFEST_DIR${NC}" + echo -e "${RED}ERROR: No Teams app package found for $ENVIRONMENT environment${NC}" echo "" echo "Create one with:" - echo " $0 --create" + echo " $0 --env $ENVIRONMENT --create" echo "" echo "Or manually:" echo " cd $MANIFEST_DIR" - echo " zip pennie-app-v1.1.0.zip manifest.json color.png outline.png" + echo " cp manifest.${ENVIRONMENT}.json manifest.json" + echo " zip pennie-app-${ENVIRONMENT}-v1.6.0.zip manifest.json color.png outline.png" + echo " rm manifest.json" exit 1 fi -# Get App ID from manifest -APP_ID=$(jq -r '.id' "$MANIFEST_DIR/manifest.json") -APP_VERSION=$(jq -r '.version' "$MANIFEST_DIR/manifest.json") +# Get App ID from environment-specific manifest +APP_ID=$(jq -r '.id' "$ENV_MANIFEST") +APP_VERSION=$(jq -r '.version' "$ENV_MANIFEST") echo "=== Deploy Teams App Package ===" echo "Package: $APP_PACKAGE" @@ -115,16 +172,15 @@ echo "App ID: $APP_ID" echo "Version: $APP_VERSION" echo "" -# Get access token for Microsoft Graph using bot credentials -KEY_VAULT_NAME="${AZURE_KEY_VAULT_NAME:-"pennie-kv-mmdxqm3w7kjwm"}" -echo "Getting bot credentials from Key Vault ($KEY_VAULT_NAME)..." -BOT_APP_ID=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "MicrosoftAppId" --query value -o tsv 2>/dev/null) -BOT_APP_SECRET=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "MicrosoftAppPassword" --query value -o tsv 2>/dev/null) +# Get bot credentials from environment variables (set via GitHub Secrets) +BOT_APP_ID="${TEAMS_APP_ID:-}" +BOT_APP_SECRET="${TEAMS_APP_PASSWORD:-}" TENANT_ID=$(az account show --query tenantId -o tsv 2>/dev/null) if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_SECRET" ]; then - echo -e "${RED}ERROR: Failed to get bot credentials from Key Vault${NC}" - echo "Make sure you're logged in: az login" + echo -e "${RED}ERROR: Missing bot credentials${NC}" + echo "Set TEAMS_APP_ID and TEAMS_APP_PASSWORD environment variables" + echo "These are stored in GitHub Secrets" exit 1 fi @@ -224,10 +280,13 @@ else exit 1 fi +# Get app name from manifest for display +APP_NAME=$(jq -r '.name.short' "$ENV_MANIFEST") + echo "" -echo -e "${GREEN}=== Deployment Complete ===${NC}" +echo -e "${GREEN}=== Deployment Complete ($ENVIRONMENT) ===${NC}" echo "" echo "Next steps:" -echo " 1. Find 'Pennie the Prepper' in Teams app store" +echo " 1. Find '$APP_NAME' in Teams app store" echo " 2. Add to a chat or meeting" echo " 3. For meetings, invite before or during the meeting" diff --git a/scripts/setup-bot-app-registration.sh b/scripts/setup-bot-app-registration.sh old mode 100644 new mode 100755 index 8841425..86f064c --- a/scripts/setup-bot-app-registration.sh +++ b/scripts/setup-bot-app-registration.sh @@ -3,33 +3,80 @@ set -e # Setup Bot App Registration Script # This script automates the creation of the Azure AD app registration for Pennie the Prepper Teams Bot -# and stores credentials in Azure Key Vault. - -# Usage: ./scripts/setup-bot-app-registration.sh +# Credentials are output for storage in GitHub Secrets. +# +# Usage: +# ./scripts/setup-bot-app-registration.sh # Create production app +# ./scripts/setup-bot-app-registration.sh --env test # Create test app +# ./scripts/setup-bot-app-registration.sh --env prod # Create production app (explicit) # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' +CYAN='\033[0;36m' NC='\033[0m' # No Color +# Parse arguments +ENV="prod" +while [[ $# -gt 0 ]]; do + case $1 in + --env|-e) + ENV="$2" + shift 2 + ;; + --help|-h) + echo "Setup Bot App Registration for Pennie the Prepper" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --env, -e Environment: 'test' or 'prod' (default: prod)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 # Create production app registration" + echo " $0 --env test # Create test app registration" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +# Validate environment +if [[ "$ENV" != "test" && "$ENV" != "prod" ]]; then + echo -e "${RED}Invalid environment: $ENV${NC}" + echo "Use 'test' or 'prod'" + exit 1 +fi + +# Set environment-specific values +if [[ "$ENV" == "test" ]]; then + APP_NAME="Pennie the Prepper (Test)" + SECRET_NAME="PennieBot-Test-Secret" + ACCENT_COLOR="#9C27B0" # Purple for test +else + APP_NAME="Pennie the Prepper Bot" + SECRET_NAME="PennieBot-Prod-Secret" + ACCENT_COLOR="#9DFF0A" # Green for prod +fi + echo -e "${GREEN}=== Pennie Bot App Registration Setup ===${NC}" +echo -e "${CYAN}Environment: ${YELLOW}$ENV${NC}" +echo -e "${CYAN}App Name: ${YELLOW}$APP_NAME${NC}" +echo "" -# Load environment variables safely +# Load environment variables safely (optional - for resource group) if [ -f .env ]; then set -a source .env set +a -else - echo -e "${RED}Error: .env file not found${NC}" - echo -e "${YELLOW}Please create .env file first: cp .env.example .env${NC}" - echo -e "${YELLOW}Then edit .env with your Azure subscription details${NC}" - exit 1 fi # Variables -APP_NAME="Pennie the Prepper Bot" -KEY_VAULT_NAME=${AZURE_KEY_VAULT_NAME:-"pennie-kv-mmdxqm3w7kjwm"} RESOURCE_GROUP=${AZURE_RESOURCE_GROUP:-"TMinus15Agents"} SECRET_EXPIRATION_YEARS=${SECRET_EXPIRATION_YEARS:-2} # Configurable: default 2 years @@ -43,7 +90,7 @@ APP_REGISTRATION=$(az ad app create \ APP_ID=$(echo $APP_REGISTRATION | jq -r '.appId') OBJECT_ID=$(echo $APP_REGISTRATION | jq -r '.id') -echo -e "${GREEN}✓ App Registration created${NC}" +echo -e "${GREEN} App Registration created${NC}" echo -e " App ID: ${YELLOW}$APP_ID${NC}" echo -e " Object ID: $OBJECT_ID" @@ -71,7 +118,7 @@ az ad app permission add \ --api-permissions b8bb2037-6e08-44ac-a4ea-4674e010e2a4=Role \ > /dev/null 2>&1 -echo -e "${GREEN}✓ Graph API permissions added:${NC}" +echo -e "${GREEN} Graph API permissions added:${NC}" echo -e " - Calls.AccessMedia.All" echo -e " - Calls.JoinGroupCall.All" echo -e " - OnlineMeetings.ReadWrite.All" @@ -80,67 +127,62 @@ echo -e "\n${GREEN}Step 3: Creating Client Secret${NC}" CREDENTIALS=$(az ad app credential reset \ --id $APP_ID \ --append \ - --display-name "PennieBot-Prod-Secret" \ + --display-name "$SECRET_NAME" \ --years $SECRET_EXPIRATION_YEARS \ --query "{password: password}" \ -o json) CLIENT_SECRET=$(echo $CREDENTIALS | jq -r '.password') -echo -e "${GREEN}✓ Client secret created (expires in $SECRET_EXPIRATION_YEARS years)${NC}" - -echo -e "\n${GREEN}Step 4: Storing credentials in Azure Key Vault${NC}" -az keyvault secret set \ - --vault-name $KEY_VAULT_NAME \ - --name "TEAMS-APP-ID" \ - --value "$APP_ID" \ - > /dev/null - -az keyvault secret set \ - --vault-name $KEY_VAULT_NAME \ - --name "TEAMS-APP-PASSWORD" \ - --value "$CLIENT_SECRET" \ - > /dev/null - -echo -e "${GREEN}✓ Credentials stored in Key Vault: $KEY_VAULT_NAME${NC}" -echo -e " Secret names: TEAMS-APP-ID, TEAMS-APP-PASSWORD" - -echo -e "\n${GREEN}Step 5: Updating .env file${NC}" -# Update .env file with new app ID (cross-platform compatible) -if grep -q "^TEAMS_APP_ID=" .env; then - sed -i.bak "s|^TEAMS_APP_ID=.*|TEAMS_APP_ID=$APP_ID|" .env -else - echo "TEAMS_APP_ID=$APP_ID" >> .env +EXPIRY_DATE=$(date -d "+${SECRET_EXPIRATION_YEARS} years" +%Y-%m-%d 2>/dev/null || date -v+${SECRET_EXPIRATION_YEARS}y +%Y-%m-%d) +echo -e "${GREEN} Client secret created (expires: $EXPIRY_DATE)${NC}" + +echo -e "\n${GREEN}Step 4: Store credentials in GitHub Secrets${NC}" +echo -e "${YELLOW}Run these commands to store credentials:${NC}" +echo "" +echo -e " gh secret set TEAMS_APP_ID --env $ENV --body \"$APP_ID\"" +echo -e " gh secret set TEAMS_APP_PASSWORD --env $ENV --body \"$CLIENT_SECRET\"" +echo "" + +# Attempt to set secrets automatically if gh is available and user confirms +if command -v gh &> /dev/null; then + echo -e "${CYAN}Would you like to set these secrets automatically? (y/N)${NC}" + read -r CONFIRM + if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then + echo -e "Setting TEAMS_APP_ID..." + gh secret set TEAMS_APP_ID --env "$ENV" --body "$APP_ID" + echo -e "Setting TEAMS_APP_PASSWORD..." + gh secret set TEAMS_APP_PASSWORD --env "$ENV" --body "$CLIENT_SECRET" + echo -e "${GREEN} Secrets set successfully!${NC}" + fi fi -if grep -q "^TEAMS_APP_PASSWORD=" .env; then - sed -i.bak "s|^TEAMS_APP_PASSWORD=.*|TEAMS_APP_PASSWORD=$CLIENT_SECRET|" .env -else - echo "TEAMS_APP_PASSWORD=$CLIENT_SECRET" >> .env -fi - -# Remove sed backup file -rm -f .env.bak - -echo -e "${GREEN}✓ .env file updated${NC}" -echo -e "${RED}⚠️ WARNING: .env file now contains sensitive credentials${NC}" -echo -e "${YELLOW} - Ensure .env is in .gitignore (never commit to Git)${NC}" -echo -e "${YELLOW} - Restrict file permissions: chmod 600 .env${NC}" -echo -e "${YELLOW} - Credentials are also stored securely in Key Vault${NC}" - echo -e "\n${YELLOW}=== MANUAL STEP REQUIRED ===${NC}" echo -e "${YELLOW}Admin consent is required for the Graph API permissions.${NC}" -echo -e "\n${YELLOW}To grant admin consent:${NC}" +echo -e "\n${YELLOW}Option 1 - Azure Portal:${NC}" echo -e "1. Go to: ${GREEN}https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/$APP_ID${NC}" echo -e "2. Click 'Grant admin consent for [Your Organization]'" echo -e "3. Confirm the consent prompt" -echo -e "\nAlternatively, run:" -echo -e "${GREEN}az ad app permission admin-consent --id $APP_ID${NC}" -echo -e "\n${YELLOW}Note: This requires Global Administrator or Privileged Role Administrator permissions.${NC}" +echo -e "\n${YELLOW}Option 2 - Azure CLI (requires admin permissions):${NC}" +echo -e " ${GREEN}az ad app permission admin-consent --id $APP_ID${NC}" + +echo -e "\n${GREEN}=== Summary ===${NC}" +echo -e "Environment: ${YELLOW}$ENV${NC}" +echo -e "App Name: ${YELLOW}$APP_NAME${NC}" +echo -e "App ID: ${YELLOW}$APP_ID${NC}" +echo -e "Accent Color: ${YELLOW}$ACCENT_COLOR${NC} (for Teams manifest)" -echo -e "\n${GREEN}=== Setup Complete ===${NC}" -echo -e "App ID: ${YELLOW}$APP_ID${NC}" -echo -e "Key Vault: ${YELLOW}$KEY_VAULT_NAME${NC}" echo -e "\n${GREEN}Next steps:${NC}" echo -e "1. Grant admin consent (see above)" -echo -e "2. Deploy the Teams bot to the Windows VM" -echo -e "3. Configure the bot endpoint in Teams App Studio" +if [[ "$ENV" == "test" ]]; then + echo -e "2. Create test Teams manifest: cp bot/teams-manifest/manifest.json bot/teams-manifest/manifest.test.json" + echo -e "3. Update manifest.test.json with:" + echo -e " - id: $(uuidgen 2>/dev/null || echo '')" + echo -e " - name.short: \"Pennie the Prepper (Test)\"" + echo -e " - accentColor: \"$ACCENT_COLOR\"" + echo -e " - bots[0].botId: \"$APP_ID\"" + echo -e "4. Create test app package: cd bot/teams-manifest && zip pennie-app-test.zip manifest.test.json color.png outline.png" + echo -e "5. Upload to Teams Admin Center (first time only)" +else + echo -e "2. Deploy the Teams bot to the Windows VM" + echo -e "3. Upload Teams manifest to Admin Center (first time only)" +fi diff --git a/src/deploy.zip b/src/deploy.zip deleted file mode 100644 index b0c5779..0000000 Binary files a/src/deploy.zip and /dev/null differ diff --git a/src/function-app.zip b/src/function-app.zip deleted file mode 100644 index 89eeadd..0000000 Binary files a/src/function-app.zip and /dev/null differ diff --git a/tests/Helpers/MeetingHelpersTests.cs b/tests/Helpers/MeetingHelpersTests.cs new file mode 100644 index 0000000..1da2052 --- /dev/null +++ b/tests/Helpers/MeetingHelpersTests.cs @@ -0,0 +1,167 @@ +using FluentAssertions; +using PennieBot.Helpers; +using Xunit; + +namespace PennieBot.Tests.Helpers; + +public class MeetingHelpersTests +{ + #region IsValidMeetingIdFormat Tests + + [Theory] + [InlineData("1234567890", true)] // Exactly 10 digits (minimum) + [InlineData("123456789012345", true)] // Exactly 15 digits (maximum) + [InlineData("12345678901", true)] // 11 digits (valid) + [InlineData("123 456 789 012", true)] // 12 digits with spaces + [InlineData("396 240 783 591 15", true)] // Real Teams format + public void IsValidMeetingIdFormat_ValidIds_ReturnsTrue(string meetingId, bool expected) + { + var result = MeetingHelpers.IsValidMeetingIdFormat(meetingId); + result.Should().Be(expected); + } + + [Theory] + [InlineData("123456789", false)] // 9 digits (too short) + [InlineData("1234567890123456", false)] // 16 digits (too long) + [InlineData("", false)] // Empty + [InlineData(null, false)] // Null + [InlineData(" ", false)] // Whitespace only + [InlineData("12345678a0", false)] // Contains letter + [InlineData("1234-5678-90", false)] // Contains hyphens + public void IsValidMeetingIdFormat_InvalidIds_ReturnsFalse(string? meetingId, bool expected) + { + var result = MeetingHelpers.IsValidMeetingIdFormat(meetingId); + result.Should().Be(expected); + } + + #endregion + + #region ExtractMeetingId Tests + + [Theory] + [InlineData("join meeting id: 396 240 783 591 15", "396 240 783 591 15")] + [InlineData("join id:39624078359115", "39624078359115")] + [InlineData("meeting ID 1234567890", "1234567890")] + [InlineData("id: 123 456 789 012 passcode: ABC123", "123 456 789 012")] + public void ExtractMeetingId_ValidFormats_ExtractsCorrectly(string text, string expectedId) + { + var result = MeetingHelpers.ExtractMeetingId(text); + result.Should().Be(expectedId); + } + + [Theory] + [InlineData("hello world")] // No meeting ID + [InlineData("id: 123")] // Too short + [InlineData("")] // Empty + [InlineData("join the meeting")] // No ID at all + public void ExtractMeetingId_InvalidFormats_ReturnsNull(string text) + { + var result = MeetingHelpers.ExtractMeetingId(text); + result.Should().BeNull(); + } + + [Fact] + public void ExtractMeetingId_NullInput_ReturnsNull() + { + var result = MeetingHelpers.ExtractMeetingId(null!); + result.Should().BeNull(); + } + + #endregion + + #region ExtractPasscode Tests + + [Theory] + [InlineData("passcode: ABC123", "ABC123")] + [InlineData("Passcode:xyz789", "xyz789")] + [InlineData("PASSCODE : test123", "test123")] + [InlineData("meeting id: 123456789012 passcode: secret", "secret")] + public void ExtractPasscode_ValidFormats_ExtractsCorrectly(string text, string expectedPasscode) + { + var result = MeetingHelpers.ExtractPasscode(text); + result.Should().Be(expectedPasscode); + } + + [Theory] + [InlineData("hello world")] // No passcode + [InlineData("passcode")] // No value + [InlineData("")] // Empty + public void ExtractPasscode_InvalidFormats_ReturnsNull(string text) + { + var result = MeetingHelpers.ExtractPasscode(text); + result.Should().BeNull(); + } + + [Fact] + public void ExtractPasscode_NullInput_ReturnsNull() + { + var result = MeetingHelpers.ExtractPasscode(null!); + result.Should().BeNull(); + } + + #endregion + + #region StripAtMentions Tests + + [Theory] + [InlineData("Pennie what projects do we have?", "what projects do we have?")] + [InlineData("Pennie hello", "hello")] + [InlineData("Bot User test", "test")] + [InlineData("no mentions here", "no mentions here")] + [InlineData("", "")] + public void StripAtMentions_VariousInputs_StripsCorrectly(string text, string expected) + { + var result = MeetingHelpers.StripAtMentions(text); + result.Should().Be(expected); + } + + [Fact] + public void StripAtMentions_NullInput_ReturnsNull() + { + var result = MeetingHelpers.StripAtMentions(null!); + result.Should().BeNull(); + } + + [Theory] + [InlineData("Name", "")] + [InlineData(" Name text ", "text")] + public void StripAtMentions_OnlyMention_ReturnsEmptyOrTrimmed(string text, string expected) + { + var result = MeetingHelpers.StripAtMentions(text); + result.Should().Be(expected); + } + + #endregion + + #region IsSimpleJoinCommand Tests + + [Theory] + [InlineData("join", true)] + [InlineData("join meeting", true)] + [InlineData("join the meeting", true)] + [InlineData("join this meeting", true)] + [InlineData("join call", true)] + [InlineData("JOIN", true)] + [InlineData("Join Meeting", true)] + [InlineData("Pennie join", true)] + [InlineData("Pennie join the meeting", true)] + public void IsSimpleJoinCommand_SimpleJoins_ReturnsTrue(string text, bool expected) + { + var result = MeetingHelpers.IsSimpleJoinCommand(text); + result.Should().Be(expected); + } + + [Theory] + [InlineData("join meeting 1234567890", false)] // Has meeting ID + [InlineData("hello", false)] // Not a join command + [InlineData("joining", false)] // Not exact match + [InlineData("", false)] // Empty + [InlineData("join id: 123456789012", false)] // Has meeting ID + public void IsSimpleJoinCommand_NotSimpleJoins_ReturnsFalse(string text, bool expected) + { + var result = MeetingHelpers.IsSimpleJoinCommand(text); + result.Should().Be(expected); + } + + #endregion +} diff --git a/tests/PennieBot.Tests.csproj b/tests/PennieBot.Tests.csproj new file mode 100644 index 0000000..efb7c34 --- /dev/null +++ b/tests/PennieBot.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/tests/bot-endpoint-test.sh b/tests/bot-endpoint-test.sh new file mode 100755 index 0000000..8429bc1 --- /dev/null +++ b/tests/bot-endpoint-test.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Test bot endpoint connectivity (SSL, health, messaging endpoint) +# Usage: ./tests/bot-endpoint-test.sh [environment] +# environment: prod (default) or test + +set -e + +ENV="${1:-prod}" + +# Set environment-specific values +if [ "$ENV" = "test" ]; then + RG_NAME="TMinus15Agents-Test" + VM_NAME="pennie-vm-test" + PIP_NAME="pennie-pip-test" +else + RG_NAME="TMinus15Agents" + VM_NAME="pennie-vm-prod" + PIP_NAME="pennie-pip-prod" +fi + +# Dynamically resolve FQDN from Azure (avoids hardcoding unique suffixes) +if command -v az &> /dev/null; then + VM_FQDN=$(az network public-ip show \ + --resource-group "$RG_NAME" \ + --name "$PIP_NAME" \ + --query "dnsSettings.fqdn" -o tsv 2>/dev/null || echo "") +fi + +# Fall back to pattern if Azure CLI unavailable or query failed +if [ -z "$VM_FQDN" ]; then + echo "WARNING: Could not resolve FQDN from Azure, using pattern-based URL" + VM_FQDN="pennie-${ENV}.uksouth.cloudapp.azure.com" +fi + +BOT_URL="https://${VM_FQDN}" + +echo "Bot Endpoint Connectivity Test" +echo "Environment: $ENV" +echo "Bot URL: $BOT_URL" +echo "==============================================" +echo "" + +FAILED=0 + +# Test 1: DNS Resolution +echo "Test 1: DNS Resolution" +HOST=$(echo "$BOT_URL" | sed 's|https://||') +IP=$(nslookup "$HOST" 2>/dev/null | grep -A1 "Name:" | grep "Address" | head -1 | awk '{print $2}') +if [ -n "$IP" ]; then + echo " OK: $HOST resolves to $IP" +else + echo " FAIL: Could not resolve $HOST" + FAILED=1 +fi +echo "" + +# Test 2: SSL/TLS Connection +echo "Test 2: SSL/TLS Connection" +SSL_INFO=$(curl -s -k -v "$BOT_URL" 2>&1 | grep -E "SSL connection|subject:" | head -2) +if echo "$SSL_INFO" | grep -q "SSL connection"; then + echo " OK: SSL connection established" + CERT_CN=$(echo "$SSL_INFO" | grep "subject:" | sed 's/.*CN=//' | cut -d',' -f1) + echo " Certificate CN: $CERT_CN" +else + echo " FAIL: SSL connection failed" + FAILED=1 +fi +echo "" + +# Test 3: Health Endpoint +echo "Test 3: Health Endpoint" +HEALTH_RESPONSE=$(curl -s -k "$BOT_URL/health" 2>&1) +if [ "$HEALTH_RESPONSE" = "Healthy" ]; then + echo " OK: Health check returned 'Healthy'" +else + echo " FAIL: Health check returned '$HEALTH_RESPONSE'" + FAILED=1 +fi +echo "" + +# Test 4: Root Endpoint (bot info) +echo "Test 4: Root Endpoint" +ROOT_RESPONSE=$(curl -s -k "$BOT_URL/" 2>&1) +if echo "$ROOT_RESPONSE" | grep -q "Pennie"; then + echo " OK: Root endpoint responded with bot info" + STATUS=$(echo "$ROOT_RESPONSE" | jq -r '.status // "unknown"' 2>/dev/null) + echo " Bot Status: $STATUS" +else + echo " FAIL: Root endpoint did not return expected response" + echo " Response: $ROOT_RESPONSE" + FAILED=1 +fi +echo "" + +# Test 5: Messages Endpoint (expects 401 for unauthenticated) +echo "Test 5: Messages Endpoint Authentication" +HTTP_CODE=$(curl -s -k -o /dev/null -w "%{http_code}" -X POST "$BOT_URL/api/messages" \ + -H "Content-Type: application/json" \ + -d '{"type":"message","text":"test"}') + +if [ "$HTTP_CODE" = "401" ]; then + echo " OK: Messages endpoint requires authentication (HTTP 401)" + echo " This is correct - Bot Framework validates bearer tokens" +elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "202" ]; then + echo " WARNING: Messages endpoint accepted unauthenticated request" + echo " This may indicate authentication is disabled" +else + echo " FAIL: Messages endpoint returned unexpected HTTP $HTTP_CODE" + FAILED=1 +fi +echo "" + +# Test 6: Check VM is running (if we have Azure CLI access) +echo "Test 6: VM Status Check" +if command -v az &> /dev/null; then + VM_STATE=$(az vm get-instance-view -g "$RG_NAME" -n "$VM_NAME" \ + --query "instanceView.statuses[1].displayStatus" -o tsv 2>/dev/null || echo "Unknown") + if [ "$VM_STATE" = "VM running" ]; then + echo " OK: VM is running" + elif [ "$VM_STATE" = "VM deallocated" ]; then + echo " FAIL: VM is deallocated (stopped)" + echo " To start: az vm start -g $RG_NAME -n $VM_NAME" + FAILED=1 + else + echo " INFO: VM state is '$VM_STATE'" + fi +else + echo " SKIP: Azure CLI not available" +fi +echo "" + +# Summary +echo "==============================================" +if [ "$FAILED" -eq 0 ]; then + echo "RESULT: All tests passed" + exit 0 +else + echo "RESULT: Some tests failed" + echo "" + echo "Troubleshooting:" + echo " - Check VM logs: ./scripts/bot-logs.sh $ENV" + echo " - Restart service: ./scripts/bot-restart.sh $ENV" + echo " - For test env, start VM: az vm start -g TMinus15Agents-Test -n pennie-vm-test" + exit 1 +fi