File: NativeSymbolicWorkloadSocketTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.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.Security.Cryptography;
using Aspire.Hosting.Native.Client;
using Aspire.Hosting.Native.Core.Tests.TestServices;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativeSymbolicWorkloadSocketTests
{
    [Fact]
    public async Task PublishedServerConsumesSecretAndConnectionReferencesAcrossRealRedisRevisions()
    {
        var executable = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_SERVER");
        var dcp = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_DCP");
        var node = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_NODE");
        if (executable is null || dcp is null || node is null)
        {
            Assert.Skip("This contract smoke requires explicitly supplied published server, DCP and Node executables; the dedicated native CI lane supplies them.");
        }
        var clock = TimeProvider.System;
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2), clock);
        var token = timeout.Token;
        await using var host = await NativeClientTestHost.StartWithWorkloadsAsync(executable, dcp);
        await using var client = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(15), clock, token);
        var workspace = await client.Server.CreateApplicationWorkspaceAsync(token);
        var firstSecret = RandomNumberGenerator.GetHexString(32);
        var secondSecret = RandomNumberGenerator.GetHexString(32);
        var first = await workspace.BeginRevisionAsync(token);
        var (redis, consumer, secretHandle) = await DeclareAsync(first, firstSecret, token);
        await workspace.CommitRevisionAsync(first, token);
        var execution = await workspace.GetApplicationExecutionAsync(token);
        var redisOwner = await client.Server.JoinResourceExecutionAsync(await execution.InviteResourceExecutionAsync(redis, token), token);
        var consumerOwner = await client.Server.JoinResourceExecutionAsync(await execution.InviteResourceExecutionAsync(consumer, token), token);
        var redisLaunch = new ContainerWorkloadLaunch
        {
            Image = "docker.io/library/redis:8.2-alpine", Entrypoint = "/bin/sh", Mounts = [], Environment = [],
            Arguments = ["-c", """exec redis-server --requirepass "$REDIS_PASSWORD" --save "" --appendonly no"""],
            Endpoints = [new WorkloadEndpointDeclaration
            {
                Name = "tcp", Protocol = "TCP", UriScheme = "redis", TargetPort = 6379, HostPort = 0, EnvironmentVariable = ""
            }]
        };
        var redisBindings = new SymbolicLaunchBindings
        {
            Environment = [new SymbolicLaunchEnvironment { Name = "REDIS_PASSWORD", ConfigurationKey = "password" }], Arguments = []
        };
        var consumerLaunch = new ExecutableWorkloadLaunch
        {
            ExecutablePath = node, WorkingDirectory = AppContext.BaseDirectory, Environment = [],
            Arguments = [Path.Combine(AppContext.BaseDirectory, "AotSmoke", "SymbolicRedisConsumer.mts")],
            Endpoints = [new WorkloadEndpointDeclaration
            {
                Name = "http", Protocol = "TCP", UriScheme = "http", TargetPort = 0, HostPort = 0, EnvironmentVariable = "PORT"
            }]
        };
        var consumerBindings = new SymbolicLaunchBindings
        {
            Environment = [new SymbolicLaunchEnvironment { Name = "NATIVE_REDIS_URI", ConfigurationKey = "connection" }], Arguments = []
        };
        try
        {
            var redisAllocation = await redisOwner.StartContainerWithValuesAsync(1, "container", redisLaunch, redisBindings, token);
            var redisEndpoint = Assert.Single(redisAllocation.Endpoints);
            var waitingConsumer = consumerOwner.StartExecutableWithValuesAsync(1, "host", consumerLaunch, consumerBindings, token);
            Assert.False(waitingConsumer.IsCompleted);
            await RedisProtocolTestClient.WaitForAuthenticatedPingAsync(redisEndpoint, firstSecret, clock, token);
            Assert.False(await RedisProtocolTestClient.PingAsync(redisEndpoint, null, token));
            await redisOwner.CompleteResourceConfigurationAsync(1, new RuntimeOperationResult { Status = "succeeded", Message = "Authenticated Redis is ready." }, token);
            await redisOwner.PublishObservationAsync(new ResourceObservation { State = "Running", Healthy = true, Urls = [] }, token);
            var consumerAllocation = await waitingConsumer;
            await VerifyConsumerAsync(consumerAllocation, clock, token);
 
            var revision = await workspace.BeginRevisionAsync(token);
            await DeclareAsync(revision, secondSecret, token);
            await workspace.CommitRevisionAsync(revision, token);
            var stale = await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                consumerOwner.RestartExecutableWithValuesAsync(1, "host", consumerLaunch, consumerBindings, token));
            Assert.Equal("VALUE_REVISION_STALE", stale.Code);
            var revoked = await Assert.ThrowsAsync<NativeCapabilityException>(() => secretHandle.EncodeUriValueAsync(token));
            Assert.Equal("HANDLE_NOT_FOUND", revoked.Code);
            var redisRevision = (await redisOwner.ReadResourceConfigurationAsync(token)).Revision;
            var replacement = await redisOwner.RestartContainerWithValuesAsync(redisRevision, "container", redisLaunch, redisBindings, token);
            var replacementEndpoint = Assert.Single(replacement.Endpoints);
            await RedisProtocolTestClient.WaitForAuthenticatedPingAsync(replacementEndpoint, secondSecret, clock, token);
            Assert.False(await RedisProtocolTestClient.PingAsync(replacementEndpoint, firstSecret, token));
            await redisOwner.CompleteResourceConfigurationAsync(redisRevision, new RuntimeOperationResult { Status = "succeeded", Message = "Updated Redis is ready." }, token);
            await redisOwner.PublishObservationAsync(new ResourceObservation { State = "Running", Healthy = true, Urls = [] }, token);
            var consumerRevision = (await consumerOwner.ReadResourceConfigurationAsync(token)).Revision;
            var updatedConsumer = await consumerOwner.RestartExecutableWithValuesAsync(consumerRevision, "host", consumerLaunch, consumerBindings, token);
            await VerifyConsumerAsync(updatedConsumer, clock, token);
            Assert.Empty((await consumerOwner.ReadResourceConfigurationAsync(token)).Properties);
            Assert.True(Assert.Single((await consumerOwner.InspectExecutionSymbolicConfigurationAsync(token)).Properties).IsSecret);
        }
        finally
        {
            await workspace.RetireApplicationWorkspaceAsync();
        }
    }
 
    private static async Task<(DeclaredResource Redis, DeclaredResource Consumer, DeclaredValue Secret)> DeclareAsync(
        Composition composition, string password, CancellationToken cancellationToken)
    {
        var redis = await composition.AddResourceAsync("cache", "example/redis", cancellationToken);
        var consumer = await composition.AddResourceAsync("consumer", "example/executable", cancellationToken);
        await consumer.WaitForAsync(redis, cancellationToken);
        var secret = await composition.AddSecretParameterAsync("cache-password", password, cancellationToken);
        await redis.SetSymbolicConfigurationAsync("password", secret, cancellationToken);
        var connection = await composition.CreateLiteralValueAsync("redis://:", cancellationToken);
        connection = await connection.ConcatValueAsync(await secret.EncodeUriValueAsync(cancellationToken), cancellationToken);
        connection = await connection.ConcatValueAsync(await composition.CreateLiteralValueAsync("@", cancellationToken), cancellationToken);
        connection = await connection.ConcatValueAsync(await composition.CreateEndpointValueAsync(redis, "tcp", "hostAndPort", cancellationToken), cancellationToken);
        await consumer.SetSymbolicConfigurationAsync("connection", connection, cancellationToken);
 
        return (redis, consumer, secret);
    }
 
    private static async Task VerifyConsumerAsync(WorkloadAllocation allocation, TimeProvider clock, CancellationToken cancellationToken)
    {
        var endpoint = Assert.Single(allocation.Endpoints);
        using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
        var address = new UriBuilder("http", endpoint.Host, endpoint.Port).Uri;
        while (true)
        {
            try
            {
                var response = await http.GetStringAsync(address, cancellationToken);
                Assert.Equal("verified\n", response);
 
                return;
            }
            catch (HttpRequestException)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(100), clock, cancellationToken);
            }
        }
    }
}