diff --git a/.github/workflows/azure-functions-smoke-tests.yml b/.github/workflows/azure-functions-smoke-tests.yml new file mode 100644 index 000000000..9ee868fda --- /dev/null +++ b/.github/workflows/azure-functions-smoke-tests.yml @@ -0,0 +1,45 @@ +name: Azure Functions Smoke Tests + +on: + push: + branches: + - main + - 'feature/**' + paths-ignore: [ '**.md' ] + pull_request: + paths-ignore: [ '**.md' ] + workflow_dispatch: + +jobs: + smoke-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + + - name: Restore dependencies + run: dotnet restore test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj + + - name: Run smoke tests + run: | + cd test/AzureFunctionsSmokeTests + pwsh -File run-smoketests.ps1 + + - name: Upload smoke test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-logs + path: test/AzureFunctionsSmokeTests/logs/ + if-no-files-found: ignore diff --git a/Directory.Packages.props b/Directory.Packages.props index b36897e3f..d4a8354ce 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,8 @@ + + diff --git a/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj b/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj new file mode 100644 index 000000000..219d5bc98 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + v4 + Exe + enable + + false + false + + false + false + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + diff --git a/test/AzureFunctionsSmokeTests/Dockerfile b/test/AzureFunctionsSmokeTests/Dockerfile new file mode 100644 index 000000000..4df636799 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/Dockerfile @@ -0,0 +1,10 @@ +# Use the Azure Functions base image for .NET 8.0 isolated +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 + +# Set environment variables +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ + FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + +# Copy the published app +COPY ./publish /home/site/wwwroot diff --git a/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs b/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs new file mode 100644 index 000000000..14a5b5f53 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionsSmokeTests; + +/// +/// Smoke test orchestration functions for Azure Functions with Durable Task. +/// +public static class HelloCitiesOrchestration +{ + [Function(nameof(HelloCitiesOrchestration))] + public static async Task> RunOrchestrator( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(HelloCitiesOrchestration)); + logger.LogInformation("Starting HelloCities orchestration."); + + List outputs = new List(); + + // Call activities in sequence + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Tokyo")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Seattle")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "London")); + + logger.LogInformation("HelloCities orchestration completed."); + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger(nameof(SayHello)); + logger.LogInformation($"Saying hello to {name}."); + return $"Hello {name}!"; + } + + [Function("HelloCitiesOrchestration_HttpStart")] + public static async Task HttpStart( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger("HelloCitiesOrchestration_HttpStart"); + + // Function input comes from the request content. + string instanceId = await client + .ScheduleNewOrchestrationInstanceAsync(nameof(HelloCitiesOrchestration)); + + logger.LogInformation($"Started orchestration with ID = '{instanceId}'."); + + // Returns an HTTP 202 response with an instance management payload. + return client.CreateCheckStatusResponse(req, instanceId); + } +} diff --git a/test/AzureFunctionsSmokeTests/Program.cs b/test/AzureFunctionsSmokeTests/Program.cs new file mode 100644 index 000000000..eddb3547e --- /dev/null +++ b/test/AzureFunctionsSmokeTests/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; + +namespace AzureFunctionsSmokeTests; + +public class Program +{ + public static void Main() + { + IHost host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + + host.Run(); + } +} diff --git a/test/AzureFunctionsSmokeTests/README.md b/test/AzureFunctionsSmokeTests/README.md new file mode 100644 index 000000000..bfdf4241c --- /dev/null +++ b/test/AzureFunctionsSmokeTests/README.md @@ -0,0 +1,83 @@ +# Azure Functions Smoke Tests + +This directory contains smoke tests for Azure Functions with Durable Task, designed to validate the SDK and Source Generator functionality in a real Azure Functions isolated .NET environment. + +## Overview + +The smoke tests ensure that: +- The Durable Task SDK works correctly with Azure Functions isolated worker +- Source generators produce valid code +- Orchestrations can be triggered and completed successfully +- The complete end-to-end workflow functions as expected + +## Structure + +- **HelloCitiesOrchestration.cs** - Simple orchestration that calls multiple activities +- **Program.cs** - Azure Functions host entry point +- **host.json** - Azure Functions host configuration +- **local.settings.json** - Local development settings +- **Dockerfile** - Docker image configuration for the Functions app +- **run-smoketests.ps1** - PowerShell script to run smoke tests locally or in CI + +## Running Smoke Tests Locally + +### Prerequisites + +- Docker installed and running +- PowerShell Core (pwsh) installed +- .NET 8.0 SDK or later + +### Run the Tests + +From the `test/AzureFunctionsSmokeTests` directory: + +```bash +pwsh -File run-smoketests.ps1 +``` + +The script will: +1. Build and publish the Azure Functions project +2. Create a Docker image +3. Start Azurite (Azure Storage emulator) in a Docker container +4. Start the Azure Functions app in a Docker container +5. Trigger the HelloCities orchestration via HTTP +6. Poll for orchestration completion +7. Validate the result +8. Clean up all containers + +### Parameters + +The script accepts the following optional parameters: + +```powershell +pwsh -File run-smoketests.ps1 ` + -ImageName "custom-image-name" ` + -ContainerName "custom-container-name" ` + -Port 8080 ` + -Timeout 120 +``` + +## CI Integration + +The smoke tests are automatically run in GitHub Actions via the `.github/workflows/azure-functions-smoke-tests.yml` workflow on: +- Push to `main` or `feature/**` branches +- Pull requests targeting `main` or `feature/**` branches +- Manual workflow dispatch + +## Troubleshooting + +If the smoke tests fail: + +1. **Check container logs**: The script will display logs automatically on failure +2. **Verify Azurite is running**: Ensure port 10000-10002 are available +3. **Check Functions app port**: Ensure the configured port (default 8080) is available +4. **Build errors**: Ensure all dependencies are restored with `dotnet restore` + +## Adding New Smoke Tests + +To add new orchestration scenarios: + +1. Create new function classes following the pattern in `HelloCitiesOrchestration.cs` +2. Ensure proper XML documentation comments +3. Add test logic to validate the new scenario +4. Update this README with the new test case diff --git a/test/AzureFunctionsSmokeTests/host.json b/test/AzureFunctionsSmokeTests/host.json new file mode 100644 index 000000000..305e9bf49 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/host.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Default": "Information", + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning" + }, + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "durableTask": { + "hubName": "DotNetIsolatedSmokeTests" + } + } +} diff --git a/test/AzureFunctionsSmokeTests/local.settings.json b/test/AzureFunctionsSmokeTests/local.settings.json new file mode 100644 index 000000000..8eea88f48 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + } +} diff --git a/test/AzureFunctionsSmokeTests/run-smoketests.ps1 b/test/AzureFunctionsSmokeTests/run-smoketests.ps1 new file mode 100644 index 000000000..185b9e3df --- /dev/null +++ b/test/AzureFunctionsSmokeTests/run-smoketests.ps1 @@ -0,0 +1,271 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Runs smoke tests for the Azure Functions app using Docker containers. +.DESCRIPTION + This script builds and publishes the Azure Functions smoke test app, + starts required containers (Azurite for storage emulation and the Functions app), + triggers the orchestration, and validates successful completion. +.PARAMETER ImageName + Docker image name for the Functions app (default: "azurefunctions-smoketests") +.PARAMETER ContainerName + Docker container name for the Functions app (default: "azurefunctions-smoketests-container") +.PARAMETER Port + Port to expose the Functions app on (default: 8080) +.PARAMETER Timeout + Timeout in seconds to wait for orchestration completion (default: 120) +#> + +param( + [string]$ImageName = "azurefunctions-smoketests", + [string]$ContainerName = "azurefunctions-smoketests-container", + [int]$Port = 8080, + [int]$Timeout = 120 +) + +$ErrorActionPreference = "Stop" + +# Get the directory where the script is located +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDir = $scriptDir +$publishDir = Join-Path $projectDir "publish" + +Write-Host "=== Azure Functions Smoke Test Runner ===" -ForegroundColor Cyan +Write-Host "" + +# Function to clean up containers +function Cleanup { + Write-Host "Cleaning up containers..." -ForegroundColor Yellow + + # Stop and remove the Functions app container + docker stop $ContainerName 2>$null | Out-Null + docker rm $ContainerName 2>$null | Out-Null + + # Stop and remove Azurite container + docker stop azurite-smoketest 2>$null | Out-Null + docker rm azurite-smoketest 2>$null | Out-Null + + # Remove the Docker network + docker network rm smoketest-network 2>$null | Out-Null +} + +# Cleanup on script exit +trap { + Write-Host "Error occurred. Cleaning up..." -ForegroundColor Red + Cleanup + exit 1 +} + +try { + # Cleanup any existing containers first + Write-Host "Cleaning up any existing containers..." -ForegroundColor Yellow + Cleanup + Write-Host "" + + # Step 1: Build the project + Write-Host "Step 1: Building the Azure Functions project..." -ForegroundColor Green + dotnet build $projectDir -c Release + if ($LASTEXITCODE -ne 0) { + throw "Build failed with exit code $LASTEXITCODE" + } + Write-Host "Build completed successfully." -ForegroundColor Green + Write-Host "" + + # Step 2: Publish the project + Write-Host "Step 2: Publishing the Azure Functions project..." -ForegroundColor Green + if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force + } + dotnet publish $projectDir -c Release -o $publishDir + if ($LASTEXITCODE -ne 0) { + throw "Publish failed with exit code $LASTEXITCODE" + } + Write-Host "Publish completed successfully." -ForegroundColor Green + Write-Host "" + + # Step 3: Build Docker image + Write-Host "Step 3: Building Docker image '$ImageName'..." -ForegroundColor Green + docker build -t $ImageName $projectDir + if ($LASTEXITCODE -ne 0) { + throw "Docker build failed with exit code $LASTEXITCODE" + } + Write-Host "Docker image built successfully." -ForegroundColor Green + Write-Host "" + + # Step 4: Create Docker network + Write-Host "Step 4: Creating Docker network..." -ForegroundColor Green + docker network create smoketest-network 2>$null + Write-Host "Docker network created or already exists." -ForegroundColor Green + Write-Host "" + + # Step 5: Start Azurite container + Write-Host "Step 5: Starting Azurite storage emulator..." -ForegroundColor Green + docker run -d ` + --name azurite-smoketest ` + --network smoketest-network ` + -p 10000:10000 ` + -p 10001:10001 ` + -p 10002:10002 ` + mcr.microsoft.com/azure-storage/azurite:latest + + if ($LASTEXITCODE -ne 0) { + throw "Failed to start Azurite container" + } + + # Wait for Azurite to be ready + Write-Host "Waiting for Azurite to be ready..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + Write-Host "Azurite is ready." -ForegroundColor Green + Write-Host "" + + # Step 6: Start Azure Functions container + Write-Host "Step 6: Starting Azure Functions container..." -ForegroundColor Green + + # Azurite connection string for Docker network + # Using the default Azurite development account credentials + $accountName = "devstoreaccount1" + $accountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + $blobEndpoint = "http://azurite-smoketest:10000/$accountName" + $queueEndpoint = "http://azurite-smoketest:10001/$accountName" + $tableEndpoint = "http://azurite-smoketest:10002/$accountName" + + $storageConnectionString = @( + "DefaultEndpointsProtocol=http" + "AccountName=$accountName" + "AccountKey=$accountKey" + "BlobEndpoint=$blobEndpoint" + "QueueEndpoint=$queueEndpoint" + "TableEndpoint=$tableEndpoint" + ) -join ";" + + docker run -d ` + --name $ContainerName ` + --network smoketest-network ` + -p "${Port}:80" ` + -e AzureWebJobsStorage="$storageConnectionString" ` + -e FUNCTIONS_WORKER_RUNTIME=dotnet-isolated ` + -e WEBSITE_HOSTNAME="localhost:$Port" ` + $ImageName + + if ($LASTEXITCODE -ne 0) { + throw "Failed to start Functions container" + } + + # Wait for Functions host to start + Write-Host "Waiting for Azure Functions host to start..." -ForegroundColor Yellow + + # Give the host time to fully initialize + # The admin/host/status endpoint is not available in all configurations, + # so we'll wait a reasonable amount of time and check logs + Start-Sleep -Seconds 15 + + # Check if the container is still running + $containerStatus = docker inspect --format='{{.State.Status}}' $ContainerName + if ($containerStatus -ne "running") { + Write-Host "Functions container is not running. Checking logs..." -ForegroundColor Red + docker logs $ContainerName + throw "Functions container failed to start" + } + + # Check logs for successful startup + $logs = docker logs $ContainerName 2>&1 | Out-String + if ($logs -match "Job host started" -or $logs -match "Host started") { + Write-Host "Azure Functions host is ready." -ForegroundColor Green + } + else { + Write-Host "Warning: Could not confirm host startup from logs." -ForegroundColor Yellow + Write-Host "Attempting to continue with orchestration trigger..." -ForegroundColor Yellow + } + Write-Host "" + + # Step 7: Trigger orchestration + Write-Host "Step 7: Triggering orchestration..." -ForegroundColor Green + $startUrl = "http://localhost:$Port/api/HelloCitiesOrchestration_HttpStart" + + try { + $startResponse = Invoke-WebRequest -Uri $startUrl -Method Post -UseBasicParsing + if ($startResponse.StatusCode -ne 202) { + throw "Unexpected status code: $($startResponse.StatusCode)" + } + } + catch { + Write-Host "Failed to trigger orchestration. Error: $_" -ForegroundColor Red + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw + } + + $responseContent = $startResponse.Content | ConvertFrom-Json + $statusQueryGetUri = $responseContent.statusQueryGetUri + $instanceId = $responseContent.id + + Write-Host "Orchestration started with instance ID: $instanceId" -ForegroundColor Green + Write-Host "Status query URI: $statusQueryGetUri" -ForegroundColor Cyan + Write-Host "" + + # Step 8: Poll for completion + Write-Host "Step 8: Polling for orchestration completion..." -ForegroundColor Green + $startTime = Get-Date + $completed = $false + $consecutiveErrors = 0 + $maxConsecutiveErrors = 3 + + while (((Get-Date) - $startTime).TotalSeconds -lt $Timeout) { + Start-Sleep -Seconds 2 + + try { + $statusResponse = Invoke-WebRequest -Uri $statusQueryGetUri -UseBasicParsing + $status = $statusResponse.Content | ConvertFrom-Json + + # Reset error counter on successful poll + $consecutiveErrors = 0 + + Write-Host "Current status: $($status.runtimeStatus)" -ForegroundColor Yellow + + if ($status.runtimeStatus -eq "Completed") { + $completed = $true + Write-Host "" + Write-Host "Orchestration completed successfully!" -ForegroundColor Green + Write-Host "Output: $($status.output)" -ForegroundColor Cyan + break + } + elseif ($status.runtimeStatus -eq "Failed" -or $status.runtimeStatus -eq "Terminated") { + throw "Orchestration ended with status: $($status.runtimeStatus)" + } + } + catch { + $consecutiveErrors++ + Write-Host "Error polling status (attempt $consecutiveErrors/$maxConsecutiveErrors): $_" -ForegroundColor Red + + if ($consecutiveErrors -ge $maxConsecutiveErrors) { + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw "Too many consecutive errors polling orchestration status" + } + } + } + + if (-not $completed) { + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw "Orchestration did not complete within timeout period" + } + + Write-Host "" + Write-Host "=== Smoke test completed successfully! ===" -ForegroundColor Green +} +finally { + # Cleanup + Cleanup + + # Cleanup publish directory + if (Test-Path $publishDir) { + Write-Host "Cleaning up publish directory..." -ForegroundColor Yellow + Remove-Item $publishDir -Recurse -Force + } +} + +Write-Host "" +Write-Host "All smoke tests passed!" -ForegroundColor Green +exit 0