File: AzureFunctionsDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
public sealed class AzureFunctionsDeploymentTests(ITestOutputHelper output)
{
    [Fact]
    public async Task DeployNodeFunctionsWithStorageBindingsToAzureContainerApps()
    {
        var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId();
        if (string.IsNullOrEmpty(subscriptionId))
        {
            Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION.");
        }
 
        if (!AzureAuthenticationHelpers.IsAzureAuthAvailable())
        {
            if (DeploymentE2ETestHelpers.IsRunningInCI)
            {
                Assert.Fail("Azure authentication not available in CI. Check OIDC configuration.");
            }
 
            Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
        }
 
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(45));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, TestContext.Current.CancellationToken);
        using var workspace = TemporaryWorkspace.Create(output);
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("node-functions");
        var started = Stopwatch.StartNew();
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(linkedCts.Token);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            await auto.PrepareEnvironmentAsync(workspace, counter);
            await auto.InstallCurrentBuildAspireBundleAsync(counter, output);
            await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
 
            foreach (var package in new[] { "Aspire.Hosting.Azure.AppContainers", "Aspire.Hosting.Azure.Functions", "Aspire.Hosting.Azure.Storage" })
            {
                await auto.TypeAsync($"aspire add {package}");
                await auto.EnterAsync();
                await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
            }
 
            WriteFunctionsApp(workspace);
            await auto.RunCommandAsync(
                $"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)} && export AZURE__SUBSCRIPTIONID={AspireCliShellCommandHelpers.QuoteBashArg(subscriptionId)}",
                counter);
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // Inspect the actual deployed resource, not just the generated Bicep. The HTTP
            // round trip below also proves the generated image listens on its declared port.
            await auto.RunCommandAsync(
                "az containerapp list --subscription \"$AZURE__SUBSCRIPTIONID\" -g \"$AZURE__RESOURCEGROUP\" -o json > containerapps.json && python3 verify-functions.py",
                counter, TimeSpan.FromMinutes(12));
 
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
            await pendingRun;
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployNodeFunctionsWithStorageBindingsToAzureContainerApps),
                resourceGroupName,
                new Dictionary<string, string>
                {
                    ["Functions"] = File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "functions-url.txt"))
                },
                started.Elapsed);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployNodeFunctionsWithStorageBindingsToAzureContainerApps),
                resourceGroupName, ex.Message, ex.StackTrace);
            throw;
        }
        finally
        {
            await DotnetProjectDeploymentHelpers.CleanupAsync(resourceGroupName, subscriptionId, output);
        }
    }
 
    private static void WriteFunctionsApp(TemporaryWorkspace workspace)
    {
        var root = workspace.WorkspaceRoot.FullName;
        var functions = Directory.CreateDirectory(Path.Combine(root, "functions")).FullName;
 
        // Deliberately omit a Dockerfile and built output: deployment must generate the
        // Node Functions image, install dependencies, and compile the TypeScript fixture.
        File.WriteAllText(Path.Combine(functions, "package.json"), """
            {
              "name": "functions-storage-deployment-test",
              "private": true,
              "version": "1.0.0",
              "main": "dist/index.js",
              "scripts": { "build": "tsc" },
              "dependencies": { "@azure/functions": "4.7.0" },
              "devDependencies": { "@types/node": "22.15.30", "typescript": "5.8.3" }
            }
            """);
        File.WriteAllText(Path.Combine(functions, "tsconfig.json"), """
            {
              "compilerOptions": {
                "target": "ES2022",
                "module": "CommonJS",
                "outDir": "dist",
                "strict": true,
                "skipLibCheck": true
              },
              "include": ["index.ts"]
            }
            """);
        File.WriteAllText(Path.Combine(functions, "host.json"), """
            {
              "version": "2.0",
              "extensionBundle": {
                "id": "Microsoft.Azure.Functions.ExtensionBundle",
                "version": "[4.*, 5.0.0)"
              }
            }
            """);
        File.WriteAllText(Path.Combine(functions, "index.ts"), """
            import { app, input, output } from '@azure/functions';
 
            const blobOutput = output.storageBlob({ path: 'messages/{id}.txt', connection: 'blobs' });
            const queueOutput = output.storageQueue({ queueName: 'work', connection: 'workQueue' });
            const receiptOutput = output.storageBlob({ path: 'receipts/{id}.txt', connection: 'blobs' });
            const blobInput = input.storageBlob({ path: 'messages/{id}.txt', connection: 'blobs' });
            const receiptInput = input.storageBlob({ path: 'receipts/{id}.txt', connection: 'blobs' });
 
            app.http('write', {
                route: 'roundtrip/{id}', methods: ['POST'], authLevel: 'anonymous',
                extraOutputs: [blobOutput, queueOutput],
                handler: async (request, context) => {
                    const value = await request.text();
                    context.extraOutputs.set(blobOutput, value);
                    context.extraOutputs.set(queueOutput, { id: request.params.id, value });
                    return { jsonBody: { accepted: request.params.id } };
                }
            });
 
            app.storageQueue('processWork', {
                queueName: 'work', connection: 'workQueue',
                extraOutputs: [receiptOutput],
                handler: async (message, context) => {
                    // Queue output is {"id":"<request-id>","value":"<payload>"}.
                    // The host may deliver the decoded object or the JSON string.
                    const work = (typeof message === 'string' ? JSON.parse(message) : message) as { value: string };
                    context.extraOutputs.set(receiptOutput, work.value);
                }
            });
 
            app.http('read', {
                route: 'roundtrip/{id}', methods: ['GET'], authLevel: 'anonymous',
                extraInputs: [blobInput, receiptInput],
                handler: async (_request, context) => ({
                    jsonBody: {
                        direct: String(context.extraInputs.get(blobInput)),
                        queued: String(context.extraInputs.get(receiptInput))
                    }
                })
            });
            """);
        File.WriteAllText(Path.Combine(root, "apphost.mts"), """
            import { AzureFunctionsLanguage, createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
            await builder.addAzureContainerAppEnvironment('env');
            const storage = await builder.addAzureStorage('storage');
            const blobs = await storage.addBlobs('blobs');
            await storage.addBlobContainer('messages');
            await storage.addBlobContainer('receipts');
            const queues = await storage.addQueues('queues');
            await storage.addQueue('work');
 
            await builder.addAzureFunctionsApp('functions', './functions', AzureFunctionsLanguage.TypeScript)
                .withExternalHttpEndpoints()
                .withReference(blobs)
                .withReference(queues, { connectionName: 'workQueue' });
 
            await builder.build().run();
            """);
        File.WriteAllText(Path.Combine(root, "verify-functions.py"), """
            import json
            import time
            import urllib.error
            import urllib.request
            import uuid
            from pathlib import Path
 
            # ARM container app JSON has top-level kind and properties.configuration.ingress.
            # Select by the Aspire resource name, rather than accepting the dashboard endpoint.
            apps = json.loads(Path('containerapps.json').read_text())
            functions = [app for app in apps if 'functions' in app['name']]
            assert len(functions) == 1, f'Expected one Functions container app, found {len(functions)}'
            app = functions[0]
            assert app.get('kind', '').lower() == 'functionapp', f"Unexpected kind: {app.get('kind')}"
            ingress = app['properties']['configuration']['ingress']
            assert ingress['external'] is True, 'Functions endpoint is not external'
            assert ingress['targetPort'] == 80, f"Unexpected target port: {ingress['targetPort']}"
            env = {
                item['name']: item
                for container in app['properties']['template']['containers']
                for item in container.get('env', [])
            }
            for name in ['AzureWebJobsStorage__blobServiceUri', 'blobs__blobServiceUri', 'workQueue__queueServiceUri']:
                assert env.get(name, {}).get('value', '').startswith('https://'), f'Missing identity-based binding: {name}'
 
            base_url = 'https://' + ingress['fqdn']
            Path('functions-url.txt').write_text(base_url)
            request_id = uuid.uuid4().hex
            value = 'aspire-functions-binding-' + request_id
            url = base_url + '/api/roundtrip/' + request_id
 
            def request_until_success(method, expected, timeout_seconds):
                deadline = time.monotonic() + timeout_seconds
                last_error = None
                while time.monotonic() < deadline:
                    try:
                        request = urllib.request.Request(
                            url, method=method,
                            data=value.encode() if method == 'POST' else None,
                            headers={'Content-Type': 'text/plain'})
                        with urllib.request.urlopen(request, timeout=10) as response:
                            actual = json.load(response)
                        if actual == expected:
                            return
                        last_error = f'Unexpected response: {actual}'
                    except (urllib.error.URLError, TimeoutError, ValueError) as error:
                        last_error = str(error)
                    print(f'{method}: {last_error}; waiting for Functions startup / storage RBAC', flush=True)
                    time.sleep(10)
                raise AssertionError(f'{method} binding verification failed: {last_error}')
 
            # Separate startup/RBAC and queue-processing budgets so a slow startup cannot
            # consume the GET retries. Eleven minutes leaves room in the 12-minute command
            # timeout for az and the final in-flight request/retry delay of each phase.
            request_until_success('POST', {'accepted': request_id}, timeout_seconds=480)
            # Both values must be read back through real Functions blob input bindings.
            # The second blob can only exist after queue output + trigger + blob output succeed.
            request_until_success('GET', {'direct': value, 'queued': value}, timeout_seconds=180)
            print('Verified generated Node image, functionapp kind, port 80, and storage binding round trip.')
            """);
    }
}