File: Dcp\AppHostLocalStateStoreTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Text.Json.Nodes;
using Aspire.Hosting.Dcp;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Hosting.Tests.Dcp;
 
[Trait("Partition", "4")]
public class AppHostLocalStateStoreTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void SetPortAllocation_MergesAllocationsAcrossStoreInstances()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "merge-test");
        var firstStore = CreatePortAllocationStore(configuration);
        var secondStore = CreatePortAllocationStore(configuration);
 
        var firstKey = new PersistentPortAllocationKey("first", "http");
        var secondKey = new PersistentPortAllocationKey("second", "http");
        firstStore.SetPort(firstKey, 12001);
        secondStore.SetPort(secondKey, 12002);
 
        var allocations = firstStore.GetAllocations();
        Assert.Equal(12001, allocations[firstKey]);
        Assert.Equal(12002, allocations[secondKey]);
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        Assert.Matches("^[0-9a-f]{16}\\.json$", Path.GetFileName(stateFile));
 
        var state = JsonNode.Parse(File.ReadAllText(stateFile));
        Assert.Equal(1, state?["portAllocations"]?["version"]?.GetValue<int>());
        Assert.Equal(12001, state?["portAllocations"]?["resources"]?["first"]?["http"]?["port"]?.GetValue<int>());
        Assert.Equal(12002, state?["portAllocations"]?["resources"]?["second"]?["http"]?["port"]?.GetValue<int>());
        Assert.Equal($"*{Environment.NewLine}", File.ReadAllText(Path.Combine(Path.GetDirectoryName(stateFile)!, ".gitignore")));
    }
 
    [Fact]
    public void SetPortAllocation_UsesResourceAndEndpointNameComparers()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "name-comparer-test");
        var store = CreatePortAllocationStore(configuration);
 
        store.SetPort(new PersistentPortAllocationKey("Program", "HTTP"), 12001);
 
        var equivalentKey = new PersistentPortAllocationKey("program", "http");
        Assert.Equal(12001, store.GetAllocations()[equivalentKey]);
 
        store.SetPort(equivalentKey, 12002);
 
        var allocation = Assert.Single(store.GetAllocations());
        Assert.Equal(equivalentKey, allocation.Key);
        Assert.Equal(12002, allocation.Value);
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        var resources = JsonNode.Parse(File.ReadAllText(stateFile))?["portAllocations"]?["resources"]?.AsObject();
        var resource = Assert.Single(resources!);
        var endpoint = Assert.Single(resource.Value!.AsObject());
        Assert.Equal(12002, endpoint.Value?["port"]?.GetValue<int>());
    }
 
    [Fact]
    public void SetPortAllocation_PreservesOtherStateKeys()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "preserve-test");
        var stateStore = new AppHostLocalStateStore(configuration, NullLogger<AppHostLocalStateStore>.Instance);
        var portAllocationStore = new PersistentPortAllocationStore(
            stateStore,
            NullLogger<PersistentPortAllocationStore>.Instance);
        portAllocationStore.SetPort(new PersistentPortAllocationKey("first", "http"), 12001);
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        stateStore.Update("runtime", _ => new JsonObject
        {
            ["identifier"] = "preserved"
        });
 
        portAllocationStore.SetPort(new PersistentPortAllocationKey("second", "http"), 12002);
 
        var state = JsonNode.Parse(File.ReadAllText(stateFile))!.AsObject();
        Assert.Equal("preserved", state["runtime"]?["identifier"]?.GetValue<string>());
        Assert.Equal(12001, state["portAllocations"]?["resources"]?["first"]?["http"]?["port"]?.GetValue<int>());
        Assert.Equal(12002, state["portAllocations"]?["resources"]?["second"]?["http"]?["port"]?.GetValue<int>());
    }
 
    [Fact]
    public void SetPortAllocation_DoesNotOverwriteNewerValueVersion()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "newer-version-test");
        var stateStore = new AppHostLocalStateStore(configuration, NullLogger<AppHostLocalStateStore>.Instance);
        stateStore.Update("portAllocations", _ => new JsonObject
        {
            ["version"] = 2,
            ["futureValue"] = "preserved"
        });
 
        var sink = new TestSink();
        var portAllocationStore = new PersistentPortAllocationStore(
            stateStore,
            new TestLogger<PersistentPortAllocationStore>(new TestLoggerFactory(sink, enabled: true)));
        portAllocationStore.SetPort(new PersistentPortAllocationKey("first", "http"), 12001);
        stateStore.Update("runtime", _ => JsonValue.Create("available"));
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        var state = JsonNode.Parse(File.ReadAllText(stateFile))!.AsObject();
        Assert.Equal(2, state["portAllocations"]?["version"]?.GetValue<int>());
        Assert.Equal("preserved", state["portAllocations"]?["futureValue"]?.GetValue<string>());
        Assert.Null(state["portAllocations"]?["resources"]);
        Assert.Equal("available", state["runtime"]?.GetValue<string>());
        Assert.Contains(sink.Writes, write =>
            write.LogLevel == LogLevel.Warning &&
            write.Message?.Contains("unsupported portAllocations AppHost local state value", StringComparison.Ordinal) == true);
    }
 
    [Fact]
    public void Read_MalformedFileLogsWarningAndReturnsEmptyState()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "malformed-test");
        var stateStore = new AppHostLocalStateStore(configuration, NullLogger<AppHostLocalStateStore>.Instance);
        var portAllocationStore = new PersistentPortAllocationStore(
            stateStore,
            NullLogger<PersistentPortAllocationStore>.Instance);
        portAllocationStore.SetPort(new PersistentPortAllocationKey("first", "http"), 12001);
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        File.WriteAllText(stateFile, "{ invalid");
 
        var sink = new TestSink();
        var loggingStateStore = new AppHostLocalStateStore(
            configuration,
            new TestLogger<AppHostLocalStateStore>(new TestLoggerFactory(sink, enabled: true)));
        var loggingPortAllocationStore = new PersistentPortAllocationStore(
            loggingStateStore,
            NullLogger<PersistentPortAllocationStore>.Instance);
 
        Assert.Empty(loggingPortAllocationStore.GetAllocations());
        Assert.Contains(sink.Writes, write =>
            write.LogLevel == LogLevel.Warning &&
            write.Message?.Contains("malformed AppHost local state file", StringComparison.Ordinal) == true);
    }
 
    [Fact]
    public void StateUsesAppHostIdentityAcrossLauncherWorkloadIds()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        const string appHostIdentity = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
        var directLaunchConfiguration = CreateConfiguration(workspace, workloadId: null, appHostIdentity: appHostIdentity);
        var cliLaunchConfiguration = CreateConfiguration(workspace, "apphost-different-launcher-id", appHostIdentity);
        var key = new PersistentPortAllocationKey("program", "http");
 
        CreatePortAllocationStore(directLaunchConfiguration).SetPort(key, 12001);
 
        Assert.Equal(12001, CreatePortAllocationStore(cliLaunchConfiguration).GetAllocations()[key]);
        Assert.Single(Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json"));
    }
 
    [Fact]
    public void LockTimeoutDisablesSubsequentDiskAccessButKeepsStoreAvailable()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var configuration = CreateConfiguration(workspace, "lock-timeout-test");
        var initialStore = CreatePortAllocationStore(configuration);
        var firstKey = new PersistentPortAllocationKey("first", "http");
        var secondKey = new PersistentPortAllocationKey("second", "http");
        initialStore.SetPort(firstKey, 12001);
 
        var stateFile = Directory.GetFiles(
            Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "state"),
            "*.json").Single();
        var sink = new TestSink();
        var stateStore = new AppHostLocalStateStore(
            configuration,
            new TestLogger<AppHostLocalStateStore>(new TestLoggerFactory(sink, enabled: true)),
            TimeSpan.FromMilliseconds(100));
        var portAllocationStore = new PersistentPortAllocationStore(
            stateStore,
            NullLogger<PersistentPortAllocationStore>.Instance);
 
        using (FileLock.Acquire(stateFile + ".lock"))
        {
            Assert.Empty(portAllocationStore.GetAllocations());
        }
 
        Assert.True(stateStore.IsAvailable);
        portAllocationStore.SetPort(secondKey, 12002);
 
        var allocations = CreatePortAllocationStore(configuration).GetAllocations();
        var allocation = Assert.Single(allocations);
        Assert.Equal(firstKey, allocation.Key);
        Assert.Equal(12001, allocation.Value);
        Assert.Single(sink.Writes, write =>
            write.LogLevel == LogLevel.Warning &&
            write.Message?.Contains("disk access is disabled for this process", StringComparison.Ordinal) == true);
    }
 
    private static PersistentPortAllocationStore CreatePortAllocationStore(IConfiguration configuration)
    {
        var stateStore = new AppHostLocalStateStore(configuration, NullLogger<AppHostLocalStateStore>.Instance);
        return new PersistentPortAllocationStore(stateStore, NullLogger<PersistentPortAllocationStore>.Instance);
    }
 
    private static IConfiguration CreateConfiguration(
        TemporaryWorkspace workspace,
        string? workloadId,
        string? appHostIdentity = null)
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["AppHost:Directory"] = workspace.WorkspaceRoot.FullName,
                ["AppHost:DeploymentStatePathSha256"] = appHostIdentity,
                [KnownConfigNames.DcpWorkloadId] = workloadId
            })
            .Build();
    }
}